Просмотр исходного кода

[visual] Place cursor in editable content when closing the preamble (#14162)

GitOrigin-RevId: 35f146caa4469c7f31fb00dc6047a421b1daadb0
Alf Eaton 3 лет назад
Родитель
Сommit
03fbc5e0a8

+ 27 - 22
services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts

@@ -39,7 +39,7 @@ import {
 import { centeringNodeForEnvironment } from '../../utils/tree-operations/figure'
 import { Frame, FrameWidget } from './visual-widgets/frame'
 import { DividerWidget } from './visual-widgets/divider'
-import { PreambleWidget } from './visual-widgets/preamble'
+import { Preamble, PreambleWidget } from './visual-widgets/preamble'
 import { EndDocumentWidget } from './visual-widgets/end-document'
 import { EnvironmentLineWidget } from './visual-widgets/environment-line'
 import {
@@ -64,6 +64,7 @@ import { parseTheoremArguments } from '../../utils/tree-operations/theorems'
 import { IndicatorWidget } from './visual-widgets/indicator'
 import { TabularWidget } from './visual-widgets/tabular'
 import { nextSnippetField, pickedCompletion } from '@codemirror/autocomplete'
+import { skipPreambleWithCursor } from './skip-preamble-cursor'
 
 type Options = {
   fileTreeManager: {
@@ -141,7 +142,10 @@ export const atomicDecorations = (options: Options) => {
   const getPreviewByPath = (path: string) =>
     options.fileTreeManager.getPreviewByPath(path)
 
-  const createDecorations = (state: EditorState, tree: Tree): DecorationSet => {
+  const createDecorations = (
+    state: EditorState,
+    tree: Tree
+  ): { decorations: DecorationSet; preamble: Preamble } => {
     const decorations: Range<Decoration>[] = []
 
     const listEnvironmentStack: ListEnvironmentName[] = []
@@ -161,18 +165,7 @@ export const atomicDecorations = (options: Options) => {
 
     let commandDefinitions = ''
 
-    const preamble: {
-      from: number
-      to: number
-      title?: {
-        node: SyntaxNode
-        content: string
-      }
-      authors: {
-        node: SyntaxNode
-        content: string
-      }[]
-    } = { from: 0, to: 0, authors: [] }
+    const preamble: Preamble = { from: 0, to: 0, authors: [] }
 
     const startListEnvironment = (envName: ListEnvironmentName) => {
       if (currentListEnvironment) {
@@ -1091,8 +1084,9 @@ export const atomicDecorations = (options: Options) => {
     })
 
     if (preamble.to > 0) {
-      // hide the preamble. We use selectionIntersects directly, so that it also
-      // expands in readOnly mode.
+      // add environmentclass names to each line of the preamble
+      // note: this should be in markDecorations,
+      // but the preamble extents are calculated in this extension.
       const endLine = state.doc.lineAt(preamble.to).number
       for (let lineNumber = 1; lineNumber <= endLine; ++lineNumber) {
         const line = state.doc.line(lineNumber)
@@ -1110,39 +1104,47 @@ export const atomicDecorations = (options: Options) => {
         )
       }
 
+      // hide the preamble. We use selectionIntersects directly, so that it also
+      // expands in readOnly mode.
       const isExpanded = selectionIntersects(state.selection, preamble)
       if (!isExpanded) {
         decorations.push(
           Decoration.replace({
-            widget: new PreambleWidget(preamble.to, isExpanded),
+            widget: new PreambleWidget(isExpanded),
             block: true,
           }).range(0, preamble.to)
         )
       } else {
         decorations.push(
           Decoration.widget({
-            widget: new PreambleWidget(preamble.to, isExpanded),
+            widget: new PreambleWidget(isExpanded),
             block: true,
             side: -1,
           }).range(0)
         )
       }
     }
-    return Decoration.set(decorations, true)
+    return {
+      decorations: Decoration.set(decorations, true),
+      preamble,
+    }
   }
 
   return [
     StateField.define<{
       mousedown: boolean
       decorations: DecorationSet
+      preamble: Preamble
       previousTree: Tree
     }>({
       create(state) {
         const previousTree = syntaxTree(state)
+        const { decorations, preamble } = createDecorations(state, previousTree)
 
         return {
           mousedown: false,
-          decorations: createDecorations(state, previousTree),
+          decorations,
+          preamble,
           previousTree,
         }
       },
@@ -1174,11 +1176,13 @@ export const atomicDecorations = (options: Options) => {
             tr.selection ||
             hasMouseDownEffect(tr))
         ) {
-          // tree changed
+          // tree changed, or selection changed, or mousedown ended
           // TODO: update the existing decorations for the changed range(s)?
+          const { decorations, preamble } = createDecorations(tr.state, tree)
           value = {
             ...value,
-            decorations: createDecorations(tr.state, tree),
+            decorations,
+            preamble,
             previousTree: tree,
           }
         }
@@ -1200,6 +1204,7 @@ export const atomicDecorations = (options: Options) => {
               },
             }
           }),
+          skipPreambleWithCursor(field),
         ]
       },
     }),

+ 117 - 39
services/web/frontend/js/features/source-editor/extensions/visual/skip-preamble-cursor.ts

@@ -1,46 +1,124 @@
-import { EditorView, ViewPlugin } from '@codemirror/view'
-import { EditorSelection } from '@codemirror/state'
-import { findStartOfDocumentContent } from '../../utils/tree-operations/environments'
+import { DecorationSet, EditorView, ViewPlugin } from '@codemirror/view'
+import {
+  EditorSelection,
+  EditorState,
+  RangeSet,
+  StateField,
+} from '@codemirror/state'
 import { syntaxTree } from '@codemirror/language'
-import { extendForwardsOverEmptyLines } from './selection'
-
+import { Preamble } from './visual-widgets/preamble'
 /**
  * A view plugin that moves the cursor from the start of the preamble into the document body when the doc is opened.
  */
-export const skipPreambleWithCursor = ViewPlugin.define((view: EditorView) => {
-  let checkedOnce = false
-  return {
-    update(update) {
-      if (
-        !checkedOnce &&
-        syntaxTree(update.state).length === update.state.doc.length
-      ) {
-        checkedOnce = true
-
-        // Only move the cursor if we're at the default position (0). Otherwise
-        // switching back and forth between source/RT while editing the preamble
-        // would be annoying.
-        if (
-          update.view.state.selection.eq(
-            EditorSelection.create([EditorSelection.cursor(0)])
+export const skipPreambleWithCursor = (
+  field: StateField<{ preamble: Preamble; decorations: DecorationSet }>
+) =>
+  ViewPlugin.define((view: EditorView) => {
+    let checkedOnce = false
+
+    const escapeFromAtomicRanges = (
+      selection: EditorSelection,
+      force = false
+    ) => {
+      const originalSelection = selection
+
+      const atomicRangeSets = view.state
+        .facet(EditorView.atomicRanges)
+        .map(item => item(view))
+
+      for (const [index, range] of selection.ranges.entries()) {
+        const anchor = skipAtomicRanges(
+          view.state,
+          atomicRangeSets,
+          range.anchor
+        )
+        const head = skipAtomicRanges(view.state, atomicRangeSets, range.head)
+
+        if (anchor !== range.anchor || head !== range.head) {
+          selection = selection.replaceRange(
+            EditorSelection.range(anchor, head),
+            index
           )
-        ) {
-          setTimeout(() => {
-            const position =
-              extendForwardsOverEmptyLines(
-                update.state.doc,
-                update.state.doc.lineAt(
-                  findStartOfDocumentContent(update.state) ?? 0
-                )
-              ) + 1
-            view.dispatch({
-              selection: EditorSelection.cursor(
-                Math.min(position, update.state.doc.length)
-              ),
-            })
-          }, 0)
         }
       }
-    },
-  }
-})
+
+      if (force || selection !== originalSelection) {
+        // TODO: needs to happen after cursor position is restored?
+        window.setTimeout(() => {
+          view.dispatch({
+            selection,
+            scrollIntoView: true,
+          })
+        })
+      }
+    }
+
+    const escapeFromPreamble = () => {
+      const preamble = view.state.field(field, false)?.preamble
+      if (preamble) {
+        escapeFromAtomicRanges(
+          EditorSelection.create([EditorSelection.cursor(preamble.to + 1)]),
+          true
+        )
+      }
+    }
+
+    view.dom.addEventListener('editor:collapse-preamble', escapeFromPreamble)
+
+    return {
+      update(update) {
+        if (!checkedOnce) {
+          const { state } = update
+
+          if (syntaxTree(state).length === state.doc.length) {
+            checkedOnce = true
+
+            // Only move the cursor if we're at the default position (0). Otherwise
+            // switching back and forth between source/RT while editing the preamble
+            // would be annoying.
+            if (
+              state.selection.eq(
+                EditorSelection.create([EditorSelection.cursor(0)])
+              )
+            ) {
+              escapeFromPreamble()
+            } else {
+              escapeFromAtomicRanges(state.selection)
+            }
+          }
+        }
+      },
+      destroy() {
+        view.dom?.removeEventListener(
+          'editor:collapse-preamble',
+          escapeFromPreamble
+        )
+      },
+    }
+  })
+
+const skipAtomicRanges = (
+  state: EditorState,
+  rangeSets: RangeSet<any>[],
+  pos: number
+) => {
+  let oldPos
+  do {
+    oldPos = pos
+
+    for (const rangeSet of rangeSets) {
+      rangeSet.between(pos, pos, (_from, to) => {
+        if (to > pos) {
+          pos = to
+        }
+      })
+    }
+
+    // move from the end of a line to the start of the next line
+    if (pos !== oldPos && state.doc.lineAt(pos).to === pos) {
+      pos++
+    }
+  } while (pos !== oldPos)
+
+  return Math.min(pos, state.doc.length)
+}

+ 17 - 7
services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/preamble.ts

@@ -1,8 +1,22 @@
 import { EditorSelection } from '@codemirror/state'
 import { EditorView, WidgetType } from '@codemirror/view'
+import { SyntaxNode } from '@lezer/common'
+
+export type Preamble = {
+  from: number
+  to: number
+  title?: {
+    node: SyntaxNode
+    content: string
+  }
+  authors: {
+    node: SyntaxNode
+    content: string
+  }[]
+}
 
 export class PreambleWidget extends WidgetType {
-  constructor(public length: number, public expanded: boolean) {
+  constructor(public expanded: boolean) {
     super()
   }
 
@@ -46,14 +60,10 @@ export class PreambleWidget extends WidgetType {
       }
       event.preventDefault()
       if (this.expanded) {
-        const target = Math.min(this.length + 1, view.state.doc.length)
-        view.dispatch({
-          selection: EditorSelection.single(target),
-          scrollIntoView: true,
-        })
+        view.dom.dispatchEvent(new Event('editor:collapse-preamble'))
       } else {
         view.dispatch({
-          selection: EditorSelection.single(0),
+          selection: EditorSelection.cursor(0),
           scrollIntoView: true,
         })
       }

+ 0 - 2
services/web/frontend/js/features/source-editor/extensions/visual/visual.ts

@@ -11,7 +11,6 @@ import { atomicDecorations } from './atomic-decorations'
 import { markDecorations } from './mark-decorations'
 import { EditorView, ViewPlugin } from '@codemirror/view'
 import { visualKeymap } from './visual-keymap'
-import { skipPreambleWithCursor } from './skip-preamble-cursor'
 import { mousedown, mouseDownEffect } from './selection'
 import { findEffect } from '../../utils/effects'
 import { forceParsing, syntaxTree } from '@codemirror/language'
@@ -200,7 +199,6 @@ const extension = (options: Options) => [
   listItemMarker,
   atomicDecorations(options),
   markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
-  skipPreambleWithCursor,
   visualKeymap,
   commandTooltip,
   scrollJumpAdjuster,

+ 1 - 67
services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts

@@ -1,6 +1,6 @@
 import { ensureSyntaxTree } from '@codemirror/language'
 import { EditorState } from '@codemirror/state'
-import { SyntaxNode, SyntaxNodeRef, Tree } from '@lezer/common'
+import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
 import { previousSiblingIs } from './common'
 import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
 import { FigureData } from '../../extensions/figure-modal'
@@ -138,72 +138,6 @@ export const cursorIsAtEndEnvironment = (
     }
   }
 }
-
-const findStartOfDocumentEnvironment = (tree: Tree): number | null => {
-  const docEnvironment = findNodeInDocument(tree, 'DocumentEnvironment')
-  return docEnvironment?.getChild('Content')?.from || null
-}
-
-const findStartOfAbstractEnvironment = (
-  tree: Tree,
-  state: EditorState
-): number | null => {
-  const abstractEnvironment = findNodeInDocument(
-    tree,
-    (nodeRef: SyntaxNodeRef) => {
-      return Boolean(
-        nodeRef.type.is('$Environment') &&
-          getEnvironmentName(nodeRef.node, state) === 'abstract'
-      )
-    }
-  )
-  return abstractEnvironment?.getChild('Content')?.from || null
-}
-
-const findMaketitleCommand = (tree: Tree): number | null => {
-  const maketitle = findNodeInDocument(tree, 'Maketitle')
-  return maketitle?.to ?? null
-}
-
-const findNodeInDocument = (
-  tree: Tree,
-  predicate: number | string | ((node: SyntaxNodeRef) => boolean)
-): SyntaxNode | null => {
-  let node: SyntaxNode | null = null
-  const predicateFn =
-    typeof predicate !== 'function'
-      ? (nodeRef: SyntaxNodeRef) => {
-          return nodeRef.type.is(predicate)
-        }
-      : predicate
-  tree?.iterate({
-    enter(nodeRef) {
-      if (node !== null) {
-        return false
-      }
-      if (predicateFn(nodeRef)) {
-        node = nodeRef.node
-        return false
-      }
-    },
-  })
-  return node
-}
-
-export const findStartOfDocumentContent = (
-  state: EditorState
-): number | null => {
-  const tree = ensureSyntaxTree(state, state.doc.length, HUNDRED_MS)
-  if (!tree) {
-    return null
-  }
-  return (
-    findStartOfAbstractEnvironment(tree, state) ??
-    findMaketitleCommand(tree) ??
-    findStartOfDocumentEnvironment(tree)
-  )
-}
-
 /**
  *
  * @param node A node of type `$Environment`, `BeginEnv`, or `EndEnv`

+ 7 - 1
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx

@@ -44,6 +44,9 @@ describe('<CodeMirrorEditor/> command tooltip in Visual mode', function () {
       cy.stub(win, 'open').as('window-open')
     })
 
+    // wait for preamble to be escaped
+    cy.get('.cm-line').eq(0).should('have.text', '')
+
     // enter the command
     cy.get('.cm-line').eq(0).as('content-line')
     cy.get('@content-line').type('\\href{{}}{{}foo')
@@ -77,6 +80,9 @@ describe('<CodeMirrorEditor/> command tooltip in Visual mode', function () {
     ].join('\n')
     mountEditor(content)
 
+    // wait for preamble to be escaped
+    cy.get('.cm-line').eq(0).should('have.text', '')
+
     // enter the command
     cy.get('.cm-line').eq(0).as('content-line')
     cy.get('@content-line').type('\\href{{}}{{}foo')
@@ -173,7 +179,7 @@ describe('<CodeMirrorEditor/> command tooltip in Visual mode', function () {
 
     // assert the unfocused label is decorated
     cy.get('.cm-line').eq(0).as('heading-line')
-    cy.get('@heading-line').should('have.text', 'Foo 🏷sec:foo')
+    cy.get('@heading-line').should('have.text', '{Foo} 🏷sec:foo')
 
     // enter the command and cross-reference label
     cy.get('.cm-line').eq(1).as('content-line')

+ 6 - 25
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx

@@ -96,9 +96,8 @@ describe('<CodeMirrorEditor/> lists in Rich Text mode', function () {
     mountEditor(content)
 
     // focus a line (at the end of a list item) and press Tab
-    cy.get('.cm-line').eq(1).as('line')
-    cy.get('@line').click()
-    cy.get('@line').trigger('keydown', {
+    cy.get('.cm-line').eq(2).click()
+    cy.get('.cm-line').eq(1).trigger('keydown', {
       key: 'Tab',
     })
 
@@ -143,6 +142,7 @@ describe('<CodeMirrorEditor/> lists in Rich Text mode', function () {
     mountEditor(content)
 
     // focus a list item and press Shift-Tab
+    cy.get('.cm-line').eq(2).click()
     cy.get('.cm-line').eq(1).trigger('keydown', {
       key: 'Tab',
       shiftKey: true,
@@ -214,24 +214,6 @@ describe('<CodeMirrorEditor/> lists in Rich Text mode', function () {
     })
   })
 
-  it('uses autocomplete to create an list item', function () {
-    const content = [
-      '\\begin{itemize}',
-      '\\item first',
-      '',
-      '\\end{itemize}',
-    ].join('\n')
-    mountEditor(content)
-
-    cy.get('.cm-line').eq(0).as('line')
-    cy.get('@line').click()
-    cy.get('@line').type('\\ite')
-    cy.get('@line').type('{enter}')
-    cy.get('@line').type('second')
-
-    cy.get('.cm-content').should('have.text', [' first', ' second'].join(''))
-  })
-
   it('positions the cursor after creating a new line with leading whitespace', function () {
     const content = [
       '\\begin{itemize}',
@@ -240,10 +222,9 @@ describe('<CodeMirrorEditor/> lists in Rich Text mode', function () {
     ].join('\n')
     mountEditor(content)
 
-    cy.get('.cm-line').eq(0).as('line')
-    cy.get('@line').click()
-    cy.get('@line').type('{leftArrow}'.repeat(4))
-    cy.get('@line').type('{enter}baz')
+    cy.get('.cm-line').eq(1).click()
+    cy.get('.cm-line').eq(0).type('{leftArrow}'.repeat(4))
+    cy.get('.cm-line').eq(0).type('{enter}baz')
 
     cy.get('.cm-content').should('have.text', [' foo', ' bazbar'].join(''))
   })