'use client' import type { RawContentKind } from '@admin/utils/editor/raw-content-kind' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' import type { EditorView } from '@tiptap/pm/view' import { getRawContentKind } from '@admin/utils/editor/get-raw-content-kind' import { getStringAttr } from '@admin/utils/editor/get-string-attr' import { renderRawContentPreview } from '@admin/utils/editor/render-raw-content-preview' import { serializeRawContentPreview } from '@admin/utils/editor/serialize-raw-content-preview' interface RawContentBlockNodeViewOptions { getPos: () => number | undefined node: ProseMirrorNode view: EditorView } export class RawContentBlockNodeView { readonly dom: HTMLElement private readonly preview: HTMLElement private readonly abortController = new AbortController() private currentRaw: string private node: ProseMirrorNode constructor(private readonly options: RawContentBlockNodeViewOptions) { this.node = options.node this.currentRaw = getStringAttr(options.node.attrs.raw) const kind = getRawContentKind(options.node.attrs.kind) this.dom = document.createElement('div') this.dom.dataset.contentEditorRawBlock = kind this.dom.dataset.raw = this.currentRaw this.dom.contentEditable = 'false' this.dom.className = 'content-editor-raw-block not-prose my-4 overflow-hidden rounded-md border bg-muted/20' this.preview = document.createElement('div') this.preview.contentEditable = 'true' this.preview.dataset.contentEditorRawEditable = 'true' this.preview.setAttribute('role', 'textbox') this.preview.setAttribute('aria-label', 'Editable rendered HTML') this.preview.spellcheck = true this.preview.className = 'content-editor-raw-block-preview overflow-auto p-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50' this.render(this.currentRaw, kind) this.preview.addEventListener('input', this.handleInput, { signal: this.abortController.signal }) this.preview.addEventListener('blur', this.handleInput, { signal: this.abortController.signal }) this.dom.append(this.preview) } update(node: ProseMirrorNode) { if (node.type !== this.node.type) return false this.node = node const nextKind = getRawContentKind(node.attrs.kind) const nextRaw = getStringAttr(node.attrs.raw) this.dom.dataset.contentEditorRawBlock = nextKind this.dom.dataset.raw = nextRaw if (nextRaw !== this.currentRaw) { this.currentRaw = nextRaw this.render(nextRaw, nextKind) } return true } stopEvent(event: Event) { const kind = getRawContentKind(this.node.attrs.kind) return ( kind !== 'math' && event.target instanceof globalThis.Node && this.preview.contains(event.target) ) } ignoreMutation() { return true } destroy() { this.abortController.abort() } private readonly handleInput = () => { const kind = getRawContentKind(this.node.attrs.kind) if (kind === 'math') return const nextRaw = serializeRawContentPreview(this.preview, kind) if (nextRaw === this.currentRaw) return this.currentRaw = nextRaw this.dom.dataset.raw = nextRaw const position = this.options.getPos() if (typeof position !== 'number') return this.options.view.dispatch( this.options.view.state.tr.setNodeMarkup(position, undefined, { ...this.node.attrs, raw: nextRaw }) ) } private render(raw: string, kind: RawContentKind) { this.preview.contentEditable = kind === 'math' ? 'false' : 'true' this.preview.setAttribute( 'aria-label', kind === 'math' ? 'Rendered LaTeX' : 'Editable rendered HTML' ) this.preview.innerHTML = renderRawContentPreview(raw, kind) } }