fix: stabilize background operations and large embedding results

This commit is contained in:
2026-09-06 16:26:17 +08:00
parent 874e916106
commit 3b9490e3fb
73 changed files with 3847 additions and 149 deletions
@@ -55,6 +55,22 @@ afterEach(() => {
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it('opens a rendered Markdown link on Ctrl click without changing its source', async () => {
const wrapper = mount(VisualMarkdownEditor, {
props: { initialContent: '[**文档**](https://example.com/docs)' }, attachTo: document.body,
})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
const before = editor.action(getMarkdown())
const open = vi.spyOn(window, 'open').mockReturnValue(null)
try {
const link = wrapper.get('.ProseMirror a')
await link.trigger('click', { ctrlKey: true, button: 0 })
expect(open).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer')
expect(editor.action(getMarkdown())).toBe(before)
} finally { open.mockRestore() }
})
it('applies syntax and renderer preferences when opening the visual editor', async () => {
const preferences = useMarkdownPreferencesStore()
preferences.preferences.heading = 'setext'
@@ -16,6 +16,7 @@ import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCod
import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels'
import { installLinkNavigation } from './linkNavigation'
import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
import { getMarkdown, $remark, $prose } from '@milkdown/kit/utils'
@@ -80,6 +81,7 @@ const fontSizeInput = ref(16)
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
let disposeCodeLabels: (() => void) | undefined
let disposeLinkNavigation: (() => void) | undefined
let disposeCommands: (() => void) | undefined
let disposed = false
@@ -392,6 +394,7 @@ onMounted(async () => {
await crepe.create()
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value)
if (editorRoot.value) disposeLinkNavigation = installLinkNavigation(editorRoot.value)
applyProofingPreferences()
loading.value = false
if (!disposed) installCommands()
@@ -412,7 +415,7 @@ watch(() => editorStore.headingRequest, request => {
})
})
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeLinkNavigation?.(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { installLinkNavigation } from './linkNavigation'
afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks() })
it('opens nested link content with Ctrl/Command click but leaves ordinary editing alone', () => {
const root = document.createElement('div')
root.innerHTML = '<div class="ProseMirror"><a href="https://example.com/docs"><strong>Docs</strong></a></div>'
document.body.append(root)
const dispose = installLinkNavigation(root)
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const target = root.querySelector('strong')!
const click = (options: MouseEventInit) => {
const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...options })
target.dispatchEvent(event)
return event
}
expect(click({}).defaultPrevented).toBe(false)
click({ ctrlKey: true, button: 2 })
expect(open).not.toHaveBeenCalled()
expect(click({ ctrlKey: true }).defaultPrevented).toBe(true)
expect(open).toHaveBeenLastCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer')
click({ metaKey: true })
expect(open).toHaveBeenCalledTimes(2)
root.querySelector('a')!.href = 'javascript:alert(1)'
expect(click({ ctrlKey: true }).defaultPrevented).toBe(true)
expect(open).toHaveBeenCalledTimes(2)
dispose()
root.querySelector('a')!.href = 'https://example.com'
expect(click({ ctrlKey: true }).defaultPrevented).toBe(false)
expect(open).toHaveBeenCalledTimes(2)
})
@@ -0,0 +1,20 @@
/** Editable anchors need explicit navigation; plain clicks keep editing the link. */
export function installLinkNavigation(root: HTMLElement): () => void {
const navigate = (event: MouseEvent) => {
if (event.button !== 0 || !(event.ctrlKey || event.metaKey) || event.altKey) return
const target = event.target instanceof Element ? event.target : (event.target as Node | null)?.parentElement
const link = target?.closest<HTMLAnchorElement>('.ProseMirror a[href]')
if (!link || !root.contains(link)) return
const href = link.getAttribute('href')?.trim()
if (!href) return
// Consume modified clicks before Milkdown's link editor or native navigation.
event.preventDefault()
event.stopPropagation()
let url: URL
try { url = new URL(href, document.baseURI) } catch { return }
if (!['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)) return
window.open(url.href, '_blank', 'noopener,noreferrer')
}
root.addEventListener('click', navigate, true)
return () => root.removeEventListener('click', navigate, true)
}
@@ -1,13 +1,37 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { afterEach, expect, it, vi } from 'vitest'
import { Compartment } from '@codemirror/state'
import { bundledLanguagesInfo } from 'shiki/langs'
import { EditorView } from '@codemirror/view'
import { shikiLanguage, shikiLanguages } from './shikiCodeMirror'
import { getCodeTokenizer } from '@/utils/markdown'
import * as markdown from '@/utils/markdown'
const editors: EditorView[] = []
afterEach(() => { editors.splice(0).forEach(view => view.destroy()) })
afterEach(() => { editors.splice(0).forEach(view => view.destroy()); vi.restoreAllMocks() })
it('reuses highlighting across recreated views and bounds retained entries', async () => {
const tokenize = vi.fn(await getCodeTokenizer('github-light', 'javascript'))
vi.spyOn(markdown, 'getCodeTokenizer').mockResolvedValue(tokenize)
const support = await shikiLanguage('javascript', 'github-light')
const create = (doc: string) => {
const view = new EditorView({ doc, extensions: [support] })
editors.push(view)
return view
}
const source = 'const answer = 42'
create(source)
const recreated = create(source)
expect(tokenize).toHaveBeenCalledTimes(1)
expect(recreated.dom.textContent).toContain(source)
recreated.dispatch({ changes: { from: 0, to: source.length, insert: 'let changed = 1' } })
expect(tokenize).toHaveBeenCalledTimes(2)
expect(recreated.dom.textContent).toContain('let changed = 1')
for (let i = 0; i < 33; i++) create(`const value = ${i}`)
const before = tokenize.mock.calls.length
create(source)
expect(tokenize).toHaveBeenCalledTimes(before + 1)
})
it.each(['github-light', 'github-dark'] as const)('uses Shiki %s tokens and updates editable content', async theme => {
const support = await shikiLanguage('python', theme)
@@ -7,6 +7,10 @@ type CodeTheme = 'github-light' | 'github-dark'
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
const tokenize = await getCodeTokenizer(theme, language)
// Milkdown recreates off-screen CodeMirror views. Reuse immutable ranges for
// identical code within this language/theme, with a bounded retention budget.
const cache = new Map<string, DecorationSet>()
let cachedCharacters = 0
const highlights = ViewPlugin.fromClass(class {
decorations: DecorationSet
@@ -17,7 +21,13 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
}
highlight(view: EditorView): DecorationSet {
const tokens = tokenize(view.state.doc.toString(), language)
const source = view.state.doc.toString()
const cached = cache.get(source)
if (cached) {
cache.delete(source); cache.set(source, cached)
return cached
}
const tokens = tokenize(source, language)
const ranges = tokens.flatMap((line, index) => {
let offset = view.state.doc.line(index + 1).from
return line.flatMap(token => {
@@ -31,7 +41,15 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
}).range(from, offset)]
})
})
return Decoration.set(ranges)
const decorations = Decoration.set(ranges)
if (source.length <= 16000) {
cache.set(source, decorations); cachedCharacters += source.length
while (cache.size > 32 || cachedCharacters > 64000) {
const oldest = cache.keys().next().value!
cachedCharacters -= oldest.length; cache.delete(oldest)
}
}
return decorations
}
}, { decorations: value => value.decorations })