diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx index 3fbdefd0ed..23b6d29b80 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx @@ -125,7 +125,9 @@ export const Cell: FC<{ toDisplay.substring.bind(toDisplay) ) loadMathJax().then(async MathJax => { - await MathJax.typesetPromise([renderDiv.current]) + if (renderDiv.current) { + await MathJax.typesetPromise([renderDiv.current]) + } }) } }, [cellData.content, editing]) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx index 59b987c7a3..e074b82827 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx @@ -6,10 +6,17 @@ import { RowPosition, RowSeparator, generateTable, + parseTableEnvironment, } from '../utils' import { EditorView } from '@codemirror/view' import { SyntaxNode } from '@lezer/common' +export type TableEnvironmentData = { + table: { from: number; to: number } + caption?: { from: number; to: number } + label?: { from: number; to: number } +} + const TableContext = createContext< | { table: TableData @@ -19,6 +26,7 @@ const TableContext = createContext< rowSeparators: RowSeparator[] cellSeparators: CellSeparator[][] positions: Positions + tableEnvironment?: TableEnvironmentData } | undefined >(undefined) @@ -26,7 +34,8 @@ const TableContext = createContext< export const TableProvider: FC<{ tabularNode: SyntaxNode view: EditorView -}> = ({ tabularNode, view, children }) => { + tableNode: SyntaxNode | null +}> = ({ tabularNode, view, children, tableNode }) => { const tableData = generateTable(tabularNode, view.state) // TODO: Validate better that the table matches the column definition @@ -40,9 +49,21 @@ export const TableProvider: FC<{ cells: tableData.cellPositions, columnDeclarations: tableData.specification, rowPositions: tableData.rowPositions, + tabular: { from: tabularNode.from, to: tabularNode.to }, } + + const tableEnvironment = tableNode + ? parseTableEnvironment(tableNode) + : undefined + return ( - + {children} ) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx index 397a5f4bfe..4d19c249a4 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx @@ -47,6 +47,7 @@ export type Positions = { cells: CellPosition[][] columnDeclarations: { from: number; to: number } rowPositions: RowPosition[] + tabular: { from: number; to: number } } export const TableRenderingError: FC<{ @@ -79,7 +80,8 @@ export const TableRenderingError: FC<{ export const Tabular: FC<{ tabularNode: SyntaxNode view: EditorView -}> = ({ tabularNode, view }) => { + tableNode: SyntaxNode | null +}> = ({ tabularNode, view, tableNode }) => { return ( ( @@ -88,7 +90,11 @@ export const Tabular: FC<{ > - + diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts index 2da5215426..6e4b19ce66 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts @@ -1,12 +1,18 @@ import { EditorView } from '@codemirror/view' import { ColumnDefinition, Positions } from '../tabular' -import { ChangeSpec } from '@codemirror/state' +import { ChangeSpec, EditorSelection } from '@codemirror/state' import { CellSeparator, RowSeparator, parseColumnSpecifications, } from '../utils' import { TableSelection } from '../contexts/selection-context' +import { ensureEmptyLine } from '../../../extensions/toolbar/commands' +import { TableEnvironmentData } from '../contexts/table-context' +import { + extendBackwardsOverEmptyLines, + extendForwardsOverEmptyLines, +} from '../../../extensions/visual/selection' /* eslint-disable no-unused-vars */ export enum BorderTheme { @@ -295,3 +301,135 @@ export const insertColumn = ( }) view.dispatch({ changes }) } + +export const removeNodes = ( + view: EditorView, + ...nodes: ({ from: number; to: number } | undefined)[] +) => { + const changes: ChangeSpec[] = [] + for (const node of nodes) { + if (node !== undefined) { + changes.push({ from: node.from, to: node.to, insert: '' }) + } + } + view.dispatch({ + changes, + }) +} + +const contains = ( + { from: outerFrom, to: outerTo }: { from: number; to: number }, + { from: innerFrom, to: innerTo }: { from: number; to: number } +) => { + return outerFrom <= innerFrom && outerTo >= innerTo +} + +export const moveCaption = ( + view: EditorView, + positions: Positions, + target: 'above' | 'below', + tableEnvironment?: TableEnvironmentData +) => { + const changes: ChangeSpec[] = [] + const position = + target === 'above' ? positions.tabular.from : positions.tabular.to + const cursor = EditorSelection.cursor(position) + + if (tableEnvironment?.caption) { + const { caption: existingCaption } = tableEnvironment + if ( + (existingCaption.from < positions.tabular.from && target === 'above') || + (existingCaption.from > positions.tabular.to && target === 'below') + ) { + // It's already in the right place + return + } + } + + const { pos, prefix, suffix } = ensureEmptyLine(view.state, cursor, target) + + if (!tableEnvironment?.caption) { + let labelText = '\\label{tab:my_table}' + if (tableEnvironment?.label) { + // We have a label, but no caption. Move the label after our caption + changes.push({ + from: tableEnvironment.label.from, + to: tableEnvironment.label.to, + insert: '', + }) + labelText = view.state.sliceDoc( + tableEnvironment.label.from, + tableEnvironment.label.to + ) + } + changes.push({ + ...gobbleEmptyLines(view, pos, 2, target), + insert: `${prefix}\\caption{Caption}\n${labelText}${suffix}`, + }) + } else { + const { caption: existingCaption, label: existingLabel } = tableEnvironment + // We have a caption, and we need to move it + let currentCaption = view.state.sliceDoc( + existingCaption.from, + existingCaption.to + ) + if (existingLabel && !contains(existingCaption, existingLabel)) { + // Move label with it + const labelText = view.state.sliceDoc( + existingLabel.from, + existingLabel.to + ) + currentCaption += `\n${labelText}` + changes.push({ + from: existingLabel.from, + to: existingLabel.to, + insert: '', + }) + } + changes.push({ + ...gobbleEmptyLines(view, pos, 2, target), + insert: `${prefix}${currentCaption}${suffix}`, + }) + // remove exsisting caption + changes.push({ + from: existingCaption.from, + to: existingCaption.to, + insert: '', + }) + } + view.dispatch({ changes }) +} + +export const removeCaption = ( + view: EditorView, + tableEnvironment?: TableEnvironmentData +) => { + if (tableEnvironment?.caption && tableEnvironment.label) { + if (contains(tableEnvironment.caption, tableEnvironment.label)) { + return removeNodes(view, tableEnvironment.caption) + } + } + return removeNodes(view, tableEnvironment?.caption, tableEnvironment?.label) +} + +const gobbleEmptyLines = ( + view: EditorView, + pos: number, + lines: number, + target: 'above' | 'below' +) => { + const line = view.state.doc.lineAt(pos) + if (line.length !== 0) { + return { from: pos, to: pos } + } + if (target === 'above') { + return { + from: extendBackwardsOverEmptyLines(view.state.doc, line, lines), + to: pos, + } + } + return { + from: pos, + to: extendForwardsOverEmptyLines(view.state.doc, line, lines), + } +} diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx index d3c28c342b..addb32de9f 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx @@ -8,6 +8,9 @@ import { BorderTheme, insertColumn, insertRow, + moveCaption, + removeCaption, + removeNodes, removeRowOrColumns, setAlignment, setBorders, @@ -18,7 +21,8 @@ import { useTableContext } from '../contexts/table-context' export const Toolbar = memo(function Toolbar() { const { selection } = useSelectionContext() const view = useCodeMirrorViewContext() - const { positions, rowSeparators, cellSeparators } = useTableContext() + const { positions, rowSeparators, cellSeparators, tableEnvironment } = + useTableContext() if (!selection) { return null } @@ -27,12 +31,15 @@ export const Toolbar = memo(function Toolbar() { @@ -40,6 +47,9 @@ export const Toolbar = memo(function Toolbar() { className="ol-cm-toolbar-menu-item" role="menuitem" type="button" + onClick={() => { + moveCaption(view, positions, 'above', tableEnvironment) + }} > Caption above @@ -47,6 +57,9 @@ export const Toolbar = memo(function Toolbar() { className="ol-cm-toolbar-menu-item" role="menuitem" type="button" + onClick={() => { + moveCaption(view, positions, 'below', tableEnvironment) + }} > Caption below @@ -212,7 +225,9 @@ export const Toolbar = memo(function Toolbar() { icon="delete_forever" id="table-generator-remove-table" label="Delete table" - disabled + command={() => { + removeNodes(view, tableEnvironment?.table ?? positions.tabular) + }} /> diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts b/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts index 83a9635327..29f0ac8f80 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts +++ b/services/web/frontend/js/features/source-editor/components/table-generator/utils.ts @@ -1,6 +1,7 @@ import { EditorState } from '@codemirror/state' import { SyntaxNode } from '@lezer/common' import { ColumnDefinition, TableData } from './tabular' +import { TableEnvironmentData } from './contexts/table-context' const ALIGNMENT_CHARACTERS = ['c', 'l', 'r', 'p'] @@ -279,3 +280,21 @@ export function generateTable( cellSeparators, } } + +export function parseTableEnvironment(tableNode: SyntaxNode) { + const tableEnvironment: TableEnvironmentData = { + table: { from: tableNode.from, to: tableNode.to }, + } + tableNode.cursor().iterate(({ type, from, to }) => { + if (tableEnvironment.caption && tableEnvironment.label) { + // Stop looking once we've found both caption and label + return false + } + if (type.is('Caption')) { + tableEnvironment.caption = { from, to } + } else if (type.is('Label')) { + tableEnvironment.label = { from, to } + } + }) + return tableEnvironment +} 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 dcbacf4b34..367f987e04 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 @@ -32,21 +32,32 @@ export const toggleNumberedList = toggleListForRanges('enumerate') export const wrapInInlineMath = wrapRanges('\\(', '\\)') export const wrapInDisplayMath = wrapRanges('\n\\[', '\\]\n') -export const ensureEmptyLine = (state: EditorState, range: SelectionRange) => { +export const ensureEmptyLine = ( + state: EditorState, + range: SelectionRange, + direction: 'above' | 'below' = 'below' +) => { let pos = range.anchor let suffix = '' + let prefix = '' const line = state.doc.lineAt(pos) if (line.text.trim().length) { - pos = Math.min(line.to + 1, state.doc.length) - const nextLine = state.doc.lineAt(pos) + if (direction === 'below') { + pos = Math.min(line.to + 1, state.doc.length) + } else { + pos = Math.max(line.from - 1, 0) + } + const neighbouringLine = state.doc.lineAt(pos) - if (nextLine.length) { + if (neighbouringLine.length && direction === 'below') { suffix = '\n' + } else if (neighbouringLine.length && direction === 'above') { + prefix = '\n' } } - return { pos, suffix } + return { pos, suffix, prefix } } export const insertFigure: Command = view => { @@ -66,6 +77,8 @@ export const insertTable = (view: EditorView, sizeX: number, sizeY: number) => { ${('\t\t' + '#{} & #{}'.repeat(sizeX - 1) + '\\\\\n').repeat( sizeY )}\\end{tabular} +\t\\caption{Caption} +\t\\label{tab:my_label} \\end{table}${suffix}` snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos) return true 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 a2bbd6d415..3a90a7d4fe 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 @@ -42,7 +42,10 @@ import { DividerWidget } from './visual-widgets/divider' import { PreambleWidget } from './visual-widgets/preamble' import { EndDocumentWidget } from './visual-widgets/end-document' import { EnvironmentLineWidget } from './visual-widgets/environment-line' -import { ListEnvironmentName } from '../../utils/tree-operations/ancestors' +import { + ListEnvironmentName, + ancestorOfNodeWithType, +} from '../../utils/tree-operations/ancestors' import { InlineGraphicsWidget } from './visual-widgets/inline-graphics' import getMeta from '../../../../utils/meta' import { EditableGraphicsWidget } from './visual-widgets/editable-graphics' @@ -310,11 +313,19 @@ export const atomicDecorations = (options: Options) => { nodeRef.type.is('TabularEnvironment') ) { if (shouldDecorate(state, nodeRef)) { + const tableNode = ancestorOfNodeWithType( + nodeRef.node, + 'TableEnvironment' + ) decorations.push( Decoration.replace({ widget: new TabularWidget( nodeRef.node, - state.doc.sliceString(nodeRef.from, nodeRef.to) + state.doc.sliceString( + (tableNode ?? nodeRef).from, + (tableNode ?? nodeRef).to + ), + tableNode ), block: true, }).range(nodeRef.from, nodeRef.to) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/selection.ts b/services/web/frontend/js/features/source-editor/extensions/visual/selection.ts index dddb6cb858..d7e6bfc33b 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/selection.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/selection.ts @@ -35,9 +35,17 @@ export const placeSelectionInsideBlock = ( return { selection, effects: EditorView.scrollIntoView(line.to) } } -export const extendBackwardsOverEmptyLines = (doc: Text, line: Line) => { +export const extendBackwardsOverEmptyLines = ( + doc: Text, + line: Line, + limit: number = Number.POSITIVE_INFINITY +) => { let { number, from } = line - for (let lineNumber = number - 1; lineNumber > 0; lineNumber--) { + for ( + let lineNumber = number - 1; + lineNumber > 0 && number - lineNumber <= limit; + lineNumber-- + ) { const line = doc.line(lineNumber) if (line.text.trim().length > 0) { break @@ -47,9 +55,17 @@ export const extendBackwardsOverEmptyLines = (doc: Text, line: Line) => { return from } -export const extendForwardsOverEmptyLines = (doc: Text, line: Line) => { +export const extendForwardsOverEmptyLines = ( + doc: Text, + line: Line, + limit: number = Number.POSITIVE_INFINITY +) => { let { number, to } = line - for (let lineNumber = number + 1; lineNumber <= doc.lines; lineNumber++) { + for ( + let lineNumber = number + 1; + lineNumber <= doc.lines && lineNumber - number <= limit; + lineNumber++ + ) { const line = doc.line(lineNumber) if (line.text.trim().length > 0) { break 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 index 2ac2005904..fa9af58141 100644 --- 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 @@ -6,7 +6,11 @@ import { Tabular } from '../../../components/table-generator/tabular' export class TabularWidget extends WidgetType { private element: HTMLElement | undefined - constructor(private node: SyntaxNode, private content: string) { + constructor( + private tabularNode: SyntaxNode, + private content: string, + private tableNode: SyntaxNode | null + ) { super() } @@ -15,7 +19,11 @@ export class TabularWidget extends WidgetType { this.element.classList.add('ol-cm-tabular') this.element.style.backgroundColor = 'rgba(125, 125, 125, 0.05)' ReactDOM.render( - , + , this.element ) return this.element @@ -23,7 +31,10 @@ export class TabularWidget extends WidgetType { eq(widget: TabularWidget): boolean { return ( - this.node.from === widget.node.from && this.content === widget.content + this.tabularNode.from === widget.tabularNode.from && + this.tableNode?.from === widget.tableNode?.from && + this.tableNode?.to === widget.tableNode?.to && + this.content === widget.content ) } 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 7e8bebbb4d..6924593567 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 @@ -105,7 +105,8 @@ VerbatimEnvName, TikzPictureEnvName, FigureEnvName, - ListEnvName + ListEnvName, + TableEnvName } @external specialize {CtrlSym} specializeCtrlSym from "./tokens.mjs" { @@ -521,6 +522,7 @@ KnownEnvironment { | TikzPictureEnvironment | FigureEnvironment | ListEnvironment + | TableEnvironment ) } @@ -551,6 +553,12 @@ TabularEnvironment[@isGroup="$Environment"] { EndEnv } +TableEnvironment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv +} + EquationEnvironment[@isGroup="$Environment"] { BeginEnv Content 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 f3942a88e8..931dc68dbc 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 @@ -76,6 +76,7 @@ import { TopRuleCtrlSeq, MidRuleCtrlSeq, BottomRuleCtrlSeq, + TableEnvName, } from './latex.terms.mjs' function nameChar(ch) { @@ -736,6 +737,7 @@ const otherKnownEnvNames = { subfigure: FigureEnvName, enumerate: ListEnvName, itemize: ListEnvName, + table: TableEnvName, } export const specializeEnvName = (name, terms) => {