349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType, type DecorationSet } from '@codemirror/view';
|
|
import { Annotation, EditorState, SelectionRange, StateField, type Range } from '@codemirror/state';
|
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
import { bracketMatching, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
|
|
import { search, searchKeymap } from '@codemirror/search';
|
|
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
import { IterMode, Tree, type SyntaxNodeRef } from '@lezer/common';
|
|
import { tags } from '@lezer/highlight';
|
|
import { dom, type RedrawableHTML } from '#shared/dom.util';
|
|
import { callout as calloutExtension } from '#shared/grammar/callout.extension';
|
|
import { wikilink as wikilinkExtension, autocompletion as wikilinkAutocompletion } from '#shared/grammar/wikilink.extension';
|
|
import renderMarkdown from '#shared/markdown.util';
|
|
import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout } from "#shared/proses";
|
|
import { tagTag, tag as tagExtension } from './grammar/tag.extension';
|
|
|
|
const External = Annotation.define<boolean>();
|
|
const Hidden = Decoration.mark({ class: 'hidden' });
|
|
const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' });
|
|
const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' });
|
|
|
|
const intersects = (a: {
|
|
from: number;
|
|
to: number;
|
|
}, b: {
|
|
from: number;
|
|
to: number;
|
|
}) => !(a.to < b.from || b.to < a.from);
|
|
|
|
const highlight = HighlightStyle.define([
|
|
{ tag: tags.heading1, class: 'text-5xl pt-4 pb-2 after:hidden' },
|
|
{ tag: tags.heading2, class: 'text-4xl pt-4 pb-2 ps-1 leading-loose after:hidden' },
|
|
{ tag: tags.heading3, class: 'text-2xl font-bold pt-1 after:hidden' },
|
|
{ tag: tags.heading4, class: 'text-xl font-semibold pt-1 after:hidden variant-cap' },
|
|
{ tag: tags.meta, class: 'text-light-60 dark:text-dark-60' },
|
|
{ tag: tags.link, class: 'text-accent-blue hover:underline' },
|
|
{ tag: tags.special(tags.link), class: 'text-accent-blue font-semibold' },
|
|
{ tag: tags.heading, textDecoration: "underline", fontWeight: "bold" },
|
|
{ tag: tags.emphasis, fontStyle: "italic" },
|
|
{ tag: tags.strong, fontWeight: "bold" },
|
|
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
|
{ tag: tags.keyword, class: "text-accent-blue" },
|
|
{ tag: tags.monospace, class: "border border-light-35 dark:border-dark-35 px-2 py-px rounded-sm bg-light-20 dark:bg-dark-20" },
|
|
{ tag: tagTag, class: "cursor-default bg-accent-blue bg-opacity-10 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30" },
|
|
]);
|
|
|
|
class CalloutWidget extends WidgetType
|
|
{
|
|
from: number;
|
|
to: number;
|
|
title: string;
|
|
type: string;
|
|
foldable?: boolean;
|
|
content: string;
|
|
|
|
contentMD: RedrawableHTML;
|
|
|
|
static create(node: SyntaxNodeRef, state: EditorState): CalloutWidget | undefined
|
|
{
|
|
let type = '';
|
|
let title = '';
|
|
const content: string[] = [];
|
|
|
|
let cursor = node.node.cursor();
|
|
if (!cursor.firstChild()) return undefined;
|
|
|
|
do
|
|
{
|
|
if (cursor.name === 'CalloutMarker')
|
|
{
|
|
const _cursor = cursor.node.cursor();
|
|
_cursor.lastChild();
|
|
type = state.doc.sliceString(_cursor.from, _cursor.to).toLowerCase();
|
|
}
|
|
else if (cursor.name === 'CalloutTitle')
|
|
{
|
|
title = state.doc.sliceString(cursor.from, cursor.to).trim();
|
|
}
|
|
else if (cursor.name === 'CalloutLine')
|
|
{
|
|
const _cursor = cursor.node.cursor();
|
|
_cursor.lastChild();
|
|
content.push(state.doc.sliceString(_cursor.from, _cursor.to));
|
|
}
|
|
}
|
|
while (cursor.nextSibling());
|
|
|
|
return new CalloutWidget(node.from, node.to, title || (type.substring(0, 1).toUpperCase() + type.substring(1).toLowerCase()), type, content.join('\n'));
|
|
}
|
|
constructor(from: number, to: number, title: string, type: string, content: string, foldable?: boolean)
|
|
{
|
|
super();
|
|
|
|
this.from = from;
|
|
this.to = to;
|
|
|
|
this.title = title;
|
|
this.type = type;
|
|
this.content = content;
|
|
this.foldable = foldable;
|
|
|
|
this.contentMD = renderMarkdown(content, undefined, { tags: { a, blockquote, tag, callout: callout, h1, h2, h3, h4, h5, hr, li, small, table, td, th } });
|
|
}
|
|
override eq(other: CalloutWidget)
|
|
{
|
|
return this.from === other.from && this.to === other.to;
|
|
}
|
|
toDOM(view: EditorView)
|
|
{
|
|
return dom('div', { class: 'flex cm-line', listeners: { click: e => view.dispatch({ selection: { anchor: this.from, head: this.to } }) } }, [prose('blockquote', callout, [ this.contentMD ], { title: this.title, type: this.type, fold: this.foldable, class: '!m-px ' }) as RedrawableHTML | undefined]);
|
|
}
|
|
override ignoreEvent(event: Event)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
class Decorator
|
|
{
|
|
static hiddenNodes: string[] = [
|
|
'HardBreak',
|
|
'LinkMark',
|
|
'EmphasisMark',
|
|
'CodeMark',
|
|
'CodeInfo',
|
|
'URL',
|
|
'CalloutMark',
|
|
'WikilinkMeta',
|
|
'WikilinkHref',
|
|
'TagMeta'
|
|
]
|
|
decorations: DecorationSet;
|
|
constructor(view: EditorView)
|
|
{
|
|
this.decorations = Decoration.set(this.iterate(syntaxTree(view.state), view.visibleRanges, [], view.state), true);
|
|
}
|
|
update(update: ViewUpdate)
|
|
{
|
|
if(!update.docChanged && !update.viewportChanged && !update.selectionSet)
|
|
return;
|
|
|
|
this.decorations = this.decorations.update({
|
|
filter: (f, t, v) => false,
|
|
add: this.iterate(syntaxTree(update.state), update.view.visibleRanges, update.state.selection.ranges, update.state),
|
|
sort: true,
|
|
});
|
|
}
|
|
iterate(tree: Tree, visible: readonly {
|
|
from: number;
|
|
to: number;
|
|
}[], selection: readonly SelectionRange[], state: EditorState): Range<Decoration>[]
|
|
{
|
|
const decorations: Range<Decoration>[] = [];
|
|
|
|
for (let { from, to } of visible) {
|
|
tree.iterate({
|
|
from, to, mode: IterMode.IgnoreMounts,
|
|
enter: node => {
|
|
if(node.node.parent && node.node.parent.name !== 'Document' && selection.some(e => intersects(e, node.node.parent!)))
|
|
return true;
|
|
|
|
else if(node.name === 'HeaderMark')
|
|
decorations.push(Hidden.range(node.from, node.to + 1));
|
|
|
|
else if(Decorator.hiddenNodes.includes(node.name))
|
|
decorations.push(Hidden.range(node.from, node.to));
|
|
|
|
else if(node.matchContext(['BulletList', 'ListItem']) && node.name === 'ListMark')
|
|
decorations.push(Bullet.range(node.from, node.to + 1));
|
|
|
|
else if(node.matchContext(['Blockquote']))
|
|
decorations.push(Blockquote.range(node.from, node.from));
|
|
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
|
|
return decorations;
|
|
}
|
|
}
|
|
function blockIterate(tree: Tree, state: EditorState): Range<Decoration>[]
|
|
{
|
|
const decorations: Range<Decoration>[] = [];
|
|
const selection = state.selection.ranges;
|
|
|
|
tree.iterate({
|
|
mode: IterMode.IgnoreMounts,
|
|
enter: node => {
|
|
if(selection.some(e => intersects(e, node)))
|
|
return true;
|
|
|
|
else if(node.name === 'CalloutBlock')
|
|
return decorations.push(Decoration.replace({ widget: CalloutWidget.create(node, state), block: true, }).range(node.from, node.to)), false;
|
|
|
|
return true;
|
|
},
|
|
});
|
|
|
|
return decorations;
|
|
}
|
|
const BlockDecorator = StateField.define<DecorationSet>({
|
|
create(state)
|
|
{
|
|
return Decoration.set(blockIterate(syntaxTree(state), state), true);
|
|
},
|
|
update(decorations, transaction)
|
|
{
|
|
if(transaction.docChanged || transaction.selection)
|
|
return Decoration.set(blockIterate(syntaxTree(transaction.state), transaction.state), true);
|
|
return decorations.map(transaction.changes);
|
|
},
|
|
provide: f => EditorView.decorations.from(f),
|
|
})
|
|
|
|
export class MarkdownEditor
|
|
{
|
|
private static _singleton: MarkdownEditor;
|
|
|
|
private view: EditorView;
|
|
private viewer: 'read' | 'live' | 'edit' = 'live';
|
|
onChange?: (content: string) => void;
|
|
constructor()
|
|
{
|
|
this.view = new EditorView({
|
|
extensions: [
|
|
markdown({
|
|
base: markdownLanguage,
|
|
extensions: [ calloutExtension, wikilinkExtension, tagExtension ]
|
|
}),
|
|
BlockDecorator,
|
|
history(),
|
|
search(),
|
|
dropCursor(),
|
|
EditorState.allowMultipleSelections.of(true),
|
|
indentOnInput(),
|
|
syntaxHighlighting(highlight),
|
|
bracketMatching(),
|
|
closeBrackets(),
|
|
autocompletion({
|
|
icons: false,
|
|
defaultKeymap: true,
|
|
maxRenderedOptions: 10,
|
|
activateOnTyping: true,
|
|
override: [ wikilinkAutocompletion ]
|
|
}),
|
|
crosshairCursor(),
|
|
EditorView.lineWrapping,
|
|
keymap.of([
|
|
...completionKeymap,
|
|
...closeBracketsKeymap,
|
|
...defaultKeymap,
|
|
...searchKeymap,
|
|
...historyKeymap,
|
|
]),
|
|
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
|
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
|
|
this.onChange && this.onChange(viewUpdate.state.doc.toString());
|
|
}),
|
|
EditorView.contentAttributes.of({spellcheck: "true"}),
|
|
ViewPlugin.fromClass(Decorator, {
|
|
decorations: e => e.decorations,
|
|
})
|
|
]
|
|
});
|
|
}
|
|
focus()
|
|
{
|
|
this.view.focus();
|
|
}
|
|
|
|
set content(value: string)
|
|
{
|
|
if (value === undefined)
|
|
return;
|
|
|
|
const currentValue = this.view ? this.view.state.doc.toString() : "";
|
|
if (this.view && value !== currentValue)
|
|
{
|
|
this.view.dispatch({
|
|
changes: { from: 0, to: currentValue.length, insert: value || "" },
|
|
annotations: [External.of(true)],
|
|
});
|
|
}
|
|
}
|
|
get content(): string
|
|
{
|
|
return this.view.state.doc.toString();
|
|
}
|
|
|
|
get dom()
|
|
{
|
|
return this.view.dom;
|
|
}
|
|
|
|
static get singleton(): MarkdownEditor
|
|
{
|
|
if(!MarkdownEditor._singleton)
|
|
MarkdownEditor._singleton = new MarkdownEditor();
|
|
return MarkdownEditor._singleton;
|
|
}
|
|
}
|
|
|
|
export class FramedEditor
|
|
{
|
|
editor: MarkdownEditor;
|
|
dom: HTMLIFrameElement;
|
|
|
|
private static _singleton: FramedEditor;
|
|
static get singleton()
|
|
{
|
|
if(!FramedEditor._singleton)
|
|
FramedEditor._singleton = new FramedEditor();
|
|
|
|
return FramedEditor._singleton;
|
|
}
|
|
private constructor()
|
|
{
|
|
this.editor = MarkdownEditor.singleton;
|
|
this.dom = dom('iframe');
|
|
this.dom.addEventListener('load', () => {
|
|
if(!this.dom.contentDocument)
|
|
return;
|
|
|
|
this.dom.contentDocument.documentElement.setAttribute('class', document.documentElement.getAttribute('class') ?? '');
|
|
this.dom.contentDocument.documentElement.setAttribute('style', document.documentElement.getAttribute('style') ?? '');
|
|
|
|
const base = this.dom.contentDocument.head.appendChild(this.dom.contentDocument.createElement('base'));
|
|
base.setAttribute('href', window.location.href);
|
|
|
|
for(let element of document.getElementsByTagName('link'))
|
|
{
|
|
if(element.getAttribute('rel') === 'stylesheet')
|
|
this.dom.contentDocument.head.appendChild(element.cloneNode(true));
|
|
}
|
|
|
|
for(let element of document.getElementsByTagName('style'))
|
|
{
|
|
this.dom.contentDocument.head.appendChild(element.cloneNode(true));
|
|
}
|
|
|
|
this.dom.contentDocument.body.setAttribute('class', document.body.getAttribute('class') ?? '');
|
|
this.dom.contentDocument.body.setAttribute('style', document.body.getAttribute('style') ?? '');
|
|
|
|
this.dom.contentDocument.body.appendChild(this.editor.dom);
|
|
|
|
this.editor.focus();
|
|
})
|
|
}
|
|
} |