mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
713 lines
19 KiB
TypeScript
713 lines
19 KiB
TypeScript
|
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.start,
|
||
|
});
|
||
|
|
||
|
// 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 indicies 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;
|
||
|
|
||
|
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: '',
|
||
|
});
|
||
|
|
||
|
charsAdded -= match[0].length;
|
||
|
} else {
|
||
|
changes.push({
|
||
|
from: contentFrom,
|
||
|
insert: template,
|
||
|
});
|
||
|
|
||
|
charsAdded += 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) {
|
||
|
const regionEnd = toLine.to + charsAdded;
|
||
|
newSel = EditorSelection.cursor(regionEnd);
|
||
|
} 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 renumberList = (state: EditorState, sel: SelectionRange): SelectionUpdate => {
|
||
|
const doc = state.doc;
|
||
|
|
||
|
const listItemRegex = /^(\s*)(\d+)\.\s?/;
|
||
|
const changes: ChangeSpec[] = [];
|
||
|
const fromLine = doc.lineAt(sel.from);
|
||
|
const toLine = doc.lineAt(sel.to);
|
||
|
let charsAdded = 0;
|
||
|
|
||
|
// Re-numbers ordered lists and sublists with numbers on each line in [linesToHandle]
|
||
|
const handleLines = (linesToHandle: Line[]) => {
|
||
|
let currentGroupIndentation = '';
|
||
|
let nextListNumber = 1;
|
||
|
const listNumberStack: number[] = [];
|
||
|
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);
|
||
|
const indentation = match[1];
|
||
|
|
||
|
const indentationLen = tabsToSpaces(state, indentation).length;
|
||
|
const targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length;
|
||
|
if (targetIndentLen < indentationLen) {
|
||
|
listNumberStack.push(nextListNumber);
|
||
|
nextListNumber = 1;
|
||
|
} else if (targetIndentLen > indentationLen) {
|
||
|
nextListNumber = listNumberStack.pop() ?? parseInt(match[2], 10);
|
||
|
}
|
||
|
|
||
|
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,
|
||
|
});
|
||
|
charsAdded -= to - from;
|
||
|
charsAdded += inserted.length;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const linesToHandle: Line[] = [];
|
||
|
syntaxTree(state).iterate({
|
||
|
from: sel.from,
|
||
|
to: sel.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) {
|
||
|
linesToHandle.push(line);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
});
|
||
|
|
||
|
linesToHandle.sort((a, b) => a.number - b.number);
|
||
|
handleLines(linesToHandle);
|
||
|
|
||
|
// Re-position the selection in a way that makes sense
|
||
|
if (sel.empty) {
|
||
|
sel = EditorSelection.cursor(toLine.to + charsAdded);
|
||
|
} else {
|
||
|
sel = EditorSelection.range(
|
||
|
fromLine.from,
|
||
|
toLine.to + charsAdded
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
range: sel,
|
||
|
changes,
|
||
|
};
|
||
|
};
|