396 lines
17 KiB
JavaScript
396 lines
17 KiB
JavaScript
import { EditorSelection, Prec, EditorState } from '@codemirror/state';
|
|
import { keymap } from '@codemirror/view';
|
|
import { syntaxTree, LanguageSupport, Language, defineLanguageFacet, foldNodeProp, indentNodeProp, languageDataProp, foldService, LanguageDescription, ParseContext } from '@codemirror/language';
|
|
import { CompletionContext } from '@codemirror/autocomplete';
|
|
import { MarkdownParser, parseCode, parser, GFM, Subscript, Superscript, Emoji } from '@lezer/markdown';
|
|
import { htmlCompletionSource, html } from '@codemirror/lang-html';
|
|
import { NodeProp } from '@lezer/common';
|
|
|
|
const data = /*@__PURE__*/defineLanguageFacet({ commentTokens: { block: { open: "<!--", close: "-->" } } });
|
|
const headingProp = /*@__PURE__*/new NodeProp();
|
|
const commonmark = /*@__PURE__*/parser.configure({
|
|
props: [
|
|
/*@__PURE__*/foldNodeProp.add(type => {
|
|
return !type.is("Block") || type.is("Document") || isHeading(type) != null ? undefined
|
|
: (tree, state) => ({ from: state.doc.lineAt(tree.from).to, to: tree.to });
|
|
}),
|
|
/*@__PURE__*/headingProp.add(isHeading),
|
|
/*@__PURE__*/indentNodeProp.add({
|
|
Document: () => null
|
|
}),
|
|
/*@__PURE__*/languageDataProp.add({
|
|
Document: data
|
|
})
|
|
]
|
|
});
|
|
function isHeading(type) {
|
|
let match = /^(?:ATX|Setext)Heading(\d)$/.exec(type.name);
|
|
return match ? +match[1] : undefined;
|
|
}
|
|
function findSectionEnd(headerNode, level) {
|
|
let last = headerNode;
|
|
for (;;) {
|
|
let next = last.nextSibling, heading;
|
|
if (!next || (heading = isHeading(next.type)) != null && heading <= level)
|
|
break;
|
|
last = next;
|
|
}
|
|
return last.to;
|
|
}
|
|
const headerIndent = /*@__PURE__*/foldService.of((state, start, end) => {
|
|
for (let node = syntaxTree(state).resolveInner(end, -1); node; node = node.parent) {
|
|
if (node.from < start)
|
|
break;
|
|
let heading = node.type.prop(headingProp);
|
|
if (heading == null)
|
|
continue;
|
|
let upto = findSectionEnd(node, heading);
|
|
if (upto > end)
|
|
return { from: end, to: upto };
|
|
}
|
|
return null;
|
|
});
|
|
function mkLang(parser) {
|
|
return new Language(data, parser, [headerIndent], "markdown");
|
|
}
|
|
/**
|
|
Language support for strict CommonMark.
|
|
*/
|
|
const commonmarkLanguage = /*@__PURE__*/mkLang(commonmark);
|
|
const extended = /*@__PURE__*/commonmark.configure([GFM, Subscript, Superscript, Emoji]);
|
|
/**
|
|
Language support for [GFM](https://github.github.com/gfm/) plus
|
|
subscript, superscript, and emoji syntax.
|
|
*/
|
|
const markdownLanguage = /*@__PURE__*/mkLang(extended);
|
|
function getCodeParser(languages, defaultLanguage) {
|
|
return (info) => {
|
|
if (info && languages) {
|
|
let found = null;
|
|
// Strip anything after whitespace
|
|
info = /\S*/.exec(info)[0];
|
|
if (typeof languages == "function")
|
|
found = languages(info);
|
|
else
|
|
found = LanguageDescription.matchLanguageName(languages, info, true);
|
|
if (found instanceof LanguageDescription)
|
|
return found.support ? found.support.language.parser : ParseContext.getSkippingParser(found.load());
|
|
else if (found)
|
|
return found.parser;
|
|
}
|
|
return defaultLanguage ? defaultLanguage.parser : null;
|
|
};
|
|
}
|
|
|
|
class Context {
|
|
constructor(node, from, to, spaceBefore, spaceAfter, type, item) {
|
|
this.node = node;
|
|
this.from = from;
|
|
this.to = to;
|
|
this.spaceBefore = spaceBefore;
|
|
this.spaceAfter = spaceAfter;
|
|
this.type = type;
|
|
this.item = item;
|
|
}
|
|
blank(maxWidth, trailing = true) {
|
|
let result = this.spaceBefore + (this.node.name == "Blockquote" ? ">" : "");
|
|
if (maxWidth != null) {
|
|
while (result.length < maxWidth)
|
|
result += " ";
|
|
return result;
|
|
}
|
|
else {
|
|
for (let i = this.to - this.from - result.length - this.spaceAfter.length; i > 0; i--)
|
|
result += " ";
|
|
return result + (trailing ? this.spaceAfter : "");
|
|
}
|
|
}
|
|
marker(doc, add) {
|
|
let number = this.node.name == "OrderedList" ? String((+itemNumber(this.item, doc)[2] + add)) : "";
|
|
return this.spaceBefore + number + this.type + this.spaceAfter;
|
|
}
|
|
}
|
|
function getContext(node, doc) {
|
|
let nodes = [];
|
|
for (let cur = node; cur && cur.name != "Document"; cur = cur.parent) {
|
|
if (cur.name == "ListItem" || cur.name == "Blockquote" || cur.name == "FencedCode")
|
|
nodes.push(cur);
|
|
}
|
|
let context = [];
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
let node = nodes[i], match;
|
|
let line = doc.lineAt(node.from), startPos = node.from - line.from;
|
|
if (node.name == "FencedCode") {
|
|
context.push(new Context(node, startPos, startPos, "", "", "", null));
|
|
}
|
|
else if (node.name == "Blockquote" && (match = /^[ \t]*>( ?)/.exec(line.text.slice(startPos)))) {
|
|
context.push(new Context(node, startPos, startPos + match[0].length, "", match[1], ">", null));
|
|
}
|
|
else if (node.name == "ListItem" && node.parent.name == "OrderedList" &&
|
|
(match = /^([ \t]*)\d+([.)])([ \t]*)/.exec(line.text.slice(startPos)))) {
|
|
let after = match[3], len = match[0].length;
|
|
if (after.length >= 4) {
|
|
after = after.slice(0, after.length - 4);
|
|
len -= 4;
|
|
}
|
|
context.push(new Context(node.parent, startPos, startPos + len, match[1], after, match[2], node));
|
|
}
|
|
else if (node.name == "ListItem" && node.parent.name == "BulletList" &&
|
|
(match = /^([ \t]*)([-+*])([ \t]{1,4}\[[ xX]\])?([ \t]+)/.exec(line.text.slice(startPos)))) {
|
|
let after = match[4], len = match[0].length;
|
|
if (after.length > 4) {
|
|
after = after.slice(0, after.length - 4);
|
|
len -= 4;
|
|
}
|
|
let type = match[2];
|
|
if (match[3])
|
|
type += match[3].replace(/[xX]/, ' ');
|
|
context.push(new Context(node.parent, startPos, startPos + len, match[1], after, type, node));
|
|
}
|
|
}
|
|
return context;
|
|
}
|
|
function itemNumber(item, doc) {
|
|
return /^(\s*)(\d+)(?=[.)])/.exec(doc.sliceString(item.from, item.from + 10));
|
|
}
|
|
function renumberList(after, doc, changes, offset = 0) {
|
|
for (let prev = -1, node = after;;) {
|
|
if (node.name == "ListItem") {
|
|
let m = itemNumber(node, doc);
|
|
let number = +m[2];
|
|
if (prev >= 0) {
|
|
if (number != prev + 1)
|
|
return;
|
|
changes.push({ from: node.from + m[1].length, to: node.from + m[0].length, insert: String(prev + 2 + offset) });
|
|
}
|
|
prev = number;
|
|
}
|
|
let next = node.nextSibling;
|
|
if (!next)
|
|
break;
|
|
node = next;
|
|
}
|
|
}
|
|
/**
|
|
This command, when invoked in Markdown context with cursor
|
|
selection(s), will create a new line with the markup for
|
|
blockquotes and lists that were active on the old line. If the
|
|
cursor was directly after the end of the markup for the old line,
|
|
trailing whitespace and list markers are removed from that line.
|
|
|
|
The command does nothing in non-Markdown context, so it should
|
|
not be used as the only binding for Enter (even in a Markdown
|
|
document, HTML and code regions might use a different language).
|
|
*/
|
|
const insertNewlineContinueMarkup = ({ state, dispatch }) => {
|
|
let tree = syntaxTree(state), { doc } = state;
|
|
let dont = null, changes = state.changeByRange(range => {
|
|
if (!range.empty || !markdownLanguage.isActiveAt(state, range.from))
|
|
return dont = { range };
|
|
let pos = range.from, line = doc.lineAt(pos);
|
|
let context = getContext(tree.resolveInner(pos, -1), doc);
|
|
while (context.length && context[context.length - 1].from > pos - line.from)
|
|
context.pop();
|
|
if (!context.length)
|
|
return dont = { range };
|
|
let inner = context[context.length - 1];
|
|
if (inner.to - inner.spaceAfter.length > pos - line.from)
|
|
return dont = { range };
|
|
let emptyLine = pos >= (inner.to - inner.spaceAfter.length) && !/\S/.test(line.text.slice(inner.to));
|
|
// Empty line in list
|
|
if (inner.item && emptyLine) {
|
|
// First list item or blank line before: delete a level of markup
|
|
if (inner.node.firstChild.to >= pos ||
|
|
line.from > 0 && !/[^\s>]/.test(doc.lineAt(line.from - 1).text)) {
|
|
let next = context.length > 1 ? context[context.length - 2] : null;
|
|
let delTo, insert = "";
|
|
if (next && next.item) { // Re-add marker for the list at the next level
|
|
delTo = line.from + next.from;
|
|
insert = next.marker(doc, 1);
|
|
}
|
|
else {
|
|
delTo = line.from + (next ? next.to : 0);
|
|
}
|
|
let changes = [{ from: delTo, to: pos, insert }];
|
|
if (inner.node.name == "OrderedList")
|
|
renumberList(inner.item, doc, changes, -2);
|
|
if (next && next.node.name == "OrderedList")
|
|
renumberList(next.item, doc, changes);
|
|
return { range: EditorSelection.cursor(delTo + insert.length), changes };
|
|
}
|
|
else { // Move this line down
|
|
let insert = "";
|
|
for (let i = 0, e = context.length - 2; i <= e; i++) {
|
|
insert += context[i].blank(i < e ? context[i + 1].from - insert.length : null, i < e);
|
|
}
|
|
insert += state.lineBreak;
|
|
return { range: EditorSelection.cursor(pos + insert.length), changes: { from: line.from, insert } };
|
|
}
|
|
}
|
|
if (inner.node.name == "Blockquote" && emptyLine && line.from) {
|
|
let prevLine = doc.lineAt(line.from - 1), quoted = />\s*$/.exec(prevLine.text);
|
|
// Two aligned empty quoted lines in a row
|
|
if (quoted && quoted.index == inner.from) {
|
|
let changes = state.changes([{ from: prevLine.from + quoted.index, to: prevLine.to },
|
|
{ from: line.from + inner.from, to: line.to }]);
|
|
return { range: range.map(changes), changes };
|
|
}
|
|
}
|
|
let changes = [];
|
|
if (inner.node.name == "OrderedList")
|
|
renumberList(inner.item, doc, changes);
|
|
let continued = inner.item && inner.item.from < line.from;
|
|
let insert = "";
|
|
// If not dedented
|
|
if (!continued || /^[\s\d.)\-+*>]*/.exec(line.text)[0].length >= inner.to) {
|
|
for (let i = 0, e = context.length - 1; i <= e; i++) {
|
|
insert += i == e && !continued ? context[i].marker(doc, 1)
|
|
: context[i].blank(i < e ? context[i + 1].from - insert.length : null);
|
|
}
|
|
}
|
|
let from = pos;
|
|
while (from > line.from && /\s/.test(line.text.charAt(from - line.from - 1)))
|
|
from--;
|
|
insert = state.lineBreak + insert;
|
|
changes.push({ from, to: pos, insert });
|
|
return { range: EditorSelection.cursor(from + insert.length), changes };
|
|
});
|
|
if (dont)
|
|
return false;
|
|
dispatch(state.update(changes, { scrollIntoView: true, userEvent: "input" }));
|
|
return true;
|
|
};
|
|
function isMark(node) {
|
|
return node.name == "QuoteMark" || node.name == "ListMark";
|
|
}
|
|
function contextNodeForDelete(tree, pos) {
|
|
let node = tree.resolveInner(pos, -1), scan = pos;
|
|
if (isMark(node)) {
|
|
scan = node.from;
|
|
node = node.parent;
|
|
}
|
|
for (let prev; prev = node.childBefore(scan);) {
|
|
if (isMark(prev)) {
|
|
scan = prev.from;
|
|
}
|
|
else if (prev.name == "OrderedList" || prev.name == "BulletList") {
|
|
node = prev.lastChild;
|
|
scan = node.to;
|
|
}
|
|
else {
|
|
break;
|
|
}
|
|
}
|
|
return node;
|
|
}
|
|
/**
|
|
This command will, when invoked in a Markdown context with the
|
|
cursor directly after list or blockquote markup, delete one level
|
|
of markup. When the markup is for a list, it will be replaced by
|
|
spaces on the first invocation (a further invocation will delete
|
|
the spaces), to make it easy to continue a list.
|
|
|
|
When not after Markdown block markup, this command will return
|
|
false, so it is intended to be bound alongside other deletion
|
|
commands, with a higher precedence than the more generic commands.
|
|
*/
|
|
const deleteMarkupBackward = ({ state, dispatch }) => {
|
|
let tree = syntaxTree(state);
|
|
let dont = null, changes = state.changeByRange(range => {
|
|
let pos = range.from, { doc } = state;
|
|
if (range.empty && markdownLanguage.isActiveAt(state, range.from)) {
|
|
let line = doc.lineAt(pos);
|
|
let context = getContext(contextNodeForDelete(tree, pos), doc);
|
|
if (context.length) {
|
|
let inner = context[context.length - 1];
|
|
let spaceEnd = inner.to - inner.spaceAfter.length + (inner.spaceAfter ? 1 : 0);
|
|
// Delete extra trailing space after markup
|
|
if (pos - line.from > spaceEnd && !/\S/.test(line.text.slice(spaceEnd, pos - line.from)))
|
|
return { range: EditorSelection.cursor(line.from + spaceEnd),
|
|
changes: { from: line.from + spaceEnd, to: pos } };
|
|
if (pos - line.from == spaceEnd &&
|
|
// Only apply this if we're on the line that has the
|
|
// construct's syntax, or there's only indentation in the
|
|
// target range
|
|
(!inner.item || line.from <= inner.item.from || !/\S/.test(line.text.slice(0, inner.to)))) {
|
|
let start = line.from + inner.from;
|
|
// Replace a list item marker with blank space
|
|
if (inner.item && inner.node.from < inner.item.from && /\S/.test(line.text.slice(inner.from, inner.to)))
|
|
return { range, changes: { from: start, to: line.from + inner.to, insert: inner.blank(inner.to - inner.from) } };
|
|
// Delete one level of indentation
|
|
if (start < pos)
|
|
return { range: EditorSelection.cursor(start), changes: { from: start, to: pos } };
|
|
}
|
|
}
|
|
}
|
|
return dont = { range };
|
|
});
|
|
if (dont)
|
|
return false;
|
|
dispatch(state.update(changes, { scrollIntoView: true, userEvent: "delete" }));
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
A small keymap with Markdown-specific bindings. Binds Enter to
|
|
[`insertNewlineContinueMarkup`](https://codemirror.net/6/docs/ref/#lang-markdown.insertNewlineContinueMarkup)
|
|
and Backspace to
|
|
[`deleteMarkupBackward`](https://codemirror.net/6/docs/ref/#lang-markdown.deleteMarkupBackward).
|
|
*/
|
|
const markdownKeymap = [
|
|
{ key: "Enter", run: insertNewlineContinueMarkup },
|
|
{ key: "Backspace", run: deleteMarkupBackward }
|
|
];
|
|
const htmlNoMatch = /*@__PURE__*/html({ matchClosingTags: false });
|
|
/**
|
|
Markdown language support.
|
|
*/
|
|
function markdown(config = {}) {
|
|
let { codeLanguages, defaultCodeLanguage, addKeymap = true, base: { parser } = commonmarkLanguage, completeHTMLTags = true } = config;
|
|
if (!(parser instanceof MarkdownParser))
|
|
throw new RangeError("Base parser provided to `markdown` should be a Markdown parser");
|
|
let extensions = config.extensions ? [config.extensions] : [];
|
|
let support = [htmlNoMatch.support], defaultCode;
|
|
if (defaultCodeLanguage instanceof LanguageSupport) {
|
|
support.push(defaultCodeLanguage.support);
|
|
defaultCode = defaultCodeLanguage.language;
|
|
}
|
|
else if (defaultCodeLanguage) {
|
|
defaultCode = defaultCodeLanguage;
|
|
}
|
|
let codeParser = codeLanguages || defaultCode ? getCodeParser(codeLanguages, defaultCode) : undefined;
|
|
extensions.push(parseCode({ codeParser, htmlParser: htmlNoMatch.language.parser }));
|
|
if (addKeymap)
|
|
support.push(Prec.high(keymap.of(markdownKeymap)));
|
|
let lang = mkLang(parser.configure(extensions));
|
|
if (completeHTMLTags)
|
|
support.push(lang.data.of({ autocomplete: htmlTagCompletion }));
|
|
return new LanguageSupport(lang, support);
|
|
}
|
|
function htmlTagCompletion(context) {
|
|
let { state, pos } = context, m = /<[:\-\.\w\u00b7-\uffff]*$/.exec(state.sliceDoc(pos - 25, pos));
|
|
if (!m)
|
|
return null;
|
|
let tree = syntaxTree(state).resolveInner(pos, -1);
|
|
while (tree && !tree.type.isTop) {
|
|
if (tree.name == "CodeBlock" || tree.name == "FencedCode" || tree.name == "ProcessingInstructionBlock" ||
|
|
tree.name == "CommentBlock" || tree.name == "Link" || tree.name == "Image")
|
|
return null;
|
|
tree = tree.parent;
|
|
}
|
|
return {
|
|
from: pos - m[0].length, to: pos,
|
|
options: htmlTagCompletions(),
|
|
validFor: /^<[:\-\.\w\u00b7-\uffff]*$/
|
|
};
|
|
}
|
|
let _tagCompletions = null;
|
|
function htmlTagCompletions() {
|
|
if (_tagCompletions)
|
|
return _tagCompletions;
|
|
let result = htmlCompletionSource(new CompletionContext(EditorState.create({ extensions: htmlNoMatch }), 0, true));
|
|
return _tagCompletions = result ? result.options : [];
|
|
}
|
|
|
|
export { commonmarkLanguage, deleteMarkupBackward, insertNewlineContinueMarkup, markdown, markdownKeymap, markdownLanguage };
|