obsidian-visualiser/shared/editor.util.ts

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 } 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: HTMLElement;
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(useMarkdown().parseSync(content), { 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 HTMLElement | 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();
})
}
}