Merge pull request #14105 from overleaf/mj-table-generator

[visual] Initial table generator

GitOrigin-RevId: 5c1a9cad898c988d21390358872a6c2eaf1c32fc
This commit is contained in:
Mathias Jakobsen 2023-08-08 13:14:25 +01:00 committed by Copybot
parent b9444a8805
commit c332a65eb0
30 changed files with 2077 additions and 31 deletions

View file

@ -683,6 +683,12 @@ const ProjectController = {
cb()
})
},
tableGeneratorAssignment(cb) {
SplitTestHandler.getAssignment(req, res, 'table-generator', () => {
// We'll pick up the assignment from the res.locals assignment.
cb()
})
},
sourceEditorToolbarAssigment(cb) {
SplitTestHandler.getAssignment(
req,

View file

@ -1077,6 +1077,8 @@
"toolbar_insert_table": "",
"toolbar_numbered_list": "",
"toolbar_redo": "",
"toolbar_table_insert_size_table": "",
"toolbar_table_insert_table_lowercase": "",
"toolbar_toggle_symbol_palette": "",
"toolbar_undo": "",
"total_per_month": "",

View file

@ -54,7 +54,7 @@ function CodeMirrorEditor() {
return (
<CodeMirrorStateContext.Provider value={state}>
<CodeMirrorViewContext.Provider value={viewRef.current}>
<CodeMirrorViewContextProvider value={viewRef.current}>
<CodemirrorOutline />
<CodeMirrorView />
<FigureModal />
@ -66,7 +66,7 @@ function CodeMirrorEditor() {
<Component key={path} />
)
)}
</CodeMirrorViewContext.Provider>
</CodeMirrorViewContextProvider>
</CodeMirrorStateContext.Provider>
)
}
@ -89,12 +89,14 @@ export const useCodeMirrorStateContext = (): EditorState => {
const CodeMirrorViewContext = createContext<EditorView | undefined>(undefined)
export const CodeMirrorViewContextProvider = CodeMirrorViewContext.Provider
export const useCodeMirrorViewContext = (): EditorView => {
const context = useContext(CodeMirrorViewContext)
if (!context) {
throw new Error(
'useCodeMirrorViewContext is only available inside CodeMirrorEditor'
'useCodeMirrorViewContext is only available inside CodeMirrorViewContextProvider'
)
}

View file

@ -0,0 +1,131 @@
import { FC, MouseEventHandler, useCallback, useEffect, useRef } from 'react'
import { CellData, ColumnDefinition, RowData } from './tabular'
import classNames from 'classnames'
import {
TableSelection,
useSelectionContext,
} from './contexts/selection-context'
import { useEditingContext } from './contexts/editing-context'
import { loadMathJax } from '../../../mathjax/load-mathjax'
import { typesetNodeIntoElement } from '../../extensions/visual/utils/typeset-content'
import { parser } from '../../lezer-latex/latex.mjs'
export const Cell: FC<{
cellData: CellData
columnSpecification: ColumnDefinition
rowIndex: number
columnIndex: number
row: RowData
}> = ({ cellData, columnSpecification, rowIndex, columnIndex, row }) => {
const { selection, setSelection } = useSelectionContext()
const renderDiv = useRef<HTMLDivElement>(null)
const {
cellData: editingCellData,
updateCellData: update,
startEditing,
} = useEditingContext()
const inputRef = useRef<HTMLInputElement>(null)
const editing =
editingCellData?.rowIndex === rowIndex &&
editingCellData?.cellIndex === columnIndex
const onMouseDown: MouseEventHandler = useCallback(
event => {
if (event.button !== 0) {
return
}
event.stopPropagation()
setSelection(current => {
if (event.shiftKey && current) {
return new TableSelection(current.from, {
row: rowIndex,
cell: columnIndex,
})
} else {
return new TableSelection(
{ row: rowIndex, cell: columnIndex },
{ row: rowIndex, cell: columnIndex }
)
}
})
},
[setSelection, rowIndex, columnIndex]
)
useEffect(() => {
if (editing) {
inputRef.current?.focus()
}
}, [editing])
const filterInput = (input: string) => {
// TODO: Are there situations where we don't want to filter the input?
return input.replaceAll(/(?<!\\)&/g, '\\&').replaceAll('\\\\', '')
}
const hasFocus = selection?.contains({ row: rowIndex, cell: columnIndex })
useEffect(() => {
const toDisplay = cellData.content.trim()
if (renderDiv.current && !editing) {
const tree = parser.parse(toDisplay)
const node = tree.topNode
typesetNodeIntoElement(
node,
renderDiv.current,
toDisplay.substring.bind(toDisplay)
)
loadMathJax().then(async MathJax => {
await MathJax.typesetPromise([renderDiv.current])
})
}
}, [cellData.content, editing])
let body = <div ref={renderDiv} />
if (editing) {
body = (
<input
className="table-generator-cell-input"
ref={inputRef}
value={editingCellData.content}
style={{ width: `inherit` }}
onChange={e => {
update(filterInput(e.target.value))
}}
/>
)
}
const onDoubleClick = useCallback(() => {
startEditing(rowIndex, columnIndex, cellData.content.trim())
}, [columnIndex, rowIndex, cellData, startEditing])
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<td
onDoubleClick={onDoubleClick}
tabIndex={row.cells.length * rowIndex + columnIndex + 1}
onMouseDown={onMouseDown}
className={classNames('table-generator-cell', {
'table-generator-cell-border-left': columnSpecification.borderLeft > 0,
'table-generator-cell-border-right':
columnSpecification.borderRight > 0,
'table-generator-row-border-top': row.borderTop > 0,
'table-generator-row-border-bottom': row.borderBottom > 0,
'alignment-left': columnSpecification.alignment === 'left',
'alignment-center': columnSpecification.alignment === 'center',
'alignment-right': columnSpecification.alignment === 'right',
'alignment-paragraph': columnSpecification.alignment === 'paragraph',
focused: hasFocus,
'selection-edge-top': hasFocus && selection?.bordersTop(rowIndex),
'selection-edge-bottom': hasFocus && selection?.bordersBottom(rowIndex),
'selection-edge-left': hasFocus && selection?.bordersLeft(columnIndex),
'selection-edge-right':
hasFocus && selection?.bordersRight(columnIndex),
})}
>
{body}
</td>
)
}

View file

@ -0,0 +1,96 @@
import { FC, createContext, useCallback, useContext, useState } from 'react'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
import { useTableContext } from './table-context'
type EditingContextData = {
rowIndex: number
cellIndex: number
content: string
dirty: boolean
}
const EditingContext = createContext<
| {
cellData: EditingContextData | null
updateCellData: (content: string) => void
cancelEditing: () => void
commitCellData: () => void
startEditing: (
rowIndex: number,
cellIndex: number,
content: string
) => void
}
| undefined
>(undefined)
export const useEditingContext = () => {
const context = useContext(EditingContext)
if (context === undefined) {
throw new Error(
'useEditingContext is only available inside EditingContext.Provider'
)
}
return context
}
export const EditingContextProvider: FC = ({ children }) => {
const { cellPositions } = useTableContext()
const [cellData, setCellData] = useState<EditingContextData | null>(null)
const view = useCodeMirrorViewContext()
const write = useCallback(
(rowIndex: number, cellIndex: number, content: string) => {
const { from, to } = cellPositions[rowIndex][cellIndex]
view.dispatch({
changes: { from, to, insert: content },
})
setCellData(null)
},
[view, cellPositions]
)
const commitCellData = useCallback(() => {
if (!cellData) {
return
}
const { rowIndex, cellIndex, content } = cellData
write(rowIndex, cellIndex, content)
setCellData(null)
}, [setCellData, cellData, write])
const cancelEditing = useCallback(() => {
setCellData(null)
}, [setCellData])
const startEditing = useCallback(
(rowIndex: number, cellIndex: number, content: string) => {
if (cellData?.dirty) {
// We're already editing something else
commitCellData()
}
setCellData({ cellIndex, rowIndex, content, dirty: false })
},
[setCellData, cellData, commitCellData]
)
const updateCellData = useCallback(
(content: string) => {
setCellData(prev => prev && { ...prev, content, dirty: true })
},
[setCellData]
)
return (
<EditingContext.Provider
value={{
cellData,
updateCellData,
cancelEditing,
commitCellData,
startEditing,
}}
>
{children}
</EditingContext.Provider>
)
}

View file

@ -0,0 +1,96 @@
import {
Dispatch,
FC,
SetStateAction,
createContext,
useContext,
useState,
} from 'react'
type TableCoordinate = {
row: number
cell: number
}
export class TableSelection {
// eslint-disable-next-line no-useless-constructor
constructor(public from: TableCoordinate, public to: TableCoordinate) {}
contains(point: TableCoordinate) {
const { minX, maxX, minY, maxY } = this.normalized()
return (
point.cell >= minX &&
point.cell <= maxX &&
point.row >= minY &&
point.row <= maxY
)
}
normalized() {
const minX = Math.min(this.from.cell, this.to.cell)
const maxX = Math.max(this.from.cell, this.to.cell)
const minY = Math.min(this.from.row, this.to.row)
const maxY = Math.max(this.from.row, this.to.row)
return { minX, maxX, minY, maxY }
}
bordersLeft(x: number) {
const { minX } = this.normalized()
return minX === x
}
bordersRight(x: number) {
const { maxX } = this.normalized()
return maxX === x
}
bordersTop(y: number) {
const { minY } = this.normalized()
return minY === y
}
bordersBottom(y: number) {
const { maxY } = this.normalized()
return maxY === y
}
isRowSelected(row: number, totalColumns: number) {
const { minX, maxX, minY, maxY } = this.normalized()
return row >= minY && row <= maxY && minX === 0 && maxX === totalColumns - 1
}
isColumnSelected(cell: number, totalRows: number) {
const { minX, maxX, minY, maxY } = this.normalized()
return cell >= minX && cell <= maxX && minY === 0 && maxY === totalRows - 1
}
}
const SelectionContext = createContext<
| {
selection: TableSelection | null
setSelection: Dispatch<SetStateAction<TableSelection | null>>
}
| undefined
>(undefined)
export const useSelectionContext = () => {
const context = useContext(SelectionContext)
if (context === undefined) {
throw new Error(
'useSelectionContext is only available inside SelectionContext.Provider'
)
}
return context
}
export const SelectionContextProvider: FC = ({ children }) => {
const [selection, setSelection] = useState<TableSelection | null>(null)
return (
<SelectionContext.Provider value={{ selection, setSelection }}>
{children}
</SelectionContext.Provider>
)
}

View file

@ -0,0 +1,55 @@
import { FC, createContext, useContext } from 'react'
import { Positions, TableData } from '../tabular'
import {
CellPosition,
RowPosition,
RowSeparator,
generateTable,
} from '../utils'
import { EditorView } from '@codemirror/view'
import { SyntaxNode } from '@lezer/common'
const TableContext = createContext<
| {
table: TableData
cellPositions: CellPosition[][]
specification: { from: number; to: number }
rowPositions: RowPosition[]
rowSeparators: RowSeparator[]
positions: Positions
}
| undefined
>(undefined)
export const TableProvider: FC<{
tabularNode: SyntaxNode
view: EditorView
}> = ({ tabularNode, view, children }) => {
const tableData = generateTable(tabularNode, view.state)
// TODO: Validate better that the table matches the column definition
for (const row of tableData.table.rows) {
if (row.cells.length !== tableData.table.columns.length) {
throw new Error("Table doesn't match column definition")
}
}
const positions: Positions = {
cells: tableData.cellPositions,
columnDeclarations: tableData.specification,
rowPositions: tableData.rowPositions,
}
return (
<TableContext.Provider value={{ ...tableData, positions }}>
{children}
</TableContext.Provider>
)
}
export const useTableContext = () => {
const context = useContext(TableContext)
if (context === undefined) {
throw new Error('useTableContext must be used within a TableProvider')
}
return context
}

View file

@ -0,0 +1,26 @@
import { FC } from 'react'
import { ColumnDefinition, RowData } from './tabular'
import { Cell } from './cell'
import { RowSelector } from './selectors'
export const Row: FC<{
rowIndex: number
row: RowData
columnSpecifications: ColumnDefinition[]
}> = ({ columnSpecifications, row, rowIndex }) => {
return (
<tr>
<RowSelector index={rowIndex} columns={row.cells.length} />
{row.cells.map((cell, cellIndex) => (
<Cell
key={cellIndex}
cellData={cell}
rowIndex={rowIndex}
row={row}
columnIndex={cellIndex}
columnSpecification={columnSpecifications[cellIndex]}
/>
))}
</tr>
)
}

View file

@ -0,0 +1,62 @@
import { useCallback } from 'react'
import {
TableSelection,
useSelectionContext,
} from './contexts/selection-context'
import classNames from 'classnames'
export const ColumnSelector = ({
index,
rows,
}: {
index: number
rows: number
}) => {
const { selection, setSelection } = useSelectionContext()
const onColumnSelect = useCallback(() => {
setSelection(
new TableSelection(
{ row: 0, cell: index },
{ row: rows - 1, cell: index }
)
)
}, [rows, index, setSelection])
const fullySelected = selection?.isColumnSelected(index, rows)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td
onMouseDown={onColumnSelect}
className={classNames('table-generator-selector-cell column-selector', {
'fully-selected': fullySelected,
})}
/>
)
}
export const RowSelector = ({
index,
columns,
}: {
index: number
columns: number
}) => {
const { selection, setSelection } = useSelectionContext()
const onSelect = useCallback(() => {
setSelection(
new TableSelection(
{ row: index, cell: 0 },
{ row: index, cell: columns - 1 }
)
)
}, [index, setSelection, columns])
const fullySelected = selection?.isRowSelected(index, columns)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td
onMouseDown={onSelect}
className={classNames('table-generator-selector-cell row-selector', {
'fully-selected': fullySelected,
})}
/>
)
}

View file

@ -0,0 +1,73 @@
import { FC, KeyboardEventHandler, useCallback } from 'react'
import { Row } from './row'
import { ColumnSelector } from './selectors'
import { useSelectionContext } from './contexts/selection-context'
import { useEditingContext } from './contexts/editing-context'
import { useTableContext } from './contexts/table-context'
export const Table: FC = () => {
const { selection, setSelection } = useSelectionContext()
const { cellData, cancelEditing, startEditing } = useEditingContext()
const { table: tableData } = useTableContext()
const onKeyDown: KeyboardEventHandler = useCallback(
event => {
if (event.code === 'Enter') {
event.preventDefault()
event.stopPropagation()
if (!selection) {
return
}
const cell =
tableData.rows[selection.from.row].cells[selection.from.cell]
startEditing(selection.from.row, selection.from.cell, cell.content)
} else if (event.code === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (!cellData) {
setSelection(null)
} else {
cancelEditing()
}
}
},
[
selection,
tableData.rows,
cellData,
setSelection,
cancelEditing,
startEditing,
]
)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<table
className="table-generator-table"
onKeyDown={onKeyDown}
tabIndex={-1}
>
<thead>
<tr>
<td />
{tableData.columns.map((_, columnIndex) => (
<ColumnSelector
index={columnIndex}
key={columnIndex}
rows={tableData.rows.length}
/>
))}
</tr>
</thead>
<tbody>
{tableData.rows.map((row, rowIndex) => (
<Row
row={row}
rowIndex={rowIndex}
key={rowIndex}
columnSpecifications={tableData.columns}
/>
))}
</tbody>
</table>
)
}

View file

@ -0,0 +1,88 @@
import { SyntaxNode } from '@lezer/common'
import { FC } from 'react'
import { CellPosition, RowPosition } from './utils'
import { Toolbar } from './toolbar/toolbar'
import { Table } from './table'
import { SelectionContextProvider } from './contexts/selection-context'
import { EditingContextProvider } from './contexts/editing-context'
import { EditorView } from '@codemirror/view'
import { ErrorBoundary } from 'react-error-boundary'
import { Alert, Button } from 'react-bootstrap'
import { EditorSelection } from '@codemirror/state'
import { CodeMirrorViewContextProvider } from '../codemirror-editor'
import { TableProvider } from './contexts/table-context'
export type CellData = {
// TODO: Add columnSpan
content: string
}
export type RowData = {
cells: CellData[]
borderTop: number
borderBottom: number
}
export type ColumnDefinition = {
alignment: 'left' | 'center' | 'right' | 'paragraph'
borderLeft: number
borderRight: number
}
export type TableData = {
rows: RowData[]
columns: ColumnDefinition[]
}
export type Positions = {
cells: CellPosition[][]
columnDeclarations: { from: number; to: number }
rowPositions: RowPosition[]
}
export const FallbackComponent: FC<{ view: EditorView; node: SyntaxNode }> = ({
view,
node,
}) => {
return (
<Alert bsStyle="warning" style={{ marginBottom: 0 }}>
Table rendering error{' '}
<Button
onClick={() =>
view.dispatch({
selection: EditorSelection.cursor(node.from),
})
}
>
View code
</Button>
</Alert>
)
}
export const Tabular: FC<{
tabularNode: SyntaxNode
view: EditorView
}> = ({ tabularNode, view }) => {
return (
<ErrorBoundary
fallbackRender={() => (
<FallbackComponent view={view} node={tabularNode} />
)}
onError={(error, componentStack) => console.error(error, componentStack)}
>
<CodeMirrorViewContextProvider value={view}>
<TableProvider tabularNode={tabularNode} view={view}>
<SelectionContextProvider>
<EditingContextProvider>
<div className="table-generator">
<Toolbar />
<Table />
</div>
</EditingContextProvider>
</SelectionContextProvider>
</TableProvider>
</CodeMirrorViewContextProvider>
</ErrorBoundary>
)
}

View file

@ -0,0 +1,98 @@
import { EditorView } from '@codemirror/view'
import { Positions } from '../tabular'
import { ChangeSpec } from '@codemirror/state'
import { RowSeparator } from '../utils'
/* eslint-disable no-unused-vars */
export enum BorderTheme {
NO_BORDERS = 0,
FULLY_BORDERED = 1,
}
/* eslint-enable no-unused-vars */
export const setBorders = (
view: EditorView,
theme: BorderTheme,
positions: Positions,
rowSeparators: RowSeparator[]
) => {
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
)
if (theme === BorderTheme.NO_BORDERS) {
const removeColumnBorders = view.state.changes({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: specification.replace(/\|/g, ''),
})
const removeHlines: ChangeSpec[] = []
for (const row of positions.rowPositions) {
for (const hline of row.hlines) {
removeHlines.push({
from: hline.from,
to: hline.to,
insert: '',
})
}
}
view.dispatch({
changes: [removeColumnBorders, ...removeHlines],
})
} else if (theme === BorderTheme.FULLY_BORDERED) {
let newSpec = '|'
let consumingBrackets = 0
for (const char of specification) {
if (char === '{') {
consumingBrackets++
}
if (char === '}' && consumingBrackets > 0) {
consumingBrackets--
}
if (consumingBrackets) {
newSpec += char
}
if (char === '|') {
continue
}
newSpec += char + '|'
}
const insertColumns = view.state.changes({
from: positions.columnDeclarations.from,
to: positions.columnDeclarations.to,
insert: newSpec,
})
const insertHlines: ChangeSpec[] = []
for (const row of positions.rowPositions) {
if (row.hlines.length === 0) {
insertHlines.push(
view.state.changes({
from: row.from,
to: row.from,
insert: ' \\hline ',
})
)
}
}
const lastRow = positions.rowPositions[positions.rowPositions.length - 1]
if (lastRow.hlines.length < 2) {
let toInsert = ' \\hline'
if (rowSeparators.length < positions.rowPositions.length) {
// We need a trailing \\
toInsert = ` \\\\${toInsert}`
}
insertHlines.push(
view.state.changes({
from: lastRow.to,
to: lastRow.to,
insert: toInsert,
})
)
}
view.dispatch({
changes: [insertColumns, ...insertHlines],
})
}
}

View file

@ -0,0 +1,88 @@
import { FC, memo, useRef } from 'react'
import useDropdown from '../../../../../shared/hooks/use-dropdown'
import { Button, ListGroup, Overlay, Popover } from 'react-bootstrap'
import Tooltip from '../../../../../shared/components/tooltip'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
export const ToolbarButtonMenu: FC<{
id: string
label: string
icon: string
disabled?: boolean
}> = memo(function ButtonMenu({ icon, id, label, children, disabled }) {
const target = useRef<any>(null)
const { open, onToggle, ref } = useDropdown()
const view = useCodeMirrorViewContext()
const button = (
<Button
type="button"
className="table-generator-toolbar-button table-generator-toolbar-button-menu"
aria-label={label}
bsStyle={null}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
}}
onClick={event => {
onToggle(!open)
}}
disabled={disabled}
ref={target}
>
<MaterialIcon type={icon} />
<MaterialIcon type="expand_more" />
</Button>
)
const overlay = (
<Overlay
show={open}
target={target.current}
placement="bottom"
container={view.dom}
containerPadding={0}
animation
onHide={() => onToggle(false)}
>
<Popover
id={`${id}-menu`}
ref={ref}
className="table-generator-button-menu-popover"
>
<ListGroup
role="menu"
onClick={() => {
onToggle(false)
}}
>
{children}
</ListGroup>
</Popover>
</Overlay>
)
if (!label) {
return (
<>
{button}
{overlay}
</>
)
}
return (
<>
<Tooltip
hidden={open}
id={id}
description={<div>{label}</div>}
overlayProps={{ placement: 'bottom' }}
>
{button}
</Tooltip>
{overlay}
</>
)
})

View file

@ -0,0 +1,83 @@
import { EditorView } from '@codemirror/view'
import classNames from 'classnames'
import { memo, useCallback } from 'react'
import { Button } from 'react-bootstrap'
import Tooltip from '../../../../../shared/components/tooltip'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
export const ToolbarButton = memo<{
id: string
className?: string
label: string
command?: (view: EditorView) => void
active?: boolean
disabled?: boolean
icon: string
hidden?: boolean
shortcut?: string
}>(function ToolbarButton({
id,
className,
label,
command,
active = false,
disabled,
icon,
hidden = false,
shortcut,
}) {
const view = useCodeMirrorViewContext()
const handleMouseDown = useCallback(event => {
event.preventDefault()
}, [])
const handleClick = useCallback(
event => {
if (command) {
event.preventDefault()
command(view)
view.focus()
}
},
[command, view]
)
const button = (
<Button
className={classNames('table-generator-toolbar-button', className, {
hidden,
})}
aria-label={label}
onMouseDown={handleMouseDown}
onClick={!disabled ? handleClick : undefined}
bsStyle={null}
active={active}
disabled={disabled}
type="button"
>
<MaterialIcon type={icon} />
</Button>
)
if (!label) {
return button
}
const description = (
<>
<div>{label}</div>
{shortcut && <div>{shortcut}</div>}
</>
)
return (
<Tooltip
id={id}
description={description}
overlayProps={{ placement: 'bottom' }}
>
{button}
</Tooltip>
)
})

View file

@ -0,0 +1,93 @@
import { FC, useRef } from 'react'
import useDropdown from '../../../../../shared/hooks/use-dropdown'
import { Overlay, Popover } from 'react-bootstrap'
import MaterialIcon from '../../../../../shared/components/material-icon'
import Tooltip from '../../../../../shared/components/tooltip'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
export const ToolbarDropdown: FC<{
id: string
label?: string
btnClassName?: string
icon?: string
tooltip?: string
disabled?: boolean
}> = ({
id,
label,
children,
btnClassName = 'table-generator-toolbar-dropdown-toggle',
icon = 'expand_more',
tooltip,
disabled,
}) => {
const { open, onToggle } = useDropdown()
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
const view = useCodeMirrorViewContext()
const button = (
<button
ref={toggleButtonRef}
type="button"
id={id}
aria-haspopup="true"
className={btnClassName}
onMouseDown={event => event.preventDefault()}
onClick={() => onToggle(!open)}
aria-label={tooltip}
disabled={disabled}
aria-disabled={disabled}
>
{label && <span>{label}</span>}
<MaterialIcon type={icon} />
</button>
)
const overlay = open && (
<Overlay
show
onHide={() => onToggle(false)}
animation={false}
container={view.dom}
containerPadding={0}
placement="bottom"
rootClose
target={toggleButtonRef.current ?? undefined}
>
<Popover
id={`${id}-popover`}
className="table-generator-toolbar-dropdown-popover"
>
<div
className="table-generator-toolbar-dropdown-menu"
id={`${id}-menu`}
role="menu"
aria-labelledby={id}
>
{children}
</div>
</Popover>
</Overlay>
)
if (!tooltip) {
return (
<>
{button}
{overlay}
</>
)
}
return (
<>
<Tooltip
id={`${id}-tooltip`}
description={tooltip}
overlayProps={{ placement: 'bottom' }}
>
{button}
</Tooltip>
{overlay}
</>
)
}

View file

@ -0,0 +1,181 @@
import { memo } from 'react'
import { useSelectionContext } from '../contexts/selection-context'
import { ToolbarButton } from './toolbar-button'
import { ToolbarButtonMenu } from './toolbar-button-menu'
import { ToolbarDropdown } from './toolbar-dropdown'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { BorderTheme, setBorders } from './commands'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
import { useTableContext } from '../contexts/table-context'
export const Toolbar = memo(function Toolbar() {
const { selection } = useSelectionContext()
const view = useCodeMirrorViewContext()
const { positions, rowSeparators } = useTableContext()
if (!selection) {
return null
}
return (
<div className="table-generator-floating-toolbar">
<ToolbarDropdown
id="table-generator-caption-dropdown"
label="Caption below"
disabled
>
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
>
No caption
</button>
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
>
Caption above
</button>
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
>
Caption below
</button>
</ToolbarDropdown>
<ToolbarDropdown
id="table-generator-borders-dropdown"
label="All borders"
>
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
onClick={() => {
setBorders(
view,
BorderTheme.FULLY_BORDERED,
positions,
rowSeparators
)
}}
>
<MaterialIcon type="border_all" />
<span className="table-generator-button-label">All borders</span>
</button>
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
onClick={() => {
setBorders(view, BorderTheme.NO_BORDERS, positions, rowSeparators)
}}
>
<MaterialIcon type="border_clear" />
<span className="table-generator-button-label">No borders</span>
</button>
<div className="table-generator-border-options-coming-soon">
<div className="info-icon">
<MaterialIcon type="info" />
</div>
More options for border settings coming soon.
</div>
</ToolbarDropdown>
<div className="table-generator-button-group">
<ToolbarButtonMenu
label="Alignment"
icon="format_align_left"
id="table-generator-align-dropdown"
disabled
>
<ToolbarButton
icon="format_align_left"
id="table-generator-align-left"
label="Left"
/>
<ToolbarButton
icon="format_align_center"
id="table-generator-align-center"
label="Center"
/>
<ToolbarButton
icon="format_align_right"
id="table-generator-align-right"
label="Right"
/>
<ToolbarButton
icon="format_align_justify"
id="table-generator-align-justify"
label="Justify"
/>
</ToolbarButtonMenu>
<ToolbarButton
icon="cell_merge"
id="table-generator-merge-cells"
label="Merge cells"
disabled
/>
<ToolbarButton
icon="delete"
id="table-generator-remove-column-row"
label="Remove row or column"
disabled
/>
<ToolbarDropdown
id="table-generator-add-dropdown"
btnClassName="table-generator-toolbar-button"
icon="add"
tooltip="Insert"
disabled
>
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
>
<span className="table-generator-button-label">
Insert column left
</span>
</button>
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
>
<span className="table-generator-button-label">
Insert column right
</span>
</button>
<hr />
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
>
<span className="table-generator-button-label">
Insert row above
</span>
</button>
<button
className="ol-cm-toolbar-menu-item"
role="menuitem"
type="button"
>
<span className="table-generator-button-label">
Insert row below
</span>
</button>
</ToolbarDropdown>
</div>
<div className="table-generator-button-group">
<ToolbarButton
icon="delete_forever"
id="table-generator-remove-table"
label="Remove table"
disabled
/>
</div>
</div>
)
})

View file

@ -0,0 +1,262 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
import { ColumnDefinition, TableData } from './tabular'
const ALIGNMENT_CHARACTERS = ['c', 'l', 'r', 'p']
export type CellPosition = { from: number; to: number }
export type RowPosition = {
from: number
to: number
hlines: { from: number; to: number }[]
}
function parseColumnSpecifications(specification: string): ColumnDefinition[] {
const columns: ColumnDefinition[] = []
let currentAlignment: ColumnDefinition['alignment'] | undefined
let currentBorderLeft = 0
let currentBorderRight = 0
function maybeCommit() {
if (currentAlignment !== undefined) {
columns.push({
alignment: currentAlignment,
borderLeft: currentBorderLeft,
borderRight: currentBorderRight,
})
currentAlignment = undefined
currentBorderLeft = 0
currentBorderRight = 0
}
}
for (let i = 0; i < specification.length; i++) {
if (ALIGNMENT_CHARACTERS.includes(specification.charAt(i))) {
maybeCommit()
}
const hasAlignment = currentAlignment !== undefined
const char = specification.charAt(i)
switch (char) {
case '|': {
if (hasAlignment) {
currentBorderRight++
} else {
currentBorderLeft++
}
break
}
case 'c':
currentAlignment = 'center'
break
case 'l':
currentAlignment = 'left'
break
case 'r':
currentAlignment = 'right'
break
case 'p': {
currentAlignment = 'paragraph'
// TODO: Parse these details
while (i < specification.length && specification.charAt(i) !== '}') {
i++
}
break
}
}
}
maybeCommit()
return columns
}
const isRowSeparator = (node: SyntaxNode, state: EditorState) =>
node.type.is('Command') && state.sliceDoc(node.from, node.to) === '\\\\'
const isHLine = (node: SyntaxNode) =>
node.type.is('Command') &&
Boolean(node.getChild('KnownCommand')?.getChild('HLine'))
type Position = {
from: number
to: number
}
type HLineData = {
position: Position
atBottom: boolean
}
type ParsedCell = {
content: string
position: Position
}
type CellSeparator = Position
export type RowSeparator = Position
type ParsedRow = {
position: Position
cells: ParsedCell[]
cellSeparators: CellSeparator[]
hlines: HLineData[]
}
type ParsedTableBody = {
rows: ParsedRow[]
rowSeparators: RowSeparator[]
}
function parseTabularBody(
node: SyntaxNode,
state: EditorState
): ParsedTableBody {
const body: ParsedTableBody = {
rows: [
{
cells: [],
hlines: [],
cellSeparators: [],
position: { from: node.from, to: node.from },
},
],
rowSeparators: [],
}
getLastRow().cells.push({
content: '',
position: { from: node.from, to: node.from },
})
function getLastRow() {
return body.rows[body.rows.length - 1]
}
function getLastCell(): ParsedCell | undefined {
return getLastRow().cells[getLastRow().cells.length - 1]
}
for (
let currentChild: SyntaxNode | null = node;
currentChild;
currentChild = currentChild.nextSibling
) {
if (isRowSeparator(currentChild, state)) {
const lastRow = getLastRow()
body.rows.push({
cells: [],
hlines: [],
cellSeparators: [],
position: { from: currentChild.to, to: currentChild.to },
})
lastRow.position.to = currentChild.to
body.rowSeparators.push({ from: currentChild.from, to: currentChild.to })
getLastRow().cells.push({
content: '',
position: { from: currentChild.to, to: currentChild.to },
})
continue
} else if (currentChild.type.is('Ampersand')) {
// Add another cell
getLastRow().cells.push({
content: '',
position: { from: currentChild.to, to: currentChild.to },
})
getLastRow().cellSeparators.push({
from: currentChild.from,
to: currentChild.to,
})
} else if (
currentChild.type.is('NewLine') ||
currentChild.type.is('Whitespace')
) {
const lastCell = getLastCell()
if (lastCell) {
if (lastCell.content.trim() === '') {
lastCell.position.from = currentChild.to
lastCell.position.to = currentChild.to
} else {
lastCell.content += state.sliceDoc(currentChild.from, currentChild.to)
lastCell.position.to = currentChild.to
}
}
// Try to preserve whitespace by skipping past it when locating cells
} else if (isHLine(currentChild)) {
const lastCell = getLastCell()
if (lastCell?.content) {
throw new Error('\\hline must be at the start of a row')
}
const lastRow = getLastRow()
lastRow.hlines.push({
position: { from: currentChild.from, to: currentChild.to },
// They will always be at the top, we patch the bottom border later.
atBottom: false,
})
} else {
// Add to the last cell
if (!getLastCell()) {
getLastRow().cells.push({
content: '',
position: { from: currentChild.from, to: currentChild.from },
})
}
const lastCell = getLastCell()!
lastCell.content += state.sliceDoc(currentChild.from, currentChild.to)
lastCell.position.to = currentChild.to
}
getLastRow().position.to = currentChild.to
}
const lastRow = getLastRow()
if (lastRow.cells.length === 1 && lastRow.cells[0].content.trim() === '') {
// Remove the last row if it's empty, but move hlines up to previous row
const hlines = lastRow.hlines.map(hline => ({ ...hline, atBottom: true }))
body.rows.pop()
getLastRow().hlines.push(...hlines)
}
return body
}
export function generateTable(
node: SyntaxNode,
state: EditorState
): {
table: TableData
cellPositions: CellPosition[][]
specification: { from: number; to: number }
rowPositions: RowPosition[]
rowSeparators: RowSeparator[]
} {
const specification = node
.getChild('BeginEnv')
?.getChild('TextArgument')
?.getChild('LongArg')
if (!specification) {
throw new Error('Missing column specification')
}
const columns = parseColumnSpecifications(
state.sliceDoc(specification.from, specification.to)
)
const body = node.getChild('Content')?.getChild('TabularContent')?.firstChild
if (!body) {
throw new Error('Missing table body')
}
const tableData = parseTabularBody(body, state)
const cellPositions = tableData.rows.map(row =>
row.cells.map(cell => cell.position)
)
const rowPositions = tableData.rows.map(row => ({
...row.position,
hlines: row.hlines.map(hline => hline.position),
}))
const rows = tableData.rows.map(row => ({
cells: row.cells.map(cell => ({
content: cell.content,
})),
borderTop: row.hlines.filter(hline => !hline.atBottom).length,
borderBottom: row.hlines.filter(hline => hline.atBottom).length,
}))
const table = {
rows,
columns,
}
return {
table,
cellPositions,
specification,
rowPositions,
rowSeparators: tableData.rowSeparators,
}
}

View file

@ -0,0 +1,121 @@
import { FC, useCallback, useRef, useState } from 'react'
import * as commands from '../../extensions/toolbar/commands'
import { useTranslation } from 'react-i18next'
import useDropdown from '../../../../shared/hooks/use-dropdown'
import { Button, Overlay, Popover } from 'react-bootstrap'
import { useCodeMirrorViewContext } from '../codemirror-editor'
import Tooltip from '../../../../shared/components/tooltip'
import MaterialIcon from '../../../../shared/components/material-icon'
import classNames from 'classnames'
export const TableInserterDropdown: FC = () => {
const { t } = useTranslation()
const { open, onToggle, ref } = useDropdown()
const view = useCodeMirrorViewContext()
const target = useRef<any>(null)
const onSizeSelected = useCallback(
(sizeX: number, sizeY: number) => {
onToggle(false)
commands.insertTable(view, sizeX, sizeY)
view.focus()
},
[view, onToggle]
)
return (
<>
<Tooltip
hidden={open}
id="toolbar-table"
description={<div>{t('toolbar_insert_table')}</div>}
overlayProps={{ placement: 'bottom' }}
>
<Button
type="button"
className="ol-cm-toolbar-button"
aria-label={t('toolbar_insert_table')}
bsStyle={null}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
onToggle(!open)
}}
ref={target}
>
<MaterialIcon type="table" />
</Button>
</Tooltip>
<Overlay
show={open}
target={target.current}
placement="bottom"
container={view.dom}
containerPadding={0}
animation
onHide={() => onToggle(false)}
>
<Popover
id="toolbar-table-menu"
ref={ref}
className="ol-cm-toolbar-button-menu-popover ol-cm-toolbar-table-grid-popover"
>
<SizeGrid sizeX={10} sizeY={10} onSizeSelected={onSizeSelected} />
</Popover>
</Overlay>
</>
)
}
const range = (start: number, end: number) =>
Array.from({ length: end - start + 1 }, (v, k) => k + start)
const SizeGrid: FC<{
sizeX: number
sizeY: number
onSizeSelected: (sizeX: number, sizeY: number) => void
}> = ({ sizeX, sizeY, onSizeSelected }) => {
const [currentSize, setCurrentSize] = useState<{
sizeX: number
sizeY: number
}>({ sizeX: 0, sizeY: 0 })
const { t } = useTranslation()
let label = t('toolbar_table_insert_table_lowercase')
if (currentSize.sizeX > 0 && currentSize.sizeY > 0) {
label = t('toolbar_table_insert_size_table', {
size: `${currentSize.sizeY}×${currentSize.sizeX}`,
})
}
return (
<>
<div className="ol-cm-toolbar-table-size-label">{label}</div>
<table
className="ol-cm-toolbar-table-grid"
onMouseLeave={() => {
setCurrentSize({ sizeX: 0, sizeY: 0 })
}}
>
<tbody>
{range(1, sizeY).map(y => (
<tr key={y}>
{range(1, sizeX).map(x => (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td
className={classNames('ol-cm-toolbar-table-cell', {
active: currentSize.sizeX >= x && currentSize.sizeY >= y,
})}
key={x}
onMouseEnter={() => {
setCurrentSize({ sizeX: x, sizeY: y })
}}
onMouseDown={() => onSizeSelected(x, y)}
/>
))}
</tr>
))}
</tbody>
</table>
</>
)
}

View file

@ -14,6 +14,7 @@ import getMeta from '../../../../utils/meta'
import { InsertFigureDropdown } from './insert-figure-dropdown'
import { useTranslation } from 'react-i18next'
import { MathDropdown } from './math-dropdown'
import { TableInserterDropdown } from './table-inserter-dropdown'
const isMac = /Mac/.test(window.navigator?.platform)
@ -52,6 +53,7 @@ export const ToolbarItems: FC<{
)
const showFigureModal = splitTestVariants['figure-modal'] === 'enabled'
const showTableGenerator = splitTestVariants['table-generator'] === 'enabled'
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
const showGroup = (group: string) => !overflowed || overflowed.has(group)
@ -165,13 +167,7 @@ export const ToolbarItems: FC<{
icon="picture-o"
/>
)}
<ToolbarButton
id="toolbar-table"
label={t('toolbar_insert_table')}
command={commands.insertTable}
icon="table"
hidden
/>
{showTableGenerator && <TableInserterDropdown />}
</div>
)}
{showGroup('group-list') && (

View file

@ -1,5 +1,5 @@
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
import { Command } from '@codemirror/view'
import { Command, EditorView } from '@codemirror/view'
import {
closeSearchPanel,
openSearchPanel,
@ -53,10 +53,16 @@ export const insertFigure: Command = view => {
return true
}
export const insertTable: Command = view => {
export const insertTable = (view: EditorView, sizeX: number, sizeY: number) => {
const { state, dispatch } = view
const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
const template = `\n${snippets.table}\n${suffix}`
const template = `\n\\begin{table}{#{}}
\t\\centering
\\begin{tabular}{${'c'.repeat(sizeX)}}
${('\t\t' + '#{} & #{}'.repeat(sizeX - 1) + '\\\\\n').repeat(
sizeY
)}\\end{tabular}
\\end{table}${suffix}`
snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos)
return true
}

View file

@ -255,5 +255,30 @@ export const toolbarPanel = () => [
},
},
},
'.ol-cm-toolbar-table-grid': {
borderCollapse: 'separate',
tableLayout: 'fixed',
fontSize: '6px',
cursor: 'pointer',
'& td': {
outline: '1px solid #E7E9EE',
outlineOffset: '-2px',
width: '16px',
height: '16px',
'&.active': {
outlineColor: '#3265B2',
background: '#F1F4F9',
},
},
},
'.ol-cm-toolbar-table-size-label': {
maxWidth: '160px',
fontFamily: 'Lato, sans-serif',
fontSize: '12px',
},
'.ol-cm-toolbar-table-grid-popover': {
padding: '8px',
},
}),
]

View file

@ -58,6 +58,7 @@ import { TildeWidget } from './visual-widgets/tilde'
import { BeginTheoremWidget } from './visual-widgets/begin-theorem'
import { parseTheoremArguments } from '../../utils/tree-operations/theorems'
import { IndicatorWidget } from './visual-widgets/indicator'
import { TabularWidget } from './visual-widgets/tabular'
type Options = {
fileTreeManager: {
@ -129,6 +130,8 @@ const hasClosingBrace = (node: SyntaxNode) =>
export const atomicDecorations = (options: Options) => {
const splitTestVariants = getMeta('ol-splitTestVariants', {})
const figureModalEnabled = splitTestVariants['figure-modal'] === 'enabled'
const tableGeneratorEnabled =
splitTestVariants['table-generator'] === 'enabled'
const getPreviewByPath = (path: string) =>
options.fileTreeManager.getPreviewByPath(path)
@ -300,6 +303,22 @@ export const atomicDecorations = (options: Options) => {
)
}
}
} else if (
tableGeneratorEnabled &&
nodeRef.type.is('TabularEnvironment')
) {
if (shouldDecorate(state, nodeRef)) {
decorations.push(
Decoration.replace({
widget: new TabularWidget(
nodeRef.node,
state.doc.sliceString(nodeRef.from, nodeRef.to)
),
block: true,
}).range(nodeRef.from, nodeRef.to)
)
return false
}
}
} else if (nodeRef.type.is('BeginEnv')) {
// the beginning of an environment, with an environment name argument

View file

@ -1,8 +1,23 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
import { isUnknownCommandWithName } from '../../../utils/tree-query'
import { LineBreakCtrlSym } from '../../../lezer-latex/latex.terms.mjs'
const isUnknownCommandWithName = (
node: SyntaxNode,
command: string,
getText: (from: number, to: number) => string
): boolean => {
if (!node.type.is('UnknownCommand')) {
return false
}
const commandNameNode = node.getChild('CtrlSeq')
if (!commandNameNode) {
return false
}
const commandName = getText(commandNameNode.from, commandNameNode.to)
return commandName === command
}
/**
* Does a small amount of typesetting of LaTeX content into a DOM element.
* Does **not** typeset math, you **must manually** invoke MathJax after this
@ -14,8 +29,14 @@ import { LineBreakCtrlSym } from '../../../lezer-latex/latex.terms.mjs'
export function typesetNodeIntoElement(
node: SyntaxNode,
element: HTMLElement,
state: EditorState
state: EditorState | ((from: number, to: number) => string)
) {
let getText: (from: number, to: number) => string
if (typeof state === 'function') {
getText = state
} else {
getText = state!.sliceDoc.bind(state!)
}
// If we're a TextArgument node, we should skip the braces
const argument = node.getChild('LongArg')
if (argument) {
@ -34,39 +55,39 @@ export function typesetNodeIntoElement(
const childNode = childNodeRef.node
if (from < childNode.from) {
ancestor().append(
document.createTextNode(state.sliceDoc(from, childNode.from))
document.createTextNode(getText(from, childNode.from))
)
from = childNode.from
}
if (isUnknownCommandWithName(childNode, '\\textit', state)) {
if (isUnknownCommandWithName(childNode, '\\textit', getText)) {
pushAncestor(document.createElement('i'))
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
} else if (isUnknownCommandWithName(childNode, '\\textbf', state)) {
} else if (isUnknownCommandWithName(childNode, '\\textbf', getText)) {
pushAncestor(document.createElement('b'))
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
} else if (isUnknownCommandWithName(childNode, '\\emph', state)) {
} else if (isUnknownCommandWithName(childNode, '\\emph', getText)) {
pushAncestor(document.createElement('em'))
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
} else if (isUnknownCommandWithName(childNode, '\\texttt', state)) {
} else if (isUnknownCommandWithName(childNode, '\\texttt', getText)) {
const spanElement = document.createElement('span')
spanElement.classList.add('ol-cm-command-texttt')
pushAncestor(spanElement)
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
} else if (isUnknownCommandWithName(childNode, '\\and', state)) {
} else if (isUnknownCommandWithName(childNode, '\\and', getText)) {
const spanElement = document.createElement('span')
spanElement.classList.add('ol-cm-command-and')
pushAncestor(spanElement)
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
} else if (
isUnknownCommandWithName(childNode, '\\corref', state) ||
isUnknownCommandWithName(childNode, '\\fnref', state) ||
isUnknownCommandWithName(childNode, '\\thanks', state)
isUnknownCommandWithName(childNode, '\\corref', getText) ||
isUnknownCommandWithName(childNode, '\\fnref', getText) ||
isUnknownCommandWithName(childNode, '\\thanks', getText)
) {
// ignoring these commands
from = childNode.to
@ -79,11 +100,11 @@ export function typesetNodeIntoElement(
function leave(childNodeRef) {
const childNode = childNodeRef.node
if (
isUnknownCommandWithName(childNode, '\\and', state) ||
isUnknownCommandWithName(childNode, '\\textit', state) ||
isUnknownCommandWithName(childNode, '\\textbf', state) ||
isUnknownCommandWithName(childNode, '\\emph', state) ||
isUnknownCommandWithName(childNode, '\\texttt', state)
isUnknownCommandWithName(childNode, '\\and', getText) ||
isUnknownCommandWithName(childNode, '\\textit', getText) ||
isUnknownCommandWithName(childNode, '\\textbf', getText) ||
isUnknownCommandWithName(childNode, '\\emph', getText) ||
isUnknownCommandWithName(childNode, '\\texttt', getText)
) {
const typeSetElement = popAncestor()
ancestor().appendChild(typeSetElement)
@ -96,7 +117,7 @@ export function typesetNodeIntoElement(
}
)
if (from < node.to) {
ancestor().append(document.createTextNode(state.sliceDoc(from, node.to)))
ancestor().append(document.createTextNode(getText(from, node.to)))
}
return element

View file

@ -0,0 +1,36 @@
import { EditorView, WidgetType } from '@codemirror/view'
import { SyntaxNode } from '@lezer/common'
import * as ReactDOM from 'react-dom'
import { Tabular } from '../../../components/table-generator/tabular'
export class TabularWidget extends WidgetType {
private element: HTMLElement | undefined
constructor(private node: SyntaxNode, private content: string) {
super()
}
toDOM(view: EditorView) {
this.element = document.createElement('div')
this.element.classList.add('ol-cm-tabular')
this.element.style.backgroundColor = 'rgba(125, 125, 125, 0.05)'
ReactDOM.render(
<Tabular view={view} tabularNode={this.node} />,
this.element
)
return this.element
}
eq(widget: TabularWidget): boolean {
return (
this.node.from === widget.node.from && this.content === widget.content
)
}
destroy() {
console.debug('destroying tabular widget')
if (this.element) {
ReactDOM.unmountComponentAtNode(this.element)
}
}
}

View file

@ -90,6 +90,7 @@
MaketitleCtrlSeq,
TextColorCtrlSeq,
ColorBoxCtrlSeq
HLineCtrlSeq
}
@external specialize {EnvName} specializeEnvName from "./tokens.mjs" {
@ -332,6 +333,9 @@ KnownCommand {
} |
Maketitle {
MaketitleCtrlSeq optionalWhitespace?
} |
HLine {
HLineCtrlSeq optionalWhitespace?
}
}

View file

@ -71,6 +71,7 @@ import {
MaketitleCtrlSeq,
TextColorCtrlSeq,
ColorBoxCtrlSeq,
HLineCtrlSeq,
} from './latex.terms.mjs'
function nameChar(ch) {
@ -618,6 +619,7 @@ const otherKnowncommands = {
'\\maketitle': MaketitleCtrlSeq,
'\\textcolor': TextColorCtrlSeq,
'\\colorbox': ColorBoxCtrlSeq,
'\\hline': HLineCtrlSeq,
}
// specializer for control sequences
// return new tokens for specific control sequences

View file

@ -97,7 +97,10 @@ export const Visual = (args: any, { globals: { theme } }: any) => {
useMeta({
'ol-showSymbolPalette': true,
'ol-mathJax3Path': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js',
'ol-splitTestVariants': { 'figure-modal': 'enabled' },
'ol-splitTestVariants': {
'figure-modal': 'enabled',
'table-generator': 'enabled',
},
})
return <SourceEditor />

View file

@ -20,6 +20,7 @@
@import './editor/dictionary.less';
@import './editor/compile-button.less';
@import './editor/figure-modal.less';
@import './editor/table-generator.less';
@ui-layout-toggler-def-height: 50px;
@ui-resizer-size: 7px;

View file

@ -0,0 +1,368 @@
@table-generator-active-border-color: #666;
@table-generator-inactive-border-color: #dedede;
@table-generator-focus-border-width: 2px;
@table-generator-focus-negative-border-width: -2px;
@table-generator-focus-border-color: #97b6e5;
@table-generator-selector-hover-color: #3265b2;
@table-generator-selector-handle-buffer: 12px;
@table-generator-toolbar-shadow-color: #1e253029;
@table-generator-toolbar-background: #fff;
@table-generator-inactive-border-width: 1px;
@table-generator-active-border-width: 1px;
.table-generator-cell {
border: @table-generator-inactive-border-width dashed
@table-generator-inactive-border-color;
min-width: 40px;
height: 30px;
}
.table-generator-cell-border-left {
border-left-style: solid;
border-left-color: @table-generator-active-border-color;
border-left-width: @table-generator-active-border-width;
}
.table-generator-cell-border-right {
border-right-style: solid;
border-right-color: @table-generator-active-border-color;
border-right-width: @table-generator-active-border-width;
}
.table-generator-row-border-top {
border-top-style: solid;
border-top-color: @table-generator-active-border-color;
border-top-width: @table-generator-active-border-width;
}
.table-generator-row-border-bottom {
border-bottom-style: solid;
border-bottom-color: @table-generator-active-border-color;
border-bottom-width: @table-generator-active-border-width;
}
.table-generator-cell.focused {
background-color: @blue-10;
}
.table-generator-cell {
&.selection-edge-top {
--shadow-top: 0 @table-generator-focus-negative-border-width 0
@table-generator-focus-border-color;
}
&.selection-edge-bottom {
--shadow-bottom: 0 @table-generator-focus-border-width 0
@table-generator-focus-border-color;
}
&.selection-edge-left {
--shadow-left: @table-generator-focus-negative-border-width 0 0
@table-generator-focus-border-color;
}
&.selection-edge-right {
--shadow-right: @table-generator-focus-border-width 0 0
@table-generator-focus-border-color;
}
box-shadow: var(--shadow-top, 0 0 0 transparent),
var(--shadow-bottom, 0 0 0 transparent),
var(--shadow-left, 0 0 0 transparent),
var(--shadow-right, 0 0 0 transparent);
}
.table-generator-table {
table-layout: fixed;
max-width: 80%;
margin: 0 auto;
cursor: default;
& td {
padding: 0 0.25em;
max-width: 200px;
&.alignment-left {
text-align: left;
}
&.alignment-right {
text-align: right;
}
&.alignment-center {
text-align: center;
}
&.alignment-paragraph {
text-align: justify;
}
}
.table-generator-selector-cell {
padding: 0;
border: none !important;
position: relative;
cursor: pointer;
&.row-selector {
width: @table-generator-selector-handle-buffer + 8px;
&::after {
width: 4px;
height: calc(100% - 8px);
}
}
&.column-selector {
height: @table-generator-selector-handle-buffer + 8px;
&::after {
width: calc(100% - 8px);
height: 4px;
}
}
&::after {
content: '';
display: block;
position: absolute;
bottom: 4px;
right: 4px;
width: calc(100% - 8px);
height: calc(100% - 8px);
background-color: @neutral-30;
border-radius: 4px;
}
&:hover::after {
background-color: @neutral-40;
}
&.fully-selected::after {
background-color: @table-generator-selector-hover-color;
}
}
}
.table-generator {
position: relative;
}
.table-generator-floating-toolbar {
position: absolute;
top: -36px;
left: 0;
right: 0;
margin: 0 auto;
z-index: 1;
border-radius: 4px;
width: max-content;
background-color: @table-generator-toolbar-background;
box-shadow: 0px 2px 4px 0px @table-generator-toolbar-shadow-color;
padding: 4px;
height: 40px;
display: flex;
}
.table-generator-toolbar-button {
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
background-color: transparent;
border: none;
border-radius: 4px;
line-height: 1;
overflow: hidden;
color: @neutral-70;
text-align: center;
padding: 4px;
&:not(:first-child) {
margin-left: 4px;
}
&:not(:last-child) {
margin-right: 4px;
}
& > span {
font-size: 24px;
}
&:hover,
&:focus {
background-color: rgba(47, 58, 76, 0.08);
}
&:active,
&.active {
background-color: rgba(47, 58, 76, 0.16);
}
&:hover,
&:focus,
&:active,
&.active {
color: inherit;
box-shadow: none;
}
&[aria-disabled='true'] {
&:hover,
&:focus,
&:active,
&.active {
background-color: transparent;
}
color: @neutral-40;
}
}
.table-generator-button-group {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
overflow: hidden;
&:not(:first-child) {
border-left: 1px solid @neutral-20;
padding-left: 8px;
margin-left: 8px;
}
}
.table-generator-button-menu-popover {
.popover-content {
padding: 4px;
}
.list-group {
margin: 0;
padding: 0;
}
& > .arrow {
display: none;
}
}
.table-generator-cell-input {
max-width: calc(200px - 0.5em);
width: 100%;
background-color: transparent;
border: 1px solid @table-generator-toolbar-shadow-color;
border: 0;
padding: 0;
}
.table-generator-border-options-coming-soon {
display: flex;
margin: 4px;
font-size: 12px;
background: @neutral-10;
color: @neutral-70;
padding: 8px;
gap: 6px;
align-items: flex-start;
max-width: 240px;
& .info-icon {
flex: 0 0 24px;
}
}
.table-generator-toolbar-dropdown-toggle {
border: 1px solid @neutral-60;
box-shadow: none;
background: transparent;
white-space: nowrap;
color: inherit;
border-radius: 4px;
padding: 6px 8px;
gap: 8px;
min-width: 120px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: space-between;
font-family: @font-family-sans-serif;
&:not(:first-child) {
margin-left: 8px;
}
&[aria-disabled='true'] {
background-color: #f2f2f2;
color: @neutral-40;
}
}
.table-generator-toolbar-dropdown-popover {
max-width: 300px;
.popover-content {
padding: 0;
}
& > .arrow {
display: none;
}
}
.table-generator-toolbar-dropdown-menu {
display: flex;
flex-direction: column;
min-width: 200px;
& > button {
border: none;
box-shadow: none;
background: transparent;
white-space: nowrap;
color: inherit;
border-radius: 0;
font-size: 14px;
display: flex;
align-items: center;
justify-content: space-between;
column-gap: 8px;
align-self: stretch;
padding: 12px 8px;
font-family: @font-family-sans-serif;
.table-generator-button-label {
align-self: stretch;
flex: 1 0 auto;
text-align: left;
}
&:hover,
&:focus {
background-color: rgba(47, 58, 76, 0.08);
}
&:active,
&.active {
background-color: rgba(47, 58, 76, 0.16);
}
&:hover,
&:focus,
&:active,
&.active {
color: inherit;
box-shadow: none;
}
&[aria-disabled='true'] {
&:hover,
&:focus,
&:active,
&.active {
background-color: transparent;
}
color: @neutral-40;
}
}
& > hr {
background: @neutral-20;
margin: 2px 8px;
display: block;
box-sizing: content-box;
border: 0;
height: 1px;
}
}

View file

@ -1711,6 +1711,8 @@
"toolbar_insert_table": "Insert Table",
"toolbar_numbered_list": "Numbered List",
"toolbar_redo": "Redo",
"toolbar_table_insert_size_table": "Insert __size__ table",
"toolbar_table_insert_table_lowercase": "Insert table",
"toolbar_toggle_symbol_palette": "Toggle Symbol Palette",
"toolbar_undo": "Undo",
"tooltip_hide_filetree": "Click to hide the file-tree",