obsidian-visualiser/shared/editor.ts

414 lines
18 KiB
TypeScript

import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType, type DecorationSet } from '@codemirror/view';
import { Annotation, Compartment, EditorState, Prec, SelectionRange, StateField, type Extension, type Range } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, standardKeymap } from '@codemirror/commands';
import { bracketMatching, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
import { search, searchKeymap } from '@codemirror/search';
import { acceptCompletion, 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 { div, dom, icon, span, type RedrawableHTML } from '~~/shared/dom';
import { callout as calloutExtension, calloutKeymap } from '#shared/grammar/callout.extension';
import { wikilink as wikilinkExtension, autocompletion as wikilinkAutocompletion } from '#shared/grammar/wikilink.extension';
import renderMarkdown from '~~/shared/markdown';
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';
import { WeakerSet } from './general';
import { button, numberpicker } from './components';
import { contextmenu, followermenu } from './floating';
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 after:hidden' },
{ tag: tags.heading2, class: 'text-4xl ps-1 leading-loose after:hidden' },
{ tag: tags.heading3, class: 'text-2xl font-bold after:hidden' },
{ tag: tags.heading4, class: 'text-xl font-semibold 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.state.readOnly || 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 ' })]);
}
override ignoreEvent(event: Event)
{
return false;
}
}
class Decorator
{
static hiddenNodes: string[] = [
'HardBreak',
'LinkMark',
'EmphasisMark',
'CodeMark',
'CodeInfo',
'URL',
'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' && !state.readOnly && 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;
}
export 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),
});
const BlockUndecorator = StateField.define({ create: (state) => {}, update: (value, transaction) => {} });
const _readonlyTrue = EditorState.readOnly.of(true);
const _readonlyFalse = EditorState.readOnly.of(false);
const _editableTrue = EditorView.editable.of(true);
const _editableFalse = EditorView.editable.of(false);
export class MarkdownEditor
{
private static _singleton: MarkdownEditor;
private static _set: WeakerSet<MarkdownEditor> = new WeakerSet();
private view: EditorView;
private _dom: HTMLElement;
private _readonly = new Compartment();
private _editable = new Compartment();
private _editStyle = new Compartment();
private _blockExtension = new Compartment();
private _decoratorHidden?: Extension;
private _decoratorVisible?: Extension;
onChange?: (content: string) => void;
static settings(this: HTMLElement)
{
const viewer = MarkdownEditor.viewer;
this.parentElement?.toggleAttribute('data-focused', true);
const follower = followermenu(this, [
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'edit'; follower.close(); } } }, [span('', 'Modif. source'), viewer === 'edit' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'live'; follower.close(); } } }, [span('', 'Modifi. live'), viewer === 'live' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => { MarkdownEditor.viewer = 'read'; follower.close(); } } }, [span('', 'Lecture seule'), viewer === 'read' ? icon('radix-icons:check', { width: 16, height: 16 }) : undefined ]),
], { class: 'text-light-100 dark:text-dark-100', offset: 0, placement: 'right-start', blur: () => this.parentElement?.toggleAttribute('data-focused', false) });
return follower;
}
constructor()
{
this._dom = div('flex h-full relative', [ div('absolute -top-1 -left-1 -translate-x-px -translate-y-px z-10 group/editor', [ div('group-hover/editor:hidden group-data-[focused]/editor:hidden w-0 h-0 border-8 border-transparent border-l-light-40 dark:border-l-dark-40 border-t-light-40 dark:border-t-dark-40'), button([icon('radix-icons:gear')], MarkdownEditor.settings, 'p-1 hidden group-data-[focused]/editor:block group-hover/editor:block') ]), ]);
this._decoratorVisible = ViewPlugin.fromClass(Decorator, {
decorations: undefined,
}).of(undefined);
this.view = new EditorView({
extensions: [
markdown({
base: markdownLanguage,
extensions: [ calloutExtension, wikilinkExtension, tagExtension ]
}),
this._blockExtension.of(BlockDecorator),
history(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(highlight),
bracketMatching(),
closeBrackets(),
autocompletion({
icons: false,
defaultKeymap: true,
maxRenderedOptions: 25,
activateOnTyping: true,
override: [ wikilinkAutocompletion ],
}),
crosshairCursor(),
EditorView.lineWrapping,
Prec.high(keymap.of(calloutKeymap)),
keymap.of([
...completionKeymap,
{ key: 'Tab', run: acceptCompletion },
...closeBracketsKeymap,
...defaultKeymap,
...standardKeymap,
...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());
}),
this._readonly.of(_readonlyFalse),
this._editable.of(_editableTrue),
EditorView.contentAttributes.of({spellcheck: "true"}),
this._editStyle.of(this._decoratorVisible),
],
parent: this._dom,
});
this.viewer = MarkdownEditor.viewer;
MarkdownEditor._set.add(this);
}
focus()
{
this.view.focus();
}
static set viewer(value: 'live' | 'read' | 'edit')
{
localStorage.setItem('editor-view', value);
MarkdownEditor._set.forEach(e => e.viewer = value);
}
static get viewer(): 'live' | 'read' | 'edit'
{
return (localStorage.getItem('editor-view') ?? 'live') as 'live' | 'read' | 'edit';
}
set viewer(value: 'live' | 'read' | 'edit')
{
switch(value)
{
case 'edit':
this._decoratorVisible ??= ViewPlugin.fromClass(Decorator, { decorations: undefined, }).of(undefined);
this.view.dispatch({ effects: [ this._readonly.reconfigure(_readonlyFalse), this._blockExtension.reconfigure(BlockUndecorator), this._editable.reconfigure(_editableTrue), this._editStyle.reconfigure(this._decoratorVisible!) ] });
return;
case 'live':
this._decoratorHidden ??= ViewPlugin.fromClass(Decorator, { decorations: e => e.decorations, }).of(undefined);
this.view.dispatch({ effects: [ this._readonly.reconfigure(_readonlyFalse), this._blockExtension.reconfigure(BlockDecorator), this._editable.reconfigure(_editableTrue), this._editStyle.reconfigure(this._decoratorHidden!) ] });
return;
case 'read':
this._decoratorHidden ??= ViewPlugin.fromClass(Decorator, { decorations: e => e.decorations, }).of(undefined);
this.view.dispatch({ effects: [ this._readonly.reconfigure(_readonlyTrue), this._blockExtension.reconfigure(BlockDecorator), this._editable.reconfigure(_editableFalse), this._editStyle.reconfigure(this._decoratorHidden!) ] });
return;
}
}
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._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();
})
}
}