mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-02 12:47:41 +02:00
Chore: Refactor editor
package: Move functions in editorStateUtils into separate files (#10591)
This commit is contained in:
parent
8cf4ef88b5
commit
1dcf528443
@ -766,6 +766,9 @@ packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
|
|||||||
packages/editor/CodeMirror/markdown/markdownCommands.js
|
packages/editor/CodeMirror/markdown/markdownCommands.js
|
||||||
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
|
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
|
||||||
packages/editor/CodeMirror/markdown/markdownMathParser.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/PluginLoader.js
|
||||||
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
|
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
|
||||||
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.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/pressReleaseKey.js
|
||||||
packages/editor/CodeMirror/testUtil/typeText.js
|
packages/editor/CodeMirror/testUtil/typeText.js
|
||||||
packages/editor/CodeMirror/theme.js
|
packages/editor/CodeMirror/theme.js
|
||||||
packages/editor/CodeMirror/util/editorStateUtils.test.js
|
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
|
||||||
packages/editor/CodeMirror/util/editorStateUtils.js
|
packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.js
|
||||||
packages/editor/CodeMirror/util/isInSyntaxNode.js
|
packages/editor/CodeMirror/utils/formatting/findInlineMatch.js
|
||||||
packages/editor/CodeMirror/util/setupVim.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/SelectionFormatting.js
|
||||||
packages/editor/events.js
|
packages/editor/events.js
|
||||||
packages/editor/types.js
|
packages/editor/types.js
|
||||||
|
23
.gitignore
vendored
23
.gitignore
vendored
@ -745,6 +745,9 @@ packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
|
|||||||
packages/editor/CodeMirror/markdown/markdownCommands.js
|
packages/editor/CodeMirror/markdown/markdownCommands.js
|
||||||
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
|
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
|
||||||
packages/editor/CodeMirror/markdown/markdownMathParser.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/PluginLoader.js
|
||||||
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
|
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
|
||||||
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.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/pressReleaseKey.js
|
||||||
packages/editor/CodeMirror/testUtil/typeText.js
|
packages/editor/CodeMirror/testUtil/typeText.js
|
||||||
packages/editor/CodeMirror/theme.js
|
packages/editor/CodeMirror/theme.js
|
||||||
packages/editor/CodeMirror/util/editorStateUtils.test.js
|
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
|
||||||
packages/editor/CodeMirror/util/editorStateUtils.js
|
packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.js
|
||||||
packages/editor/CodeMirror/util/isInSyntaxNode.js
|
packages/editor/CodeMirror/utils/formatting/findInlineMatch.js
|
||||||
packages/editor/CodeMirror/util/setupVim.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/SelectionFormatting.js
|
||||||
packages/editor/events.js
|
packages/editor/events.js
|
||||||
packages/editor/types.js
|
packages/editor/types.js
|
||||||
|
@ -4,7 +4,7 @@ import KeymapService, { KeymapItem } from '@joplin/lib/services/KeymapService';
|
|||||||
import { EditorCommand } from '../../../../utils/types';
|
import { EditorCommand } from '../../../../utils/types';
|
||||||
import shim from '@joplin/lib/shim';
|
import shim from '@joplin/lib/shim';
|
||||||
import { reg } from '@joplin/lib/registry';
|
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 { EventName } from '@joplin/lib/eventManager';
|
||||||
import normalizeAccelerator from '../../utils/normalizeAccelerator';
|
import normalizeAccelerator from '../../utils/normalizeAccelerator';
|
||||||
import { CodeMirrorVersion } from '../../utils/types';
|
import { CodeMirrorVersion } from '../../utils/types';
|
||||||
|
@ -8,7 +8,7 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
|||||||
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
|
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
|
||||||
import shim from '@joplin/lib/shim';
|
import shim from '@joplin/lib/shim';
|
||||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
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 { dirname } from 'path';
|
||||||
import useKeymap from './utils/useKeymap';
|
import useKeymap from './utils/useKeymap';
|
||||||
import useEditorSearch from '../utils/useEditorSearchExtension';
|
import useEditorSearch from '../utils/useEditorSearchExtension';
|
||||||
|
@ -8,7 +8,8 @@ import { SearchQuery, setSearchQuery } from '@codemirror/search';
|
|||||||
import PluginLoader from './pluginApi/PluginLoader';
|
import PluginLoader from './pluginApi/PluginLoader';
|
||||||
import customEditorCompletion, { editorCompletionSource, enableLanguageDataAutocomplete } from './pluginApi/customEditorCompletion';
|
import customEditorCompletion, { editorCompletionSource, enableLanguageDataAutocomplete } from './pluginApi/customEditorCompletion';
|
||||||
import { CompletionSource } from '@codemirror/autocomplete';
|
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 {
|
interface Callbacks {
|
||||||
onUndoRedo(): void;
|
onUndoRedo(): void;
|
||||||
|
@ -7,12 +7,16 @@ import {
|
|||||||
SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec,
|
SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec,
|
||||||
} from '@codemirror/state';
|
} from '@codemirror/state';
|
||||||
import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language';
|
import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language';
|
||||||
import {
|
import intersectsSyntaxNode from '../utils/isInSyntaxNode';
|
||||||
RegionSpec, growSelectionToNode, renumberSelectedLists,
|
import toggleRegionFormatGlobally from '../utils/formatting/toggleRegionFormatGlobally';
|
||||||
toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith,
|
import { RegionSpec } from '../utils/formatting/RegionSpec';
|
||||||
isIndentationEquivalent, stripBlockquote, tabsToSpaces,
|
import toggleInlineFormatGlobally from '../utils/formatting/toggleInlineFormatGlobally';
|
||||||
} from '../util/editorStateUtils';
|
import stripBlockquote from './utils/stripBlockquote';
|
||||||
import intersectsSyntaxNode from '../util/isInSyntaxNode';
|
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*)/;
|
const startingSpaceRegex = /^(\s*)/;
|
||||||
|
|
||||||
|
@ -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'));
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
15
packages/editor/CodeMirror/markdown/utils/stripBlockquote.ts
Normal file
15
packages/editor/CodeMirror/markdown/utils/stripBlockquote.ts
Normal file
@ -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;
|
@ -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'));
|
|
||||||
});
|
|
||||||
});
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
65
packages/editor/CodeMirror/utils/formatting/RegionSpec.ts
Normal file
65
packages/editor/CodeMirror/utils/formatting/RegionSpec.ts
Normal file
@ -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'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
|
@ -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;
|
@ -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(' ');
|
||||||
|
});
|
||||||
|
});
|
19
packages/editor/CodeMirror/utils/formatting/tabsToSpaces.ts
Normal file
19
packages/editor/CodeMirror/utils/formatting/tabsToSpaces.ts
Normal file
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
5
packages/editor/CodeMirror/utils/formatting/types.ts
Normal file
5
packages/editor/CodeMirror/utils/formatting/types.ts
Normal file
@ -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 };
|
||||||
|
|
52
packages/editor/CodeMirror/utils/growSelectionToNode.ts
Normal file
52
packages/editor/CodeMirror/utils/growSelectionToNode.ts
Normal file
@ -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;
|
Loading…
Reference in New Issue
Block a user