227 lines
8.2 KiB
TypeScript
227 lines
8.2 KiB
TypeScript
import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, type DecorationSet } from '@codemirror/view';
|
|
import { Annotation, EditorState, SelectionRange, type Range } from '@codemirror/state';
|
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
import { bracketMatching, foldKeymap, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
|
|
import { search, searchKeymap } from '@codemirror/search';
|
|
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
|
import { lintKeymap } from '@codemirror/lint';
|
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
import { IterMode, Tree } from '@lezer/common';
|
|
import { tags } from '@lezer/highlight';
|
|
import { dom } from './dom.util';
|
|
|
|
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, color: "#404740" },
|
|
{ tag: tags.link, textDecoration: "underline" },
|
|
{ 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, color: "#708" },
|
|
]);
|
|
|
|
class Decorator
|
|
{
|
|
static hiddenNodes: string[] = [
|
|
'HardBreak',
|
|
'LinkMark',
|
|
'EmphasisMark',
|
|
'CodeMark',
|
|
'CodeInfo',
|
|
'URL',
|
|
]
|
|
decorations: DecorationSet;
|
|
constructor(view: EditorView)
|
|
{
|
|
this.decorations = Decoration.set(this.iterate(syntaxTree(view.state), view.visibleRanges, []), 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),
|
|
sort: true,
|
|
});
|
|
}
|
|
iterate(tree: Tree, visible: readonly {
|
|
from: number;
|
|
to: number;
|
|
}[], selection: readonly SelectionRange[]): 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 && 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;
|
|
}
|
|
}
|
|
|
|
export class MarkdownEditor
|
|
{
|
|
private static _singleton: MarkdownEditor;
|
|
|
|
private view: EditorView;
|
|
onChange?: (content: string) => void;
|
|
constructor()
|
|
{
|
|
this.view = new EditorView({
|
|
extensions: [
|
|
markdown({
|
|
base: markdownLanguage
|
|
}),
|
|
history(),
|
|
search(),
|
|
dropCursor(),
|
|
EditorState.allowMultipleSelections.of(true),
|
|
indentOnInput(),
|
|
syntaxHighlighting(highlight),
|
|
bracketMatching(),
|
|
closeBrackets(),
|
|
crosshairCursor(),
|
|
EditorView.lineWrapping,
|
|
keymap.of([
|
|
...closeBracketsKeymap,
|
|
...defaultKeymap,
|
|
...searchKeymap,
|
|
...historyKeymap,
|
|
...foldKeymap,
|
|
...completionKeymap,
|
|
...lintKeymap
|
|
]),
|
|
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();
|
|
})
|
|
}
|
|
} |