import { $prose } from '@milkdown/kit/utils' import { Plugin } from '@milkdown/kit/prose/state' import { Decoration, DecorationSet } from '@milkdown/kit/prose/view' import type { Node as ProseNode } from '@milkdown/kit/prose/model' import { parseCallout } from '@/utils/callouts' import '@/styles/callouts.css' import { remarkStringifyOptionsCtx, type Editor } from '@milkdown/kit/core' export const configureCalloutSerialization: Parameters[0] = ctx => { ctx.update(remarkStringifyOptionsCtx, options => ({ ...options, handlers: { ...options.handlers, blockquote(node, _parent, state, info) { const exit = state.enter('blockquote') const tracker = state.createTracker(info) tracker.move('> ') tracker.shift(2) const result = state.indentLines(state.containerFlow(node, tracker.current()), (line, _index, blank) => `>${blank ? '' : ' '}${line}`) exit() // Only remove escaping from a leading callout marker, never body literals. return result.replace(/^(> )\\\[!([\w-]+)\\?\]/, '$1[!$2]') } }, })) } const markerCache = new WeakMap() function calloutMarkers(doc: ProseNode) { const cached = markerCache.get(doc) if (cached) return cached const markers: { from: number; to: number }[] = [] doc.descendants((node, position) => { if (node.type.name !== 'blockquote' || node.firstChild?.type.name !== 'paragraph' || node.firstChild.firstChild?.marks.length) return const callout = parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n')) if (callout) markers.push({ from: position + 2, to: position + 2 + callout.markerLength }) }) markerCache.set(doc, markers) return markers } // Keep native blockquotes in the document: typing, undo and Markdown serialization // remain Milkdown transactions; the view never rewrites a user's callout source. export const calloutPlugin = $prose(() => new Plugin({ props: { decorations(state) { const decorations: Decoration[] = [] for (const { from, to } of calloutMarkers(state.doc)) { const editing = state.selection.from <= to && state.selection.to >= from decorations.push(Decoration.inline(from, to, { class: editing ? 'callout-marker-editing' : 'callout-marker' })) } return DecorationSet.create(state.doc, decorations) }, nodeViews: { blockquote(initialNode) { const dom = document.createElement('blockquote') const header = document.createElement('button') header.type = 'button' header.className = 'callout-title' header.contentEditable = 'false' const contentDOM = document.createElement('div') contentDOM.className = 'callout-body' dom.append(header, contentDOM) let signature = '' let foldable = false const update = (node: typeof initialNode) => { if (node.type.name !== 'blockquote') return false const callout = node.firstChild?.type.name === 'paragraph' && !node.firstChild.firstChild?.marks.length ? parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n')) : null dom.className = callout ? 'markdown-callout' : '' header.hidden = !callout foldable = !!callout?.fold header.disabled = !foldable if (callout) { dom.dataset.callout = callout.type const next = `${callout.name}:${callout.fold}` if (signature !== next) dom.dataset.collapsed = String(callout.fold === '-') signature = next header.textContent = `${foldable ? (dom.dataset.collapsed === 'true' ? '▸ ' : '▾ ') : ''}${callout.title}` if (foldable) header.setAttribute('aria-expanded', String(dom.dataset.collapsed !== 'true')) else header.removeAttribute('aria-expanded') } else { delete dom.dataset.callout delete dom.dataset.collapsed signature = '' } return true } let current = initialNode header.onclick = () => { if (!foldable) return dom.dataset.collapsed = String(dom.dataset.collapsed !== 'true') update(current) } update(initialNode) return { dom, contentDOM, update(node) { current = node; return update(node) }, stopEvent: event => header.contains(event.target as Node), ignoreMutation: mutation => mutation.type !== 'selection' && !contentDOM.contains(mutation.target) } }, }, }, }))