feat(editor): add themed callouts and desktop command boundary
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { $prose } from '@milkdown/kit/utils'
|
||||
import { Plugin } from '@milkdown/kit/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||
import { parseCallout } from '@/utils/callouts'
|
||||
import '@/styles/callouts.css'
|
||||
import { remarkStringifyOptionsCtx, type Editor } from '@milkdown/kit/core'
|
||||
|
||||
export const configureCalloutSerialization: Parameters<Editor['config']>[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]')
|
||||
} },
|
||||
}))
|
||||
}
|
||||
|
||||
// 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[] = []
|
||||
state.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) return
|
||||
const from = position + 2
|
||||
const to = from + callout.markerLength
|
||||
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) }
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user