(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(/(? {
+ 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 =
+ if (editing) {
+ body = (
+ {
+ 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
+ | 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}
+ |
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx
new file mode 100644
index 0000000000..c25f82635c
--- /dev/null
+++ b/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(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 (
+
+ {children}
+
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx
new file mode 100644
index 0000000000..443805d14d
--- /dev/null
+++ b/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>
+ }
+ | 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(null)
+ return (
+
+ {children}
+
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx
new file mode 100644
index 0000000000..b7831cf07b
--- /dev/null
+++ b/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 (
+
+ {children}
+
+ )
+}
+
+export const useTableContext = () => {
+ const context = useContext(TableContext)
+ if (context === undefined) {
+ throw new Error('useTableContext must be used within a TableProvider')
+ }
+ return context
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/row.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/row.tsx
new file mode 100644
index 0000000000..6b73159d04
--- /dev/null
+++ b/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 (
+
+
+ {row.cells.map((cell, cellIndex) => (
+ |
+ ))}
+
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/selectors.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/selectors.tsx
new file mode 100644
index 0000000000..9fbef5b883
--- /dev/null
+++ b/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
+ |
+ )
+}
+
+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
+ |
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx
new file mode 100644
index 0000000000..6b4bc003c1
--- /dev/null
+++ b/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
+
+
+
+ |
+ {tableData.columns.map((_, columnIndex) => (
+
+ ))}
+
+
+
+ {tableData.rows.map((row, rowIndex) => (
+
+ ))}
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx
new file mode 100644
index 0000000000..eb04baf1cb
--- /dev/null
+++ b/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 (
+
+ Table rendering error{' '}
+
+
+ )
+}
+
+export const Tabular: FC<{
+ tabularNode: SyntaxNode
+ view: EditorView
+}> = ({ tabularNode, view }) => {
+ return (
+ (
+
+ )}
+ onError={(error, componentStack) => console.error(error, componentStack)}
+ >
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts
new file mode 100644
index 0000000000..59c4b4ee32
--- /dev/null
+++ b/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],
+ })
+ }
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx
new file mode 100644
index 0000000000..073221dbdc
--- /dev/null
+++ b/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(null)
+ const { open, onToggle, ref } = useDropdown()
+ const view = useCodeMirrorViewContext()
+
+ const button = (
+
+ )
+
+ const overlay = (
+ onToggle(false)}
+ >
+
+
+ )
+
+ if (!label) {
+ return (
+ <>
+ {button}
+ {overlay}
+ >
+ )
+ }
+
+ return (
+ <>
+ {label}}
+ overlayProps={{ placement: 'bottom' }}
+ >
+ {button}
+
+ {overlay}
+ >
+ )
+})
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx
new file mode 100644
index 0000000000..457eed4cb2
--- /dev/null
+++ b/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 = (
+
+ )
+
+ if (!label) {
+ return button
+ }
+
+ const description = (
+ <>
+ {label}
+ {shortcut && {shortcut}
}
+ >
+ )
+
+ return (
+
+ {button}
+
+ )
+})
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx
new file mode 100644
index 0000000000..c55cb94b43
--- /dev/null
+++ b/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(null)
+ const view = useCodeMirrorViewContext()
+
+ const button = (
+
+ )
+ const overlay = open && (
+ onToggle(false)}
+ animation={false}
+ container={view.dom}
+ containerPadding={0}
+ placement="bottom"
+ rootClose
+ target={toggleButtonRef.current ?? undefined}
+ >
+
+
+
+
+ )
+
+ if (!tooltip) {
+ return (
+ <>
+ {button}
+ {overlay}
+ >
+ )
+ }
+
+ return (
+ <>
+
+ {button}
+
+ {overlay}
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx
new file mode 100644
index 0000000000..6ff5f80123
--- /dev/null
+++ b/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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ More options for border settings coming soon.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+})
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts b/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts
new file mode 100644
index 0000000000..8e83afbb7f
--- /dev/null
+++ b/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,
+ }
+}
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx
new file mode 100644
index 0000000000..93b8f323fd
--- /dev/null
+++ b/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(null)
+
+ const onSizeSelected = useCallback(
+ (sizeX: number, sizeY: number) => {
+ onToggle(false)
+ commands.insertTable(view, sizeX, sizeY)
+ view.focus()
+ },
+ [view, onToggle]
+ )
+
+ return (
+ <>
+ {t('toolbar_insert_table')}}
+ overlayProps={{ placement: 'bottom' }}
+ >
+
+
+ onToggle(false)}
+ >
+
+
+ >
+ )
+}
+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 (
+ <>
+ {label}
+ {
+ setCurrentSize({ sizeX: 0, sizeY: 0 })
+ }}
+ >
+
+ {range(1, sizeY).map(y => (
+
+ {range(1, sizeX).map(x => (
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
+ | = x && currentSize.sizeY >= y,
+ })}
+ key={x}
+ onMouseEnter={() => {
+ setCurrentSize({ sizeX: x, sizeY: y })
+ }}
+ onMouseDown={() => onSizeSelected(x, y)}
+ />
+ ))}
+ |
+ ))}
+
+
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
index b914296671..299121941b 100644
--- a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
+++ b/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 { useTranslation } from 'react-i18next'
import { MathDropdown } from './math-dropdown'
+import { TableInserterDropdown } from './table-inserter-dropdown'
const isMac = /Mac/.test(window.navigator?.platform)
@@ -52,6 +53,7 @@ export const ToolbarItems: FC<{
)
const showFigureModal = splitTestVariants['figure-modal'] === 'enabled'
+ const showTableGenerator = splitTestVariants['table-generator'] === 'enabled'
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
const showGroup = (group: string) => !overflowed || overflowed.has(group)
@@ -165,13 +167,7 @@ export const ToolbarItems: FC<{
icon="picture-o"
/>
)}
-
+ {showTableGenerator && }
)}
{showGroup('group-list') && (
diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
index 50b8ade9b6..4185911d70 100644
--- a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
@@ -1,5 +1,5 @@
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
-import { Command } from '@codemirror/view'
+import { Command, EditorView } from '@codemirror/view'
import {
closeSearchPanel,
openSearchPanel,
@@ -53,10 +53,16 @@ export const insertFigure: Command = view => {
return true
}
-export const insertTable: Command = view => {
+export const insertTable = (view: EditorView, sizeX: number, sizeY: number) => {
const { state, dispatch } = view
const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
- const template = `\n${snippets.table}\n${suffix}`
+ const template = `\n\\begin{table}{#{}}
+\t\\centering
+\\begin{tabular}{${'c'.repeat(sizeX)}}
+${('\t\t' + '#{} & #{}'.repeat(sizeX - 1) + '\\\\\n').repeat(
+ sizeY
+)}\\end{tabular}
+\\end{table}${suffix}`
snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos)
return true
}
diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts
index f7862f172a..d599ef72cb 100644
--- a/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts
+++ b/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',
+ },
}),
]
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
index 85a4099c00..1028daeff9 100644
--- a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
+++ b/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 { parseTheoremArguments } from '../../utils/tree-operations/theorems'
import { IndicatorWidget } from './visual-widgets/indicator'
+import { TabularWidget } from './visual-widgets/tabular'
type Options = {
fileTreeManager: {
@@ -129,6 +130,8 @@ const hasClosingBrace = (node: SyntaxNode) =>
export const atomicDecorations = (options: Options) => {
const splitTestVariants = getMeta('ol-splitTestVariants', {})
const figureModalEnabled = splitTestVariants['figure-modal'] === 'enabled'
+ const tableGeneratorEnabled =
+ splitTestVariants['table-generator'] === 'enabled'
const getPreviewByPath = (path: string) =>
options.fileTreeManager.getPreviewByPath(path)
@@ -300,6 +303,22 @@ export const atomicDecorations = (options: Options) => {
)
}
}
+ } else if (
+ tableGeneratorEnabled &&
+ nodeRef.type.is('TabularEnvironment')
+ ) {
+ if (shouldDecorate(state, nodeRef)) {
+ decorations.push(
+ Decoration.replace({
+ widget: new TabularWidget(
+ nodeRef.node,
+ state.doc.sliceString(nodeRef.from, nodeRef.to)
+ ),
+ block: true,
+ }).range(nodeRef.from, nodeRef.to)
+ )
+ return false
+ }
}
} else if (nodeRef.type.is('BeginEnv')) {
// the beginning of an environment, with an environment name argument
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts b/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts
index 1b9a348e49..6e6a826e62 100644
--- a/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts
@@ -1,8 +1,23 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
-import { isUnknownCommandWithName } from '../../../utils/tree-query'
import { LineBreakCtrlSym } from '../../../lezer-latex/latex.terms.mjs'
+const isUnknownCommandWithName = (
+ node: SyntaxNode,
+ command: string,
+ getText: (from: number, to: number) => string
+): boolean => {
+ if (!node.type.is('UnknownCommand')) {
+ return false
+ }
+ const commandNameNode = node.getChild('CtrlSeq')
+ if (!commandNameNode) {
+ return false
+ }
+ const commandName = getText(commandNameNode.from, commandNameNode.to)
+ return commandName === command
+}
+
/**
* Does a small amount of typesetting of LaTeX content into a DOM element.
* Does **not** typeset math, you **must manually** invoke MathJax after this
@@ -14,8 +29,14 @@ import { LineBreakCtrlSym } from '../../../lezer-latex/latex.terms.mjs'
export function typesetNodeIntoElement(
node: SyntaxNode,
element: HTMLElement,
- state: EditorState
+ state: EditorState | ((from: number, to: number) => string)
) {
+ let getText: (from: number, to: number) => string
+ if (typeof state === 'function') {
+ getText = state
+ } else {
+ getText = state!.sliceDoc.bind(state!)
+ }
// If we're a TextArgument node, we should skip the braces
const argument = node.getChild('LongArg')
if (argument) {
@@ -34,39 +55,39 @@ export function typesetNodeIntoElement(
const childNode = childNodeRef.node
if (from < childNode.from) {
ancestor().append(
- document.createTextNode(state.sliceDoc(from, childNode.from))
+ document.createTextNode(getText(from, childNode.from))
)
from = childNode.from
}
- if (isUnknownCommandWithName(childNode, '\\textit', state)) {
+ if (isUnknownCommandWithName(childNode, '\\textit', getText)) {
pushAncestor(document.createElement('i'))
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
- } else if (isUnknownCommandWithName(childNode, '\\textbf', state)) {
+ } else if (isUnknownCommandWithName(childNode, '\\textbf', getText)) {
pushAncestor(document.createElement('b'))
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
- } else if (isUnknownCommandWithName(childNode, '\\emph', state)) {
+ } else if (isUnknownCommandWithName(childNode, '\\emph', getText)) {
pushAncestor(document.createElement('em'))
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
- } else if (isUnknownCommandWithName(childNode, '\\texttt', state)) {
+ } else if (isUnknownCommandWithName(childNode, '\\texttt', getText)) {
const spanElement = document.createElement('span')
spanElement.classList.add('ol-cm-command-texttt')
pushAncestor(spanElement)
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
- } else if (isUnknownCommandWithName(childNode, '\\and', state)) {
+ } else if (isUnknownCommandWithName(childNode, '\\and', getText)) {
const spanElement = document.createElement('span')
spanElement.classList.add('ol-cm-command-and')
pushAncestor(spanElement)
const textArgument = childNode.getChild('TextArgument')
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
} else if (
- isUnknownCommandWithName(childNode, '\\corref', state) ||
- isUnknownCommandWithName(childNode, '\\fnref', state) ||
- isUnknownCommandWithName(childNode, '\\thanks', state)
+ isUnknownCommandWithName(childNode, '\\corref', getText) ||
+ isUnknownCommandWithName(childNode, '\\fnref', getText) ||
+ isUnknownCommandWithName(childNode, '\\thanks', getText)
) {
// ignoring these commands
from = childNode.to
@@ -79,11 +100,11 @@ export function typesetNodeIntoElement(
function leave(childNodeRef) {
const childNode = childNodeRef.node
if (
- isUnknownCommandWithName(childNode, '\\and', state) ||
- isUnknownCommandWithName(childNode, '\\textit', state) ||
- isUnknownCommandWithName(childNode, '\\textbf', state) ||
- isUnknownCommandWithName(childNode, '\\emph', state) ||
- isUnknownCommandWithName(childNode, '\\texttt', state)
+ isUnknownCommandWithName(childNode, '\\and', getText) ||
+ isUnknownCommandWithName(childNode, '\\textit', getText) ||
+ isUnknownCommandWithName(childNode, '\\textbf', getText) ||
+ isUnknownCommandWithName(childNode, '\\emph', getText) ||
+ isUnknownCommandWithName(childNode, '\\texttt', getText)
) {
const typeSetElement = popAncestor()
ancestor().appendChild(typeSetElement)
@@ -96,7 +117,7 @@ export function typesetNodeIntoElement(
}
)
if (from < node.to) {
- ancestor().append(document.createTextNode(state.sliceDoc(from, node.to)))
+ ancestor().append(document.createTextNode(getText(from, node.to)))
}
return element
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx
new file mode 100644
index 0000000000..2ac2005904
--- /dev/null
+++ b/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(
+ ,
+ 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)
+ }
+ }
+}
diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
index ef36c32d02..40a3b21003 100644
--- a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
+++ b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar
@@ -90,6 +90,7 @@
MaketitleCtrlSeq,
TextColorCtrlSeq,
ColorBoxCtrlSeq
+ HLineCtrlSeq
}
@external specialize {EnvName} specializeEnvName from "./tokens.mjs" {
@@ -332,6 +333,9 @@ KnownCommand {
} |
Maketitle {
MaketitleCtrlSeq optionalWhitespace?
+ } |
+ HLine {
+ HLineCtrlSeq optionalWhitespace?
}
}
diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
index 3c7729a42a..56d6c66425 100644
--- a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
+++ b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs
@@ -71,6 +71,7 @@ import {
MaketitleCtrlSeq,
TextColorCtrlSeq,
ColorBoxCtrlSeq,
+ HLineCtrlSeq,
} from './latex.terms.mjs'
function nameChar(ch) {
@@ -618,6 +619,7 @@ const otherKnowncommands = {
'\\maketitle': MaketitleCtrlSeq,
'\\textcolor': TextColorCtrlSeq,
'\\colorbox': ColorBoxCtrlSeq,
+ '\\hline': HLineCtrlSeq,
}
// specializer for control sequences
// return new tokens for specific control sequences
diff --git a/services/web/frontend/stories/source-editor/source-editor.stories.tsx b/services/web/frontend/stories/source-editor/source-editor.stories.tsx
index a1eca33196..267b421888 100644
--- a/services/web/frontend/stories/source-editor/source-editor.stories.tsx
+++ b/services/web/frontend/stories/source-editor/source-editor.stories.tsx
@@ -97,7 +97,10 @@ export const Visual = (args: any, { globals: { theme } }: any) => {
useMeta({
'ol-showSymbolPalette': true,
'ol-mathJax3Path': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js',
- 'ol-splitTestVariants': { 'figure-modal': 'enabled' },
+ 'ol-splitTestVariants': {
+ 'figure-modal': 'enabled',
+ 'table-generator': 'enabled',
+ },
})
return
diff --git a/services/web/frontend/stylesheets/app/editor.less b/services/web/frontend/stylesheets/app/editor.less
index 2a89c79c90..e3930c8fe8 100644
--- a/services/web/frontend/stylesheets/app/editor.less
+++ b/services/web/frontend/stylesheets/app/editor.less
@@ -20,6 +20,7 @@
@import './editor/dictionary.less';
@import './editor/compile-button.less';
@import './editor/figure-modal.less';
+@import './editor/table-generator.less';
@ui-layout-toggler-def-height: 50px;
@ui-resizer-size: 7px;
diff --git a/services/web/frontend/stylesheets/app/editor/table-generator.less b/services/web/frontend/stylesheets/app/editor/table-generator.less
new file mode 100644
index 0000000000..5d61d0c58b
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 7530c5a6e9..d942b8735e 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1711,6 +1711,8 @@
"toolbar_insert_table": "Insert Table",
"toolbar_numbered_list": "Numbered List",
"toolbar_redo": "Redo",
+ "toolbar_table_insert_size_table": "Insert __size__ table",
+ "toolbar_table_insert_table_lowercase": "Insert table",
"toolbar_toggle_symbol_palette": "Toggle Symbol Palette",
"toolbar_undo": "Undo",
"tooltip_hide_filetree": "Click to hide the file-tree",