1
0
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:
Henry Heino 2024-06-14 11:40:07 -07:00 committed by GitHub
parent 8cf4ef88b5
commit 1dcf528443
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1081 additions and 966 deletions

View File

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

23
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -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*)/;

View File

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

View File

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

View 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;

View File

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

View File

@ -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,
};
};

View 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'),
};
};
}

View File

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

View File

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

View File

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

View File

@ -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(' ');
});
});

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 };

View 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;