mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-05 12:49:29 +00:00
[visual] Place cursor in editable content when closing the preamble (#14162)
GitOrigin-RevId: 35f146caa4469c7f31fb00dc6047a421b1daadb0
This commit is contained in:
parent
1e286c263c
commit
03fbc5e0a8
7 changed files with 174 additions and 162 deletions
|
@ -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),
|
||||
]
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -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
|
||||
export const skipPreambleWithCursor = (
|
||||
field: StateField<{ preamble: Preamble; decorations: DecorationSet }>
|
||||
) =>
|
||||
ViewPlugin.define((view: EditorView) => {
|
||||
let checkedOnce = false
|
||||
|
||||
// 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)])
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,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`
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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(''))
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue