diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/cell-input.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/cell-input.tsx index eea425223c..461b299822 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/cell-input.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/cell-input.tsx @@ -6,7 +6,7 @@ interface CellInputProps } export type CellInputRef = { - focus: () => void + focus: (options?: FocusOptions) => void } export const CellInput = forwardRef( @@ -14,9 +14,9 @@ export const CellInput = forwardRef( const inputRef = useRef(null) useImperativeHandle(ref, () => { return { - focus() { - inputRef.current?.focus() + focus(options) { inputRef.current?.setSelectionRange(value.length, value.length) + inputRef.current?.focus(options) }, } }) 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 168f74eb8d..e71260ca97 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 @@ -11,6 +11,7 @@ import { typesetNodeIntoElement } from '../../extensions/visual/utils/typeset-co import { parser } from '../../lezer-latex/latex.mjs' import { useTableContext } from './contexts/table-context' import { CellInput, CellInputRef } from './cell-input' +import { useCodeMirrorViewContext } from '../codemirror-editor' export const Cell: FC<{ cellData: CellData @@ -40,6 +41,7 @@ export const Cell: FC<{ commitCellData, } = useEditingContext() const inputRef = useRef(null) + const view = useCodeMirrorViewContext() const editing = editingCellData?.rowIndex === rowIndex && @@ -132,6 +134,9 @@ export const Cell: FC<{ .replaceAll(/(^&|[^\\]&)/g, match => match.length === 1 ? '\\&' : `${match[0]}\\&` ) + .replaceAll(/(^%|[^\\]%)/g, match => + match.length === 1 ? '\\%' : `${match[0]}\\%` + ) .replaceAll('\\\\', '') }, []) @@ -142,7 +147,7 @@ export const Cell: FC<{ useEffect(() => { if (isFocused && !editing && cellRef.current) { - cellRef.current.focus() + cellRef.current.focus({ preventScroll: true }) } }, [isFocused, editing]) @@ -161,11 +166,12 @@ export const Cell: FC<{ .then(async MathJax => { if (renderDiv.current) { await MathJax.typesetPromise([renderDiv.current]) + view.requestMeasure() } }) .catch(() => {}) } - }, [cellData.content, editing]) + }, [cellData.content, editing, view]) const onInput = useCallback( e => { @@ -227,6 +233,7 @@ export const Cell: FC<{ inSelection && selection?.bordersLeft(rowIndex, columnIndex, table), 'selection-edge-right': inSelection && selection?.bordersRight(rowIndex, columnIndex, table), + editing, })} > {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 index c785b983cb..32b188e0aa 100644 --- 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 @@ -57,6 +57,7 @@ export const EditingContextProvider: FC = ({ children }) => { view.dispatch({ changes: { from, to, insert: content }, }) + view.requestMeasure() setCellData(null) }, [view, table, initialContent] @@ -68,6 +69,7 @@ export const EditingContextProvider: FC = ({ children }) => { } if (!cellData.dirty) { setCellData(null) + setInitialContent(undefined) return } const { rowIndex, cellIndex, content } = cellData 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 index f06a7c6789..e792e95521 100644 --- 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 @@ -3,6 +3,7 @@ import { KeyboardEvent, KeyboardEventHandler, useCallback, + useEffect, useMemo, useRef, } from 'react' @@ -15,6 +16,8 @@ import { import { useEditingContext } from './contexts/editing-context' import { useTableContext } from './contexts/table-context' import { useCodeMirrorViewContext } from '../codemirror-editor' +import { undo, redo } from '@codemirror/commands' +import { ChangeSpec } from '@codemirror/state' type NavigationKey = | 'ArrowRight' @@ -28,6 +31,8 @@ type NavigationMap = { [key in NavigationKey]: [() => TableSelection, () => TableSelection] } +const isMac = /Mac/.test(window.navigator?.platform) + export const Table: FC = () => { const { selection, setSelection } = useSelectionContext() const { @@ -70,6 +75,7 @@ export const Table: FC = () => { const isCharacterInput = useCallback((event: KeyboardEvent) => { return ( + Boolean(event.code) && // is a keyboard key event.key?.length === 1 && !event.ctrlKey && !event.metaKey && @@ -79,6 +85,7 @@ export const Table: FC = () => { const onKeyDown: KeyboardEventHandler = useCallback( event => { + const commandKey = isMac ? event.metaKey : event.ctrlKey if (event.code === 'Enter' && !event.shiftKey) { event.preventDefault() event.stopPropagation() @@ -116,6 +123,18 @@ export const Table: FC = () => { event.preventDefault() event.stopPropagation() clearCells(selection) + view.requestMeasure() + setTimeout(() => { + if (tableRef.current) { + const { minY } = selection.normalized() + const row = tableRef.current.querySelectorAll('tbody tr')[minY] + if (row) { + if (row.getBoundingClientRect().top < 0) { + row.scrollIntoView({ block: 'center' }) + } + } + } + }, 0) } else if (Object.prototype.hasOwnProperty.call(navigation, event.code)) { const [defaultNavigation, shiftNavigation] = navigation[event.code as NavigationKey] @@ -146,6 +165,21 @@ export const Table: FC = () => { startEditing(selection.to.row, selection.to.cell) updateCellData(event.key) setSelection(new TableSelection(selection.to, selection.to)) + } else if ( + !cellData && + event.key === 'z' && + !event.shiftKey && + commandKey + ) { + event.preventDefault() + undo(view) + } else if ( + !cellData && + (event.key === 'y' || + (event.key === 'Z' && event.shiftKey && commandKey)) + ) { + event.preventDefault() + redo(view) } }, [ @@ -163,6 +197,67 @@ export const Table: FC = () => { tableData, ] ) + + useEffect(() => { + const onPaste = (event: ClipboardEvent) => { + if (cellData || !selection) { + // We're editing a cell, so allow browser to insert there + return false + } + event.preventDefault() + const changes: ChangeSpec[] = [] + const data = event.clipboardData?.getData('text/plain') + if (data) { + const rows = data.split('\n') + const { minX, minY } = selection.normalized() + for (let row = 0; row < rows.length; row++) { + if (tableData.rows.length <= minY + row) { + // TODO: Add rows + continue + } + const cells = rows[row].split('\t') + for (let cell = 0; cell < cells.length; cell++) { + if (tableData.columns.length <= minX + cell) { + // TODO: Add columns + continue + } + const cellData = tableData.getCell(minY + row, minX + cell) + changes.push({ + from: cellData.from, + to: cellData.to, + insert: cells[cell], + }) + } + } + } + view.dispatch({ changes }) + } + + const onCopy = (event: ClipboardEvent) => { + if (cellData || !selection) { + // We're editing a cell, so allow browser to insert there + return false + } + event.preventDefault() + const { minX, minY, maxX, maxY } = selection.normalized() + const content = [] + for (let row = minY; row <= maxY; row++) { + const rowContent = [] + for (let cell = minX; cell <= maxX; cell++) { + rowContent.push(tableData.getCell(row, cell).content) + } + content.push(rowContent.join('\t')) + } + navigator.clipboard.writeText(content.join('\n')) + } + window.addEventListener('paste', onPaste) + window.addEventListener('copy', onCopy) + return () => { + window.removeEventListener('paste', onPaste) + window.removeEventListener('copy', onCopy) + } + }, [cellData, selection, tableData, view]) + return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions 1 && + 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) + const lastLineContents = state.sliceDoc( + lastRow.position.from, + lastRow.position.to + ) + const lastLineOffset = + lastLineContents.length - lastLineContents.trimEnd().length + getLastRow().position.to = lastRow.position.to - lastLineOffset } return body } @@ -339,7 +352,7 @@ export function generateTable( const columns = parseColumnSpecifications( state.sliceDoc(specification.from, specification.to) ) - const body = node.getChild('Content')?.getChild('TabularContent')?.firstChild + const body = node.getChild('Content')?.getChild('TabularContent') if (!body) { throw new Error('Missing table body') } diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/table-generator.ts b/services/web/frontend/js/features/source-editor/extensions/visual/table-generator.ts index 984bf10c94..d95eb0d767 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/table-generator.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/table-generator.ts @@ -113,6 +113,8 @@ export const tableGeneratorTheme = EditorView.baseTheme({ 'border-bottom-color': 'var(--table-generator-active-border-color)', 'border-bottom-width': 'var(--table-generator-active-border-width)', }, + 'overflow-x': 'auto', + 'overflow-y': 'hidden', }, '.table-generator-table': { @@ -122,8 +124,11 @@ export const tableGeneratorTheme = EditorView.baseTheme({ cursor: 'default', '& td': { - padding: '0 0.25em', + '&:not(.editing)': { + padding: '0 0.25em', + }, 'max-width': '200px', + 'vertical-align': 'top', '&.alignment-left': { 'text-align': 'left', @@ -280,11 +285,16 @@ export const tableGeneratorTheme = EditorView.baseTheme({ 'background-color': 'transparent', width: '100%', height: '1.5em', + 'min-height': '100%', border: '1px solid var(--table-generator-toolbar-shadow-color)', - padding: '0', + padding: '0 0.25em', resize: 'none', 'box-sizing': 'border-box', overflow: 'hidden', + '&:focus, &:focus-visible': { + outline: '2px solid var(--table-generator-focus-border-color)', + 'outline-offset': '-2px', + }, }, '.table-generator-border-options-coming-soon': { 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 aed41c838e..46d74886c0 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,20 +1,59 @@ import { EditorState } from '@codemirror/state' import { SyntaxNode } from '@lezer/common' +import { COMMAND_SUBSTITUTIONS } from '../visual-widgets/character' const isUnknownCommandWithName = ( node: SyntaxNode, command: string, getText: (from: number, to: number) => string ): boolean => { - if (!node.type.is('UnknownCommand')) { + const commandName = getUnknownCommandName(node, getText) + if (commandName === undefined) { return false } + return commandName === command +} + +const getUnknownCommandName = ( + node: SyntaxNode, + getText: (from: number, to: number) => string +): string | undefined => { + if (!node.type.is('UnknownCommand')) { + return undefined + } const commandNameNode = node.getChild('CtrlSeq') if (!commandNameNode) { - return false + return undefined } const commandName = getText(commandNameNode.from, commandNameNode.to) - return commandName === command + return commandName +} + +type NodeMapping = { + elementType: keyof HTMLElementTagNameMap + className?: string +} +type MarkupMapping = { + [command: string]: NodeMapping +} +const MARKUP_COMMANDS: MarkupMapping = { + '\\textit': { + elementType: 'i', + }, + '\\textbf': { + elementType: 'b', + }, + '\\emph': { + elementType: 'em', + }, + '\\texttt': { + elementType: 'span', + className: 'ol-cm-command-texttt', + }, + '\\textsc': { + elementType: 'span', + className: 'ol-cm-command-textsc', + }, } /** @@ -56,28 +95,27 @@ export function typesetNodeIntoElement( ancestor().append( document.createTextNode(getText(from, childNode.from)) ) - from = childNode.from } - 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', getText)) { - pushAncestor(document.createElement('b')) - const textArgument = childNode.getChild('TextArgument') - from = textArgument?.getChild('LongArg')?.from ?? childNode.to - } 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', 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', getText)) { + + if (childNode.type.is('UnknownCommand')) { + const commandNameNode = childNode.getChild('CtrlSeq') + if (commandNameNode) { + const commandName = getText(commandNameNode.from, commandNameNode.to) + const mapping: NodeMapping | undefined = MARKUP_COMMANDS[commandName] + if (mapping) { + const element = document.createElement(mapping.elementType) + if (mapping.className) { + element.classList.add(mapping.className) + } + pushAncestor(element) + const textArgument = childNode.getChild('TextArgument') + from = textArgument?.getChild('LongArg')?.from ?? childNode.to + return + } + } + } + if (isUnknownCommandWithName(childNode, '\\and', getText)) { const spanElement = document.createElement('span') spanElement.classList.add('ol-cm-command-and') pushAncestor(spanElement) @@ -94,16 +132,22 @@ export function typesetNodeIntoElement( } else if (childNode.type.is('LineBreak')) { ancestor().appendChild(document.createElement('br')) from = childNode.to + } else if (childNode.type.is('UnknownCommand')) { + const command = getText(childNode.from, childNode.to) + const symbol = COMMAND_SUBSTITUTIONS.get(command.trim()) + if (symbol !== undefined) { + ancestor().append(document.createTextNode(symbol)) + from = childNode.to + return false + } } }, function leave(childNodeRef) { const childNode = childNodeRef.node + const commandName = getUnknownCommandName(childNode, getText) if ( - isUnknownCommandWithName(childNode, '\\and', getText) || - isUnknownCommandWithName(childNode, '\\textit', getText) || - isUnknownCommandWithName(childNode, '\\textbf', getText) || - isUnknownCommandWithName(childNode, '\\emph', getText) || - isUnknownCommandWithName(childNode, '\\texttt', getText) + (commandName && Boolean(MARKUP_COMMANDS[commandName])) || + isUnknownCommandWithName(childNode, '\\and', getText) ) { const typeSetElement = popAncestor() ancestor().appendChild(typeSetElement) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/character.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/character.ts index 6169c01b25..2ab20a19a1 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/character.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/character.ts @@ -26,7 +26,7 @@ export class CharacterWidget extends WidgetType { } } -const SUBSTITUTIONS = new Map([ +export const COMMAND_SUBSTITUTIONS = new Map([ ['\\', ' '], // a trimmed \\ ' ['\\%', '\u0025'], ['\\_', '\u005F'], @@ -151,12 +151,12 @@ const SUBSTITUTIONS = new Map([ export function createCharacterCommand( command: string ): CharacterWidget | undefined { - const substitution = SUBSTITUTIONS.get(command) + const substitution = COMMAND_SUBSTITUTIONS.get(command) if (substitution !== undefined) { return new CharacterWidget(substitution) } } export function hasCharacterSubstitution(command: string): boolean { - return SUBSTITUTIONS.has(command) + return COMMAND_SUBSTITUTIONS.has(command) } 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 7abd4ed14b..2bb4953c8a 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 @@ -10,7 +10,7 @@ import { export class TabularWidget extends WidgetType { private element: HTMLElement | undefined - private readonly parseResult: ParsedTableData + private readonly parseResult: ParsedTableData | null = null constructor( private tabularNode: SyntaxNode, @@ -19,10 +19,17 @@ export class TabularWidget extends WidgetType { state: EditorState ) { super() - this.parseResult = generateTable(tabularNode, state) + try { + this.parseResult = generateTable(tabularNode, state) + } catch (e) { + this.parseResult = null + } } isValid() { + if (!this.parseResult) { + return false + } for (const row of this.parseResult.table.rows) { const rowLength = row.cells.reduce( (acc, cell) => acc + (cell.multiColumn?.columnSpan ?? 1), @@ -49,15 +56,17 @@ export class TabularWidget extends WidgetType { if (this.tableNode) { this.element.classList.add('ol-cm-environment-table') } - ReactDOM.render( - , - this.element - ) + if (this.parseResult) { + ReactDOM.render( + , + this.element + ) + } return this.element } @@ -71,6 +80,9 @@ export class TabularWidget extends WidgetType { } updateDOM(dom: HTMLElement, view: EditorView): boolean { + if (!this.parseResult) { + return false + } this.element = dom ReactDOM.render( in Visual mode', function () { 'title with command' ) - // unsupported commands cy.get('@second-line').type(deleteLine) cy.get('@second-line').type('\\title{{}Title with \\& ampersands}') - cy.get('.ol-cm-title').should( - 'contain.html', - 'Title with \\& ampersands' - ) + cy.get('.ol-cm-title').should('contain.html', 'Title with & ampersands') + // unsupported command cy.get('@second-line').type(deleteLine) cy.get('@second-line').type('\\title{{}My \\LaTeX{{}} document}') cy.get('.ol-cm-title').should('contain.html', 'My \\LaTeX{} document')