import { ChangeSpec, EditorSelection, StateCommand, Text, } from "@codemirror/state"; import { syntaxTree } from "@codemirror/language"; import { SyntaxNode, Tree } from "@lezer/common"; import { markdownLanguage } from "./markdown"; function nodeStart(node: SyntaxNode, doc: Text) { return doc.sliceString(node.from, node.from + 50); } class Context { constructor( readonly node: SyntaxNode, readonly from: number, readonly to: number, readonly spaceBefore: string, readonly spaceAfter: string, readonly type: string, readonly item: SyntaxNode | null ) {} blank(trailing: boolean = true) { let result = this.spaceBefore; if (this.node.name == "Blockquote") { result += ">"; } else if (this.node.name == "Comment") { 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: Text, add: number) { let number = this.node.name == "OrderedList" ? String(+itemNumber(this.item!, doc)[2] + add) : ""; return this.spaceBefore + number + this.type + this.spaceAfter; } } function getContext(node: SyntaxNode, line: string, doc: Text) { let nodes = []; for ( let cur: SyntaxNode | null = node; cur && cur.name != "Document"; cur = cur.parent ) { if ( cur.name == "ListItem" || cur.name == "Blockquote" || cur.name == "Comment" ) nodes.push(cur); } let context = [], pos = 0; for (let i = nodes.length - 1; i >= 0; i--) { let node = nodes[i], match, start = pos; if ( node.name == "Blockquote" && (match = /^[ \t]*>( ?)/.exec(line.slice(pos))) ) { pos += match[0].length; context.push(new Context(node, start, pos, "", match[1], ">", null)); } else if ( node.name == "Comment" && (match = /^[ \t]*%%( ?)/.exec(line.slice(pos))) ) { pos += match[0].length; context.push(new Context(node, start, pos, "", match[1], "%%", null)); } else if ( node.name == "ListItem" && node.parent!.name == "OrderedList" && (match = /^([ \t]*)\d+([.)])([ \t]*)/.exec(nodeStart(node, doc))) ) { let after = match[3], len = match[0].length; if (after.length >= 4) { after = after.slice(0, after.length - 4); len -= 4; } pos += len; context.push( new Context(node.parent!, start, pos, match[1], after, match[2], node) ); } else if ( node.name == "ListItem" && node.parent!.name == "BulletList" && (match = /^([ \t]*)([-+*])([ \t]+)/.exec(nodeStart(node, doc))) ) { let after = match[3], len = match[0].length; if (after.length > 4) { after = after.slice(0, after.length - 4); len -= 4; } pos += len; context.push( new Context(node.parent!, start, pos, match[1], after, match[2], node) ); } } return context; } function itemNumber(item: SyntaxNode, doc: Text) { return /^(\s*)(\d+)(?=[.)])/.exec( doc.sliceString(item.from, item.from + 10) )!; } function renumberList( after: SyntaxNode, doc: Text, changes: ChangeSpec[], 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). export const insertNewlineContinueMarkup: StateCommand = ({ 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), line.text, 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: ChangeSpec[] = [{ 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); 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 }; } } if (inner.node.name == "Comment" && emptyLine && line.from) { let prevLine = doc.lineAt(line.from - 1), commented = /%%\s*$/.exec(prevLine.text); // Two aligned empty quoted lines in a row if (commented && commented.index == inner.from) { let changes = state.changes([ { from: prevLine.from + commented.index, to: prevLine.to }, { from: line.from + inner.from, to: line.to }, ]); return { range: range.map(changes), changes }; } } let changes: ChangeSpec[] = []; if (inner.node.name == "OrderedList") renumberList(inner.item!, doc, changes); let insert = state.lineBreak; let continued = inner.item && inner.item.from < line.from; // 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(); } let from = pos; while ( from > line.from && /\s/.test(line.text.charAt(from - line.from - 1)) ) from--; 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: SyntaxNode) { return node.name == "QuoteMark" || node.name == "ListMark"; } function contextNodeForDelete(tree: Tree, pos: number) { 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. export const deleteMarkupBackward: StateCommand = ({ 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), line.text, 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) { 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(), }, }; // 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; };