diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx index 9e0040ede7..19531f650d 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx @@ -9,6 +9,7 @@ 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' +import { useTableContext } from './contexts/table-context' export const Cell: FC<{ cellData: CellData @@ -16,9 +17,19 @@ export const Cell: FC<{ rowIndex: number columnIndex: number row: RowData -}> = ({ cellData, columnSpecification, rowIndex, columnIndex, row }) => { +}> = ({ + cellData, + columnSpecification: columnSpecificationFromTabular, + rowIndex, + columnIndex, + row, +}) => { + const columnSpecification = cellData.multiColumn + ? cellData.multiColumn.columns.specification[0] + : columnSpecificationFromTabular const { selection, setSelection, dragging, setDragging } = useSelectionContext() + const { table } = useTableContext() const renderDiv = useRef(null) const cellRef = useRef(null) const { @@ -30,7 +41,9 @@ export const Cell: FC<{ const editing = editingCellData?.rowIndex === rowIndex && - editingCellData?.cellIndex === columnIndex + editingCellData?.cellIndex >= columnIndex && + editingCellData?.cellIndex < + columnIndex + (cellData.multiColumn?.columnSpan ?? 1) const onMouseDown: MouseEventHandler = useCallback( event => { @@ -44,12 +57,14 @@ export const Cell: FC<{ return new TableSelection(current.from, { cell: columnIndex, row: rowIndex, - }) + }).explode(table) } - return new TableSelection({ cell: columnIndex, row: rowIndex }) + return new TableSelection({ cell: columnIndex, row: rowIndex }).explode( + table + ) }) }, - [setDragging, columnIndex, rowIndex, setSelection] + [setDragging, columnIndex, rowIndex, setSelection, table] ) const onMouseUp = useCallback(() => { @@ -79,14 +94,25 @@ export const Cell: FC<{ return new TableSelection(current.from, { row: rowIndex, cell: columnIndex, - }) + }).explode(table) } else { - return new TableSelection({ row: rowIndex, cell: columnIndex }) + return new TableSelection({ + row: rowIndex, + cell: columnIndex, + }).explode(table) } }) } }, - [dragging, columnIndex, rowIndex, setSelection, selection, setDragging] + [ + dragging, + columnIndex, + rowIndex, + setSelection, + selection, + setDragging, + table, + ] ) useEffect(() => { @@ -105,7 +131,9 @@ export const Cell: FC<{ } const isFocused = - selection?.to.cell === columnIndex && selection?.to.row === rowIndex + selection?.to.row === rowIndex && + selection?.to.cell >= columnIndex && + selection?.to.cell < columnIndex + (cellData.multiColumn?.columnSpan ?? 1) useEffect(() => { if (isFocused && !editing && cellRef.current) { @@ -147,11 +175,17 @@ export const Cell: FC<{ ) } - const inSelection = selection?.contains({ row: rowIndex, cell: columnIndex }) + const inSelection = selection?.contains( + { + row: rowIndex, + cell: columnIndex, + }, + table + ) const onDoubleClick = useCallback(() => { - startEditing(rowIndex, columnIndex, cellData.content.trim()) - }, [columnIndex, rowIndex, cellData, startEditing]) + startEditing(rowIndex, columnIndex, cellData.content) + }, [columnIndex, rowIndex, startEditing, cellData.content]) return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions @@ -161,6 +195,7 @@ export const Cell: FC<{ onMouseDown={onMouseDown} onMouseUp={onMouseUp} onMouseMove={onMouseMove} + colSpan={cellData.multiColumn?.columnSpan} ref={cellRef} className={classNames('table-generator-cell', { 'table-generator-cell-border-left': columnSpecification.borderLeft > 0, @@ -177,9 +212,9 @@ export const Cell: FC<{ 'selection-edge-bottom': inSelection && selection?.bordersBottom(rowIndex), 'selection-edge-left': - inSelection && selection?.bordersLeft(columnIndex), + inSelection && selection?.bordersLeft(rowIndex, columnIndex, table), 'selection-edge-right': - inSelection && selection?.bordersRight(columnIndex), + inSelection && selection?.bordersRight(rowIndex, columnIndex, table), })} > {body} diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx index 2f464bc24a..c785b983cb 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx @@ -20,7 +20,7 @@ const EditingContext = createContext< startEditing: ( rowIndex: number, cellIndex: number, - content: string + initialContent?: string ) => void } | undefined @@ -38,18 +38,28 @@ export const useEditingContext = () => { } export const EditingContextProvider: FC = ({ children }) => { - const { cellPositions } = useTableContext() + const { table } = useTableContext() const [cellData, setCellData] = useState(null) + const [initialContent, setInitialContent] = useState( + undefined + ) const view = useCodeMirrorViewContext() const write = useCallback( (rowIndex: number, cellIndex: number, content: string) => { - const { from, to } = cellPositions[rowIndex][cellIndex] + const { from, to } = table.getCell(rowIndex, cellIndex) + const currentText = view.state.sliceDoc(from, to) + if (currentText !== initialContent && initialContent !== undefined) { + // The cell has changed since we started editing, so we don't want to overwrite it + console.error('Cell has changed since editing started, not overwriting') + return + } + setInitialContent(undefined) view.dispatch({ changes: { from, to, insert: content }, }) setCellData(null) }, - [view, cellPositions] + [view, table, initialContent] ) const commitCellData = useCallback(() => { @@ -70,14 +80,21 @@ export const EditingContextProvider: FC = ({ children }) => { }, [setCellData]) const startEditing = useCallback( - (rowIndex: number, cellIndex: number, content: string) => { + (rowIndex: number, cellIndex: number, initialContent = undefined) => { if (cellData?.dirty) { // We're already editing something else commitCellData() } - setCellData({ cellIndex, rowIndex, content, dirty: false }) + setInitialContent(initialContent) + const content = table.getCell(rowIndex, cellIndex).content.trim() + setCellData({ + cellIndex, + rowIndex, + content, + dirty: false, + }) }, - [setCellData, cellData, commitCellData] + [setCellData, cellData, commitCellData, table] ) const updateCellData = useCallback( @@ -93,13 +110,13 @@ export const EditingContextProvider: FC = ({ children }) => { const { minX, minY, maxX, maxY } = selection.normalized() for (let row = minY; row <= maxY; row++) { for (let cell = minX; cell <= maxX; cell++) { - const { from, to } = cellPositions[row][cell] + const { from, to } = table.getCell(row, cell) changes.push({ from, to, insert: '' }) } } view.dispatch({ changes }) }, - [view, cellPositions] + [view, table] ) return ( diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx index 9fe11d0423..0539408d93 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx @@ -6,10 +6,11 @@ import { useContext, useState, } from 'react' +import { TableData } from '../tabular' type TableCoordinate = { - row: number - cell: number + readonly row: number + readonly cell: number } export class TableSelection { @@ -21,14 +22,18 @@ export class TableSelection { this.to = to ?? from } - contains(point: TableCoordinate) { + contains(anchor: TableCoordinate, table: TableData) { const { minX, maxX, minY, maxY } = this.normalized() - + const { from, to } = table.getCellBoundaries(anchor.row, anchor.cell) return ( - point.cell >= minX && - point.cell <= maxX && - point.row >= minY && - point.row <= maxY + from >= minX && to <= maxX && anchor.row >= minY && anchor.row <= maxY + ) + } + + static selectRow(row: number, table: TableData) { + return new TableSelection( + { row, cell: 0 }, + { row, cell: table.columns.length - 1 } ) } @@ -41,14 +46,14 @@ export class TableSelection { return { minX, maxX, minY, maxY } } - bordersLeft(x: number) { + bordersLeft(row: number, cell: number, table: TableData) { const { minX } = this.normalized() - return minX === x + return minX === table.getCellBoundaries(row, cell).from } - bordersRight(x: number) { + bordersRight(row: number, cell: number, table: TableData) { const { maxX } = this.normalized() - return maxX === x + return maxX === table.getCellBoundaries(row, cell).to } bordersTop(y: number) { @@ -61,103 +66,221 @@ export class TableSelection { return maxY === y } - isRowSelected(row: number, totalColumns: number) { + toString() { + return `TableSelection(${this.from.row}, ${this.from.cell}) -> (${this.to.row}, ${this.to.cell})` + } + + isRowSelected(row: number, table: TableData) { const { minX, maxX, minY, maxY } = this.normalized() - return row >= minY && row <= maxY && minX === 0 && maxX === totalColumns - 1 + return ( + row >= minY && + row <= maxY && + minX === 0 && + maxX === table.columns.length - 1 + ) } - isAnyRowSelected(totalColumns: number) { - const { minX, maxX } = this.normalized() - return minX === 0 && maxX === totalColumns - 1 + isAnyRowSelected(table: TableData) { + for (let i = 0; i < table.rows.length; ++i) { + if (this.isRowSelected(i, table)) { + return true + } + } + return false } - isAnyColumnSelected(totalRows: number) { - const { minY, maxY } = this.normalized() - return minY === 0 && maxY === totalRows - 1 + isAnyColumnSelected(table: TableData) { + for (let i = 0; i < table.columns.length; ++i) { + if (this.isColumnSelected(i, table)) { + return true + } + } + return false } - isColumnSelected(cell: number, totalRows: number) { + isColumnSelected(cell: number, table: TableData) { + const totalRows = table.rows.length const { minX, maxX, minY, maxY } = this.normalized() return cell >= minX && cell <= maxX && minY === 0 && maxY === totalRows - 1 } - moveRight(totalColumns: number) { - const newColumn = Math.min(totalColumns - 1, this.to.cell + 1) - return new TableSelection({ row: this.to.row, cell: newColumn }) + public eq(other: TableSelection) { + return ( + this.from.row === other.from.row && + this.from.cell === other.from.cell && + this.to.row === other.to.row && + this.to.cell === other.to.cell + ) } - moveLeft() { - const newColumn = Math.max(0, this.to.cell - 1) - return new TableSelection({ row: this.to.row, cell: newColumn }) + public explode(table: TableData) { + const expandOnce = (current: TableSelection) => { + if ( + current.to.row >= table.rows.length || + current.to.cell >= table.columns.length + ) { + throw new Error("Can't expand selection outside of table") + } + const { minX, maxX, minY, maxY } = current.normalized() + for (let row = minY; row <= maxY; ++row) { + const cellBoundariesMinX = table.getCellBoundaries(row, minX) + const cellBoundariesMaxX = table.getCellBoundaries(row, maxX) + if (cellBoundariesMinX.from < minX) { + if (current.from.cell === minX) { + return new TableSelection( + { row: current.from.row, cell: cellBoundariesMinX.from }, + { row: current.to.row, cell: current.to.cell } + ) + } else { + return new TableSelection( + { row: current.from.row, cell: current.from.cell }, + { row: current.to.row, cell: cellBoundariesMinX.from } + ) + } + } else if (cellBoundariesMaxX.to > maxX) { + if (current.to.cell === maxX) { + return new TableSelection( + { row: current.from.row, cell: current.from.cell }, + { row: current.to.row, cell: cellBoundariesMaxX.to } + ) + } else { + return new TableSelection( + { row: current.from.row, cell: cellBoundariesMaxX.to }, + { row: current.to.row, cell: current.to.cell } + ) + } + } + } + return current + } + let last: TableSelection = this + for ( + let current = expandOnce(last); + !current.eq(last); + current = expandOnce(last) + ) { + last = current + } + return last } - moveUp() { + moveRight(table: TableData) { + const totalColumns = table.columns.length + const newColumn = Math.min( + totalColumns - 1, + table.getCellBoundaries(this.to.row, this.to.cell).to + 1 + ) + return new TableSelection({ + row: this.to.row, + cell: newColumn, + }).explode(table) + } + + moveLeft(table: TableData) { + const row = this.to.row + const from = table.getCellBoundaries(row, this.to.cell).from + const newColumn = Math.max(0, from - 1) + return new TableSelection({ row: this.to.row, cell: newColumn }).explode( + table + ) + } + + moveUp(table: TableData) { const newRow = Math.max(0, this.to.row - 1) - return new TableSelection({ row: newRow, cell: this.to.cell }) + return new TableSelection({ row: newRow, cell: this.to.cell }).explode( + table + ) } - moveDown(totalRows: number) { + moveDown(table: TableData) { + const totalRows: number = table.rows.length const newRow = Math.min(totalRows - 1, this.to.row + 1) - return new TableSelection({ row: newRow, cell: this.to.cell }) + const cell = table.getCellBoundaries(this.to.row, this.to.cell).from + return new TableSelection({ row: newRow, cell }).explode(table) } - moveNext(totalColumns: number, totalRows: number) { + moveNext(table: TableData) { + const totalRows = table.rows.length + const totalColumns = table.columns.length const { row, cell } = this.to - if (cell === totalColumns - 1 && row === totalRows - 1) { - return new TableSelection(this.to) + const boundaries = table.getCellBoundaries(row, cell) + if (boundaries.to === totalColumns - 1 && row === totalRows - 1) { + return new TableSelection(this.to).explode(table) } - if (cell === totalColumns - 1) { - return new TableSelection({ row: row + 1, cell: 0 }) + if (boundaries.to === totalColumns - 1) { + return new TableSelection({ row: row + 1, cell: 0 }).explode(table) } - return new TableSelection({ row, cell: cell + 1 }) + return new TableSelection({ row, cell: boundaries.to + 1 }).explode(table) } - movePrevious(totalColumns: number) { - if (this.to.cell === 0 && this.to.row === 0) { - return new TableSelection(this.to) + movePrevious(table: TableData) { + const totalColumns = table.columns.length + const { row, cell } = this.to + const boundaries = table.getCellBoundaries(row, cell) + if (boundaries.from === 0 && this.to.row === 0) { + return new TableSelection(this.to).explode(table) } - if (this.to.cell === 0) { + if (boundaries.from === 0) { return new TableSelection({ row: this.to.row - 1, cell: totalColumns - 1, - }) + }).explode(table) } - return new TableSelection({ row: this.to.row, cell: this.to.cell - 1 }) + return new TableSelection({ + row: this.to.row, + cell: boundaries.from - 1, + }) } - extendRight(totalColumns: number) { - const newColumn = Math.min(totalColumns - 1, this.to.cell + 1) + extendRight(table: TableData) { + const totalColumns = table.columns.length + const { minY, maxY } = this.normalized() + let newColumn = this.to.cell + for (let row = minY; row <= maxY; ++row) { + const boundary = table.getCellBoundaries(row, this.to.cell).to + 1 + newColumn = Math.max(newColumn, boundary) + } + newColumn = Math.min(totalColumns - 1, newColumn) return new TableSelection( { row: this.from.row, cell: this.from.cell }, { row: this.to.row, cell: newColumn } - ) + ).explode(table) } - extendLeft() { - const newColumn = Math.max(0, this.to.cell - 1) + extendLeft(table: TableData) { + const { minY, maxY } = this.normalized() + let newColumn = this.to.cell + for (let row = minY; row <= maxY; ++row) { + const boundary = table.getCellBoundaries(row, this.to.cell).from - 1 + newColumn = Math.min(newColumn, boundary) + } + newColumn = Math.max(0, newColumn) return new TableSelection( { row: this.from.row, cell: this.from.cell }, { row: this.to.row, cell: newColumn } - ) + ).explode(table) } - extendUp() { + extendUp(table: TableData) { const newRow = Math.max(0, this.to.row - 1) return new TableSelection( { row: this.from.row, cell: this.from.cell }, { row: newRow, cell: this.to.cell } - ) + ).explode(table) } - extendDown(totalRows: number) { + extendDown(table: TableData) { + const totalRows = table.rows.length const newRow = Math.min(totalRows - 1, this.to.row + 1) return new TableSelection( { row: this.from.row, cell: this.from.cell }, { row: newRow, cell: this.to.cell } - ) + ).explode(table) } - spansEntireTable(totalColumns: number, totalRows: number) { + spansEntireTable(table: TableData) { + const totalRows = table.rows.length + const totalColumns = table.columns.length const { minX, maxX, minY, maxY } = this.normalized() return ( minX === 0 && @@ -167,6 +290,40 @@ export class TableSelection { ) } + isMergedCellSelected(table: TableData) { + if (this.from.row !== this.to.row) { + return false + } + const boundariesFrom = table.getCellBoundaries( + this.from.row, + this.from.cell + ) + const boundariesTo = table.getCellBoundaries(this.to.row, this.to.cell) + if (boundariesFrom.from !== boundariesTo.from) { + // boundaries are for two different cells, so it's not a merged cell + return false + } + const cellData = table.getCell(this.from.row, boundariesFrom.from) + return cellData && Boolean(cellData.multiColumn) + } + + isMergeableCells(table: TableData) { + const { minX, maxX, minY, maxY } = this.normalized() + if (minY !== maxY) { + return false + } + if (minX === maxX) { + return false + } + for (let cell = minX; cell <= maxX; ++cell) { + const cellData = table.getCell(this.from.row, cell) + if (cellData.multiColumn) { + return false + } + } + return true + } + width() { const { minX, maxX } = this.normalized() return maxX - minX + 1 diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx index 2560b004ee..043f1964d0 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx @@ -27,6 +27,8 @@ const TableContext = createContext< cellSeparators: CellSeparator[][] positions: Positions tableEnvironment?: TableEnvironmentData + rows: number + columns: number } | undefined >(undefined) @@ -36,37 +38,57 @@ export const TableProvider: FC<{ view: EditorView tableNode: SyntaxNode | null }> = ({ tabularNode, view, children, tableNode }) => { - const tableData = generateTable(tabularNode, view.state) + try { + 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) { - return + // TODO: Validate better that the table matches the column definition + for (const row of tableData.table.rows) { + const rowLength = row.cells.reduce( + (acc, cell) => acc + (cell.multiColumn?.columnSpan ?? 1), + 0 + ) + for (const cell of row.cells) { + if ( + cell.multiColumn?.columns.specification && + cell.multiColumn.columns.specification.length !== 1 + ) { + throw new Error( + 'Multi-column cells must have exactly one column definition' + ) + } + } + if (rowLength !== tableData.table.columns.length) { + throw new Error('Row length does not match column definition') + } } + + const positions: Positions = { + cells: tableData.cellPositions, + columnDeclarations: tableData.specification, + rowPositions: tableData.rowPositions, + tabular: { from: tabularNode.from, to: tabularNode.to }, + } + + const tableEnvironment = tableNode + ? parseTableEnvironment(tableNode) + : undefined + + return ( + + {children} + + ) + } catch { + return } - - const positions: Positions = { - cells: tableData.cellPositions, - columnDeclarations: tableData.specification, - rowPositions: tableData.rowPositions, - tabular: { from: tabularNode.from, to: tabularNode.to }, - } - - const tableEnvironment = tableNode - ? parseTableEnvironment(tableNode) - : undefined - - return ( - - {children} - - ) } export const useTableContext = () => { diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/row.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/row.tsx index 6b73159d04..4a4a2cb0b8 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/row.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/row.tsx @@ -3,6 +3,14 @@ import { ColumnDefinition, RowData } from './tabular' import { Cell } from './cell' import { RowSelector } from './selectors' +const normalizedCellIndex = (row: RowData, index: number) => { + let normalized = 0 + for (let i = 0; i < index; ++i) { + normalized += row.cells[i].multiColumn?.columnSpan ?? 1 + } + return normalized +} + export const Row: FC<{ rowIndex: number row: RowData @@ -10,15 +18,17 @@ export const Row: FC<{ }> = ({ columnSpecifications, row, rowIndex }) => { return ( - + {row.cells.map((cell, cellIndex) => ( ))} diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/selectors.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/selectors.tsx index 9fbef5b883..9090281733 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/selectors.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/selectors.tsx @@ -4,24 +4,20 @@ import { useSelectionContext, } from './contexts/selection-context' import classNames from 'classnames' +import { useTableContext } from './contexts/table-context' -export const ColumnSelector = ({ - index, - rows, -}: { - index: number - rows: number -}) => { +export const ColumnSelector = ({ index }: { index: number }) => { const { selection, setSelection } = useSelectionContext() + const { table } = useTableContext() const onColumnSelect = useCallback(() => { setSelection( new TableSelection( { row: 0, cell: index }, - { row: rows - 1, cell: index } + { row: table.rows.length - 1, cell: index } ) ) - }, [rows, index, setSelection]) - const fullySelected = selection?.isColumnSelected(index, rows) + }, [table.rows.length, index, setSelection]) + const fullySelected = selection?.isColumnSelected(index, table) return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions { +export const RowSelector = ({ index }: { index: number }) => { + const { table } = useTableContext() 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) + setSelection(TableSelection.selectRow(index, table)) + }, [index, setSelection, table]) + const fullySelected = selection?.isRowSelected(index, table) return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions { const navigation: NavigationMap = useMemo( () => ({ ArrowRight: [ - () => selection!.moveRight(tableData.columns.length), - () => selection!.extendRight(tableData.columns.length), + () => selection!.moveRight(tableData), + () => selection!.extendRight(tableData), + ], + ArrowLeft: [ + () => selection!.moveLeft(tableData), + () => selection!.extendLeft(tableData), + ], + ArrowUp: [ + () => selection!.moveUp(tableData), + () => selection!.extendUp(tableData), ], - ArrowLeft: [() => selection!.moveLeft(), () => selection!.extendLeft()], - ArrowUp: [() => selection!.moveUp(), () => selection!.extendUp()], ArrowDown: [ - () => selection!.moveDown(tableData.rows.length), - () => selection!.extendDown(tableData.rows.length), + () => selection!.moveDown(tableData), + () => selection!.extendDown(tableData), ], Tab: [ - () => - selection!.moveNext(tableData.columns.length, tableData.rows.length), - () => selection!.movePrevious(tableData.columns.length), + () => selection!.moveNext(tableData), + () => selection!.movePrevious(tableData), ], }), - [selection, tableData.columns.length, tableData.rows.length] + [selection, tableData] ) const isCharacterInput = useCallback((event: KeyboardEvent) => { @@ -82,11 +87,16 @@ export const Table: FC = () => { } if (cellData) { commitCellData() - return + } else { + const initialContent = tableData.getCell( + selection.to.row, + selection.to.cell + ).content + startEditing(selection.to.row, selection.to.cell, initialContent) } - const cell = tableData.rows[selection.to.row].cells[selection.to.cell] - startEditing(selection.to.row, selection.to.cell, cell.content) - setSelection(new TableSelection(selection.to, selection.to)) + setSelection( + new TableSelection(selection.to, selection.to).explode(tableData) + ) } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() @@ -115,7 +125,10 @@ export const Table: FC = () => { event.preventDefault() if (!selection) { setSelection( - new TableSelection({ row: 0, cell: 0 }, { row: 0, cell: 0 }) + new TableSelection( + { row: 0, cell: 0 }, + { row: 0, cell: 0 } + ).explode(tableData) ) return } @@ -130,15 +143,13 @@ export const Table: FC = () => { if (!selection) { return } - const cell = tableData.rows[selection.to.row].cells[selection.to.cell] - startEditing(selection.to.row, selection.to.cell, cell.content) + startEditing(selection.to.row, selection.to.cell) updateCellData(event.key) setSelection(new TableSelection(selection.to, selection.to)) } }, [ selection, - tableData.rows, cellData, setSelection, cancelEditing, @@ -149,6 +160,7 @@ export const Table: FC = () => { clearCells, updateCellData, isCharacterInput, + tableData, ] ) return ( @@ -163,11 +175,7 @@ export const Table: FC = () => { {tableData.columns.map((_, columnIndex) => ( - + ))} @@ -180,6 +188,15 @@ export const Table: FC = () => { columnSpecifications={tableData.columns} /> ))} + {/* A workaround for a chrome bug where it will not respect colspan + unless there is a row filled with cells without colspan */} + + {/* A td for the row selector */} + + {tableData.columns.map((_, columnIndex) => ( + + ))} + ) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx index 4d19c249a4..35c09b5687 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx @@ -20,27 +20,77 @@ import { TableProvider } from './contexts/table-context' import { TabularProvider, useTabularContext } from './contexts/tabular-context' import Icon from '../../../../shared/components/icon' -export type CellData = { - // TODO: Add columnSpan +export type ColumnDefinition = { + alignment: 'left' | 'center' | 'right' | 'paragraph' + borderLeft: number + borderRight: number content: string } +export type CellData = { + content: string + from: number + to: number + multiColumn?: { + columnSpan: number + columns: { + specification: ColumnDefinition[] + from: number + to: number + } + from: number + to: number + preamble: { + from: number + to: number + } + postamble: { + from: number + to: number + } + } +} + export type RowData = { cells: CellData[] borderTop: number borderBottom: number } -export type ColumnDefinition = { - alignment: 'left' | 'center' | 'right' | 'paragraph' - borderLeft: number - borderRight: number - content: string -} +export class TableData { + // eslint-disable-next-line no-useless-constructor + constructor( + public readonly rows: RowData[], + public readonly columns: ColumnDefinition[] + ) {} -export type TableData = { - rows: RowData[] - columns: ColumnDefinition[] + getCellIndex(row: number, column: number): number { + let cellIndex = 0 + for (let i = 0; i < this.rows[row].cells.length; i++) { + cellIndex += this.rows[row].cells[i].multiColumn?.columnSpan ?? 1 + if (column < cellIndex) { + return i + } + } + return this.rows[row].cells.length - 1 + } + + getCell(row: number, column: number): CellData { + return this.rows[row].cells[this.getCellIndex(row, column)] + } + + getCellBoundaries(row: number, cell: number) { + let currentCellOffset = 0 + for (let index = 0; index < this.rows[row].cells.length; ++index) { + const currentCell = this.rows[row].cells[index] + const skip = currentCell.multiColumn?.columnSpan ?? 1 + if (currentCellOffset + skip > cell) { + return { from: currentCellOffset, to: currentCellOffset + skip - 1 } + } + currentCellOffset += skip + } + throw new Error("Couldn't find cell boundaries") + } } export type Positions = { diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts index c72704958f..5e4370ebe0 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts @@ -1,5 +1,5 @@ import { EditorView } from '@codemirror/view' -import { ColumnDefinition, Positions } from '../tabular' +import { ColumnDefinition, Positions, TableData } from '../tabular' import { ChangeSpec, EditorSelection } from '@codemirror/state' import { CellSeparator, @@ -24,7 +24,8 @@ export const setBorders = ( view: EditorView, theme: BorderTheme, positions: Positions, - rowSeparators: RowSeparator[] + rowSeparators: RowSeparator[], + table: TableData ) => { const specification = view.state.sliceDoc( positions.columnDeclarations.from, @@ -46,27 +47,31 @@ export const setBorders = ( }) } } + const removeMulticolumnBorders: ChangeSpec[] = [] + for (const row of table.rows) { + for (const cell of row.cells) { + if (cell.multiColumn) { + const specification = view.state.sliceDoc( + cell.multiColumn.columns.from, + cell.multiColumn.columns.to + ) + removeMulticolumnBorders.push({ + from: cell.multiColumn.columns.from, + to: cell.multiColumn.columns.to, + insert: specification.replace(/\|/g, ''), + }) + } + } + } view.dispatch({ - changes: [removeColumnBorders, ...removeHlines], + changes: [ + removeColumnBorders, + ...removeHlines, + ...removeMulticolumnBorders, + ], }) } 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 newSpec = addColumnBordersToSpecification(specification) const insertColumns = view.state.changes({ from: positions.columnDeclarations.from, @@ -101,19 +106,85 @@ export const setBorders = ( }) ) } + const addMulticolumnBorders: ChangeSpec[] = [] + for (const row of table.rows) { + for (const cell of row.cells) { + if (cell.multiColumn) { + const specification = view.state.sliceDoc( + cell.multiColumn.columns.from, + cell.multiColumn.columns.to + ) + addMulticolumnBorders.push({ + from: cell.multiColumn.columns.from, + to: cell.multiColumn.columns.to, + insert: addColumnBordersToSpecification(specification), + }) + } + } + } view.dispatch({ - changes: [insertColumns, ...insertHlines], + changes: [insertColumns, ...insertHlines, ...addMulticolumnBorders], }) } } +const addColumnBordersToSpecification = (specification: string) => { + 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 + '|' + } + return newSpec +} + export const setAlignment = ( view: EditorView, selection: TableSelection, alignment: 'left' | 'right' | 'center', - positions: Positions + positions: Positions, + table: TableData ) => { + if (selection.isMergedCellSelected(table)) { + // change for mergedColumn + const { minX, minY } = selection.normalized() + const cell = table.getCell(minY, minX) + if (!cell.multiColumn) { + return + } + const specification = view.state.sliceDoc( + cell.multiColumn.columns.from, + cell.multiColumn.columns.to + ) + const columnSpecification = parseColumnSpecifications(specification) + for (const column of columnSpecification) { + if (column.alignment !== alignment) { + column.alignment = alignment + column.content = alignment[0] + } + } + const newSpec = generateColumnSpecification(columnSpecification) + view.dispatch({ + changes: { + from: cell.multiColumn.columns.from, + to: cell.multiColumn.columns.to, + insert: newSpec, + }, + }) + return + } const specification = view.state.sliceDoc( positions.columnDeclarations.from, positions.columnDeclarations.to @@ -121,7 +192,7 @@ export const setAlignment = ( const columnSpecification = parseColumnSpecifications(specification) const { minX, maxX } = selection.normalized() for (let i = minX; i <= maxX; i++) { - if (selection.isColumnSelected(i, positions.rowPositions.length)) { + if (selection.isColumnSelected(i, table)) { if (columnSpecification[i].alignment === alignment) { continue } @@ -155,7 +226,8 @@ export const removeRowOrColumns = ( view: EditorView, selection: TableSelection, positions: Positions, - cellSeparators: CellSeparator[][] + cellSeparators: CellSeparator[][], + table: TableData ) => { const { minX: startCell, @@ -172,19 +244,17 @@ export const removeRowOrColumns = ( const numberOfColumns = columnSpecification.length const numberOfRows = positions.rowPositions.length - if (selection.spansEntireTable(numberOfColumns, numberOfRows)) { + if (selection.spansEntireTable(table)) { emptyTable(view, columnSpecification, positions) return new TableSelection({ cell: 0, row: 0 }) } const removedRows = - Number(selection.isRowSelected(startRow, numberOfColumns)) * - selection.height() + Number(selection.isRowSelected(startRow, table)) * selection.height() const removedColumns = - Number(selection.isColumnSelected(startCell, numberOfRows)) * - selection.width() + Number(selection.isColumnSelected(startCell, table)) * selection.width() for (let row = startRow; row <= endRow; row++) { - if (selection.isRowSelected(row, numberOfColumns)) { + if (selection.isRowSelected(row, table)) { const rowPosition = positions.rowPositions[row] changes.push({ from: rowPosition.from, @@ -192,10 +262,13 @@ export const removeRowOrColumns = ( insert: '', }) } else { - for (let cell = startCell; cell <= endCell; cell++) { - if (selection.isColumnSelected(cell, numberOfRows)) { - // FIXME: handle multicolumn - if (cell === 0 && cellSeparators[row].length > 0) { + for (let cell = startCell; cell <= endCell; ) { + const cellIndex = table.getCellIndex(row, cell) + const cellPosition = positions.cells[row][cellIndex] + if (selection.isColumnSelected(cell, table)) { + // We should remove this column. + const boundaries = table.getCellBoundaries(row, cell) + if (boundaries.from === 0 && cellSeparators[row].length > 0) { // Remove the cell separator between the first and second cell changes.push({ from: positions.cells[row][cell].from, @@ -204,9 +277,8 @@ export const removeRowOrColumns = ( }) } else { // Remove the cell separator between the cell before and this if possible - const cellPosition = positions.cells[row][cell] const from = - cellSeparators[row][cell - 1]?.from ?? cellPosition.from + cellSeparators[row][cellIndex - 1]?.from ?? cellPosition.from const to = cellPosition.to changes.push({ from, @@ -215,11 +287,12 @@ export const removeRowOrColumns = ( }) } } + cell += table.rows[row].cells[cellIndex].multiColumn?.columnSpan ?? 1 } } } const filteredColumns = columnSpecification.filter( - (_, i) => !selection.isColumnSelected(i, numberOfRows) + (_, i) => !selection.isColumnSelected(i, table) ) const newSpecification = generateColumnSpecification(filteredColumns) changes.push({ @@ -266,14 +339,15 @@ export const insertRow = ( view: EditorView, selection: TableSelection, positions: Positions, - below: boolean + below: boolean, + table: TableData ) => { // TODO: Handle borders const { maxY, minY } = selection.normalized() const from = below ? positions.rowPositions[maxY].to : positions.rowPositions[minY].from - const numberOfColumns = positions.cells[maxY].length + const numberOfColumns = table.columns.length const insert = `\n${' &'.repeat(numberOfColumns - 1)}\\\\` view.dispatch({ changes: { from, to: from, insert } }) if (!below) { @@ -287,19 +361,22 @@ export const insertRow = ( export const insertColumn = ( view: EditorView, - selection: TableSelection, + initialSelection: TableSelection, positions: Positions, - after: boolean + after: boolean, + table: TableData ) => { // TODO: Handle borders - // FIXME: Handle multicolumn + const selection = initialSelection.explode(table) const { maxX, minX } = selection.normalized() const changes: ChangeSpec[] = [] - for (const row of positions.cells) { - const from = after ? row[maxX].to : row[minX].from + const targetColumn = after ? maxX : minX + for (let row = 0; row < positions.rowPositions.length; row++) { + const cell = table.getCell(row, targetColumn) + const target = cell.multiColumn ?? cell + const from = after ? target.to : target.from changes.push({ from, - to: from, insert: ' &', }) } @@ -461,3 +538,59 @@ const gobbleEmptyLines = ( to: extendForwardsOverEmptyLines(view.state.doc, line, lines), } } + +export const unmergeCells = ( + view: EditorView, + selection: TableSelection, + table: TableData +) => { + const cell = table.getCell(selection.from.row, selection.from.cell) + if (!cell.multiColumn) { + return + } + view.dispatch({ + changes: [ + { + from: cell.multiColumn.preamble.from, + to: cell.multiColumn.preamble.to, + insert: '', + }, + { + from: cell.multiColumn.postamble.from, + to: cell.multiColumn.postamble.to, + insert: '&'.repeat(cell.multiColumn.columnSpan - 1), + }, + ], + }) +} + +export const mergeCells = ( + view: EditorView, + selection: TableSelection, + table: TableData +) => { + const { minX, maxX, minY, maxY } = selection.normalized() + if (minY !== maxY) { + return + } + if (minX === maxX) { + return + } + const cellContent = [] + for (let i = minX; i <= maxX; i++) { + cellContent.push(table.getCell(minY, i).content) + } + const content = cellContent.join(' ').trim() + // TODO: respect border theme + const preamble = '\\multicolumn{' + (maxX - minX + 1) + '}{c}{' + const postamble = '}' + const { from } = table.getCell(minY, minX) + const { to } = table.getCell(minY, maxX) + view.dispatch({ + changes: { + from, + to, + insert: `${preamble}${content}${postamble}`, + }, + }) +} diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx index 16f9e00dcc..027b39117b 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx @@ -8,12 +8,14 @@ import { BorderTheme, insertColumn, insertRow, + mergeCells, moveCaption, removeCaption, removeNodes, removeRowOrColumns, setAlignment, setBorders, + unmergeCells, } from './commands' import { useCodeMirrorViewContext } from '../../codemirror-editor' import { useTableContext } from '../contexts/table-context' @@ -21,7 +23,7 @@ import { useTableContext } from '../contexts/table-context' export const Toolbar = memo(function Toolbar() { const { selection, setSelection } = useSelectionContext() const view = useCodeMirrorViewContext() - const { positions, rowSeparators, cellSeparators, tableEnvironment } = + const { positions, rowSeparators, cellSeparators, tableEnvironment, table } = useTableContext() if (!selection) { return null @@ -77,7 +79,8 @@ export const Toolbar = memo(function Toolbar() { view, BorderTheme.FULLY_BORDERED, positions, - rowSeparators + rowSeparators, + table ) }} > @@ -89,7 +92,13 @@ export const Toolbar = memo(function Toolbar() { role="menuitem" type="button" onClick={() => { - setBorders(view, BorderTheme.NO_BORDERS, positions, rowSeparators) + setBorders( + view, + BorderTheme.NO_BORDERS, + positions, + rowSeparators, + table + ) }} > @@ -109,10 +118,8 @@ export const Toolbar = memo(function Toolbar() { id="table-generator-align-dropdown" disabledLabel="Select a column or a merged cell to align" disabled={ - !selection.isColumnSelected( - selection.from.cell, - positions.rowPositions.length - ) + !selection.isColumnSelected(selection.from.cell, table) && + !selection.isMergedCellSelected(table) } > { - setAlignment(view, selection, 'left', positions) + setAlignment(view, selection, 'left', positions, table) }} /> { - setAlignment(view, selection, 'center', positions) + setAlignment(view, selection, 'center', positions, table) }} /> { - setAlignment(view, selection, 'right', positions) + setAlignment(view, selection, 'right', positions, table) }} /> { + if (selection.isMergedCellSelected(table)) { + unmergeCells(view, selection, table) + } else { + mergeCells(view, selection, table) + } + }} /> setSelection( - removeRowOrColumns(view, selection, positions, cellSeparators) + removeRowOrColumns( + view, + selection, + positions, + cellSeparators, + table + ) ) } /> @@ -176,7 +203,9 @@ export const Toolbar = memo(function Toolbar() { role="menuitem" type="button" onClick={() => { - setSelection(insertColumn(view, selection, positions, false)) + setSelection( + insertColumn(view, selection, positions, false, table) + ) }} > @@ -188,7 +217,9 @@ export const Toolbar = memo(function Toolbar() { role="menuitem" type="button" onClick={() => { - setSelection(insertColumn(view, selection, positions, true)) + setSelection( + insertColumn(view, selection, positions, true, table) + ) }} > @@ -201,7 +232,7 @@ export const Toolbar = memo(function Toolbar() { role="menuitem" type="button" onClick={() => { - setSelection(insertRow(view, selection, positions, false)) + setSelection(insertRow(view, selection, positions, false, table)) }} > @@ -213,7 +244,7 @@ export const Toolbar = memo(function Toolbar() { role="menuitem" type="button" onClick={() => { - setSelection(insertRow(view, selection, positions, true)) + setSelection(insertRow(view, selection, positions, true, table)) }} > diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts b/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts index 29f0ac8f80..29af26d86a 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts +++ b/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts @@ -1,6 +1,6 @@ import { EditorState } from '@codemirror/state' import { SyntaxNode } from '@lezer/common' -import { ColumnDefinition, TableData } from './tabular' +import { CellData, ColumnDefinition, TableData } from './tabular' import { TableEnvironmentData } from './contexts/table-context' const ALIGNMENT_CHARACTERS = ['c', 'l', 'r', 'p'] @@ -85,6 +85,10 @@ const isHLine = (node: SyntaxNode) => node.type.is('Command') && Boolean(node.getChild('KnownCommand')?.getChild('HorizontalLine')) +const isMultiColumn = (node: SyntaxNode) => + node.type.is('Command') && + Boolean(node.getChild('KnownCommand')?.getChild('MultiColumn')) + type Position = { from: number to: number @@ -98,6 +102,16 @@ type HLineData = { type ParsedCell = { content: string position: Position + multiColumn?: { + columnSpecification: { + position: Position + specification: ColumnDefinition[] + } + span: number + position: Position + preamble: Position + postamble: Position + } } export type CellSeparator = Position @@ -170,24 +184,100 @@ function parseTabularBody( from: currentChild.from, to: currentChild.to, }) + } else if (isMultiColumn(currentChild)) { + // do stuff + const multiColumn = currentChild + .getChild('KnownCommand')! + .getChild('MultiColumn')! + const columnArgument = multiColumn + .getChild('ColumnArgument') + ?.getChild('ShortTextArgument') + ?.getChild('ShortArg') + const spanArgument = multiColumn + .getChild('SpanArgument') + ?.getChild('ShortTextArgument') + ?.getChild('ShortArg') + const tabularArgument = multiColumn + .getChild('TabularArgument') + ?.getChild('TabularContent') + if (!columnArgument) { + throw new Error( + 'Invalid multicolumn definition: missing column specification argument' + ) + } + if (!spanArgument) { + throw new Error( + 'Invalid multicolumn definition: missing colspan argument' + ) + } + if (!tabularArgument) { + throw new Error('Invalid multicolumn definition: missing cell content') + } + if (getLastCell()?.content) { + throw new Error( + 'Invalid multicolumn definition: multicolumn must be at the start of a cell' + ) + } + const columnSpecification = parseColumnSpecifications( + state.sliceDoc(columnArgument.from, columnArgument.to) + ) + const span = parseInt(state.sliceDoc(spanArgument.from, spanArgument.to)) + const cellContent = state.sliceDoc( + tabularArgument.from, + tabularArgument.to + ) + if (!getLastCell()) { + getLastRow().cells.push({ + content: '', + position: { from: currentChild.from, to: currentChild.from }, + }) + } + const lastCell = getLastCell()! + lastCell.multiColumn = { + columnSpecification: { + position: { from: columnArgument.from, to: columnArgument.to }, + specification: columnSpecification, + }, + span, + preamble: { + from: currentChild.from, + to: tabularArgument.from, + }, + postamble: { + from: tabularArgument.to, + to: currentChild.to, + }, + position: { from: currentChild.from, to: currentChild.to }, + } + lastCell.content = cellContent + lastCell.position.from = tabularArgument.from + lastCell.position.to = tabularArgument.to + // Don't update position at the end of the loop + continue } 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 + if (!lastCell?.multiColumn) { + 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) { + if (lastCell?.content.trim()) { + console.error(lastCell) throw new Error('\\hline must be at the start of a row') } // push start of cell past the hline @@ -253,7 +343,7 @@ export function generateTable( } const tableData = parseTabularBody(body, state) const cellPositions = tableData.rows.map(row => - row.cells.map(cell => cell.position) + row.cells.map(cell => cell.multiColumn?.position ?? cell.position) ) const cellSeparators = tableData.rows.map(row => row.cellSeparators) const rowPositions = tableData.rows.map(row => ({ @@ -261,16 +351,38 @@ export function generateTable( hlines: row.hlines.map(hline => hline.position), })) const rows = tableData.rows.map(row => ({ - cells: row.cells.map(cell => ({ - content: cell.content, - })), + cells: row.cells.map(cell => { + const cellData: CellData = { + content: cell.content, + from: cell.position.from, + to: cell.position.to, + } + if (cell.multiColumn) { + cellData.multiColumn = { + columns: { + specification: cell.multiColumn.columnSpecification.specification, + from: cell.multiColumn.columnSpecification.position.from, + to: cell.multiColumn.columnSpecification.position.to, + }, + columnSpan: cell.multiColumn.span, + from: cell.multiColumn.position.from, + to: cell.multiColumn.position.to, + preamble: { + from: cell.multiColumn.preamble.from, + to: cell.multiColumn.preamble.to, + }, + postamble: { + from: cell.multiColumn.postamble.from, + to: cell.multiColumn.postamble.to, + }, + } + } + return cellData + }), borderTop: row.hlines.filter(hline => !hline.atBottom).length, borderBottom: row.hlines.filter(hline => hline.atBottom).length, })) - const table = { - rows, - columns, - } + const table = new TableData(rows, columns) return { table, cellPositions, diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar index 6924593567..002fa81fba 100644 --- a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar +++ b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar @@ -94,7 +94,8 @@ HLineCtrlSeq, TopRuleCtrlSeq, MidRuleCtrlSeq, - BottomRuleCtrlSeq + BottomRuleCtrlSeq, + MultiColumnCtrlSeq } @external specialize {EnvName} specializeEnvName from "./tokens.mjs" { @@ -189,6 +190,10 @@ PackageArgument { !argument ShortTextArgument } +TabularArgument { + !argument OpenBrace TabularContent CloseBrace +} + UrlArgument { OpenBrace LiteralArgContent CloseBrace } @@ -348,6 +353,12 @@ KnownCommand { } | HorizontalLine { (HLineCtrlSeq | TopRuleCtrlSeq | MidRuleCtrlSeq | BottomRuleCtrlSeq) optionalWhitespace? + } | + MultiColumn { + MultiColumnCtrlSeq + optionalWhitespace? SpanArgument { ShortTextArgument } + optionalWhitespace? ColumnArgument { ShortTextArgument } + optionalWhitespace? TabularArgument } } @@ -545,11 +556,13 @@ DocumentEnvironment[@isGroup="$Environment"] { (TrailingWhitespaceOnly | TrailingContent)? } +TabularContent { + (textWithGroupsEnvironmentsAndBlankLines)* +} + TabularEnvironment[@isGroup="$Environment"] { BeginEnv - Content + Content EndEnv } diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs index 931dc68dbc..a00f8b2d2f 100644 --- a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs +++ b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs @@ -77,6 +77,7 @@ import { MidRuleCtrlSeq, BottomRuleCtrlSeq, TableEnvName, + MultiColumnCtrlSeq, } from './latex.terms.mjs' function nameChar(ch) { @@ -629,6 +630,7 @@ const otherKnowncommands = { '\\toprule': TopRuleCtrlSeq, '\\midrule': MidRuleCtrlSeq, '\\bottomrule': BottomRuleCtrlSeq, + '\\multicolumn': MultiColumnCtrlSeq, } // specializer for control sequences // return new tokens for specific control sequences diff --git a/services/web/frontend/stylesheets/app/editor/table-generator.less b/services/web/frontend/stylesheets/app/editor/table-generator.less index efbf766576..302c35a6e3 100644 --- a/services/web/frontend/stylesheets/app/editor/table-generator.less +++ b/services/web/frontend/stylesheets/app/editor/table-generator.less @@ -388,3 +388,10 @@ margin-right: 12px; } } + +.table-generator-filler-row { + border: none !important; + td { + min-width: 40px; + } +}