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