import type { MarkdownConfig } from '@lezer/markdown'; import { styleTags, tags } from '@lezer/highlight'; import type { Decoration, EditorView, KeyBinding } from '@codemirror/view'; import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'; import { BlockDecorator } from '../editor'; export const callout: MarkdownConfig = { defineNodes: [ 'CalloutBlock', 'CalloutMarker', 'CalloutMark', 'CalloutType', 'CalloutTitle', 'CalloutLine', 'CalloutContent', ], parseBlock: [{ name: 'Callout', before: 'Blockquote', parse(cx, line) { const match = /^>\s*\[!(\w+)\](?:\s+(.*))?/.exec(line.text); if (!match || !match[1]) return false; //No match const start = cx.lineStart, children = []; let continued = false; const quoteEnd = start + line.text.indexOf('[!'); const typeStart = quoteEnd + 2; const typeEnd = typeStart + match[1].length; const bracketEnd = typeEnd + 1; children.push(cx.elt('CalloutMarker', start, bracketEnd, [ cx.elt('CalloutMark', start, quoteEnd), cx.elt('CalloutType', typeStart, typeEnd) ])); if(match[2]) children.push(cx.elt('CalloutTitle', bracketEnd + 1, start + line.text.length)); while ((continued = cx.nextLine()) && line.text.startsWith('>')) { const pos = line.text.substring(1).search(/\S/) + 1; children.push(cx.elt('CalloutLine', cx.lineStart, cx.lineStart + line.text.length, [ cx.elt('CalloutMark', cx.lineStart, cx.lineStart + pos), cx.elt('CalloutContent', cx.lineStart + pos, cx.lineStart + line.text.length), ])); } cx.addElement(cx.elt('Blockquote', start, continued ? cx.lineStart - 1 : cx.lineStart, [cx.elt('CalloutBlock', start, continued ? cx.lineStart - 1 : cx.lineStart, children)])); return true; } }], props: [ styleTags({ 'CalloutBlock': tags.special(tags.quote), 'CalloutMarker': tags.meta, 'CalloutMark': tags.meta, 'CalloutType': tags.keyword, 'CalloutTitle': tags.heading, 'CalloutLine': tags.content, 'CalloutContent': tags.content, }) ] }; //Use the BlockDecorator to fetch every built block widgets and try to check if the future line should be positionned inside a block function fetchBlockLine(state: EditorState, range: SelectionRange, forward: boolean): SelectionRange | null { const start = state.doc.lineAt(range.head), next = start.number + (forward ? 1 : -1); if (next < 1 || next > state.doc.lines) return null; const nextLine = state.doc.line(next); let matched = !0, current: Decoration | null = null; state.field(BlockDecorator, false)?.between(nextLine.from, nextLine.to, (from, to, value) => { if (value.spec.block) { if(current || from > nextLine.from || to < nextLine.to) return (matched = false); else { if(!current) current = value; return; } } }); if (!matched || !current) return null; if (!(current as Decoration).spec.block) return null; const position = range.head - start.from; return EditorSelection.cursor(nextLine.from + Math.min(nextLine.length, position), range.assoc) } function moveCursor(view: EditorView, select: boolean, forward: boolean) { const state = view.state, selection = state.selection; const range = EditorSelection.create(selection.ranges.map((range) => { if(!select && !range.empty) //If I have already selected something and I stop holding Shift, I just deselect return EditorSelection.cursor(forward ? range.to : range.from); let target = view.moveVertically(range, forward); const blockTarget = fetchBlockLine(state, range, forward); target = blockTarget && Math.abs(target.head - range.head) > Math.abs(blockTarget.head - range.head) ? blockTarget : target.head != range.head ? target : view.moveToLineBoundary(range, forward); return select ? EditorSelection.range(range.anchor, target.head, target.goalColumn) : target; }, selection.mainIndex)); view.dispatch({ userEvent: 'select', selection: range, scrollIntoView: true, }); return true; } export const calloutKeymap: KeyBinding[] = [ { key: "ArrowUp", run: (view) => moveCursor(view, false, false), shift: (view) => moveCursor(view, true, false), }, { key: "ArrowDown", run: (view) => moveCursor(view, false, true), shift: (view) => moveCursor(view, true, true), } ]