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(); 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[] { const decorations: Range[] = []; 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(); }) } }