perf(editor): reduce long-document decoration work and fix fold navigation
This commit is contained in:
@@ -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 }>()
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,13 +81,36 @@ async function loadCodeLanguage(requestedLanguage: string) {
|
||||
return { shiki, language }
|
||||
}
|
||||
|
||||
// Bounded LRU of dual-theme HTML. Large one-off blocks never remain in the cache.
|
||||
const highlightedBlocks = new Map<string, string>()
|
||||
let highlightedCharacters = 0
|
||||
const highlightBudget = 1_000_000
|
||||
export async function highlightCode(source: string, requestedLanguage = 'text'): Promise<string> {
|
||||
const key = JSON.stringify([requestedLanguage.toLowerCase(), source])
|
||||
const cached = highlightedBlocks.get(key)
|
||||
if (cached !== undefined) {
|
||||
highlightedBlocks.delete(key); highlightedBlocks.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
const { shiki, language } = await loadCodeLanguage(requestedLanguage)
|
||||
return shiki.codeToHtml(source, {
|
||||
const html = shiki.codeToHtml(source, {
|
||||
lang: language,
|
||||
themes: { light: 'github-light', dark: 'github-dark' },
|
||||
defaultColor: false,
|
||||
})
|
||||
const cost = key.length + html.length
|
||||
if (cost <= highlightBudget / 4) {
|
||||
// A concurrent caller may already have filled the same entry.
|
||||
const previous = highlightedBlocks.get(key)
|
||||
if (previous !== undefined) { highlightedCharacters -= key.length + previous.length; highlightedBlocks.delete(key) }
|
||||
while (highlightedBlocks.size && (highlightedBlocks.size >= 64 || highlightedCharacters + cost > highlightBudget)) {
|
||||
const oldest = highlightedBlocks.keys().next().value!
|
||||
highlightedCharacters -= oldest.length + highlightedBlocks.get(oldest)!.length
|
||||
highlightedBlocks.delete(oldest)
|
||||
}
|
||||
highlightedBlocks.set(key, html); highlightedCharacters += cost
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
/** Share the initialized grammar/theme registry with editable code blocks. */
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 长文渲染压测
|
||||
|
||||
使用真实无头 Chrome 和 Chrome DevTools Protocol,加载当前 Vite 工作区。样本由 `fixture.js` 确定性生成,包含至少 25000、60000、120000 汉字、H1–H3、表格、代码块、提示框、链接和行内格式,不读写真实 Vault。
|
||||
|
||||
## 运行
|
||||
|
||||
仓库根目录启动独立服务:
|
||||
|
||||
```powershell
|
||||
npm --prefix frontend run dev -- --port 5175 --strictPort
|
||||
```
|
||||
|
||||
在另一个终端执行(Python 环境需安装 websockets):
|
||||
|
||||
```powershell
|
||||
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url http://127.0.0.1:5175/tests/performance/stress.html --runs 3 --output .local-plans/stress-results.json
|
||||
```
|
||||
|
||||
可用 `--chrome` 指定 Chromium 路径,用 `--sizes 25000 60000 120000` 指定样本。脚本使用独立临时浏览器配置,结束后关闭测试进程;不接管用户 Chrome。样本在每次导航后重建,不向后端保存。
|
||||
|
||||
## 指标口径
|
||||
|
||||
- openMs:组件挂载到编辑器完成初始化及两个 animation frame;不包含模块下载与 Vite 编译。
|
||||
- selectionMs:30 次光标选区事务的同步耗时;insertMs:20 次插入“压测输入”的事务耗时。
|
||||
- foldMs:6 次全折叠/展开按钮操作的同步耗时。
|
||||
- previewMs:同一正文静态渲染三次,包含 HTML 插入与两个 animation frame。第一次包含首次高亮初始化,后两次为热运行。
|
||||
- longTasks:浏览器 Long Tasks API,包含整个测量过程;heapUsedBytes 为单次采样,不代表峰值或泄漏结论。
|
||||
- integrity:序列化输出保留插入内容与文末标记。常规单元测试另外覆盖 Markdown 往返。
|
||||
|
||||
这些是开发模式下的微基准,不等于真实键盘/输入法的端到端延迟,不覆盖滚动帧率、自动保存网络、向量计算或 Mermaid 图表压力。不同机器、后台负载和缓存状态会影响结果,不能把一次结果作为通用 SLA。重型图表应单独使用已有 `tests/visual/mermaid-matrix.html` 验证。
|
||||
|
||||
打开 stress.html 后也可通过控制台调用 `await runBenchmark(25000)` 查看 JSON 结果。页面只用于测试,不在正式路由中注册。
|
||||
|
||||
## 连续滚轮与折叠定位
|
||||
|
||||
```powershell
|
||||
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url http://127.0.0.1:5175/tests/performance/stress.html --scroll --runs 2 --output .local-plans/scroll-results.json
|
||||
```
|
||||
|
||||
`--scroll` 使用纸间时光主题及高度受限的编辑区,派发 120 次真实 CDP 滚轮事件(先向下再向上),记录 animation frame 间隔与长任务;随后从文末全部折叠,记录滚动位置和光标位置。可追加 `--profile --sizes 120000 --runs 1` 保存 CPU profile,用 Chrome DevTools Performance 面板导入。采样会增加开销,勿将 profile 结果与无采样结果直接比较。
|
||||
|
||||
帧间隔包含无头浏览器、CDP 调度和布局开销,不等同于用户设备的 FPS。滚轮模式不验证输入法、保存或图表渲染。当前测试容器改为有限高度的 flex 布局,早期普通事务报告使用的容器布局不同,跨版本比较应分别保留同一布局下的基线。
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Deterministic CJK prose plus headings, tables, code and callouts; no user documents. */
|
||||
export function makeStressDocument(minHan = 25000) {
|
||||
const prose = '本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。'
|
||||
let source = '# 长文渲染压力测试\n\n', han = 0, section = 0
|
||||
while (han < minHan) {
|
||||
source += `## 第 ${++section} 节:知识整理\n\n${prose.repeat(3)}\n\n### 小结 ${section}\n\n重点包含 **强调文字**、\`inlineCode\` 和 [链接](https://example.com)。\n\n`
|
||||
han += prose.length * 3
|
||||
if (section % 8 === 0) source += '> [!TIP] 验收提示\n> 内容需要保留,折叠后仍可展开。\n\n| 项目 | 状态 |\n| --- | --- |\n| 渲染 | 待验证 |\n\n```javascript\nconst note = { title: "长文测试", ready: true };\nconsole.log(note);\n```\n\n'
|
||||
}
|
||||
source += '\n## 文末校验\n\n结束标记:长文内容完整。\n'
|
||||
return source
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Real Chromium benchmark. Run with backend/.venv/Scripts/python.exe; requires websockets.
|
||||
Vite must be serving the frontend. Uses an isolated disposable browser profile.
|
||||
"""
|
||||
import argparse, asyncio, json, pathlib, subprocess, tempfile, urllib.request
|
||||
import websockets
|
||||
|
||||
async def main(args):
|
||||
with tempfile.TemporaryDirectory(prefix='notes-stress-') as profile:
|
||||
process = subprocess.Popen([args.chrome, '--headless=new', '--no-first-run', '--no-proxy-server', '--no-default-browser-check', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--remote-debugging-port=0', '--window-size=1440,1000', f'--user-data-dir={profile}', 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
port_file = pathlib.Path(profile) / 'DevToolsActivePort'
|
||||
for _ in range(100):
|
||||
if port_file.exists(): break
|
||||
await asyncio.sleep(.1)
|
||||
port = port_file.read_text().splitlines()[0]
|
||||
with urllib.request.urlopen(f'http://127.0.0.1:{port}/json') as response: target = next(item for item in json.load(response) if item['type'] == 'page')
|
||||
async with websockets.connect(target['webSocketDebuggerUrl'], max_size=100_000_000) as socket:
|
||||
sequence = 0
|
||||
async def call(method, params=None):
|
||||
nonlocal sequence
|
||||
sequence += 1; request = sequence
|
||||
await socket.send(json.dumps({'id':request,'method':method,'params':params or {}}))
|
||||
while True:
|
||||
response = json.loads(await asyncio.wait_for(socket.recv(), 180))
|
||||
if response.get('method') in ['Runtime.exceptionThrown','Log.entryAdded','Network.loadingFailed']: print(json.dumps(response),flush=True)
|
||||
if response.get('id') == request:
|
||||
if 'error' in response: raise RuntimeError(response['error'])
|
||||
return response.get('result', {})
|
||||
await call('Runtime.enable')
|
||||
await call('Log.enable')
|
||||
await call('Network.enable')
|
||||
await asyncio.sleep(1)
|
||||
results=[]
|
||||
for size in args.sizes:
|
||||
for repeat in range(args.runs):
|
||||
navigation = await call('Page.navigate', {'url':args.url})
|
||||
if navigation.get('errorText'): raise RuntimeError(navigation['errorText'])
|
||||
for _ in range(600):
|
||||
state = await call('Runtime.evaluate', {'expression':'typeof window.runBenchmark', 'returnByValue':True})
|
||||
if state.get('result',{}).get('value')=='function':break
|
||||
await asyncio.sleep(.1)
|
||||
else: raise RuntimeError('Benchmark page did not load; check the Vite URL and browser errors')
|
||||
expression = f'window.prepareScrollBenchmark({size})' if args.scroll else f'window.runBenchmark({size})'
|
||||
response = await call('Runtime.evaluate', {'expression':expression,'awaitPromise':True,'returnByValue':True})
|
||||
if args.scroll and 'exceptionDetails' not in response:
|
||||
point = response['result']['value']
|
||||
if args.profile:
|
||||
await call('Profiler.enable'); await call('Profiler.start')
|
||||
await call('Input.dispatchMouseEvent', {'type':'mouseMoved', **point})
|
||||
for step in range(120):
|
||||
await call('Input.dispatchMouseEvent', {'type':'mouseWheel', **point, 'deltaX':0,'deltaY':900 if step < 90 else -900})
|
||||
await asyncio.sleep(.016)
|
||||
if args.profile:
|
||||
profile_data = await call('Profiler.stop')
|
||||
pathlib.Path(args.output + f'.{size}.{repeat+1}.cpuprofile').write_text(json.dumps(profile_data['profile']),encoding='utf-8')
|
||||
await call('Runtime.evaluate', {'expression':'new Promise(r => setTimeout(r, 150))','awaitPromise':True})
|
||||
response = await call('Runtime.evaluate', {'expression':'window.finishScrollBenchmark()','awaitPromise':True,'returnByValue':True})
|
||||
if 'exceptionDetails' in response: raise RuntimeError(response['exceptionDetails'])
|
||||
result = response['result']['value']; result['repeat']=repeat+1
|
||||
results.append(result)
|
||||
print(json.dumps(result,ensure_ascii=False),flush=True)
|
||||
pathlib.Path(args.output).write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
finally:
|
||||
process.terminate()
|
||||
try: process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired: process.kill(); process.wait()
|
||||
await asyncio.sleep(.5)
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('--chrome',default='C:/Program Files/Google/Chrome/Application/chrome.exe')
|
||||
parser.add_argument('--url',default='http://127.0.0.1:5173/tests/performance/stress.html')
|
||||
parser.add_argument('--sizes',nargs='+',type=int,default=[25000,60000,120000])
|
||||
parser.add_argument('--runs',type=int,default=3)
|
||||
parser.add_argument('--output',required=True)
|
||||
parser.add_argument('--profile',action='store_true',help='Save CPU profiles for scroll runs')
|
||||
parser.add_argument('--scroll',action='store_true',help='Dispatch real wheel events and check fold-to-top')
|
||||
args = parser.parse_args()
|
||||
if args.runs < 1 or any(size < 1 for size in args.sizes): parser.error('runs and sizes must be positive')
|
||||
pathlib.Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||||
asyncio.run(main(args))
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>长文渲染压测</title></head>
|
||||
<body><div id="app"></div><pre id="report">通过 run-stress.py 运行,或在控制台调用 runBenchmark(25000)。</pre>
|
||||
<script type="module">
|
||||
import { createApp, h, ref, nextTick } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Editor from '/src/features/editor/VisualMarkdownEditor.vue'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { getMarkdown } from '@milkdown/kit/utils'
|
||||
import { renderMarkdown } from '/src/utils/markdown.ts'
|
||||
import { useEditorStore } from '/src/stores/editor.ts'
|
||||
import { getCommunityThemePreviewCss } from '/src/services/themePackageService.ts'
|
||||
import { makeStressDocument } from './fixture.js'
|
||||
import '/src/styles/tokens.css'
|
||||
import '/src/styles/features.css'
|
||||
const frame = () => new Promise(resolve => requestAnimationFrame(() => resolve()))
|
||||
const settle = async () => { await nextTick(); await frame(); await frame() }
|
||||
const summarize = values => { const sorted = [...values].sort((a,b)=>a-b); return {median:sorted[Math.floor(sorted.length/2)],p95:sorted[Math.min(sorted.length-1,Math.ceil(sorted.length*.95)-1)],max:sorted.at(-1)} }
|
||||
window.runBenchmark = async (size = 25000) => {
|
||||
const source = makeStressDocument(size), target = document.getElementById('app')
|
||||
const pinia = createPinia(), component = ref(), tasks = []
|
||||
const observer = new PerformanceObserver(list => tasks.push(...list.getEntries().map(t => t.duration)))
|
||||
observer.observe({type:'longtask',buffered:false})
|
||||
const app = createApp({render:()=>h(Editor,{ref:component,initialContent:source})}).use(pinia)
|
||||
const result = {requestedHan:size,hanCharacters:(source.match(/\p{Script=Han}/gu)||[]).length,sourceCharacters:source.length,userAgent:navigator.userAgent,viewport:[innerWidth,innerHeight]}
|
||||
const start = performance.now(); app.mount(target)
|
||||
try {
|
||||
const deadline = performance.now()+120000
|
||||
while (!component.value?.getEditor() || target.querySelector('.milkdown-host.loading')) { if(performance.now()>deadline)throw Error('Editor startup timeout'); await frame() }
|
||||
await settle(); result.openMs = performance.now()-start
|
||||
const editor = component.value.getEditor(), view = editor.action(ctx=>ctx.get(editorViewCtx))
|
||||
result.domNodes = target.querySelectorAll('*').length
|
||||
const measure = async (count, operation) => { const values=[]; for(let i=0;i<count;i++) {const start=performance.now(); operation(i); values.push(performance.now()-start); await settle()} return summarize(values) }
|
||||
const positions=[]; view.state.doc.descendants((node,pos)=>{if(node.isTextblock && !node.type.spec.code)positions.push(pos+1)})
|
||||
result.selectionMs = await measure(30, i => view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc,positions[Math.floor(i*positions.length/30)]))))
|
||||
result.insertMs = await measure(20, () => view.dispatch(view.state.tr.insertText('压测输入')))
|
||||
result.foldMs = await measure(6, () => target.querySelector('.section-actions button').click())
|
||||
const serialized = editor.action(getMarkdown())
|
||||
if(!serialized.includes('压测输入') || !serialized.includes('结束标记:长文内容完整。'))throw Error('Content integrity check failed')
|
||||
result.integrity = true
|
||||
const preview = document.createElement('div'); document.body.append(preview)
|
||||
const previews=[]
|
||||
for(let i=0;i<3;i++){const start=performance.now(); preview.innerHTML=await renderMarkdown(source); await settle(); previews.push(performance.now()-start)}
|
||||
result.previewMs=previews
|
||||
result.previewNodes=preview.querySelectorAll('*').length; preview.remove()
|
||||
await settle(); result.longTasks={count:tasks.length,...summarize(tasks.length?tasks:[0])}
|
||||
result.heapUsedBytes=performance.memory?.usedJSHeapSize
|
||||
return result
|
||||
} finally {
|
||||
observer.disconnect(); useEditorStore(pinia).closeFile(); app.unmount()
|
||||
document.getElementById('report').textContent=JSON.stringify(result,null,2)
|
||||
}
|
||||
}
|
||||
|
||||
window.prepareScrollBenchmark = async (size = 25000) => {
|
||||
const source = makeStressDocument(size), target = document.getElementById('app')
|
||||
document.documentElement.dataset.theme = 'paper-moments'
|
||||
const style = document.createElement('style'); style.textContent = getCommunityThemePreviewCss('paper-moments'); document.head.append(style)
|
||||
const pinia = createPinia(), component = ref()
|
||||
const app = createApp({render:()=>h(Editor,{ref:component,initialContent:source})}).use(pinia)
|
||||
app.mount(target)
|
||||
const deadline = performance.now()+120000
|
||||
while (!component.value?.getEditor() || target.querySelector('.milkdown-host.loading')) { if(performance.now()>deadline)throw Error('Editor startup timeout'); await frame() }
|
||||
await settle()
|
||||
const scroller=target.querySelector('.milkdown-host'), gaps=[], tasks=[]
|
||||
let raf=0, last=performance.now()
|
||||
const tick = now => {gaps.push(now-last);last=now;raf=requestAnimationFrame(tick)}
|
||||
const observer = new PerformanceObserver(list=>tasks.push(...list.getEntries().map(t=>t.duration)))
|
||||
observer.observe({type:'longtask',buffered:false});raf=requestAnimationFrame(tick)
|
||||
window.finishScrollBenchmark = async () => {
|
||||
cancelAnimationFrame(raf);observer.disconnect()
|
||||
const result={requestedHan:size,theme:'paper-moments',frameGapsMs:summarize(gaps),frames:gaps.length,framesOver25ms:gaps.filter(g=>g>25).length,longTasks:tasks,scrollTop:scroller.scrollTop,scrollHeight:scroller.scrollHeight}
|
||||
const editor=component.value.getEditor(),view=editor.action(ctx=>ctx.get(editorViewCtx))
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(view.state.doc.content.size-1))))
|
||||
scroller.scrollTop=scroller.scrollHeight;await settle()
|
||||
target.querySelector('.section-actions button').click();await settle()
|
||||
result.foldedScrollTop=scroller.scrollTop
|
||||
result.caretAfterFold=view.state.selection.from
|
||||
useEditorStore(pinia).closeFile();app.unmount();style.remove()
|
||||
return result
|
||||
}
|
||||
const rect=scroller.getBoundingClientRect()
|
||||
return {x:rect.left+rect.width/2,y:rect.top+rect.height/2}
|
||||
}
|
||||
</script><style>html,body{margin:0;height:100%;}#app{display:flex;flex-direction:column;height:90vh;max-width:1200px;margin:auto;}#report{white-space:pre-wrap;}</style></body></html>
|
||||
Reference in New Issue
Block a user