mirror of
https://github.com/laurent22/joplin.git
synced 2026-06-06 20:10:22 +00:00
512 lines
15 KiB
TypeScript
512 lines
15 KiB
TypeScript
// CodeMirror 6 commands that modify markdown formatting (e.g. toggleBold).
|
|
|
|
import { EditorView, Command } from '@codemirror/view';
|
|
|
|
import { ListType } from '../../types';
|
|
import {
|
|
SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec,
|
|
} from '@codemirror/state';
|
|
import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language';
|
|
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/markdown/stripBlockquote';
|
|
import renumberSelectedLists from '../utils/markdown/renumberSelectedLists';
|
|
import toggleSelectedLinesStartWith from '../utils/formatting/toggleSelectedLinesStartWith';
|
|
|
|
|
|
export const toggleBolded: Command = (view: EditorView): boolean => {
|
|
const spec = RegionSpec.of({ template: '**', nodeName: 'StrongEmphasis' });
|
|
const changes = toggleInlineFormatGlobally(view.state, spec);
|
|
|
|
view.dispatch(changes);
|
|
return true;
|
|
};
|
|
|
|
export const toggleItalicized: Command = (view: EditorView): boolean => {
|
|
let handledBoldItalicRegion = false;
|
|
|
|
// Bold-italic regions' starting and ending patterns are similar to italicized regions.
|
|
// Thus, we need additional logic to convert bold regions to bold-italic regions.
|
|
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
|
|
const changes: ChangeSpec[] = [];
|
|
|
|
// Only handle cursors (empty selections)
|
|
if (sel.empty) {
|
|
const doc = view.state.doc;
|
|
const selLine = doc.lineAt(sel.from);
|
|
|
|
const selStartLineIdx = sel.from - selLine.from;
|
|
const selEndLineIdx = sel.to - selLine.from;
|
|
const beforeSel = selLine.text.substring(0, selStartLineIdx);
|
|
const afterSel = selLine.text.substring(selEndLineIdx);
|
|
|
|
const isBolded = beforeSel.endsWith('**') && afterSel.startsWith('**');
|
|
|
|
// If at the end of a bold-italic region, exit the region.
|
|
if (afterSel.startsWith('***')) {
|
|
sel = EditorSelection.cursor(sel.to + 3);
|
|
handledBoldItalicRegion = true;
|
|
} else if (isBolded) {
|
|
// Create a bold-italic region.
|
|
changes.push({
|
|
from: sel.from,
|
|
to: sel.to,
|
|
insert: '**',
|
|
});
|
|
|
|
// Move to the center of the bold-italic region (**|**** -> ***|***)
|
|
sel = EditorSelection.cursor(sel.to + 1);
|
|
handledBoldItalicRegion = true;
|
|
}
|
|
}
|
|
|
|
return {
|
|
changes,
|
|
range: sel,
|
|
};
|
|
}));
|
|
|
|
if (!handledBoldItalicRegion) {
|
|
const changes = toggleInlineFormatGlobally(view.state, {
|
|
nodeName: 'Emphasis',
|
|
|
|
template: { start: '*', end: '*' },
|
|
matcher: { start: /[_*]/g, end: /[_*]/g },
|
|
});
|
|
view.dispatch(changes);
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
// If the selected region is an empty inline code block, it will be converted to
|
|
// a block (fenced) code block.
|
|
export const toggleCode: Command = (view: EditorView): boolean => {
|
|
const codeFenceRegex = /^```\w*\s*$/;
|
|
const inlineRegionSpec = RegionSpec.of({ template: '`', nodeName: 'InlineCode' });
|
|
const blockRegionSpec: RegionSpec = {
|
|
nodeName: 'FencedCode',
|
|
template: { start: '```', end: '```' },
|
|
matcher: { start: codeFenceRegex, end: codeFenceRegex },
|
|
};
|
|
|
|
const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
|
|
view.dispatch(changes);
|
|
|
|
return true;
|
|
};
|
|
|
|
export const toggleMath: Command = (view: EditorView): boolean => {
|
|
const blockStartRegex = /^\$\$/;
|
|
const blockEndRegex = /\$\$\s*$/;
|
|
const inlineRegionSpec = RegionSpec.of({ nodeName: 'InlineMath', template: '$' });
|
|
const blockRegionSpec = RegionSpec.of({
|
|
nodeName: 'BlockMath',
|
|
template: '$$',
|
|
matcher: {
|
|
start: blockStartRegex,
|
|
end: blockEndRegex,
|
|
},
|
|
});
|
|
|
|
const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
|
|
view.dispatch(changes);
|
|
|
|
return true;
|
|
};
|
|
|
|
export const toggleList = (listType: ListType): Command => {
|
|
enum ListAction {
|
|
AddList,
|
|
RemoveList,
|
|
SwitchFormatting,
|
|
}
|
|
|
|
return (view: EditorView): boolean => {
|
|
const state = view.state;
|
|
const doc = state.doc;
|
|
|
|
const bulletedRegex = /^\s*([-*])\s(?!\[[ xX]+\]\s)/;
|
|
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s/;
|
|
const numberedRegex = /^\s*\d+\.\s/;
|
|
const startingSpaceRegex = /^\s*/;
|
|
|
|
const listRegexes: Record<ListType, RegExp> = {
|
|
[ListType.OrderedList]: numberedRegex,
|
|
[ListType.CheckList]: checklistRegex,
|
|
[ListType.UnorderedList]: bulletedRegex,
|
|
};
|
|
|
|
const getContainerType = (line: Line): ListType | null => {
|
|
const lineContent = stripBlockquote(line);
|
|
if (lineContent.match(checklistRegex)) return ListType.CheckList;
|
|
if (lineContent.match(bulletedRegex)) return ListType.UnorderedList;
|
|
if (lineContent.match(numberedRegex)) return ListType.OrderedList;
|
|
return null;
|
|
};
|
|
|
|
// Maximum line number in the original document that has
|
|
// been processed
|
|
let maximumChangedLine = -1;
|
|
const getNextLineRange = (sel: SelectionRange) => {
|
|
let fromLine = doc.lineAt(sel.from);
|
|
const toLine = doc.lineAt(sel.to);
|
|
|
|
// Full selection already processed.
|
|
if (toLine.number <= maximumChangedLine) {
|
|
return null;
|
|
}
|
|
|
|
if (fromLine.number <= maximumChangedLine) {
|
|
fromLine = doc.line(maximumChangedLine);
|
|
}
|
|
maximumChangedLine = toLine.number;
|
|
|
|
return { fromLine, toLine };
|
|
};
|
|
|
|
const getIndent = (line: Line) => {
|
|
const content = stripBlockquote(line);
|
|
return (content.match(startingSpaceRegex)?.[0] || '').length;
|
|
};
|
|
|
|
const getBaselineIndent = (fromLine: Line, toLine: Line) => {
|
|
let baselineIndent = Infinity;
|
|
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
|
|
const line = doc.line(lineNum);
|
|
const content = stripBlockquote(line);
|
|
if (content.trim() !== '') {
|
|
baselineIndent = Math.min(baselineIndent, getIndent(line));
|
|
}
|
|
}
|
|
if (baselineIndent === Infinity) baselineIndent = 0;
|
|
|
|
return baselineIndent;
|
|
};
|
|
|
|
const getFirstBaselineIndentLine = (fromLine: Line, toLine: Line) => {
|
|
const baselineIndent = getBaselineIndent(fromLine, toLine);
|
|
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
|
|
const line = doc.line(lineNum);
|
|
const content = stripBlockquote(line);
|
|
if (content.trim() === '') continue;
|
|
|
|
const indent = getIndent(line);
|
|
if (indent === baselineIndent) {
|
|
return line;
|
|
}
|
|
}
|
|
return fromLine;
|
|
};
|
|
|
|
const getAction = (fromLine: Line, toLine: Line) => {
|
|
const firstLine = getFirstBaselineIndentLine(fromLine, toLine);
|
|
|
|
const currentListType = getContainerType(firstLine);
|
|
if (currentListType === null) {
|
|
return ListAction.AddList;
|
|
} else if (currentListType === listType) {
|
|
return ListAction.RemoveList;
|
|
}
|
|
return ListAction.SwitchFormatting;
|
|
};
|
|
|
|
const areAllLinesBlankInRange = (fromLine: Line, toLine: Line) => {
|
|
for (let lineNumber = fromLine.number; lineNumber <= toLine.number; lineNumber++) {
|
|
const line = state.doc.line(lineNumber);
|
|
|
|
// Consider lines within block quotes with no other content to be blank (this
|
|
// command should behave similarly regardless of whether in or out of a block
|
|
// quote).
|
|
if (stripBlockquote(line).trim() !== '') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
|
|
const lineRange = getNextLineRange(sel);
|
|
if (!lineRange) return { range: sel };
|
|
const { fromLine, toLine } = lineRange;
|
|
const baselineIndent = getBaselineIndent(fromLine, toLine);
|
|
const action = getAction(fromLine, toLine);
|
|
const allLinesBlank = areAllLinesBlankInRange(fromLine, toLine);
|
|
|
|
// Outermost list item number
|
|
let outerCounter = 1;
|
|
// Stack mapping parent indentation to item numbers
|
|
const stack: { indent: number; counter: number }[] = [];
|
|
const changes: ChangeSpec[] = [];
|
|
let charsAdded = 0;
|
|
|
|
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
|
|
const line = doc.line(lineNum);
|
|
const origLineContent = stripBlockquote(line);
|
|
const currentLineBlank = origLineContent.trim() === '';
|
|
|
|
// To support changing the formatting of non-tight lists, skip blank lines within
|
|
// larger content. This allows changing the list format without adding a potentially
|
|
// large number of empty list items.
|
|
//
|
|
// However, if all lines are blank (e.g. if the cursor at the beginning of an empty)
|
|
// document, we're not changing the formatting of an existing list. In this case,
|
|
// skipping blank lines would cause the command to do nothing.
|
|
// See https://github.com/laurent22/joplin/pull/12745.
|
|
if (currentLineBlank && !allLinesBlank) {
|
|
continue;
|
|
}
|
|
|
|
// Content excluding the block quote start
|
|
const lineContentFrom = line.to - origLineContent.length;
|
|
const indentation = origLineContent.match(startingSpaceRegex)?.[0] || '';
|
|
const currentIndent = indentation.length;
|
|
const normalizedIndent = currentIndent - baselineIndent;
|
|
|
|
const currentContainer = getContainerType(line);
|
|
const deleteFrom = lineContentFrom;
|
|
let deleteTo = deleteFrom + indentation.length;
|
|
let isAlreadyListItem = false;
|
|
if (currentContainer !== null) {
|
|
const containerRegex = listRegexes[currentContainer];
|
|
const containerMatch = origLineContent.match(containerRegex);
|
|
if (containerMatch) {
|
|
deleteTo = lineContentFrom + containerMatch[0].length;
|
|
isAlreadyListItem = true;
|
|
}
|
|
}
|
|
|
|
let replacementString = indentation;
|
|
if (action === ListAction.AddList || action === ListAction.SwitchFormatting) {
|
|
if (action === ListAction.SwitchFormatting && !isAlreadyListItem) {
|
|
// Skip replacement if the line didn't previously have list formatting
|
|
deleteTo = deleteFrom;
|
|
replacementString = '';
|
|
} else if (listType === ListType.OrderedList) {
|
|
if (normalizedIndent <= 0) {
|
|
// Top-level item
|
|
stack.length = 0;
|
|
replacementString = `${indentation}${outerCounter}. `;
|
|
outerCounter++;
|
|
} else {
|
|
// Nested item
|
|
while (stack.length && stack[stack.length - 1].indent > currentIndent) {
|
|
stack.pop();
|
|
}
|
|
if (!stack.length || stack[stack.length - 1].indent < currentIndent) {
|
|
stack.push({ indent: currentIndent, counter: 1 });
|
|
}
|
|
const currentLevel = stack[stack.length - 1];
|
|
replacementString = `${indentation}${currentLevel.counter}. `;
|
|
currentLevel.counter++;
|
|
}
|
|
} else if (listType === ListType.CheckList) {
|
|
replacementString = `${indentation}- [ ] `;
|
|
} else if (listType === ListType.UnorderedList) {
|
|
replacementString = `${indentation}- `;
|
|
}
|
|
}
|
|
|
|
changes.push({
|
|
from: deleteFrom,
|
|
to: deleteTo,
|
|
insert: replacementString,
|
|
});
|
|
charsAdded -= deleteTo - deleteFrom;
|
|
charsAdded += replacementString.length;
|
|
}
|
|
|
|
const newSelection = sel.empty
|
|
? EditorSelection.cursor(toLine.to + charsAdded)
|
|
: EditorSelection.range(sel.from, sel.to + charsAdded);
|
|
return { changes, range: newSelection };
|
|
});
|
|
|
|
view.dispatch(changes);
|
|
// Fix any selected lists. Do this as a separate .dispatch
|
|
// so that it can be undone separately.
|
|
view.dispatch(renumberSelectedLists(view.state));
|
|
|
|
return true;
|
|
};
|
|
};
|
|
|
|
|
|
export const toggleHeaderLevel = (level: number): Command => {
|
|
return (view: EditorView): boolean => {
|
|
let headerStr = '';
|
|
for (let i = 0; i < level; i++) {
|
|
headerStr += '#';
|
|
}
|
|
|
|
const matchEmpty = true;
|
|
// Remove header formatting for any other level
|
|
let changes = toggleSelectedLinesStartWith(
|
|
view.state,
|
|
new RegExp(
|
|
// Check all numbers of #s lower than [level]
|
|
`${level - 1 >= 1 ? `(?:^[#]{1,${level - 1}}\\s)|` : ''
|
|
|
|
// Check all number of #s higher than [level]
|
|
}(?:^[#]{${level + 1},}\\s)`,
|
|
),
|
|
'',
|
|
matchEmpty,
|
|
);
|
|
view.dispatch(changes);
|
|
|
|
// Set to the proper header level
|
|
changes = toggleSelectedLinesStartWith(
|
|
view.state,
|
|
// We want exactly [level] '#' characters.
|
|
new RegExp(`^[#]{${level}} `),
|
|
`${headerStr} `,
|
|
matchEmpty,
|
|
);
|
|
view.dispatch(changes);
|
|
|
|
return true;
|
|
};
|
|
};
|
|
|
|
export const insertHorizontalRule: Command = (view: EditorView) => {
|
|
view.dispatch(view.state.changeByRange(selection => {
|
|
const line = view.state.doc.lineAt(selection.to);
|
|
const processedLineText = stripBlockquote(line);
|
|
const inBlockQuote = processedLineText !== line.text;
|
|
const needsNewLine = processedLineText !== '';
|
|
|
|
let prefix = inBlockQuote && needsNewLine ? '> ' : '';
|
|
if (needsNewLine) {
|
|
prefix = `\n${prefix}`;
|
|
}
|
|
const insert = `${prefix}* * *`;
|
|
|
|
return {
|
|
range: EditorSelection.cursor(line.to + insert.length),
|
|
changes: {
|
|
from: line.to,
|
|
insert,
|
|
},
|
|
};
|
|
}));
|
|
return true;
|
|
};
|
|
|
|
// Prepends the given editor's indentUnit to all lines of the current selection
|
|
// and re-numbers modified ordered lists (if any).
|
|
export const increaseIndent: Command = (view: EditorView): boolean => {
|
|
const matchEmpty = true;
|
|
const matchNothing = /$ ^/;
|
|
const indentUnit = indentString(view.state, getIndentUnit(view.state));
|
|
|
|
const changes = toggleSelectedLinesStartWith(
|
|
view.state,
|
|
// Delete nothing
|
|
matchNothing,
|
|
// ...and thus always add indentUnit.
|
|
indentUnit,
|
|
matchEmpty,
|
|
);
|
|
view.dispatch(changes);
|
|
|
|
// Fix any lists
|
|
view.dispatch(renumberSelectedLists(view.state));
|
|
|
|
return true;
|
|
};
|
|
|
|
// Like `increaseIndent`, but may insert tabs, rather than
|
|
// indenting, in some instances.
|
|
export const insertOrIncreaseIndent: Command = (view: EditorView): boolean => {
|
|
const selection = view.state.selection;
|
|
const mainSelection = selection.main;
|
|
if (selection.ranges.length !== 1 || !mainSelection.empty) {
|
|
return increaseIndent(view);
|
|
}
|
|
|
|
|
|
if (intersectsSyntaxNode(view.state, mainSelection, 'ListItem')) {
|
|
return increaseIndent(view);
|
|
}
|
|
|
|
const indentUnit = indentString(view.state, getIndentUnit(view.state));
|
|
view.dispatch(view.state.changeByRange(selection => {
|
|
return {
|
|
// Move the selection to after the inserted text
|
|
range: EditorSelection.cursor(selection.from + indentUnit.length),
|
|
changes: {
|
|
from: selection.from,
|
|
insert: indentUnit,
|
|
},
|
|
};
|
|
}));
|
|
|
|
return true;
|
|
};
|
|
|
|
export const decreaseIndent: Command = (view: EditorView): boolean => {
|
|
const matchEmpty = true;
|
|
const changes = toggleSelectedLinesStartWith(
|
|
view.state,
|
|
// Assume indentation is either a tab or in units
|
|
// of n spaces.
|
|
new RegExp(`^(?:[\\t]|[ ]{1,${getIndentUnit(view.state)}})`),
|
|
// Don't add new text
|
|
'',
|
|
matchEmpty,
|
|
);
|
|
|
|
view.dispatch(changes);
|
|
|
|
// Fix any lists
|
|
view.dispatch(renumberSelectedLists(view.state));
|
|
|
|
return true;
|
|
};
|
|
|
|
export const updateLink = (label: string, url: string): Command => {
|
|
// Empty label? Just include the URL.
|
|
const linkText = label === '' ? url : `[${label}](${url})`;
|
|
|
|
return (editor: EditorView): boolean => {
|
|
const transaction = editor.state.changeByRange((sel: SelectionRange) => {
|
|
const changes = [];
|
|
|
|
// Search for a link that overlaps [sel]
|
|
let linkFrom: number | null = null;
|
|
let linkTo: number | null = null;
|
|
syntaxTree(editor.state).iterate({
|
|
from: sel.from, to: sel.to,
|
|
enter: node => {
|
|
const haveFoundLink = (linkFrom !== null && linkTo !== null);
|
|
|
|
if (node.name === 'Link' || (node.name === 'URL' && !haveFoundLink)) {
|
|
linkFrom = node.from;
|
|
linkTo = node.to;
|
|
}
|
|
},
|
|
});
|
|
|
|
linkFrom ??= sel.from;
|
|
linkTo ??= sel.to;
|
|
|
|
changes.push({
|
|
from: linkFrom, to: linkTo,
|
|
insert: linkText,
|
|
});
|
|
|
|
return {
|
|
changes,
|
|
range: EditorSelection.range(linkFrom, linkFrom + linkText.length),
|
|
};
|
|
});
|
|
|
|
editor.dispatch(transaction);
|
|
return true;
|
|
};
|
|
};
|