mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-15 09:04:04 +02:00
441 lines
13 KiB
TypeScript
441 lines
13 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 {
|
|
RegionSpec, growSelectionToNode, renumberList,
|
|
toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith,
|
|
isIndentationEquivalent, stripBlockquote, tabsToSpaces,
|
|
} from './markdownReformatter';
|
|
|
|
const startingSpaceRegex = /^(\s*)/;
|
|
|
|
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 => {
|
|
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 => {
|
|
return (view: EditorView): boolean => {
|
|
let state = view.state;
|
|
let doc = state.doc;
|
|
|
|
const orderedListTag = 'OrderedList';
|
|
const unorderedListTag = 'BulletList';
|
|
|
|
// RegExps for different list types. The regular expressions MUST
|
|
// be mutually exclusive.
|
|
// `(?!\[[ xX]+\]\s?)` means "not followed by [x] or [ ]".
|
|
const bulletedRegex = /^\s*([-*])(?!\s\[[ xX]+\])\s?/;
|
|
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s?/;
|
|
const numberedRegex = /^\s*\d+\.\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);
|
|
|
|
// Determine the container's type.
|
|
const checklistMatch = lineContent.match(checklistRegex);
|
|
const bulletListMatch = lineContent.match(bulletedRegex);
|
|
const orderedListMatch = lineContent.match(numberedRegex);
|
|
|
|
if (checklistMatch) {
|
|
return ListType.CheckList;
|
|
} else if (bulletListMatch) {
|
|
return ListType.UnorderedList;
|
|
} else if (orderedListMatch) {
|
|
return ListType.OrderedList;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
|
|
const changes: ChangeSpec[] = [];
|
|
let containerType: ListType|null = null;
|
|
|
|
// Total number of characters added (deleted if negative)
|
|
let charsAdded = 0;
|
|
|
|
const originalSel = sel;
|
|
let fromLine: Line;
|
|
let toLine: Line;
|
|
let firstLineIndentation: string;
|
|
let firstLineInBlockQuote: boolean;
|
|
let fromLineContent: string;
|
|
const computeSelectionProps = () => {
|
|
fromLine = doc.lineAt(sel.from);
|
|
toLine = doc.lineAt(sel.to);
|
|
fromLineContent = stripBlockquote(fromLine);
|
|
firstLineIndentation = fromLineContent.match(startingSpaceRegex)[0];
|
|
firstLineInBlockQuote = (fromLineContent !== fromLine.text);
|
|
|
|
containerType = getContainerType(fromLine);
|
|
};
|
|
computeSelectionProps();
|
|
|
|
const origFirstLineIndentation = firstLineIndentation;
|
|
const origContainerType = containerType;
|
|
|
|
// Grow [sel] to the smallest containing list
|
|
if (sel.empty) {
|
|
sel = growSelectionToNode(state, sel, [orderedListTag, unorderedListTag]);
|
|
computeSelectionProps();
|
|
}
|
|
|
|
// Reset the selection if it seems likely the user didn't want the selection
|
|
// to be expanded
|
|
const isIndentationDiff =
|
|
!isIndentationEquivalent(state, firstLineIndentation, origFirstLineIndentation);
|
|
if (isIndentationDiff) {
|
|
const expandedRegionIndentation = firstLineIndentation;
|
|
sel = originalSel;
|
|
computeSelectionProps();
|
|
|
|
// Use the indentation level of the expanded region if it's greater.
|
|
// This makes sense in the case where unindented text is being converted to
|
|
// the same type of list as its container. For example,
|
|
// 1. Foobar
|
|
// unindented text
|
|
// that should be made a part of the above list.
|
|
//
|
|
// becoming
|
|
//
|
|
// 1. Foobar
|
|
// 2. unindented text
|
|
// 3. that should be made a part of the above list.
|
|
const wasGreaterIndentation = (
|
|
tabsToSpaces(state, expandedRegionIndentation).length
|
|
> tabsToSpaces(state, firstLineIndentation).length
|
|
);
|
|
if (wasGreaterIndentation) {
|
|
firstLineIndentation = expandedRegionIndentation;
|
|
}
|
|
} else if (
|
|
(origContainerType !== containerType && (origContainerType ?? null) !== null)
|
|
|| containerType !== getContainerType(toLine)
|
|
) {
|
|
// If the container type changed, this could be an artifact of checklists/bulleted
|
|
// lists sharing the same node type.
|
|
// Find the closest range of the same type of list to the original selection
|
|
let newFromLineNo = doc.lineAt(originalSel.from).number;
|
|
let newToLineNo = doc.lineAt(originalSel.to).number;
|
|
let lastFromLineNo;
|
|
let lastToLineNo;
|
|
|
|
while (newFromLineNo !== lastFromLineNo || newToLineNo !== lastToLineNo) {
|
|
lastFromLineNo = newFromLineNo;
|
|
lastToLineNo = newToLineNo;
|
|
|
|
if (lastFromLineNo - 1 >= 1) {
|
|
const testFromLine = doc.line(lastFromLineNo - 1);
|
|
if (getContainerType(testFromLine) === origContainerType) {
|
|
newFromLineNo --;
|
|
}
|
|
}
|
|
|
|
if (lastToLineNo + 1 <= doc.lines) {
|
|
const testToLine = doc.line(lastToLineNo + 1);
|
|
if (getContainerType(testToLine) === origContainerType) {
|
|
newToLineNo ++;
|
|
}
|
|
}
|
|
}
|
|
|
|
sel = EditorSelection.range(
|
|
doc.line(newFromLineNo).from,
|
|
doc.line(newToLineNo).to
|
|
);
|
|
computeSelectionProps();
|
|
}
|
|
|
|
// Determine whether the expanded selection should be empty
|
|
if (originalSel.empty && fromLine.number === toLine.number) {
|
|
sel = EditorSelection.cursor(toLine.to);
|
|
}
|
|
|
|
// Select entire lines (if not just a cursor)
|
|
if (!sel.empty) {
|
|
sel = EditorSelection.range(fromLine.from, toLine.to);
|
|
}
|
|
|
|
// Number of the item in the list (e.g. 2 for the 2nd item in the list)
|
|
let listItemCounter = 1;
|
|
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum ++) {
|
|
const line = doc.line(lineNum);
|
|
const lineContent = stripBlockquote(line);
|
|
const lineContentFrom = line.to - lineContent.length;
|
|
const inBlockQuote = (lineContent !== line.text);
|
|
const indentation = lineContent.match(startingSpaceRegex)[0];
|
|
|
|
const wrongIndentaton = !isIndentationEquivalent(state, indentation, firstLineIndentation);
|
|
|
|
// If not the right list level,
|
|
if (inBlockQuote !== firstLineInBlockQuote || wrongIndentaton) {
|
|
// We'll be starting a new list
|
|
listItemCounter = 1;
|
|
continue;
|
|
}
|
|
|
|
// Don't add list numbers to otherwise empty lines (unless it's the first line)
|
|
if (lineNum !== fromLine.number && line.text.trim().length === 0) {
|
|
// Do not reset the counter -- the markdown renderer doesn't!
|
|
continue;
|
|
}
|
|
|
|
const deleteFrom = lineContentFrom;
|
|
let deleteTo = deleteFrom + indentation.length;
|
|
|
|
// If we need to remove an existing list,
|
|
const currentContainer = getContainerType(line);
|
|
if (currentContainer !== null) {
|
|
const containerRegex = listRegexes[currentContainer];
|
|
const containerMatch = lineContent.match(containerRegex);
|
|
if (!containerMatch) {
|
|
throw new Error(
|
|
'Assertion failed: container regex does not match line content.'
|
|
);
|
|
}
|
|
|
|
deleteTo = lineContentFrom + containerMatch[0].length;
|
|
}
|
|
|
|
let replacementString;
|
|
|
|
if (listType === containerType) {
|
|
// Delete the existing list if it's the same type as the current
|
|
replacementString = '';
|
|
} else if (listType === ListType.OrderedList) {
|
|
replacementString = `${firstLineIndentation}${listItemCounter}. `;
|
|
} else if (listType === ListType.CheckList) {
|
|
replacementString = `${firstLineIndentation}- [ ] `;
|
|
} else {
|
|
replacementString = `${firstLineIndentation}- `;
|
|
}
|
|
|
|
changes.push({
|
|
from: deleteFrom,
|
|
to: deleteTo,
|
|
insert: replacementString,
|
|
});
|
|
charsAdded -= deleteTo - deleteFrom;
|
|
charsAdded += replacementString.length;
|
|
listItemCounter++;
|
|
}
|
|
|
|
// Don't change cursors to selections
|
|
if (sel.empty) {
|
|
// Position the cursor at the end of the last line modified
|
|
sel = EditorSelection.cursor(toLine.to + charsAdded);
|
|
} else {
|
|
sel = EditorSelection.range(
|
|
sel.from,
|
|
sel.to + charsAdded
|
|
);
|
|
}
|
|
|
|
return {
|
|
changes,
|
|
range: sel,
|
|
};
|
|
});
|
|
view.dispatch(changes);
|
|
state = view.state;
|
|
doc = state.doc;
|
|
|
|
// Renumber the list
|
|
view.dispatch(state.changeByRange((sel: SelectionRange) => {
|
|
return renumberList(state, sel);
|
|
}));
|
|
|
|
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;
|
|
};
|
|
};
|
|
|
|
// 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(view.state.changeByRange((sel: SelectionRange) => {
|
|
return renumberList(view.state, sel);
|
|
}));
|
|
|
|
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(view.state.changeByRange((sel: SelectionRange) => {
|
|
return renumberList(view.state, sel);
|
|
}));
|
|
|
|
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;
|
|
};
|
|
};
|