From 7202d7413e03eb4204ae9a73fc003d36f4a559bb Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Tue, 4 Jul 2023 10:27:16 +0100 Subject: [PATCH] [rich text] Cover the whole command when selecting end-to-end (#11683) GitOrigin-RevId: c3559ce68798047e7001b2a9857f2c168633af6c --- .../visual/select-decorated-argument.ts | 88 +++++++++++++++++++ .../extensions/visual/selection.ts | 36 +++++++- .../source-editor/extensions/visual/visual.ts | 6 +- .../languages/latex/latex-language.ts | 8 ++ .../codemirror-editor-visual-toolbar.spec.tsx | 39 ++++---- 5 files changed, 158 insertions(+), 19 deletions(-) create mode 100644 services/web/frontend/js/features/source-editor/extensions/visual/select-decorated-argument.ts diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/select-decorated-argument.ts b/services/web/frontend/js/features/source-editor/extensions/visual/select-decorated-argument.ts new file mode 100644 index 0000000000..bd5b492278 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/select-decorated-argument.ts @@ -0,0 +1,88 @@ +import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state' +import { syntaxTree } from '@codemirror/language' +import { Tree } from '@lezer/common' +import { + ancestorOfNodeWithType, + descendantsOfNodeWithType, +} from '../../utils/tree-operations/ancestors' +import { getMousedownSelection, selectionIntersects } from './selection' + +/** + * A custom extension that updates the selection in a transaction if the mouse pointer was used + * to position a cursor at the start or end of an argument (the cursor is placed inside the brace), + * or to drag a range across the whole range of an argument (the selection is placed inside the braces), + * when the selection was not already inside the command. + */ +export const selectDecoratedArgument = EditorState.transactionFilter.of(tr => { + if (tr.selection && tr.isUserEvent('select.pointer')) { + const tree = syntaxTree(tr.state) + let selection = tr.selection + const mousedownSelection = getMousedownSelection(tr.state) + let replaced = false + for (const [index, range] of selection.ranges.entries()) { + const replacementRange = + selectArgument(tree, range, mousedownSelection, 1) || + selectArgument(tree, range, mousedownSelection, -1) + if (replacementRange) { + selection = selection.replaceRange(replacementRange, index) + replaced = true + } + } + if (replaced) { + return [tr, { selection }] + } + } + + return tr +}) + +const selectArgument = ( + tree: Tree, + range: SelectionRange, + mousedownSelection: EditorSelection | undefined, + side: -1 | 1 +): SelectionRange | undefined => { + const anchor = tree.resolveInner(range.anchor, side) + + const ancestorCommand = ancestorOfNodeWithType(anchor, '$Command') + if (!ancestorCommand) { + return + } + + const mousedownSelectionInside = + mousedownSelection !== undefined && + selectionIntersects(mousedownSelection, ancestorCommand) + if (mousedownSelectionInside) { + return + } + + const [inner] = descendantsOfNodeWithType(ancestorCommand, '$TextArgument') + if (!inner) { + return + } + + if (side === 1) { + if ( + range.anchor === inner.from + 1 || + range.anchor === ancestorCommand.from + ) { + if (range.empty) { + // selecting at the start + return EditorSelection.cursor(inner.from + 1) + } else if (Math.abs(range.head - inner.to) < 2) { + // selecting from the start to the end + return EditorSelection.range(inner.from + 1, inner.to - 1) + } + } + } else { + if (range.anchor === inner.to - 1 || range.anchor === ancestorCommand.to) { + if (range.empty) { + // selecting at the end + return EditorSelection.cursor(inner.to - 1) + } else if (Math.abs(range.head - ancestorCommand.from) < 2) { + // selecting from the end to the start + return EditorSelection.range(inner.to - 1, inner.from + 1) + } + } + } +} 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 9d7074b55b..dddb6cb858 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 @@ -1,4 +1,11 @@ -import { EditorSelection, StateEffect, Line, Text } from '@codemirror/state' +import { + EditorSelection, + StateEffect, + Line, + Text, + StateField, + EditorState, +} from '@codemirror/state' import { EditorView } from '@codemirror/view' import { hasEffect, updateHasEffect } from '../../utils/effects' @@ -61,7 +68,7 @@ export const updateHasMouseDownEffect = updateHasEffect(mouseDownEffect) * A listener for mousedown and mouseup events, dispatching an event * to record the current mousedown status, which is stored in a state field. */ -export const mouseDownListener = EditorView.domEventHandlers({ +const mouseDownListener = EditorView.domEventHandlers({ mousedown: (event, view) => { // not wrapped in a timeout, so update listeners know that the mouse is down before they process the selection view.dispatch({ @@ -77,3 +84,28 @@ export const mouseDownListener = EditorView.domEventHandlers({ }) }, }) + +const mousedownSelectionState = StateField.define({ + create() { + return undefined + }, + update(value, tr) { + if (value && tr.docChanged) { + value = value.map(tr.changes) + } + + for (const effect of tr.effects) { + // store the previous selection on mousedown + if (effect.is(mouseDownEffect)) { + value = effect.value ? tr.startState.selection : undefined + } + } + + return value + }, +}) + +export const getMousedownSelection = (state: EditorState) => + state.field(mousedownSelectionState) + +export const mousedown = [mouseDownListener, mousedownSelectionState] diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts index 1b382de99c..a232745304 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts @@ -12,7 +12,7 @@ import { markDecorations } from './mark-decorations' import { EditorView, ViewPlugin } from '@codemirror/view' import { visualKeymap } from './visual-keymap' import { skipPreambleWithCursor } from './skip-preamble-cursor' -import { mouseDownEffect, mouseDownListener } from './selection' +import { mousedown, mouseDownEffect } from './selection' import { findEffect } from '../../utils/effects' import { forceParsing, syntaxTree } from '@codemirror/language' import { hasLanguageLoadedEffect } from '../language' @@ -23,6 +23,7 @@ import { listItemMarker } from './list-item-marker' import { figureModalPasteHandler } from '../figure-modal' import { isSplitTestEnabled } from '../../../../utils/splitTestUtils' import { toolbarPanel } from '../toolbar/toolbar-panel' +import { selectDecoratedArgument } from './select-decorated-argument' type Options = { visual: boolean @@ -192,7 +193,7 @@ const scrollJumpAdjuster = EditorState.transactionExtender.of(tr => { const extension = (options: Options) => [ visualTheme, visualHighlightStyle, - mouseDownListener, + mousedown, listItemMarker, markDecorations, atomicDecorations(options), @@ -200,6 +201,7 @@ const extension = (options: Options) => [ visualKeymap, scrollJumpAdjuster, isSplitTestEnabled('source-editor-toolbar') ? [] : toolbarPanel(), + selectDecoratedArgument, showContentWhenParsed, figureModalPasteHandler(), ] diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts index da9291f38b..8b3dd5ce77 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts @@ -111,8 +111,16 @@ export const LaTeXLanguage = LRLanguage.define({ } } else if (Tokens.envName.includes(type.name)) { types.push('$EnvName') + } else if (type.name.endsWith('Command')) { + types.push('$Command') } else if (type.name.endsWith('Argument')) { types.push('$Argument') + if ( + type.name.endsWith('TextArgument') || + type.is('SectioningArgument') + ) { + types.push('$TextArgument') + } } else if (type.name.endsWith('Environment')) { types.push('$Environment') } else if (type.name.endsWith('Brace')) { diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx index 30152cb7c5..b623ae7479 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx @@ -154,6 +154,9 @@ describe(' toolbar in Rich Text mode', function () { clickToolbarButton('Numbered List') + // expose the markup + cy.get('.cm-line').eq(0).type('{rightArrow}') + cy.get('.cm-content').should( 'have.text', [ @@ -164,12 +167,8 @@ describe(' toolbar in Rich Text mode', function () { ].join('') ) - cy.get('.cm-line').eq(1).click() - clickToolbarButton('Bullet List') - cy.get('.cm-line').eq(1).click() - cy.get('.cm-content').should( 'have.text', [ @@ -187,6 +186,9 @@ describe(' toolbar in Rich Text mode', function () { clickToolbarButton('Numbered List') + // expose the markup + cy.get('.cm-line').eq(0).type('{rightArrow}') + cy.get('.cm-content').should( 'have.text', [ @@ -197,12 +199,8 @@ describe(' toolbar in Rich Text mode', function () { ].join('') ) - cy.get('.cm-line').eq(0).click() - clickToolbarButton('Numbered List') - cy.get('.cm-line').eq(0).click() - cy.get('.cm-content').should('have.text', 'test') }) @@ -212,6 +210,9 @@ describe(' toolbar in Rich Text mode', function () { clickToolbarButton('Numbered List') + // expose the markup + cy.get('.cm-line').eq(1).type('{rightArrow}') + cy.get('.cm-content').should( 'have.text', [ @@ -227,23 +228,26 @@ describe(' toolbar in Rich Text mode', function () { clickToolbarButton('Increase Indent') + // expose the markup + cy.get('.cm-line').eq(1).type('{rightArrow}') + cy.get('.cm-content').should( 'have.text', [ // - '\\begin{enumerate}', ' test', ' \\begin{enumerate}', ' test', ' \\end{enumerate}', - '\\end{enumerate}', ].join('') ) - cy.get('.cm-line').eq(2).click() + cy.get('.cm-line').eq(1).click() clickToolbarButton('Numbered List') + cy.get('.cm-line').eq(0).type('{upArrow}') + cy.get('.cm-content').should( 'have.text', [ @@ -262,6 +266,9 @@ describe(' toolbar in Rich Text mode', function () { clickToolbarButton('Numbered List') + // expose the markup + cy.get('.cm-line').eq(1).type('{rightArrow}') + cy.get('.cm-content').should( 'have.text', [ @@ -277,24 +284,26 @@ describe(' toolbar in Rich Text mode', function () { clickToolbarButton('Increase Indent') + // expose the markup + cy.get('.cm-line').eq(1).type('{rightArrow}') + cy.get('.cm-content').should( 'have.text', [ // - '\\begin{enumerate}', ' test', ' \\begin{enumerate}', ' test', ' \\end{enumerate}', - '\\end{enumerate}', ].join('') ) - cy.get('.cm-line').eq(1).click() + cy.get('.cm-line').eq(0).click() clickToolbarButton('Numbered List') - cy.get('.cm-line').eq(1).click() + // expose the markup + cy.get('.cm-line').eq(1).type('{rightArrow}') cy.get('.cm-content').should( 'have.text',