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

GitOrigin-RevId: c3559ce68798047e7001b2a9857f2c168633af6c
This commit is contained in:
Alf Eaton 2023-07-04 10:27:16 +01:00 committed by Copybot
parent 7e20d41c4c
commit 7202d7413e
5 changed files with 158 additions and 19 deletions

View file

@ -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)
}
}
}
}

View file

@ -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]

View file

@ -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(),
]

View file

@ -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')) {

View file

@ -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',