127 lines
4.8 KiB
TypeScript
127 lines
4.8 KiB
TypeScript
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.util';
|
|
|
|
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),
|
|
}
|
|
] |