From c332a65eb070df179c46230b66a5437bafc03d39 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Tue, 8 Aug 2023 13:14:25 +0100 Subject: [PATCH] Merge pull request #14105 from overleaf/mj-table-generator [visual] Initial table generator GitOrigin-RevId: 5c1a9cad898c988d21390358872a6c2eaf1c32fc --- .../src/Features/Project/ProjectController.js | 6 + .../web/frontend/extracted-translations.json | 2 + .../components/codemirror-editor.tsx | 8 +- .../components/table-generator/cell.tsx | 131 +++++++ .../contexts/editing-context.tsx | 96 +++++ .../contexts/selection-context.tsx | 96 +++++ .../contexts/table-context.tsx | 55 +++ .../components/table-generator/row.tsx | 26 ++ .../components/table-generator/selectors.tsx | 62 +++ .../components/table-generator/table.tsx | 73 ++++ .../components/table-generator/tabular.tsx | 88 +++++ .../table-generator/toolbar/commands.ts | 98 +++++ .../toolbar/toolbar-button-menu.tsx | 88 +++++ .../toolbar/toolbar-button.tsx | 83 ++++ .../toolbar/toolbar-dropdown.tsx | 93 +++++ .../table-generator/toolbar/toolbar.tsx | 181 +++++++++ .../components/table-generator/utils.ts | 262 +++++++++++++ .../toolbar/table-inserter-dropdown.tsx | 121 ++++++ .../components/toolbar/toolbar-items.tsx | 10 +- .../extensions/toolbar/commands.ts | 12 +- .../extensions/toolbar/toolbar-panel.ts | 25 ++ .../extensions/visual/atomic-decorations.ts | 19 + .../visual/utils/typeset-content.ts | 55 ++- .../visual/visual-widgets/tabular.tsx | 36 ++ .../source-editor/lezer-latex/latex.grammar | 4 + .../source-editor/lezer-latex/tokens.mjs | 2 + .../source-editor/source-editor.stories.tsx | 5 +- .../web/frontend/stylesheets/app/editor.less | 1 + .../app/editor/table-generator.less | 368 ++++++++++++++++++ services/web/locales/en.json | 2 + 30 files changed, 2077 insertions(+), 31 deletions(-) create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/row.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/selectors.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/table.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx create mode 100644 services/web/frontend/js/features/source-editor/components/table-generator/utils.ts create mode 100644 services/web/frontend/js/features/source-editor/components/toolbar/table-inserter-dropdown.tsx create mode 100644 services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx create mode 100644 services/web/frontend/stylesheets/app/editor/table-generator.less diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 7856efee06..a5ae716fb8 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -683,6 +683,12 @@ const ProjectController = { cb() }) }, + tableGeneratorAssignment(cb) { + SplitTestHandler.getAssignment(req, res, 'table-generator', () => { + // We'll pick up the assignment from the res.locals assignment. + cb() + }) + }, sourceEditorToolbarAssigment(cb) { SplitTestHandler.getAssignment( req, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index c3cf46e3ee..c9b4c5dd72 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1077,6 +1077,8 @@ "toolbar_insert_table": "", "toolbar_numbered_list": "", "toolbar_redo": "", + "toolbar_table_insert_size_table": "", + "toolbar_table_insert_table_lowercase": "", "toolbar_toggle_symbol_palette": "", "toolbar_undo": "", "total_per_month": "", diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx index afad7a7fe3..d7d4f8abef 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx +++ b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx @@ -54,7 +54,7 @@ function CodeMirrorEditor() { return ( - + @@ -66,7 +66,7 @@ function CodeMirrorEditor() { ) )} - + ) } @@ -89,12 +89,14 @@ export const useCodeMirrorStateContext = (): EditorState => { const CodeMirrorViewContext = createContext(undefined) +export const CodeMirrorViewContextProvider = CodeMirrorViewContext.Provider + export const useCodeMirrorViewContext = (): EditorView => { const context = useContext(CodeMirrorViewContext) if (!context) { throw new Error( - 'useCodeMirrorViewContext is only available inside CodeMirrorEditor' + 'useCodeMirrorViewContext is only available inside CodeMirrorViewContextProvider' ) } diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx new file mode 100644 index 0000000000..6fcef882f8 --- /dev/null +++ b/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(null) + const { + cellData: editingCellData, + updateCellData: update, + startEditing, + } = useEditingContext() + const inputRef = useRef(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.rows.map((row, rowIndex) => ( + + ))} + +
+ {tableData.columns.map((_, columnIndex) => ( + + ))} +
+ ) +} diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx 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)} + > + + { + onToggle(false) + }} + > + {children} + + + + ) + + if (!label) { + return ( + <> + {button} + {overlay} + + ) + } + + return ( + <> + + {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 ( + <> + + 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" /> )} -
)} {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",