Parcourir la source

Merge pull request #14340 from overleaf/mj-table-col-span

[visual] Support table multicolumn

GitOrigin-RevId: dd9cd2d5686d72dc9f53beb502a724a36f9c0bcf
Mathias Jakobsen il y a 3 ans
Parent
commit
b987e59d60

+ 49 - 14
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<HTMLDivElement>(null)
   const cellRef = useRef<HTMLTableCellElement>(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}

+ 26 - 9
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<EditingContextData | null>(null)
+  const [initialContent, setInitialContent] = useState<string | undefined>(
+    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 (

+ 211 - 54
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

+ 48 - 26
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 <TableRenderingError view={view} codePosition={tabularNode.from} />
+    // 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 positions: Positions = {
+      cells: tableData.cellPositions,
+      columnDeclarations: tableData.specification,
+      rowPositions: tableData.rowPositions,
+      tabular: { from: tabularNode.from, to: tabularNode.to },
+    }
 
-  const tableEnvironment = tableNode
-    ? parseTableEnvironment(tableNode)
-    : undefined
+    const tableEnvironment = tableNode
+      ? parseTableEnvironment(tableNode)
+      : undefined
 
-  return (
-    <TableContext.Provider
-      value={{
-        ...tableData,
-        positions,
-        tableEnvironment,
-      }}
-    >
-      {children}
-    </TableContext.Provider>
-  )
+    return (
+      <TableContext.Provider
+        value={{
+          ...tableData,
+          positions,
+          tableEnvironment,
+          rows: tableData.table.rows.length,
+          columns: tableData.table.columns.length,
+        }}
+      >
+        {children}
+      </TableContext.Provider>
+    )
+  } catch {
+    return <TableRenderingError view={view} codePosition={tabularNode.from} />
+  }
 }
 
 export const useTableContext = () => {

+ 13 - 3
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 (
     <tr>
-      <RowSelector index={rowIndex} columns={row.cells.length} />
+      <RowSelector index={rowIndex} />
       {row.cells.map((cell, cellIndex) => (
         <Cell
           key={cellIndex}
           cellData={cell}
           rowIndex={rowIndex}
           row={row}
-          columnIndex={cellIndex}
-          columnSpecification={columnSpecifications[cellIndex]}
+          columnIndex={normalizedCellIndex(row, cellIndex)}
+          columnSpecification={
+            columnSpecifications[normalizedCellIndex(row, cellIndex)]
+          }
         />
       ))}
     </tr>

+ 11 - 25
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
     <td
@@ -33,23 +29,13 @@ export const ColumnSelector = ({
   )
 }
 
-export const RowSelector = ({
-  index,
-  columns,
-}: {
-  index: number
-  columns: number
-}) => {
+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
     <td

+ 40 - 23
services/web/frontend/js/features/source-editor/components/table-generator/table.tsx

@@ -45,22 +45,27 @@ export const Table: FC = () => {
   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 = () => {
         <tr>
           <td />
           {tableData.columns.map((_, columnIndex) => (
-            <ColumnSelector
-              index={columnIndex}
-              key={columnIndex}
-              rows={tableData.rows.length}
-            />
+            <ColumnSelector index={columnIndex} key={columnIndex} />
           ))}
         </tr>
       </thead>
@@ -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 */}
+        <tr className="table-generator-filler-row">
+          {/* A td for the row selector */}
+          <td />
+          {tableData.columns.map((_, columnIndex) => (
+            <td key={columnIndex} />
+          ))}
+        </tr>
       </tbody>
     </table>
   )

+ 60 - 10
services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx

@@ -20,9 +20,35 @@ import { TableProvider } from './contexts/table-context'
 import { TabularProvider, useTabularContext } from './contexts/tabular-context'
 import Icon from '../../../../shared/components/icon'
 
+export type ColumnDefinition = {
+  alignment: 'left' | 'center' | 'right' | 'paragraph'
+  borderLeft: number
+  borderRight: number
+  content: string
+}
+
 export type CellData = {
-  // TODO: Add columnSpan
   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 = {
@@ -31,16 +57,40 @@ export type RowData = {
   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 = {

+ 178 - 45
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}`,
+    },
+  })
+}

+ 52 - 21
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
+            )
           }}
         >
           <MaterialIcon type="border_clear" />
@@ -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)
           }
         >
           <ToolbarButton
@@ -120,7 +127,7 @@ export const Toolbar = memo(function Toolbar() {
             id="table-generator-align-left"
             label="Left"
             command={() => {
-              setAlignment(view, selection, 'left', positions)
+              setAlignment(view, selection, 'left', positions, table)
             }}
           />
           <ToolbarButton
@@ -128,7 +135,7 @@ export const Toolbar = memo(function Toolbar() {
             id="table-generator-align-center"
             label="Center"
             command={() => {
-              setAlignment(view, selection, 'center', positions)
+              setAlignment(view, selection, 'center', positions, table)
             }}
           />
           <ToolbarButton
@@ -136,16 +143,31 @@ export const Toolbar = memo(function Toolbar() {
             id="table-generator-align-right"
             label="Right"
             command={() => {
-              setAlignment(view, selection, 'right', positions)
+              setAlignment(view, selection, 'right', positions, table)
             }}
           />
         </ToolbarButtonMenu>
         <ToolbarButton
           icon="cell_merge"
           id="table-generator-merge-cells"
-          label="Merge cells"
-          disabled
+          label={
+            selection.isMergedCellSelected(table)
+              ? 'Unmerge cells'
+              : 'Merge cells'
+          }
+          active={selection.isMergedCellSelected(table)}
+          disabled={
+            !selection.isMergedCellSelected(table) &&
+            !selection.isMergeableCells(table)
+          }
           disabledLabel="Select cells in a row to merge"
+          command={() => {
+            if (selection.isMergedCellSelected(table)) {
+              unmergeCells(view, selection, table)
+            } else {
+              mergeCells(view, selection, table)
+            }
+          }}
         />
         <ToolbarButton
           icon="delete"
@@ -153,14 +175,19 @@ export const Toolbar = memo(function Toolbar() {
           label="Delete row or column"
           disabledLabel="Select a row or a column to delete"
           disabled={
-            !(
-              positions.cells.length &&
-              selection.isAnyRowSelected(positions.cells[0].length)
-            ) && !selection.isAnyColumnSelected(positions.rowPositions.length)
+            (!selection.isAnyRowSelected(table) &&
+              !selection.isAnyColumnSelected(table)) ||
+            !selection.eq(selection.explode(table))
           }
           command={() =>
             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)
+              )
             }}
           >
             <span className="table-generator-button-label">
@@ -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)
+              )
             }}
           >
             <span className="table-generator-button-label">
@@ -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))
             }}
           >
             <span className="table-generator-button-label">
@@ -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))
             }}
           >
             <span className="table-generator-button-label">

+ 129 - 17
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,

+ 17 - 4
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<TabularEnvName>
-    Content<TabularContent {
-        (textWithGroupsEnvironmentsAndBlankLines)+
-    }>
+    Content<TabularContent>
     EndEnv<TabularEnvName>
 }
 

+ 2 - 0
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

+ 7 - 0
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;
+  }
+}