fix: stabilize background operations and large embedding results
This commit is contained in:
@@ -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 })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user