Explorar o código

[rich text] Cover the whole command when selecting end-to-end (#11683)

GitOrigin-RevId: c3559ce68798047e7001b2a9857f2c168633af6c
Alf Eaton %!s(int64=3) %!d(string=hai) anos
pai
achega
7202d7413e

+ 88 - 0
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)
+      }
+    }
+  }
+}

+ 34 - 2
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<EditorSelection | undefined>({
+  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]

+ 4 - 2
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(),
 ]

+ 8 - 0
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')) {

+ 24 - 15
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx

@@ -154,6 +154,9 @@ describe('<CodeMirrorEditor/> 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('<CodeMirrorEditor/> 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('<CodeMirrorEditor/> 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('<CodeMirrorEditor/> 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('<CodeMirrorEditor/> 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('<CodeMirrorEditor/> 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('<CodeMirrorEditor/> 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('<CodeMirrorEditor/> 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',