From 1dcf5284432b8da19f589444d5c5e6d7560f85b0 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Fri, 14 Jun 2024 11:40:07 -0700 Subject: [PATCH] Chore: Refactor `editor` package: Move functions in editorStateUtils into separate files (#10591) --- .eslintignore | 23 +- .gitignore | 23 +- .../NoteBody/CodeMirror/v5/utils/useKeymap.ts | 2 +- .../NoteBody/CodeMirror/v6/Editor.tsx | 2 +- .../editor/CodeMirror/CodeMirrorControl.ts | 3 +- .../CodeMirror/markdown/markdownCommands.ts | 16 +- .../utils/renumberSelectedLists.test.ts | 41 + .../markdown/utils/renumberSelectedLists.ts | 146 ++++ .../markdown/utils/stripBlockquote.ts | 15 + .../CodeMirror/util/editorStateUtils.test.ts | 182 ----- .../CodeMirror/util/editorStateUtils.ts | 767 ------------------ .../CodeMirror/utils/formatting/RegionSpec.ts | 65 ++ .../utils/formatting/findInlineMatch.test.ts | 85 ++ .../utils/formatting/findInlineMatch.ts | 76 ++ .../formatting/isIndentationEquivalent.ts | 11 + .../utils/formatting/tabsToSpaces.test.ts | 19 + .../utils/formatting/tabsToSpaces.ts | 19 + .../formatting/toggleInlineFormatGlobally.ts | 16 + .../toggleInlineRegionSurrounded.ts | 66 ++ .../formatting/toggleInlineSelectionFormat.ts | 33 + .../toggleRegionFormatGlobally.test.ts | 52 ++ .../formatting/toggleRegionFormatGlobally.ts | 204 +++++ .../toggleSelectedLinesStartWith.ts | 124 +++ .../CodeMirror/utils/formatting/types.ts | 5 + .../CodeMirror/utils/growSelectionToNode.ts | 52 ++ .../{util => utils}/isInSyntaxNode.ts | 0 .../CodeMirror/{util => utils}/setupVim.ts | 0 27 files changed, 1081 insertions(+), 966 deletions(-) create mode 100644 packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.ts create mode 100644 packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.ts create mode 100644 packages/editor/CodeMirror/markdown/utils/stripBlockquote.ts delete mode 100644 packages/editor/CodeMirror/util/editorStateUtils.test.ts delete mode 100644 packages/editor/CodeMirror/util/editorStateUtils.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/RegionSpec.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/findInlineMatch.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/tabsToSpaces.test.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/tabsToSpaces.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/toggleInlineFormatGlobally.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.ts create mode 100644 packages/editor/CodeMirror/utils/formatting/types.ts create mode 100644 packages/editor/CodeMirror/utils/growSelectionToNode.ts rename packages/editor/CodeMirror/{util => utils}/isInSyntaxNode.ts (100%) rename packages/editor/CodeMirror/{util => utils}/setupVim.ts (100%) diff --git a/.eslintignore b/.eslintignore index a4e71ba69..87ebb2e3d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -766,6 +766,9 @@ packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js packages/editor/CodeMirror/markdown/markdownCommands.js packages/editor/CodeMirror/markdown/markdownMathParser.test.js packages/editor/CodeMirror/markdown/markdownMathParser.js +packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.js +packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.js +packages/editor/CodeMirror/markdown/utils/stripBlockquote.js packages/editor/CodeMirror/pluginApi/PluginLoader.js packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js @@ -778,10 +781,22 @@ packages/editor/CodeMirror/testUtil/loadLanguages.js packages/editor/CodeMirror/testUtil/pressReleaseKey.js packages/editor/CodeMirror/testUtil/typeText.js packages/editor/CodeMirror/theme.js -packages/editor/CodeMirror/util/editorStateUtils.test.js -packages/editor/CodeMirror/util/editorStateUtils.js -packages/editor/CodeMirror/util/isInSyntaxNode.js -packages/editor/CodeMirror/util/setupVim.js +packages/editor/CodeMirror/utils/formatting/RegionSpec.js +packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.js +packages/editor/CodeMirror/utils/formatting/findInlineMatch.js +packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.js +packages/editor/CodeMirror/utils/formatting/tabsToSpaces.test.js +packages/editor/CodeMirror/utils/formatting/tabsToSpaces.js +packages/editor/CodeMirror/utils/formatting/toggleInlineFormatGlobally.js +packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.js +packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.js +packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js +packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js +packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js +packages/editor/CodeMirror/utils/formatting/types.js +packages/editor/CodeMirror/utils/growSelectionToNode.js +packages/editor/CodeMirror/utils/isInSyntaxNode.js +packages/editor/CodeMirror/utils/setupVim.js packages/editor/SelectionFormatting.js packages/editor/events.js packages/editor/types.js diff --git a/.gitignore b/.gitignore index 0cb6bae95..a12f80d1f 100644 --- a/.gitignore +++ b/.gitignore @@ -745,6 +745,9 @@ packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js packages/editor/CodeMirror/markdown/markdownCommands.js packages/editor/CodeMirror/markdown/markdownMathParser.test.js packages/editor/CodeMirror/markdown/markdownMathParser.js +packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.js +packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.js +packages/editor/CodeMirror/markdown/utils/stripBlockquote.js packages/editor/CodeMirror/pluginApi/PluginLoader.js packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js @@ -757,10 +760,22 @@ packages/editor/CodeMirror/testUtil/loadLanguages.js packages/editor/CodeMirror/testUtil/pressReleaseKey.js packages/editor/CodeMirror/testUtil/typeText.js packages/editor/CodeMirror/theme.js -packages/editor/CodeMirror/util/editorStateUtils.test.js -packages/editor/CodeMirror/util/editorStateUtils.js -packages/editor/CodeMirror/util/isInSyntaxNode.js -packages/editor/CodeMirror/util/setupVim.js +packages/editor/CodeMirror/utils/formatting/RegionSpec.js +packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.js +packages/editor/CodeMirror/utils/formatting/findInlineMatch.js +packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.js +packages/editor/CodeMirror/utils/formatting/tabsToSpaces.test.js +packages/editor/CodeMirror/utils/formatting/tabsToSpaces.js +packages/editor/CodeMirror/utils/formatting/toggleInlineFormatGlobally.js +packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.js +packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.js +packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js +packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js +packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js +packages/editor/CodeMirror/utils/formatting/types.js +packages/editor/CodeMirror/utils/growSelectionToNode.js +packages/editor/CodeMirror/utils/isInSyntaxNode.js +packages/editor/CodeMirror/utils/setupVim.js packages/editor/SelectionFormatting.js packages/editor/events.js packages/editor/types.js diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/utils/useKeymap.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/utils/useKeymap.ts index 97e311411..654099166 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/utils/useKeymap.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/utils/useKeymap.ts @@ -4,7 +4,7 @@ import KeymapService, { KeymapItem } from '@joplin/lib/services/KeymapService'; import { EditorCommand } from '../../../../utils/types'; import shim from '@joplin/lib/shim'; import { reg } from '@joplin/lib/registry'; -import setupVim from '@joplin/editor/CodeMirror/util/setupVim'; +import setupVim from '@joplin/editor/CodeMirror/utils/setupVim'; import { EventName } from '@joplin/lib/eventManager'; import normalizeAccelerator from '../../utils/normalizeAccelerator'; import { CodeMirrorVersion } from '../../utils/types'; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx index 78d34fb06..92a33563b 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx @@ -8,7 +8,7 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer'; import { ContentScriptType } from '@joplin/lib/services/plugins/api/types'; import shim from '@joplin/lib/shim'; import PluginService from '@joplin/lib/services/plugins/PluginService'; -import setupVim from '@joplin/editor/CodeMirror/util/setupVim'; +import setupVim from '@joplin/editor/CodeMirror/utils/setupVim'; import { dirname } from 'path'; import useKeymap from './utils/useKeymap'; import useEditorSearch from '../utils/useEditorSearchExtension'; diff --git a/packages/editor/CodeMirror/CodeMirrorControl.ts b/packages/editor/CodeMirror/CodeMirrorControl.ts index c1aeeed06..6a3109322 100644 --- a/packages/editor/CodeMirror/CodeMirrorControl.ts +++ b/packages/editor/CodeMirror/CodeMirrorControl.ts @@ -8,7 +8,8 @@ import { SearchQuery, setSearchQuery } from '@codemirror/search'; import PluginLoader from './pluginApi/PluginLoader'; import customEditorCompletion, { editorCompletionSource, enableLanguageDataAutocomplete } from './pluginApi/customEditorCompletion'; import { CompletionSource } from '@codemirror/autocomplete'; -import { RegionSpec, toggleInlineSelectionFormat } from './util/editorStateUtils'; +import { RegionSpec } from './utils/formatting/RegionSpec'; +import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectionFormat'; interface Callbacks { onUndoRedo(): void; diff --git a/packages/editor/CodeMirror/markdown/markdownCommands.ts b/packages/editor/CodeMirror/markdown/markdownCommands.ts index 9188c65ed..c2e0956f7 100644 --- a/packages/editor/CodeMirror/markdown/markdownCommands.ts +++ b/packages/editor/CodeMirror/markdown/markdownCommands.ts @@ -7,12 +7,16 @@ import { SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec, } from '@codemirror/state'; import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language'; -import { - RegionSpec, growSelectionToNode, renumberSelectedLists, - toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith, - isIndentationEquivalent, stripBlockquote, tabsToSpaces, -} from '../util/editorStateUtils'; -import intersectsSyntaxNode from '../util/isInSyntaxNode'; +import intersectsSyntaxNode from '../utils/isInSyntaxNode'; +import toggleRegionFormatGlobally from '../utils/formatting/toggleRegionFormatGlobally'; +import { RegionSpec } from '../utils/formatting/RegionSpec'; +import toggleInlineFormatGlobally from '../utils/formatting/toggleInlineFormatGlobally'; +import stripBlockquote from './utils/stripBlockquote'; +import isIndentationEquivalent from '../utils/formatting/isIndentationEquivalent'; +import growSelectionToNode from '../utils/growSelectionToNode'; +import tabsToSpaces from '../utils/formatting/tabsToSpaces'; +import renumberSelectedLists from './utils/renumberSelectedLists'; +import toggleSelectedLinesStartWith from '../utils/formatting/toggleSelectedLinesStartWith'; const startingSpaceRegex = /^(\s*)/; diff --git a/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.ts b/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.ts new file mode 100644 index 000000000..f7c4a1836 --- /dev/null +++ b/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.ts @@ -0,0 +1,41 @@ +import { EditorSelection } from '@codemirror/state'; +import createTestEditor from '../../testUtil/createTestEditor'; +import renumberSelectedLists from './renumberSelectedLists'; + +describe('renumberSelectedLists', () => { + it('should correctly renumber a list with multiple selections in that list', async () => { + const listText = [ + '1. This', + '\t2. is', + '\t3. a', + '4. test', + ].join('\n'); + + const editor = await createTestEditor( + `${listText}\n\n# End`, + EditorSelection.cursor(listText.length), + ['OrderedList', 'ATXHeading1', 'ATXHeading2'], + ); + + // Include a selection twice in the same list + const initialSelection = EditorSelection.create([ + EditorSelection.cursor('1. This\n2.'.length), // Middle of second line + EditorSelection.cursor('1. This\n2. is\n3'.length), // Beginning of third line + ]); + + editor.dispatch({ + selection: initialSelection, + }); + + editor.dispatch(renumberSelectedLists(editor.state)); + + expect(editor.state.doc.toString()).toBe([ + '1. This', + '\t1. is', + '\t2. a', + '2. test', + '', + '# End', + ].join('\n')); + }); +}); diff --git a/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.ts b/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.ts new file mode 100644 index 000000000..1e6aabfd0 --- /dev/null +++ b/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.ts @@ -0,0 +1,146 @@ +import { ChangeSpec, EditorSelection, EditorState, Line, SelectionRange, TransactionSpec } from '@codemirror/state'; +import stripBlockquote from './stripBlockquote'; +import tabsToSpaces from '../../utils/formatting/tabsToSpaces'; +import { syntaxTree } from '@codemirror/language'; +import { SyntaxNodeRef } from '@lezer/common'; + +// Ensures that ordered lists within [sel] are numbered in ascending order. +const renumberSelectedLists = (state: EditorState): TransactionSpec => { + const doc = state.doc; + + const listItemRegex = /^(\s*)(\d+)\.\s?/; + + // Re-numbers ordered lists and sublists with numbers on each line in [linesToHandle] + const handleLines = (linesToHandle: Line[]) => { + const changes: ChangeSpec[] = []; + + type ListItemRecord = { + nextListNumber: number; + indentationLength: number; + }; + const listNumberStack: ListItemRecord[] = []; + let currentGroupIndentation = ''; + let nextListNumber = 1; + let prevLineNumber; + + for (const line of linesToHandle) { + // Don't re-handle lines. + if (line.number === prevLineNumber) { + continue; + } + prevLineNumber = line.number; + + const filteredText = stripBlockquote(line); + const match = filteredText.match(listItemRegex); + + // Skip lines that aren't the correct type (e.g. blank lines) + if (!match) { + continue; + } + + const indentation = match[1]; + + const indentationLen = tabsToSpaces(state, indentation).length; + let targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length; + if (targetIndentLen < indentationLen) { + listNumberStack.push({ nextListNumber, indentationLength: indentationLen }); + nextListNumber = 1; + } else if (targetIndentLen > indentationLen) { + nextListNumber = parseInt(match[2], 10); + + // Handle the case where we deindent multiple times. For example, + // 1. test + // 1. test + // 1. test + // 2. test + while (targetIndentLen > indentationLen) { + const listNumberRecord = listNumberStack.pop(); + + if (!listNumberRecord) { + break; + } else { + targetIndentLen = listNumberRecord.indentationLength; + nextListNumber = listNumberRecord.nextListNumber; + } + } + + } + + if (targetIndentLen !== indentationLen) { + currentGroupIndentation = indentation; + } + + const from = line.to - filteredText.length; + const to = from + match[0].length; + const inserted = `${indentation}${nextListNumber}. `; + nextListNumber++; + + changes.push({ + from, + to, + insert: inserted, + }); + } + + return changes; + }; + + // Find all selected lists + const selectedListRanges: SelectionRange[] = []; + for (const selection of state.selection.ranges) { + const listLines: Line[] = []; + + syntaxTree(state).iterate({ + from: selection.from, + to: selection.to, + enter: (nodeRef: SyntaxNodeRef) => { + if (nodeRef.name === 'ListItem') { + for (const node of nodeRef.node.parent.getChildren('ListItem')) { + const line = doc.lineAt(node.from); + const filteredText = stripBlockquote(line); + const match = filteredText.match(listItemRegex); + if (match) { + listLines.push(line); + } + } + } + }, + }); + + listLines.sort((a, b) => a.number - b.number); + + if (listLines.length > 0) { + const fromLine = listLines[0]; + const toLine = listLines[listLines.length - 1]; + + selectedListRanges.push( + EditorSelection.range(fromLine.from, toLine.to), + ); + } + } + + const changes: ChangeSpec[] = []; + if (selectedListRanges.length > 0) { + // Use EditorSelection.create to merge overlapping lists + const listsToHandle = EditorSelection.create(selectedListRanges).ranges; + + for (const listSelection of listsToHandle) { + const lines = []; + + const startLine = doc.lineAt(listSelection.from); + const endLine = doc.lineAt(listSelection.to); + + for (let i = startLine.number; i <= endLine.number; i++) { + lines.push(doc.line(i)); + } + + changes.push(...handleLines(lines)); + } + } + + return { + changes, + }; +}; + +export default renumberSelectedLists; diff --git a/packages/editor/CodeMirror/markdown/utils/stripBlockquote.ts b/packages/editor/CodeMirror/markdown/utils/stripBlockquote.ts new file mode 100644 index 000000000..14847aa38 --- /dev/null +++ b/packages/editor/CodeMirror/markdown/utils/stripBlockquote.ts @@ -0,0 +1,15 @@ +import { Line } from '@codemirror/state'; + +const blockQuoteRegex = /^>\s/; + +export const stripBlockquote = (line: Line): string => { + const match = line.text.match(blockQuoteRegex); + + if (match) { + return line.text.substring(match[0].length); + } + + return line.text; +}; + +export default stripBlockquote; diff --git a/packages/editor/CodeMirror/util/editorStateUtils.test.ts b/packages/editor/CodeMirror/util/editorStateUtils.test.ts deleted file mode 100644 index 2ee90cbb1..000000000 --- a/packages/editor/CodeMirror/util/editorStateUtils.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - findInlineMatch, MatchSide, RegionSpec, renumberSelectedLists, tabsToSpaces, toggleRegionFormatGlobally, -} from './editorStateUtils'; -import { Text as DocumentText, EditorSelection, EditorState } from '@codemirror/state'; -import { indentUnit } from '@codemirror/language'; -import createTestEditor from '../testUtil/createTestEditor'; - -describe('markdownReformatter', () => { - - jest.retryTimes(2); - - const boldSpec: RegionSpec = RegionSpec.of({ - template: '**', - }); - - it('matching a bolded region: should return the length of the match', () => { - const doc = DocumentText.of(['**test**']); - const sel = EditorSelection.range(0, 5); - - // matchStart returns the length of the match - expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(2); - }); - - it('matching a bolded region: should match the end of a region, if next to the cursor', () => { - const doc = DocumentText.of(['**...** test.']); - const sel = EditorSelection.range(5, 5); - expect(findInlineMatch(doc, boldSpec, sel, MatchSide.End)).toBe(2); - }); - - it('matching a bolded region: should return -1 if no match is found', () => { - const doc = DocumentText.of(['**...** test.']); - const sel = EditorSelection.range(3, 3); - expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(-1); - }); - - it('should match a custom specification of italicized regions', () => { - const spec: RegionSpec = { - template: { start: '*', end: '*' }, - matcher: { start: /[*_]/g, end: /[*_]/g }, - }; - const testString = 'This is a _test_'; - const testDoc = DocumentText.of([testString]); - const fullSel = EditorSelection.range('This is a '.length, testString.length); - - // should match the start of the region - expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.Start)).toBe(1); - - // should match the end of the region - expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.End)).toBe(1); - }); - - const listSpec: RegionSpec = { - template: { start: ' - ', end: '' }, - matcher: { - start: /^\s*[-*]\s/g, - end: /$/g, - }, - }; - - it('matching a custom list: should not match a list if not within the selection', () => { - const doc = DocumentText.of(['- Test...']); - const sel = EditorSelection.range(1, 6); - - // Beginning of list not selected: no match - expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(-1); - }); - - it('matching a custom list: should match start of selected, unindented list', () => { - const doc = DocumentText.of(['- Test...']); - const sel = EditorSelection.range(0, 6); - - expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(2); - }); - - it('matching a custom list: should match start of indented list', () => { - const doc = DocumentText.of([' - Test...']); - const sel = EditorSelection.range(0, 6); - - expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(5); - }); - - it('matching a custom list: should match the end of an item in an indented list', () => { - const doc = DocumentText.of([' - Test...']); - const sel = EditorSelection.range(0, 6); - - // Zero-length, but found, selection - expect(findInlineMatch(doc, listSpec, sel, MatchSide.End)).toBe(0); - }); - - const multiLineTestText = `Internal text manipulation - This is a test... - of block and inline region toggling.`; - const codeFenceRegex = /^``````\w*\s*$/; - const inlineCodeRegionSpec = RegionSpec.of({ - template: '`', - nodeName: 'InlineCode', - }); - const blockCodeRegionSpec: RegionSpec = { - template: { start: '``````', end: '``````' }, - matcher: { start: codeFenceRegex, end: codeFenceRegex }, - }; - - it('should create an empty inline region around the cursor, if given an empty selection', () => { - const initialState: EditorState = EditorState.create({ - doc: multiLineTestText, - selection: EditorSelection.cursor(0), - }); - - const changes = toggleRegionFormatGlobally( - initialState, inlineCodeRegionSpec, blockCodeRegionSpec, - ); - - const newState = initialState.update(changes).state; - expect(newState.doc.toString()).toEqual(`\`\`${multiLineTestText}`); - }); - - it('should wrap multiple selected lines in block formatting', () => { - const initialState: EditorState = EditorState.create({ - doc: multiLineTestText, - selection: EditorSelection.range(0, multiLineTestText.length), - }); - - const changes = toggleRegionFormatGlobally( - initialState, inlineCodeRegionSpec, blockCodeRegionSpec, - ); - - const newState = initialState.update(changes).state; - const editorText = newState.doc.toString(); - expect(editorText).toBe(`\`\`\`\`\`\`\n${multiLineTestText}\n\`\`\`\`\`\``); - expect(newState.selection.main.from).toBe(0); - expect(newState.selection.main.to).toBe(editorText.length); - }); - - it('should convert tabs to spaces based on indentUnit', () => { - const state: EditorState = EditorState.create({ - doc: multiLineTestText, - selection: EditorSelection.cursor(0), - extensions: [ - indentUnit.of(' '), - ], - }); - expect(tabsToSpaces(state, '\t')).toBe(' '); - expect(tabsToSpaces(state, '\t ')).toBe(' '); - expect(tabsToSpaces(state, ' \t ')).toBe(' '); - }); - - it('should correctly renumber a list with multiple selections in that list', async () => { - const listText = [ - '1. This', - '\t2. is', - '\t3. a', - '4. test', - ].join('\n'); - - const editor = await createTestEditor( - `${listText}\n\n# End`, - EditorSelection.cursor(listText.length), - ['OrderedList', 'ATXHeading1', 'ATXHeading2'], - ); - - // Include a selection twice in the same list - const initialSelection = EditorSelection.create([ - EditorSelection.cursor('1. This\n2.'.length), // Middle of second line - EditorSelection.cursor('1. This\n2. is\n3'.length), // Beginning of third line - ]); - - editor.dispatch({ - selection: initialSelection, - }); - - editor.dispatch(renumberSelectedLists(editor.state)); - - expect(editor.state.doc.toString()).toBe([ - '1. This', - '\t1. is', - '\t2. a', - '2. test', - '', - '# End', - ].join('\n')); - }); -}); diff --git a/packages/editor/CodeMirror/util/editorStateUtils.ts b/packages/editor/CodeMirror/util/editorStateUtils.ts deleted file mode 100644 index 374f5e6cb..000000000 --- a/packages/editor/CodeMirror/util/editorStateUtils.ts +++ /dev/null @@ -1,767 +0,0 @@ -import { - Text as DocumentText, EditorSelection, SelectionRange, ChangeSpec, EditorState, Line, TransactionSpec, -} from '@codemirror/state'; -import { getIndentUnit, syntaxTree } from '@codemirror/language'; -import { SyntaxNodeRef } from '@lezer/common'; - -// pregQuote escapes text for usage in regular expressions -const { pregQuote } = require('@joplin/lib/string-utils-common'); - -// Length of the symbol that starts a block quote -const blockQuoteStartLen = '> '.length; -const blockQuoteRegex = /^>\s/; - -// Specifies the update of a single selection region and its contents -type SelectionUpdate = { range: SelectionRange; changes?: ChangeSpec }; - -// Specifies how a to find the start/stop of a type of formatting -interface RegionMatchSpec { - start: RegExp; - end: RegExp; -} - -// Describes a region's formatting -export interface RegionSpec { - // The name of the node corresponding to the region in the syntax tree - nodeName?: string; - - // Text to be inserted before and after the region when toggling. - template: { start: string; end: string }; - - // How to identify the region - matcher: RegionMatchSpec; -} - -export namespace RegionSpec { // eslint-disable-line no-redeclare - interface RegionSpecConfig { - nodeName?: string; - template: string | { start: string; end: string }; - matcher?: RegionMatchSpec; - } - - // Creates a new RegionSpec, given a simplified set of options. - // If [config.template] is a string, it is used as both the starting and ending - // templates. - // Similarly, if [config.matcher] is not given, a matcher is created based on - // [config.template]. - export const of = (config: RegionSpecConfig): RegionSpec => { - let templateStart: string, templateEnd: string; - if (typeof config.template === 'string') { - templateStart = config.template; - templateEnd = config.template; - } else { - templateStart = config.template.start; - templateEnd = config.template.end; - } - - const matcher: RegionMatchSpec = - config.matcher ?? matcherFromTemplate(templateStart, templateEnd); - - return { - nodeName: config.nodeName, - template: { start: templateStart, end: templateEnd }, - matcher, - }; - }; - - const matcherFromTemplate = (start: string, end: string): RegionMatchSpec => { - // See https://stackoverflow.com/a/30851002 - const escapedStart = pregQuote(start); - const escapedEnd = pregQuote(end); - - return { - start: new RegExp(escapedStart, 'g'), - end: new RegExp(escapedEnd, 'g'), - }; - }; -} - -export enum MatchSide { - Start, - End, -} - -// Returns the length of a match for this in the given selection, -// -1 if no match is found. -export const findInlineMatch = ( - doc: DocumentText, spec: RegionSpec, sel: SelectionRange, side: MatchSide, -): number => { - const [regex, template] = (() => { - if (side === MatchSide.Start) { - return [spec.matcher.start, spec.template.start]; - } else { - return [spec.matcher.end, spec.template.end]; - } - })(); - const [startIndex, endIndex] = (() => { - if (!sel.empty) { - return [sel.from, sel.to]; - } - - const bufferSize = template.length; - if (side === MatchSide.Start) { - return [sel.from - bufferSize, sel.to]; - } else { - return [sel.from, sel.to + bufferSize]; - } - })(); - const searchText = doc.sliceString(startIndex, endIndex); - - // Returns true if [idx] is in the right place (the match is at - // the end of the string or the beginning based on startIndex/endIndex). - const indexSatisfies = (idx: number, len: number): boolean => { - idx += startIndex; - if (side === MatchSide.Start) { - return idx === startIndex; - } else { - return idx + len === endIndex; - } - }; - - // Enforce 'g' flag. - if (!regex.global) { - throw new Error('Regular expressions used by RegionSpec must have the global flag!'); - } - - // Search from the beginning. - regex.lastIndex = 0; - - let foundMatch = null; - let match; - while ((match = regex.exec(searchText)) !== null) { - if (indexSatisfies(match.index, match[0].length)) { - foundMatch = match; - break; - } - } - - if (foundMatch) { - const matchLength = foundMatch[0].length; - const matchIndex = foundMatch.index; - - // If the match isn't in the right place, - if (indexSatisfies(matchIndex, matchLength)) { - return matchLength; - } - } - - return -1; -}; - -export const stripBlockquote = (line: Line): string => { - const match = line.text.match(blockQuoteRegex); - - if (match) { - return line.text.substring(match[0].length); - } - - return line.text; -}; - -export const tabsToSpaces = (state: EditorState, text: string): string => { - const chunks = text.split('\t'); - const spaceLen = getIndentUnit(state); - let result = chunks[0]; - - for (let i = 1; i < chunks.length; i++) { - for (let j = result.length % spaceLen; j < spaceLen; j++) { - result += ' '; - } - - result += chunks[i]; - } - return result; -}; - -// Returns true iff [a] (an indentation string) is roughly equivalent to [b]. -export const isIndentationEquivalent = (state: EditorState, a: string, b: string): boolean => { - // Consider sublists to be the same as their parent list if they have the same - // label plus or minus 1 space. - return Math.abs(tabsToSpaces(state, a).length - tabsToSpaces(state, b).length) <= 1; -}; - -// Expands and returns a copy of [sel] to the smallest container node with name in [nodeNames]. -export const growSelectionToNode = ( - state: EditorState, sel: SelectionRange, nodeNames: string|string[]|null, -): SelectionRange => { - if (!nodeNames) { - return sel; - } - - const isAcceptableNode = (name: string): boolean => { - if (typeof nodeNames === 'string') { - return name === nodeNames; - } - - for (const otherName of nodeNames) { - if (otherName === name) { - return true; - } - } - - return false; - }; - - let newFrom = null; - let newTo = null; - let smallestLen = Infinity; - - // Find the smallest range. - syntaxTree(state).iterate({ - from: sel.from, to: sel.to, - enter: node => { - if (isAcceptableNode(node.name)) { - if (node.to - node.from < smallestLen) { - newFrom = node.from; - newTo = node.to; - smallestLen = newTo - newFrom; - } - } - }, - }); - - // If it's in such a node, - if (newFrom !== null && newTo !== null) { - return EditorSelection.range(newFrom, newTo); - } else { - return sel; - } -}; - -// Toggles whether the given selection matches the inline region specified by [spec]. -// -// For example, something similar to toggleSurrounded('**', '**') would surround -// every selection range with asterisks (including the caret). -// If the selection is already surrounded by these characters, they are -// removed. -const toggleInlineRegionSurrounded = ( - doc: DocumentText, sel: SelectionRange, spec: RegionSpec, -): SelectionUpdate => { - let content = doc.sliceString(sel.from, sel.to); - const startMatchLen = findInlineMatch(doc, spec, sel, MatchSide.Start); - const endMatchLen = findInlineMatch(doc, spec, sel, MatchSide.End); - - const startsWithBefore = startMatchLen >= 0; - const endsWithAfter = endMatchLen >= 0; - - const changes = []; - let finalSelStart = sel.from; - let finalSelEnd = sel.to; - - if (startsWithBefore && endsWithAfter) { - // Remove the before and after. - content = content.substring(startMatchLen); - content = content.substring(0, content.length - endMatchLen); - - finalSelEnd -= startMatchLen + endMatchLen; - - changes.push({ - from: sel.from, - to: sel.to, - insert: content, - }); - } else { - changes.push({ - from: sel.from, - insert: spec.template.start, - }); - - changes.push({ - from: sel.to, - insert: spec.template.end, - }); - - // If not a caret, - if (!sel.empty) { - // Select the surrounding chars. - finalSelEnd += spec.template.start.length + spec.template.end.length; - } else { - // Position the caret within the added content. - finalSelStart = sel.from + spec.template.start.length; - finalSelEnd = finalSelStart; - } - } - - return { - changes, - range: EditorSelection.range(finalSelStart, finalSelEnd), - }; -}; - -// Returns updated selections: For all selections in the given `EditorState`, toggles -// whether each is contained in an inline region of type [spec]. -export const toggleInlineSelectionFormat = ( - state: EditorState, spec: RegionSpec, sel: SelectionRange, -): SelectionUpdate => { - const endMatchLen = findInlineMatch(state.doc, spec, sel, MatchSide.End); - - // If at the end of the region, move the - // caret to the end. - // E.g. - // **foobar|** - // **foobar**| - if (sel.empty && endMatchLen > -1) { - const newCursorPos = sel.from + endMatchLen; - - return { - range: EditorSelection.cursor(newCursorPos), - }; - } - - // Grow the selection to encompass the entire node. - const newRange = growSelectionToNode(state, sel, spec.nodeName); - return toggleInlineRegionSurrounded(state.doc, newRange, spec); -}; - -// Like toggleInlineSelectionFormat, but for all selections in [state]. -export const toggleInlineFormatGlobally = ( - state: EditorState, spec: RegionSpec, -): TransactionSpec => { - const changes = state.changeByRange((sel: SelectionRange) => { - return toggleInlineSelectionFormat(state, spec, sel); - }); - return changes; -}; - -// Toggle formatting in a region, applying block formatting -export const toggleRegionFormatGlobally = ( - state: EditorState, - - inlineSpec: RegionSpec, - blockSpec: RegionSpec, -): TransactionSpec => { - const doc = state.doc; - const preserveBlockQuotes = true; - - const getMatchEndPoints = ( - match: RegExpMatchArray, line: Line, inBlockQuote: boolean, - ): [startIdx: number, stopIdx: number] => { - const startIdx = line.from + match.index; - let stopIdx; - - // Don't treat '> ' as part of the line's content if we're in a blockquote. - let contentLength = line.text.length; - if (inBlockQuote && preserveBlockQuotes) { - contentLength -= blockQuoteStartLen; - } - - // If it matches the entire line, remove the newline character. - if (match[0].length === contentLength) { - stopIdx = line.to + 1; - } else { - stopIdx = startIdx + match[0].length; - - // Take into account the extra '> ' characters, if necessary - if (inBlockQuote && preserveBlockQuotes) { - stopIdx += blockQuoteStartLen; - } - } - - stopIdx = Math.min(stopIdx, doc.length); - return [startIdx, stopIdx]; - }; - - // Returns a change spec that converts an inline region to a block region - // only if the user's cursor is in an empty inline region. - // For example, - // $|$ -> $$\n|\n$$ where | represents the cursor. - const handleInlineToBlockConversion = (sel: SelectionRange) => { - if (!sel.empty) { - return null; - } - - const startMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.Start); - const stopMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.End); - - if (startMatchLen >= 0 && stopMatchLen >= 0) { - const fromLine = doc.lineAt(sel.from); - const inBlockQuote = fromLine.text.match(blockQuoteRegex); - - let lineStartStr = '\n'; - if (inBlockQuote && preserveBlockQuotes) { - lineStartStr = '\n> '; - } - - - const inlineStart = sel.from - startMatchLen; - const inlineStop = sel.from + stopMatchLen; - - // Determine the text that starts the new block (e.g. \n$$\n for - // a math block). - let blockStart = `${blockSpec.template.start}${lineStartStr}`; - if (fromLine.from !== inlineStart) { - // Add a line before to put the start of the block - // on its own line. - blockStart = lineStartStr + blockStart; - } - - return { - changes: [ - { - from: inlineStart, - to: inlineStop, - insert: `${blockStart}${lineStartStr}${blockSpec.template.end}`, - }, - ], - - range: EditorSelection.cursor(inlineStart + blockStart.length), - }; - } - - return null; - }; - - const changes = state.changeByRange((sel: SelectionRange) => { - const blockConversion = handleInlineToBlockConversion(sel); - if (blockConversion) { - return blockConversion; - } - - // If we're in the block version, grow the selection to cover the entire region. - sel = growSelectionToNode(state, sel, blockSpec.nodeName); - - const fromLine = doc.lineAt(sel.from); - const toLine = doc.lineAt(sel.to); - let fromLineText = fromLine.text; - let toLineText = toLine.text; - - let charsAdded = 0; - const changes = []; - - // Single line: Inline toggle. - if (fromLine.number === toLine.number) { - return toggleInlineSelectionFormat(state, inlineSpec, sel); - } - - // Are all lines in a block quote? - let inBlockQuote = true; - for (let i = fromLine.number; i <= toLine.number; i++) { - const line = doc.line(i); - - if (!line.text.match(blockQuoteRegex)) { - inBlockQuote = false; - break; - } - } - - // Ignore block quote characters if in a block quote. - if (inBlockQuote && preserveBlockQuotes) { - fromLineText = fromLineText.substring(blockQuoteStartLen); - toLineText = toLineText.substring(blockQuoteStartLen); - } - - // Otherwise, we're toggling the block version - const startMatch = blockSpec.matcher.start.exec(fromLineText); - const stopMatch = blockSpec.matcher.end.exec(toLineText); - if (startMatch && stopMatch) { - // Get start and stop indices for the starting and ending matches - const [fromMatchFrom, fromMatchTo] = getMatchEndPoints(startMatch, fromLine, inBlockQuote); - const [toMatchFrom, toMatchTo] = getMatchEndPoints(stopMatch, toLine, inBlockQuote); - - // Delete content of the first line - changes.push({ - from: fromMatchFrom, - to: fromMatchTo, - }); - charsAdded -= fromMatchTo - fromMatchFrom; - - // Delete content of the last line - changes.push({ - from: toMatchFrom, - to: toMatchTo, - }); - charsAdded -= toMatchTo - toMatchFrom; - } else { - let insertBefore, insertAfter; - - if (inBlockQuote && preserveBlockQuotes) { - insertBefore = `> ${blockSpec.template.start}\n`; - insertAfter = `\n> ${blockSpec.template.end}`; - } else { - insertBefore = `${blockSpec.template.start}\n`; - insertAfter = `\n${blockSpec.template.end}`; - } - - changes.push({ - from: fromLine.from, - insert: insertBefore, - }); - - changes.push({ - from: toLine.to, - insert: insertAfter, - }); - charsAdded += insertBefore.length + insertAfter.length; - } - - return { - changes, - - // Selection should now encompass all lines that were changed. - range: EditorSelection.range( - fromLine.from, toLine.to + charsAdded, - ), - }; - }); - - return changes; -}; - -// Toggles whether all lines in the user's selection start with [regex]. -export const toggleSelectedLinesStartWith = ( - state: EditorState, - regex: RegExp, - template: string, - matchEmpty: boolean, - - // Name associated with what [regex] matches (e.g. FencedCode) - nodeName?: string, -): TransactionSpec => { - const ignoreBlockQuotes = true; - const getLineContentStart = (line: Line): number => { - if (!ignoreBlockQuotes) { - return line.from; - } - - const blockQuoteMatch = line.text.match(blockQuoteRegex); - if (blockQuoteMatch) { - return line.from + blockQuoteMatch[0].length; - } - - return line.from; - }; - - const getLineContent = (line: Line): string => { - const contentStart = getLineContentStart(line); - return line.text.substring(contentStart - line.from); - }; - - const changes = state.changeByRange((sel: SelectionRange) => { - // Attempt to select all lines in the region - if (nodeName && sel.empty) { - sel = growSelectionToNode(state, sel, nodeName); - } - - const doc = state.doc; - const fromLine = doc.lineAt(sel.from); - const toLine = doc.lineAt(sel.to); - let hasProp = false; - let charsAdded = 0; - let charsAddedBefore = 0; - - const changes = []; - const lines = []; - - for (let i = fromLine.number; i <= toLine.number; i++) { - const line = doc.line(i); - const text = getLineContent(line); - - // If already matching [regex], - if (text.search(regex) === 0) { - hasProp = true; - } - - lines.push(line); - } - - for (const line of lines) { - const text = getLineContent(line); - const contentFrom = getLineContentStart(line); - - // Only process if the line is non-empty. - if (!matchEmpty && text.trim().length === 0 - // Treat the first line differently - && fromLine.number < line.number) { - continue; - } - - if (hasProp) { - const match = text.match(regex); - if (!match) { - continue; - } - changes.push({ - from: contentFrom, - to: contentFrom + match[0].length, - insert: '', - }); - - const deletedSize = match[0].length; - if (contentFrom <= sel.from) { - // Math.min: Handles the case where some deleted characters are before sel.from - // and others are after. - charsAddedBefore -= Math.min(sel.from - contentFrom, deletedSize); - } - charsAdded -= deletedSize; - } else { - changes.push({ - from: contentFrom, - insert: template, - }); - - charsAdded += template.length; - if (contentFrom <= sel.from) { - charsAddedBefore += template.length; - } - } - } - - // If the selection is empty and a single line was changed, don't grow it. - // (user might be adding a list/header, in which case, selecting the just - // added text isn't helpful) - let newSel; - if (sel.empty && fromLine.number === toLine.number) { - newSel = EditorSelection.cursor(sel.from + charsAddedBefore); - } else { - newSel = EditorSelection.range(fromLine.from, toLine.to + charsAdded); - } - - return { - changes, - - // Selection should now encompass all lines that were changed. - range: newSel, - }; - }); - - return changes; -}; - -// Ensures that ordered lists within [sel] are numbered in ascending order. -export const renumberSelectedLists = (state: EditorState): TransactionSpec => { - const doc = state.doc; - - const listItemRegex = /^(\s*)(\d+)\.\s?/; - - // Re-numbers ordered lists and sublists with numbers on each line in [linesToHandle] - const handleLines = (linesToHandle: Line[]) => { - const changes: ChangeSpec[] = []; - - type ListItemRecord = { - nextListNumber: number; - indentationLength: number; - }; - const listNumberStack: ListItemRecord[] = []; - let currentGroupIndentation = ''; - let nextListNumber = 1; - let prevLineNumber; - - for (const line of linesToHandle) { - // Don't re-handle lines. - if (line.number === prevLineNumber) { - continue; - } - prevLineNumber = line.number; - - const filteredText = stripBlockquote(line); - const match = filteredText.match(listItemRegex); - - // Skip lines that aren't the correct type (e.g. blank lines) - if (!match) { - continue; - } - - const indentation = match[1]; - - const indentationLen = tabsToSpaces(state, indentation).length; - let targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length; - if (targetIndentLen < indentationLen) { - listNumberStack.push({ nextListNumber, indentationLength: indentationLen }); - nextListNumber = 1; - } else if (targetIndentLen > indentationLen) { - nextListNumber = parseInt(match[2], 10); - - // Handle the case where we deindent multiple times. For example, - // 1. test - // 1. test - // 1. test - // 2. test - while (targetIndentLen > indentationLen) { - const listNumberRecord = listNumberStack.pop(); - - if (!listNumberRecord) { - break; - } else { - targetIndentLen = listNumberRecord.indentationLength; - nextListNumber = listNumberRecord.nextListNumber; - } - } - - } - - if (targetIndentLen !== indentationLen) { - currentGroupIndentation = indentation; - } - - const from = line.to - filteredText.length; - const to = from + match[0].length; - const inserted = `${indentation}${nextListNumber}. `; - nextListNumber++; - - changes.push({ - from, - to, - insert: inserted, - }); - } - - return changes; - }; - - // Find all selected lists - const selectedListRanges: SelectionRange[] = []; - for (const selection of state.selection.ranges) { - const listLines: Line[] = []; - - syntaxTree(state).iterate({ - from: selection.from, - to: selection.to, - enter: (nodeRef: SyntaxNodeRef) => { - if (nodeRef.name === 'ListItem') { - for (const node of nodeRef.node.parent.getChildren('ListItem')) { - const line = doc.lineAt(node.from); - const filteredText = stripBlockquote(line); - const match = filteredText.match(listItemRegex); - if (match) { - listLines.push(line); - } - } - } - }, - }); - - listLines.sort((a, b) => a.number - b.number); - - if (listLines.length > 0) { - const fromLine = listLines[0]; - const toLine = listLines[listLines.length - 1]; - - selectedListRanges.push( - EditorSelection.range(fromLine.from, toLine.to), - ); - } - } - - const changes: ChangeSpec[] = []; - if (selectedListRanges.length > 0) { - // Use EditorSelection.create to merge overlapping lists - const listsToHandle = EditorSelection.create(selectedListRanges).ranges; - - for (const listSelection of listsToHandle) { - const lines = []; - - const startLine = doc.lineAt(listSelection.from); - const endLine = doc.lineAt(listSelection.to); - - for (let i = startLine.number; i <= endLine.number; i++) { - lines.push(doc.line(i)); - } - - changes.push(...handleLines(lines)); - } - } - - return { - changes, - }; -}; diff --git a/packages/editor/CodeMirror/utils/formatting/RegionSpec.ts b/packages/editor/CodeMirror/utils/formatting/RegionSpec.ts new file mode 100644 index 000000000..1064f8663 --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/RegionSpec.ts @@ -0,0 +1,65 @@ +const { pregQuote } = require('@joplin/lib/string-utils-common'); + +// Specifies how a to find the start/stop of a type of formatting +interface RegionMatchSpec { + start: RegExp; + end: RegExp; +} + +// Describes a region's formatting +export interface RegionSpec { + // The name of the node corresponding to the region in the syntax tree + nodeName?: string; + + // Text to be inserted before and after the region when toggling. + template: { start: string; end: string }; + + // How to identify the region + matcher: RegionMatchSpec; +} + +export namespace RegionSpec { // eslint-disable-line no-redeclare + interface RegionSpecConfig { + nodeName?: string; + template: string | { start: string; end: string }; + matcher?: RegionMatchSpec; + } + + // Creates a new RegionSpec, given a simplified set of options. + // If [config.template] is a string, it is used as both the starting and ending + // templates. + // Similarly, if [config.matcher] is not given, a matcher is created based on + // [config.template]. + export const of = (config: RegionSpecConfig): RegionSpec => { + let templateStart: string, templateEnd: string; + if (typeof config.template === 'string') { + templateStart = config.template; + templateEnd = config.template; + } else { + templateStart = config.template.start; + templateEnd = config.template.end; + } + + const matcher: RegionMatchSpec = + config.matcher ?? matcherFromTemplate(templateStart, templateEnd); + + return { + nodeName: config.nodeName, + template: { start: templateStart, end: templateEnd }, + matcher, + }; + }; + + const matcherFromTemplate = (start: string, end: string): RegionMatchSpec => { + // See https://stackoverflow.com/a/30851002 + const escapedStart = pregQuote(start); + const escapedEnd = pregQuote(end); + + return { + start: new RegExp(escapedStart, 'g'), + end: new RegExp(escapedEnd, 'g'), + }; + }; +} + + diff --git a/packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.ts b/packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.ts new file mode 100644 index 000000000..1e1f49de4 --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.ts @@ -0,0 +1,85 @@ +import { EditorSelection, Text as DocumentText } from '@codemirror/state'; +import { RegionSpec } from './RegionSpec'; +import findInlineMatch, { MatchSide } from './findInlineMatch'; + +describe('findInlineMatch', () => { + jest.retryTimes(2); + + const boldSpec: RegionSpec = RegionSpec.of({ + template: '**', + }); + + it('matching a bolded region: should return the length of the match', () => { + const doc = DocumentText.of(['**test**']); + const sel = EditorSelection.range(0, 5); + + // matchStart returns the length of the match + expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(2); + }); + + it('matching a bolded region: should match the end of a region, if next to the cursor', () => { + const doc = DocumentText.of(['**...** test.']); + const sel = EditorSelection.range(5, 5); + expect(findInlineMatch(doc, boldSpec, sel, MatchSide.End)).toBe(2); + }); + + it('matching a bolded region: should return -1 if no match is found', () => { + const doc = DocumentText.of(['**...** test.']); + const sel = EditorSelection.range(3, 3); + expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(-1); + }); + + it('should match a custom specification of italicized regions', () => { + const spec: RegionSpec = { + template: { start: '*', end: '*' }, + matcher: { start: /[*_]/g, end: /[*_]/g }, + }; + const testString = 'This is a _test_'; + const testDoc = DocumentText.of([testString]); + const fullSel = EditorSelection.range('This is a '.length, testString.length); + + // should match the start of the region + expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.Start)).toBe(1); + + // should match the end of the region + expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.End)).toBe(1); + }); + + const listSpec: RegionSpec = { + template: { start: ' - ', end: '' }, + matcher: { + start: /^\s*[-*]\s/g, + end: /$/g, + }, + }; + + it('matching a custom list: should not match a list if not within the selection', () => { + const doc = DocumentText.of(['- Test...']); + const sel = EditorSelection.range(1, 6); + + // Beginning of list not selected: no match + expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(-1); + }); + + it('matching a custom list: should match start of selected, unindented list', () => { + const doc = DocumentText.of(['- Test...']); + const sel = EditorSelection.range(0, 6); + + expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(2); + }); + + it('matching a custom list: should match start of indented list', () => { + const doc = DocumentText.of([' - Test...']); + const sel = EditorSelection.range(0, 6); + + expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(5); + }); + + it('matching a custom list: should match the end of an item in an indented list', () => { + const doc = DocumentText.of([' - Test...']); + const sel = EditorSelection.range(0, 6); + + // Zero-length, but found, selection + expect(findInlineMatch(doc, listSpec, sel, MatchSide.End)).toBe(0); + }); +}); diff --git a/packages/editor/CodeMirror/utils/formatting/findInlineMatch.ts b/packages/editor/CodeMirror/utils/formatting/findInlineMatch.ts new file mode 100644 index 000000000..2cda0033b --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/findInlineMatch.ts @@ -0,0 +1,76 @@ +import { Text as DocumentText, SelectionRange } from '@codemirror/state'; +import { RegionSpec } from './RegionSpec'; + +export enum MatchSide { + Start, + End, +} + +// Returns the length of a match for this in the given selection, +// -1 if no match is found. +const findInlineMatch = ( + doc: DocumentText, spec: RegionSpec, sel: SelectionRange, side: MatchSide, +): number => { + const [regex, template] = (() => { + if (side === MatchSide.Start) { + return [spec.matcher.start, spec.template.start]; + } else { + return [spec.matcher.end, spec.template.end]; + } + })(); + const [startIndex, endIndex] = (() => { + if (!sel.empty) { + return [sel.from, sel.to]; + } + + const bufferSize = template.length; + if (side === MatchSide.Start) { + return [sel.from - bufferSize, sel.to]; + } else { + return [sel.from, sel.to + bufferSize]; + } + })(); + const searchText = doc.sliceString(startIndex, endIndex); + + // Returns true if [idx] is in the right place (the match is at + // the end of the string or the beginning based on startIndex/endIndex). + const indexSatisfies = (idx: number, len: number): boolean => { + idx += startIndex; + if (side === MatchSide.Start) { + return idx === startIndex; + } else { + return idx + len === endIndex; + } + }; + + if (!regex.global) { + throw new Error('Regular expressions used by RegionSpec must have the global flag! This flag is required to find multiple matches.'); + } + + // Search from the beginning. + regex.lastIndex = 0; + + let foundMatch: RegExpMatchArray|null = null; + let match: RegExpMatchArray|null; + while ((match = regex.exec(searchText)) !== null) { + if (indexSatisfies(match.index ?? -1, match[0].length)) { + foundMatch = match; + break; + } + } + + if (foundMatch) { + const matchLength = foundMatch[0].length; + const matchIndex = foundMatch.index; + + // If the match isn't in the right place, + if (indexSatisfies(matchIndex, matchLength)) { + return matchLength; + } + } + + return -1; +}; + +export default findInlineMatch; + diff --git a/packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.ts b/packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.ts new file mode 100644 index 000000000..2abc5558f --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.ts @@ -0,0 +1,11 @@ +import { EditorState } from '@codemirror/state'; +import tabsToSpaces from './tabsToSpaces'; + +// Returns true iff [a] (an indentation string) is roughly equivalent to [b]. +const isIndentationEquivalent = (state: EditorState, a: string, b: string): boolean => { + // Consider sublists to be the same as their parent list if they have the same + // label plus or minus 1 space. + return Math.abs(tabsToSpaces(state, a).length - tabsToSpaces(state, b).length) <= 1; +}; + +export default isIndentationEquivalent; diff --git a/packages/editor/CodeMirror/utils/formatting/tabsToSpaces.test.ts b/packages/editor/CodeMirror/utils/formatting/tabsToSpaces.test.ts new file mode 100644 index 000000000..94ea0060d --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/tabsToSpaces.test.ts @@ -0,0 +1,19 @@ +import { indentUnit } from '@codemirror/language'; +import { EditorSelection, EditorState } from '@codemirror/state'; +import tabsToSpaces from './tabsToSpaces'; + + +describe('tabsToSpaces', () => { + it('should convert tabs to spaces based on indentUnit', () => { + const state: EditorState = EditorState.create({ + doc: 'This is a test.', + selection: EditorSelection.cursor(0), + extensions: [ + indentUnit.of(' '), + ], + }); + expect(tabsToSpaces(state, '\t')).toBe(' '); + expect(tabsToSpaces(state, '\t ')).toBe(' '); + expect(tabsToSpaces(state, ' \t ')).toBe(' '); + }); +}); diff --git a/packages/editor/CodeMirror/utils/formatting/tabsToSpaces.ts b/packages/editor/CodeMirror/utils/formatting/tabsToSpaces.ts new file mode 100644 index 000000000..5de2ff292 --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/tabsToSpaces.ts @@ -0,0 +1,19 @@ +import { EditorState } from '@codemirror/state'; +import { getIndentUnit } from '@codemirror/language'; + +const tabsToSpaces = (state: EditorState, text: string): string => { + const chunks = text.split('\t'); + const spaceLen = getIndentUnit(state); + let result = chunks[0]; + + for (let i = 1; i < chunks.length; i++) { + for (let j = result.length % spaceLen; j < spaceLen; j++) { + result += ' '; + } + + result += chunks[i]; + } + return result; +}; + +export default tabsToSpaces; diff --git a/packages/editor/CodeMirror/utils/formatting/toggleInlineFormatGlobally.ts b/packages/editor/CodeMirror/utils/formatting/toggleInlineFormatGlobally.ts new file mode 100644 index 000000000..5d7bd1e04 --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/toggleInlineFormatGlobally.ts @@ -0,0 +1,16 @@ +import { EditorState, SelectionRange, TransactionSpec } from '@codemirror/state'; +import { RegionSpec } from './RegionSpec'; +import toggleInlineSelectionFormat from './toggleInlineSelectionFormat'; + + +// Like toggleInlineSelectionFormat, but for all selections in [state]. +const toggleInlineFormatGlobally = ( + state: EditorState, spec: RegionSpec, +): TransactionSpec => { + const changes = state.changeByRange((sel: SelectionRange) => { + return toggleInlineSelectionFormat(state, spec, sel); + }); + return changes; +}; + +export default toggleInlineFormatGlobally; diff --git a/packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.ts b/packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.ts new file mode 100644 index 000000000..992b4bf91 --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/toggleInlineRegionSurrounded.ts @@ -0,0 +1,66 @@ +import { Text as DocumentText, EditorSelection, SelectionRange } from '@codemirror/state'; +import { RegionSpec } from './RegionSpec'; +import findInlineMatch, { MatchSide } from './findInlineMatch'; +import { SelectionUpdate } from './types'; + +// Toggles whether the given selection matches the inline region specified by [spec]. +// +// For example, something similar to toggleSurrounded('**', '**') would surround +// every selection range with asterisks (including the caret). +// If the selection is already surrounded by these characters, they are +// removed. +const toggleInlineRegionSurrounded = ( + doc: DocumentText, sel: SelectionRange, spec: RegionSpec, +): SelectionUpdate => { + let content = doc.sliceString(sel.from, sel.to); + const startMatchLen = findInlineMatch(doc, spec, sel, MatchSide.Start); + const endMatchLen = findInlineMatch(doc, spec, sel, MatchSide.End); + + const startsWithBefore = startMatchLen >= 0; + const endsWithAfter = endMatchLen >= 0; + + const changes = []; + let finalSelStart = sel.from; + let finalSelEnd = sel.to; + + if (startsWithBefore && endsWithAfter) { + // Remove the before and after. + content = content.substring(startMatchLen); + content = content.substring(0, content.length - endMatchLen); + + finalSelEnd -= startMatchLen + endMatchLen; + + changes.push({ + from: sel.from, + to: sel.to, + insert: content, + }); + } else { + changes.push({ + from: sel.from, + insert: spec.template.start, + }); + + changes.push({ + from: sel.to, + insert: spec.template.end, + }); + + // If not a caret, + if (!sel.empty) { + // Select the surrounding chars. + finalSelEnd += spec.template.start.length + spec.template.end.length; + } else { + // Position the caret within the added content. + finalSelStart = sel.from + spec.template.start.length; + finalSelEnd = finalSelStart; + } + } + + return { + changes, + range: EditorSelection.range(finalSelStart, finalSelEnd), + }; +}; + +export default toggleInlineRegionSurrounded; diff --git a/packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.ts b/packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.ts new file mode 100644 index 000000000..95aeba72b --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/toggleInlineSelectionFormat.ts @@ -0,0 +1,33 @@ +import { EditorSelection, SelectionRange, EditorState } from '@codemirror/state'; +import { RegionSpec } from './RegionSpec'; +import { SelectionUpdate } from './types'; +import findInlineMatch, { MatchSide } from './findInlineMatch'; +import growSelectionToNode from '../growSelectionToNode'; +import toggleInlineRegionSurrounded from './toggleInlineRegionSurrounded'; + +// Returns updated selections: For all selections in the given `EditorState`, toggles +// whether each is contained in an inline region of type [spec]. +const toggleInlineSelectionFormat = ( + state: EditorState, spec: RegionSpec, sel: SelectionRange, +): SelectionUpdate => { + const endMatchLen = findInlineMatch(state.doc, spec, sel, MatchSide.End); + + // If at the end of the region, move the + // caret to the end. + // E.g. + // **foobar|** + // **foobar**| + if (sel.empty && endMatchLen > -1) { + const newCursorPos = sel.from + endMatchLen; + + return { + range: EditorSelection.cursor(newCursorPos), + }; + } + + // Grow the selection to encompass the entire node. + const newRange = growSelectionToNode(state, sel, spec.nodeName); + return toggleInlineRegionSurrounded(state.doc, newRange, spec); +}; + +export default toggleInlineSelectionFormat; diff --git a/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.ts b/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.ts new file mode 100644 index 000000000..1054fa839 --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.ts @@ -0,0 +1,52 @@ +import { EditorSelection, EditorState } from '@codemirror/state'; +import { RegionSpec } from './RegionSpec'; +import toggleRegionFormatGlobally from './toggleRegionFormatGlobally'; + +describe('toggleRegionFormatGlobally', () => { + jest.retryTimes(2); + + const multiLineTestText = `Internal text manipulation + This is a test... + of block and inline region toggling.`; + const codeFenceRegex = /^``````\w*\s*$/; + const inlineCodeRegionSpec = RegionSpec.of({ + template: '`', + nodeName: 'InlineCode', + }); + const blockCodeRegionSpec: RegionSpec = { + template: { start: '``````', end: '``````' }, + matcher: { start: codeFenceRegex, end: codeFenceRegex }, + }; + + it('should create an empty inline region around the cursor, if given an empty selection', () => { + const initialState: EditorState = EditorState.create({ + doc: multiLineTestText, + selection: EditorSelection.cursor(0), + }); + + const changes = toggleRegionFormatGlobally( + initialState, inlineCodeRegionSpec, blockCodeRegionSpec, + ); + + const newState = initialState.update(changes).state; + expect(newState.doc.toString()).toEqual(`\`\`${multiLineTestText}`); + }); + + it('should wrap multiple selected lines in block formatting', () => { + const initialState: EditorState = EditorState.create({ + doc: multiLineTestText, + selection: EditorSelection.range(0, multiLineTestText.length), + }); + + const changes = toggleRegionFormatGlobally( + initialState, inlineCodeRegionSpec, blockCodeRegionSpec, + ); + + const newState = initialState.update(changes).state; + const editorText = newState.doc.toString(); + expect(editorText).toBe(`\`\`\`\`\`\`\n${multiLineTestText}\n\`\`\`\`\`\``); + expect(newState.selection.main.from).toBe(0); + expect(newState.selection.main.to).toBe(editorText.length); + }); +}); + diff --git a/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.ts b/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.ts new file mode 100644 index 000000000..a310be608 --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.ts @@ -0,0 +1,204 @@ +import { EditorSelection, EditorState, Line, SelectionRange, TransactionSpec } from '@codemirror/state'; +import { RegionSpec } from './RegionSpec'; +import findInlineMatch, { MatchSide } from './findInlineMatch'; +import growSelectionToNode from '../growSelectionToNode'; +import toggleInlineSelectionFormat from './toggleInlineSelectionFormat'; + +const blockQuoteStartLen = '> '.length; +const blockQuoteRegex = /^>\s/; + +// Toggle formatting for all selections. For example, +// toggling a code RegionSpec repeatedly should create: +// 1. Empty inline code: `` +// 2. Empty block code: +// ``` +// ``` +// 3. Remove the block code. +// +// This is intended primarily for mobile, where characters +// like "`" can be difficult to type. +const toggleRegionFormatGlobally = ( + state: EditorState, + + inlineSpec: RegionSpec, + blockSpec: RegionSpec, +): TransactionSpec => { + const doc = state.doc; + const preserveBlockQuotes = true; + + const getMatchEndPoints = ( + match: RegExpMatchArray, line: Line, inBlockQuote: boolean, + ): [startIdx: number, stopIdx: number] => { + const startIdx = line.from + match.index; + let stopIdx; + + // Don't treat '> ' as part of the line's content if we're in a blockquote. + let contentLength = line.text.length; + if (inBlockQuote && preserveBlockQuotes) { + contentLength -= blockQuoteStartLen; + } + + // If it matches the entire line, remove the newline character. + if (match[0].length === contentLength) { + stopIdx = line.to + 1; + } else { + stopIdx = startIdx + match[0].length; + + // Take into account the extra '> ' characters, if necessary + if (inBlockQuote && preserveBlockQuotes) { + stopIdx += blockQuoteStartLen; + } + } + + stopIdx = Math.min(stopIdx, doc.length); + return [startIdx, stopIdx]; + }; + + // Returns a change spec that converts an inline region to a block region + // only if the user's cursor is in an empty inline region. + // For example, + // $|$ -> $$\n|\n$$ where | represents the cursor. + const handleInlineToBlockConversion = (sel: SelectionRange) => { + if (!sel.empty) { + return null; + } + + const startMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.Start); + const stopMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.End); + + if (startMatchLen >= 0 && stopMatchLen >= 0) { + const fromLine = doc.lineAt(sel.from); + const inBlockQuote = fromLine.text.match(blockQuoteRegex); + + let lineStartStr = '\n'; + if (inBlockQuote && preserveBlockQuotes) { + lineStartStr = '\n> '; + } + + + const inlineStart = sel.from - startMatchLen; + const inlineStop = sel.from + stopMatchLen; + + // Determine the text that starts the new block (e.g. \n$$\n for + // a math block). + let blockStart = `${blockSpec.template.start}${lineStartStr}`; + if (fromLine.from !== inlineStart) { + // Add a line before to put the start of the block + // on its own line. + blockStart = lineStartStr + blockStart; + } + + return { + changes: [ + { + from: inlineStart, + to: inlineStop, + insert: `${blockStart}${lineStartStr}${blockSpec.template.end}`, + }, + ], + + range: EditorSelection.cursor(inlineStart + blockStart.length), + }; + } + + return null; + }; + + const changes = state.changeByRange((sel: SelectionRange) => { + const blockConversion = handleInlineToBlockConversion(sel); + if (blockConversion) { + return blockConversion; + } + + // If we're in the block version, grow the selection to cover the entire region. + sel = growSelectionToNode(state, sel, blockSpec.nodeName); + + const fromLine = doc.lineAt(sel.from); + const toLine = doc.lineAt(sel.to); + let fromLineText = fromLine.text; + let toLineText = toLine.text; + + let charsAdded = 0; + const changes = []; + + // Single line: Inline toggle. + if (fromLine.number === toLine.number) { + return toggleInlineSelectionFormat(state, inlineSpec, sel); + } + + // Are all lines in a block quote? + let inBlockQuote = true; + for (let i = fromLine.number; i <= toLine.number; i++) { + const line = doc.line(i); + + if (!line.text.match(blockQuoteRegex)) { + inBlockQuote = false; + break; + } + } + + // Ignore block quote characters if in a block quote. + if (inBlockQuote && preserveBlockQuotes) { + fromLineText = fromLineText.substring(blockQuoteStartLen); + toLineText = toLineText.substring(blockQuoteStartLen); + } + + // Otherwise, we're toggling the block version + const startMatch = blockSpec.matcher.start.exec(fromLineText); + const stopMatch = blockSpec.matcher.end.exec(toLineText); + if (startMatch && stopMatch) { + // Get start and stop indices for the starting and ending matches + const [fromMatchFrom, fromMatchTo] = getMatchEndPoints(startMatch, fromLine, inBlockQuote); + const [toMatchFrom, toMatchTo] = getMatchEndPoints(stopMatch, toLine, inBlockQuote); + + // Delete content of the first line + changes.push({ + from: fromMatchFrom, + to: fromMatchTo, + }); + charsAdded -= fromMatchTo - fromMatchFrom; + + // Delete content of the last line + changes.push({ + from: toMatchFrom, + to: toMatchTo, + }); + charsAdded -= toMatchTo - toMatchFrom; + } else { + let insertBefore, insertAfter; + + if (inBlockQuote && preserveBlockQuotes) { + insertBefore = `> ${blockSpec.template.start}\n`; + insertAfter = `\n> ${blockSpec.template.end}`; + } else { + insertBefore = `${blockSpec.template.start}\n`; + insertAfter = `\n${blockSpec.template.end}`; + } + + changes.push({ + from: fromLine.from, + insert: insertBefore, + }); + + changes.push({ + from: toLine.to, + insert: insertAfter, + }); + charsAdded += insertBefore.length + insertAfter.length; + } + + return { + changes, + + // Selection should now encompass all lines that were changed. + range: EditorSelection.range( + fromLine.from, toLine.to + charsAdded, + ), + }; + }); + + return changes; +}; + +export default toggleRegionFormatGlobally; + diff --git a/packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.ts b/packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.ts new file mode 100644 index 000000000..2637312d4 --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.ts @@ -0,0 +1,124 @@ +import { EditorSelection, EditorState, Line, SelectionRange, TransactionSpec } from '@codemirror/state'; +import growSelectionToNode from '../growSelectionToNode'; + +// Toggles whether all lines in the user's selection start with [regex]. +const toggleSelectedLinesStartWith = ( + state: EditorState, + regex: RegExp, + template: string, + matchEmpty: boolean, + + // Determines where this formatting can begin on a line. + // Defaults to after a block quote marker + lineContentStartRegex = /^>\s/, + + // Name associated with what [regex] matches (e.g. FencedCode) + nodeName?: string, +): TransactionSpec => { + const getLineContentStart = (line: Line): number => { + const blockQuoteMatch = line.text.match(lineContentStartRegex); + if (blockQuoteMatch) { + return line.from + blockQuoteMatch[0].length; + } + + return line.from; + }; + + const getLineContent = (line: Line): string => { + const contentStart = getLineContentStart(line); + return line.text.substring(contentStart - line.from); + }; + + const changes = state.changeByRange((sel: SelectionRange) => { + // Attempt to select all lines in the region + if (nodeName && sel.empty) { + sel = growSelectionToNode(state, sel, nodeName); + } + + const doc = state.doc; + const fromLine = doc.lineAt(sel.from); + const toLine = doc.lineAt(sel.to); + let hasProp = false; + let charsAdded = 0; + let charsAddedBefore = 0; + + const changes = []; + const lines: Line[] = []; + + for (let i = fromLine.number; i <= toLine.number; i++) { + const line = doc.line(i); + const text = getLineContent(line); + + // If already matching [regex], + if (text.search(regex) === 0) { + hasProp = true; + } + + lines.push(line); + } + + for (const line of lines) { + const text = getLineContent(line); + const contentFrom = getLineContentStart(line); + + // Only process if the line is non-empty. + if (!matchEmpty && text.trim().length === 0 + // Treat the first line differently + && fromLine.number < line.number) { + continue; + } + + if (hasProp) { + const match = text.match(regex); + if (!match) { + continue; + } + changes.push({ + from: contentFrom, + to: contentFrom + match[0].length, + insert: '', + }); + + const deletedSize = match[0].length; + if (contentFrom <= sel.from) { + // Math.min: Handles the case where some deleted characters are before sel.from + // and others are after. + charsAddedBefore -= Math.min(sel.from - contentFrom, deletedSize); + } + charsAdded -= deletedSize; + } else { + changes.push({ + from: contentFrom, + insert: template, + }); + + charsAdded += template.length; + if (contentFrom <= sel.from) { + charsAddedBefore += template.length; + } + } + } + + // If the selection is empty and a single line was changed, don't grow it. + // (user might be adding a list/header, in which case, selecting the just + // added text isn't helpful) + let newSel; + if (sel.empty && fromLine.number === toLine.number) { + newSel = EditorSelection.cursor(sel.from + charsAddedBefore); + } else { + newSel = EditorSelection.range(fromLine.from, toLine.to + charsAdded); + } + + return { + changes, + + // Selection should now encompass all lines that were changed. + range: newSel, + }; + }); + + return changes; +}; + +export default toggleSelectedLinesStartWith; + diff --git a/packages/editor/CodeMirror/utils/formatting/types.ts b/packages/editor/CodeMirror/utils/formatting/types.ts new file mode 100644 index 000000000..f330f84b4 --- /dev/null +++ b/packages/editor/CodeMirror/utils/formatting/types.ts @@ -0,0 +1,5 @@ +import { ChangeSpec, SelectionRange } from '@codemirror/state'; + +// Specifies the update of a single selection region and its contents +export type SelectionUpdate = { range: SelectionRange; changes?: ChangeSpec }; + diff --git a/packages/editor/CodeMirror/utils/growSelectionToNode.ts b/packages/editor/CodeMirror/utils/growSelectionToNode.ts new file mode 100644 index 000000000..74a8bb7ee --- /dev/null +++ b/packages/editor/CodeMirror/utils/growSelectionToNode.ts @@ -0,0 +1,52 @@ +import { syntaxTree } from '@codemirror/language'; +import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'; + +// Expands and returns a copy of [sel] to the smallest container node with name in [nodeNames]. +const growSelectionToNode = ( + state: EditorState, sel: SelectionRange, nodeNames: string|string[]|null, +): SelectionRange => { + if (!nodeNames) { + return sel; + } + + const isAcceptableNode = (name: string): boolean => { + if (typeof nodeNames === 'string') { + return name === nodeNames; + } + + for (const otherName of nodeNames) { + if (otherName === name) { + return true; + } + } + + return false; + }; + + let newFrom = null; + let newTo = null; + let smallestLen = Infinity; + + // Find the smallest range. + syntaxTree(state).iterate({ + from: sel.from, to: sel.to, + enter: node => { + if (isAcceptableNode(node.name)) { + if (node.to - node.from < smallestLen) { + newFrom = node.from; + newTo = node.to; + smallestLen = newTo - newFrom; + } + } + }, + }); + + // If it's in such a node, + if (newFrom !== null && newTo !== null) { + return EditorSelection.range(newFrom, newTo); + } else { + return sel; + } +}; + +export default growSelectionToNode; diff --git a/packages/editor/CodeMirror/util/isInSyntaxNode.ts b/packages/editor/CodeMirror/utils/isInSyntaxNode.ts similarity index 100% rename from packages/editor/CodeMirror/util/isInSyntaxNode.ts rename to packages/editor/CodeMirror/utils/isInSyntaxNode.ts diff --git a/packages/editor/CodeMirror/util/setupVim.ts b/packages/editor/CodeMirror/utils/setupVim.ts similarity index 100% rename from packages/editor/CodeMirror/util/setupVim.ts rename to packages/editor/CodeMirror/utils/setupVim.ts