perf(editor): reduce long-document decoration work and fix fold navigation

This commit is contained in:
2026-09-06 14:30:56 +08:00
parent 750e17212e
commit 874e916106
23 changed files with 9398 additions and 54 deletions
@@ -102,6 +102,40 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
})
it('reuses heading decorations for cursor-only moves and invalidates them for folds and edits', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# A\n\nfirst paragraph\n\nsecond paragraph' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
editor.action(ctx => {
const view = ctx.get(editorViewCtx), plugin = headingFoldKey.get(view.state)!
const decorations = () => plugin.props.decorations!.call(plugin, view.state)
const initial = decorations()
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 6)))
expect(decorations()).toBe(initial)
view.dispatch(view.state.tr.insertText('新增'))
expect(decorations()).not.toBe(initial)
})
await wrapper.get('.section-actions button').trigger('click')
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(true)
await wrapper.get('.section-actions button').trigger('click')
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
})
it('returns to the top after collapsing many sibling chapters from the document end', async () => {
const source = Array.from({ length: 100 }, (_, i) => `# Chapter ${i}\n\nBody ${i}`).join('\n\n')
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(view.state.doc.content.size - 1))))
})
const viewport = wrapper.get('.milkdown-host').element as HTMLElement
viewport.scrollTop = 5000
await wrapper.get('.section-actions button').trigger('click')
expect(viewport.scrollTop).toBe(0)
editor.action(ctx => expect(ctx.get(editorViewCtx).state.selection.from).toBe(1))
expect(editor.action(getMarkdown()).trim()).toBe(source)
})
it('offers expand all when individually collapsed parents hide expanded children', async () => {
const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nbody'
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
@@ -148,6 +148,10 @@ function foldHeadings(action: 'toggle' | 'all' | 'none') {
const view = ctx.get(editorViewCtx)
const tr = headingFoldTransaction(view.state, action)
if (tr) view.dispatch(tr)
if (action === 'all') {
const viewport = editorRoot.value?.closest<HTMLElement>('.milkdown-host')
if (viewport) viewport.scrollTop = 0
}
})
}
const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
+17 -7
View File
@@ -1,6 +1,7 @@
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'
@@ -21,21 +22,30 @@ export const configureCalloutSerialization: Parameters<Editor['config']>[0] = ct
}))
}
const markerCache = new WeakMap<ProseNode, { from: number; to: number }[]>()
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[] = []
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
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: {
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { expect, it } from 'vitest'
import { expect, it, vi } from 'vitest'
import { installCodeBlockLabels } from './codeBlockLabels'
it('keeps footer labels in sync when the language changes and stops after disposal', async () => {
@@ -16,3 +16,21 @@ it('keeps footer labels in sync when the language changes and stops after dispos
await new Promise(resolve => setTimeout(resolve, 0))
expect(block.dataset.languageLabel).toBe('TypeScript')
})
it('ignores code text mutations and discovers newly inserted code blocks', async () => {
const root = document.createElement('div')
root.innerHTML = '<div class="milkdown-code-block"><button class="language-button">Python</button><div class="cm-content">old</div></div>'
const dispose = installCodeBlockLabels(root)
const scan = vi.spyOn(root, 'querySelectorAll')
try {
root.querySelector('.cm-content')!.textContent = 'new code'
await new Promise(resolve => setTimeout(resolve, 0))
expect(scan).not.toHaveBeenCalled()
const block = document.createElement('div')
block.className = 'milkdown-code-block'
block.innerHTML = '<button class="language-button">Rust</button>'
root.append(block)
await new Promise(resolve => setTimeout(resolve, 0))
expect(block.dataset.languageLabel).toBe('Rust')
} finally { dispose(); scan.mockRestore() }
})
@@ -1,11 +1,38 @@
/** Mirror the live picker label for theme decorations without changing Markdown. */
/** Mirror changed language labels without rescanning every code block on each DOM mutation. */
export function installCodeBlockLabels(root: HTMLElement): () => void {
const sync = () => root.querySelectorAll<HTMLElement>('.milkdown-code-block').forEach(block => {
const sync = (block: HTMLElement) => {
const label = block.querySelector('.language-button')?.textContent?.trim() || 'Plain text'
if (block.dataset.languageLabel !== label) block.dataset.languageLabel = label
}
const discover = (node: Node, blocks: Set<HTMLElement>) => {
if (!(node instanceof HTMLElement)) return
if (node.matches('.milkdown-code-block')) blocks.add(node)
node.querySelectorAll<HTMLElement>('.milkdown-code-block').forEach(block => blocks.add(block))
}
const observer = new MutationObserver(records => {
const changed = new Set<HTMLElement>()
for (const record of records) {
const element = record.target instanceof Element ? record.target : record.target.parentElement
// CodeMirror viewport/text changes do not change the footer's language.
const label = element?.closest('.language-button')
const block = label?.closest<HTMLElement>('.milkdown-code-block')
if (block) changed.add(block)
if ([...record.removedNodes].some(node => node instanceof Element &&
(node.matches('.language-button') || node.querySelector('.language-button')))) {
const owner = element?.closest<HTMLElement>('.milkdown-code-block')
if (owner) changed.add(owner)
}
for (const node of record.addedNodes) {
discover(node, changed)
if (node instanceof Element && (node.matches('.language-button') || node.querySelector('.language-button'))) {
const owner = node.closest<HTMLElement>('.milkdown-code-block')
if (owner) changed.add(owner)
}
}
}
changed.forEach(block => { if (root.contains(block)) sync(block) })
})
const observer = new MutationObserver(sync)
root.querySelectorAll<HTMLElement>('.milkdown-code-block').forEach(sync)
observer.observe(root, { subtree: true, childList: true, characterData: true })
sync()
return () => observer.disconnect()
}
@@ -1,15 +1,20 @@
import { $prose } from '@milkdown/kit/utils'
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import type { Node } from '@milkdown/kit/prose/model'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
const decorationCache = new WeakMap<Node, DecorationSet>()
const openingTag = /^<span style="font-size:\s*(\d+(?:\.\d+)?)px">$/i
const closingTag = /^<\/span>$/i
export const fontSizeMarkdownPlugin = $prose(() => new Plugin({
props: {
decorations(state) {
const cached = decorationCache.get(state.doc)
if (cached) return cached
const decorations: Decoration[] = []
const stack: Array<{ from: number; size: string }> = []
@@ -36,7 +41,9 @@ export const fontSizeMarkdownPlugin = $prose(() => new Plugin({
}
})
return DecorationSet.create(state.doc, decorations)
const result = DecorationSet.create(state.doc, decorations)
decorationCache.set(state.doc, result)
return result
},
},
}))
+22 -4
View File
@@ -7,6 +7,7 @@ import { t } from '@/i18n'
export const headingFoldKey = new PluginKey<Set<number>>('heading-folding')
type Section = { from: number; body: number; end: number; level: number }
const sectionCache = new WeakMap<Node, Section[]>()
const decorationCache = new WeakMap<Node, WeakMap<Set<number>, DecorationSet>>()
/** A section ends at the next sibling heading of the same or a higher rank. */
export function headingSections(doc: Node): Section[] {
const cached = sectionCache.get(doc)
@@ -50,7 +51,8 @@ export function headingFoldTransaction(state: EditorState, action: 'toggle' | 'a
}
const tr = state.tr
const enclosing = sections.find(section => folded.has(section.from) && state.selection.to >= section.body && state.selection.from < section.end)
if (enclosing) tr.setSelection(TextSelection.near(state.doc.resolve(enclosing.from + 1)))
if (action === 'all' && sections.length) tr.setSelection(TextSelection.near(state.doc.resolve(sections[0]!.from + 1))).scrollIntoView()
else if (enclosing) tr.setSelection(TextSelection.near(state.doc.resolve(enclosing.from + 1)))
return tr.setMeta(headingFoldKey, folded).setMeta('addToHistory', false)
}
@@ -61,11 +63,21 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
apply(tr, previous) {
const explicit = tr.getMeta(headingFoldKey) as Set<number> | undefined
if (explicit) return explicit
if (!previous.size) return previous
const sections = headingSections(tr.doc)
if (!tr.docChanged) {
if (!tr.selectionSet) return previous
const opened = sections.filter(section => previous.has(section.from) && tr.selection.to >= section.body && tr.selection.from < section.end)
if (!opened.length) return previous
const next = new Set(previous)
opened.forEach(section => next.delete(section.from))
return next
}
const positions = new Set(sections.map(section => section.from))
const mapped = new Set<number>()
for (const old of previous) {
const result = tr.mapping.mapResult(old, 1)
if (!result.deleted && sections.some(section => section.from === result.pos)) mapped.add(result.pos)
if (!result.deleted && positions.has(result.pos)) mapped.add(result.pos)
}
// Outline jumps, find and keyboard navigation must never leave a hidden caret.
if (tr.selectionSet || tr.docChanged) {
@@ -77,6 +89,8 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
props: {
decorations(state) {
const folded = headingFoldKey.getState(state) ?? new Set<number>()
const cached = decorationCache.get(state.doc)?.get(folded)
if (cached) return cached
const sections = headingSections(state.doc)
const decorations: Decoration[] = []
for (const section of sections) {
@@ -103,7 +117,7 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
else hidden.push({ body: section.body, end: section.end })
}
let rangeIndex = 0
state.doc.descendants((node, pos) => {
if (hidden.length) state.doc.descendants((node, pos) => {
if (!node.isBlock) return
while (hidden[rangeIndex] && pos >= hidden[rangeIndex]!.end) rangeIndex++
const range = hidden[rangeIndex]
@@ -112,7 +126,11 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
return false
}
})
return DecorationSet.create(state.doc, decorations)
const result = DecorationSet.create(state.doc, decorations)
let byState = decorationCache.get(state.doc)
if (!byState) { byState = new WeakMap(); decorationCache.set(state.doc, byState) }
byState.set(folded, result)
return result
},
},
}))
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { installLanguagePickerPopover } from './languagePickerPopover'
it('measures only open menus on ancestor scroll and cleans up scheduled work', async () => {
const root = document.createElement('div')
root.innerHTML = '<div><button class="language-button" data-expanded="false">JS</button><div class="language-picker"><input class="search-input"></div></div>'
document.body.append(root)
const menu = root.querySelector<HTMLElement>('.language-picker')!
const trigger = root.querySelector<HTMLElement>('button')!
let open = false
menu.showPopover = vi.fn(() => { open = true })
menu.hidePopover = vi.fn(() => { open = false })
const matches = menu.matches.bind(menu)
vi.spyOn(menu, 'matches').mockImplementation(selector => selector === ':popover-open' ? open : matches(selector))
const measure = vi.spyOn(trigger, 'getBoundingClientRect')
let callback: FrameRequestCallback | undefined
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(fn => { callback = fn; return 42 })
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
const dispose = installLanguagePickerPopover(root)
try {
document.dispatchEvent(new Event('scroll'))
expect(raf).not.toHaveBeenCalled()
expect(measure).not.toHaveBeenCalled()
trigger.dataset.expanded = 'true'
await new Promise(resolve => setTimeout(resolve, 0))
expect(menu.showPopover).toHaveBeenCalledOnce()
measure.mockClear()
document.dispatchEvent(new Event('scroll'))
document.dispatchEvent(new Event('scroll'))
expect(raf).toHaveBeenCalledOnce()
callback!(0)
expect(measure).toHaveBeenCalledOnce()
document.dispatchEvent(new Event('scroll'))
dispose()
expect(cancel).toHaveBeenCalledWith(42)
expect(menu.hidePopover).toHaveBeenCalledOnce()
raf.mockClear()
document.dispatchEvent(new Event('scroll'))
expect(raf).not.toHaveBeenCalled()
} finally { dispose(); root.remove(); vi.restoreAllMocks() }
})
@@ -1,43 +1,68 @@
/** Promote Milkdown's menu to the top layer without moving its Vue-owned DOM. */
/** Promote menus to the top layer; only open menus need scroll measurements. */
export function installLanguagePickerPopover(root: HTMLElement): () => void {
const menus = new Set<HTMLElement>()
function sync() {
root.querySelectorAll<HTMLElement>('.language-picker').forEach(menu => {
const trigger = menu.parentElement?.querySelector<HTMLElement>('.language-button')
if (!trigger || typeof menu.showPopover !== 'function') return
menus.add(menu)
menu.setAttribute('popover', 'manual')
const search = menu.querySelector<HTMLInputElement>('.search-input')
if (search) {
search.autocomplete = 'off'
search.spellcheck = false
}
if (trigger.dataset.expanded !== 'true' || !menu.firstElementChild) {
if (menu.matches(':popover-open')) menu.hidePopover()
return
}
if (!menu.matches(':popover-open')) menu.showPopover()
const anchor = trigger.getBoundingClientRect()
const below = window.innerHeight - anchor.bottom - 16
const above = anchor.top - 16
const placeAbove = below < 240 && above > below
const available = Math.max(80, placeAbove ? above : below)
menu.style.setProperty('--picker-list-height', `${Math.min(280, Math.max(32, available - 64))}px`)
const bounds = menu.getBoundingClientRect()
menu.style.setProperty('--picker-left', `${Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12))}px`)
menu.style.setProperty('--picker-top', `${Math.max(12, placeAbove ? anchor.top - bounds.height - 8 : anchor.bottom + 8)}px`)
})
for (const menu of menus) if (!root.contains(menu)) menus.delete(menu)
const openMenus = new Set<HTMLElement>()
let frame = 0
function sync(menu: HTMLElement) {
if (!root.contains(menu)) { menus.delete(menu); openMenus.delete(menu); return }
const trigger = menu.parentElement?.querySelector<HTMLElement>('.language-button')
if (!trigger || typeof menu.showPopover !== 'function') return
menus.add(menu)
menu.setAttribute('popover', 'manual')
const search = menu.querySelector<HTMLInputElement>('.search-input')
if (search) { search.autocomplete = 'off'; search.spellcheck = false }
if (trigger.dataset.expanded !== 'true' || !menu.firstElementChild) {
openMenus.delete(menu)
if (menu.matches(':popover-open')) menu.hidePopover()
return
}
openMenus.add(menu)
if (!menu.matches(':popover-open')) menu.showPopover()
const anchor = trigger.getBoundingClientRect()
const below = window.innerHeight - anchor.bottom - 16, above = anchor.top - 16
const placeAbove = below < 240 && above > below
const available = Math.max(80, placeAbove ? above : below)
menu.style.setProperty('--picker-list-height', `${Math.min(280, Math.max(32, available - 64))}px`)
const bounds = menu.getBoundingClientRect()
menu.style.setProperty('--picker-left', `${Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12))}px`)
menu.style.setProperty('--picker-top', `${Math.max(12, placeAbove ? anchor.top - bounds.height - 8 : anchor.bottom + 8)}px`)
}
const observer = new MutationObserver(sync)
function discover(node: Node, changed: Set<HTMLElement>) {
if (!(node instanceof HTMLElement)) return
if (node.matches('.language-picker')) changed.add(node)
node.querySelectorAll<HTMLElement>('.language-picker').forEach(menu => changed.add(menu))
}
const observer = new MutationObserver(records => {
const changed = new Set<HTMLElement>()
let removed = false
for (const record of records) {
const target = record.target instanceof Element ? record.target : record.target.parentElement
const menu = target?.closest<HTMLElement>('.language-picker')
if (menu) changed.add(menu)
if (record.type === 'attributes') {
const sibling = target?.parentElement?.querySelector<HTMLElement>('.language-picker')
if (sibling) changed.add(sibling)
}
record.addedNodes.forEach(node => discover(node, changed))
removed ||= record.removedNodes.length > 0
}
if (removed) for (const menu of menus) if (!root.contains(menu)) { menus.delete(menu); openMenus.delete(menu) }
changed.forEach(sync)
})
const positionOpenMenus = () => {
if (!openMenus.size || frame) return
frame = requestAnimationFrame(() => { frame = 0; openMenus.forEach(sync) })
}
root.querySelectorAll<HTMLElement>('.language-picker').forEach(sync)
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-expanded'] })
root.addEventListener('scroll', sync, true)
window.addEventListener('resize', sync)
sync()
// The outer editor viewport is an ancestor of root, so listen in capture on the document.
document.addEventListener('scroll', positionOpenMenus, { capture: true, passive: true })
window.addEventListener('resize', positionOpenMenus)
return () => {
observer.disconnect()
root.removeEventListener('scroll', sync, true)
window.removeEventListener('resize', sync)
for (const menu of menus) if (menu.matches(':popover-open')) menu.hidePopover()
observer.disconnect(); cancelAnimationFrame(frame)
document.removeEventListener('scroll', positionOpenMenus, true)
window.removeEventListener('resize', positionOpenMenus)
for (const menu of openMenus) if (menu.matches(':popover-open')) menu.hidePopover()
menus.clear(); openMenus.clear()
}
}