Просмотр исходного кода

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

[visual] Initial table generator

GitOrigin-RevId: 5c1a9cad898c988d21390358872a6c2eaf1c32fc
Mathias Jakobsen 3 лет назад
Родитель
Сommit
c332a65eb0
30 измененных файлов с 2077 добавлено и 31 удалено
  1. 6 0
      services/web/app/src/Features/Project/ProjectController.js
  2. 2 0
      services/web/frontend/extracted-translations.json
  3. 5 3
      services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx
  4. 131 0
      services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx
  5. 96 0
      services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx
  6. 96 0
      services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx
  7. 55 0
      services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx
  8. 26 0
      services/web/frontend/js/features/source-editor/components/table-generator/row.tsx
  9. 62 0
      services/web/frontend/js/features/source-editor/components/table-generator/selectors.tsx
  10. 73 0
      services/web/frontend/js/features/source-editor/components/table-generator/table.tsx
  11. 88 0
      services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx
  12. 98 0
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts
  13. 88 0
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx
  14. 83 0
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx
  15. 93 0
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx
  16. 181 0
      services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx
  17. 262 0
      services/web/frontend/js/features/source-editor/components/table-generator/utils.ts
  18. 121 0
      services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx
  19. 3 7
      services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
  20. 9 3
      services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
  21. 25 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts
  22. 19 0
      services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
  23. 38 17
      services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts
  24. 36 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx
  25. 4 0
      services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
  26. 2 0
      services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
  27. 4 1
      services/web/frontend/stories/source-editor/source-editor.stories.tsx
  28. 1 0
      services/web/frontend/stylesheets/app/editor.less
  29. 368 0
      services/web/frontend/stylesheets/app/editor/table-generator.less
  30. 2 0
      services/web/locales/en.json

+ 6 - 0
services/web/app/src/Features/Project/ProjectController.js

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

+ 2 - 0
services/web/frontend/extracted-translations.json

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

+ 5 - 3
services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx

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

+ 131 - 0
services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx

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

+ 96 - 0
services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx

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

+ 96 - 0
services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx

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

+ 55 - 0
services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx

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

+ 26 - 0
services/web/frontend/js/features/source-editor/components/table-generator/row.tsx

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

+ 62 - 0
services/web/frontend/js/features/source-editor/components/table-generator/selectors.tsx

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

+ 73 - 0
services/web/frontend/js/features/source-editor/components/table-generator/table.tsx

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

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

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

+ 98 - 0
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts

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

+ 88 - 0
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx

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

+ 83 - 0
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx

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

+ 93 - 0
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx

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

+ 181 - 0
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx

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

+ 262 - 0
services/web/frontend/js/features/source-editor/components/table-generator/utils.ts

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

+ 121 - 0
services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx

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

+ 3 - 7
services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx

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

+ 9 - 3
services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts

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

+ 25 - 0
services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts

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

+ 19 - 0
services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts

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

+ 38 - 17
services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts

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

+ 36 - 0
services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx

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

+ 4 - 0
services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar

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

+ 2 - 0
services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs

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

+ 4 - 1
services/web/frontend/stories/source-editor/source-editor.stories.tsx

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

+ 1 - 0
services/web/frontend/stylesheets/app/editor.less

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

+ 368 - 0
services/web/frontend/stylesheets/app/editor/table-generator.less

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

+ 2 - 0
services/web/locales/en.json

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