fix(editor): handle paired inline code input and complete markdown rendering
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# Markdown 渲染检查
|
||||
|
||||
日期:2026-09-05。范围为当前工程启用的 CommonMark、GFM、Milkdown Crepe 扩展及静态 Markdown 预览,不代表所有 Markdown 方言。
|
||||
|
||||
## 本次修复
|
||||
|
||||
- 行内代码:保留普通输入规则,为绕过 `handleTextInput` 的浏览器文本输入与输入法组合结束增加单反引号补偿处理。跳过代码节点、已有代码标记、转义反引号;不改写文件中的转义文本。同时识别先输入空反引号对、再移入填字的路径,并在转换后保留继续输入的代码标记;空反引号对序列化时的转义不会阻断识别。回归测试覆盖缺失事件数据、替换文本、延迟组合结束与粘贴/撤销排除。独立浏览器编辑器已实测逐字输入、段落/行内换行,以及先输入反引号对再向中间填入 s。
|
||||
- 工具栏:未选中文字时,行内代码按钮可切换后续输入的代码标记;此前上游命令在空选区直接返回。
|
||||
- 样式:工作区与静态预览使用主题代码背景、文字及边框变量,避免行内代码与正文难以区分。
|
||||
- 静态预览:补齐 `$...$`、`$$...$$` 和编辑器保存的 `LaTeX` 围栏公式;代码中的公式符号保持原文。公式使用 KaTeX,禁用可信 HTML 命令并经过最终清理。
|
||||
- 静态表格:恢复 GFM 中间、右侧对齐,避免通用单元格样式覆盖对齐属性。
|
||||
|
||||
## 检查矩阵
|
||||
|
||||
| 格式 | 工作区编辑 | 静态预览 | 验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| H1–H6 | 标题节点 | 标题元素 | 新增格式矩阵 |
|
||||
| 粗体、斜体、删除线 | 标记渲染 | strong / em / del | 新增格式矩阵 |
|
||||
| 单反引号、多反引号代码 | 行内代码;保存保留定界符 | code,转义内容不执行 | 加载、普通键入、组合输入、工具栏与序列化 |
|
||||
| 有序、无序、嵌套列表 | 列表节点 | ol / ul | 新增格式矩阵 |
|
||||
| 任务列表 | GFM 任务项 | 禁用复选框 | 格式矩阵及 GFM 输出 |
|
||||
| 引用、分割线 | 原生节点 | blockquote / hr | 新增格式矩阵 |
|
||||
| 链接、引用式链接、图片 | Crepe 原生组件 | 安全链接及图片 | 静态格式矩阵;图片实际加载受路径可访问性影响 |
|
||||
| GFM 表格 | 表格组件 | table;主题边框和对齐 | 格式矩阵、对齐规则检查 |
|
||||
| 硬换行、转义符 | 编辑器保留 Markdown 语义 | br / 转义文本 | 静态格式矩阵 |
|
||||
| 围栏代码、未知语言 | CodeMirror / Shiki | Shiki;未知语言回退纯文本 | 既有语言测试与新增回退测试 |
|
||||
| 行内、块级数学公式 | Crepe LaTeX | KaTeX | 编辑器格式矩阵与新增静态公式测试 |
|
||||
| Mermaid | 图形预览 | SVG 图形 | 既有主题、错误回退、大图文字及缩放测试 |
|
||||
| YAML 元数据 | 独立属性栏 | 普通 Markdown 场景不视为属性表单 | 既有标题、标签、引号、锚点、编码与往返测试 |
|
||||
| 自定义字号 span | 装饰渲染 | 清理后 HTML | 既有字号标记测试 |
|
||||
| 原始 HTML | 编辑器按自身 HTML 节点规则保留 | 清理后展示,脚本及事件属性移除 | 新增安全 HTML 测试 |
|
||||
|
||||
源码模式展示 Markdown 原文,不隐藏反引号、星号和围栏。脚注、定义列表、Wiki 双链、Obsidian callout、图表以外的自定义围栏等未作为独立渲染扩展启用,不在“已支持”范围内。
|
||||
|
||||
自动检查覆盖解析、DOM 输出、部分编辑交互、保存往返和主题变量。尚未完成所有浏览器、所有输入法及每个主题的逐页截图比对;不能据此宣称像素级视觉验收通过。测试使用隔离样例,没有修改用户笔记。
|
||||
@@ -36,6 +36,7 @@
|
||||
"codemirror": "^6.0.0",
|
||||
"dompurify": "^3.4.14",
|
||||
"fflate": "^0.8.3",
|
||||
"katex": "0.18.4",
|
||||
"marked": "^15.0.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"pinia": "^4.0.0",
|
||||
@@ -46,6 +47,7 @@
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/katex": "0.16.8",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
|
||||
Generated
+6
@@ -83,6 +83,9 @@ importers:
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
katex:
|
||||
specifier: 0.18.4
|
||||
version: 0.18.4
|
||||
marked:
|
||||
specifier: ^15.0.0
|
||||
version: 15.0.12
|
||||
@@ -108,6 +111,9 @@ importers:
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.0
|
||||
devDependencies:
|
||||
'@types/katex':
|
||||
specifier: 0.16.8
|
||||
version: 0.16.8
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.20.1
|
||||
|
||||
@@ -33,11 +33,17 @@ watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async
|
||||
.markdown-content .shiki { overflow: auto; margin: .85em 0; padding: 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background) !important; color: var(--color-code-text); font-family: var(--font-ui-mono); font-size: .875em; line-height: 1.45; tab-size: 4; }
|
||||
.markdown-content code { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
|
||||
.markdown-content .shiki code { display: block; min-width: max-content; padding: 0; background: transparent; font: inherit; }
|
||||
.markdown-content :not(pre) > code { background: var(--color-code-background); color: var(--color-code-text); border: 1px solid var(--color-code-border); }
|
||||
.markdown-content div.markdown-math { overflow-x: auto; padding-block: .5em; }
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 { margin: 1em 0 .5em; font-weight: 600; }
|
||||
.markdown-content input[type="checkbox"] { margin-right: .45em; accent-color: var(--color-accent-primary); }
|
||||
.markdown-content .shiki .line { display: block; min-height: 1.45em; }
|
||||
.markdown-content blockquote { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
|
||||
.markdown-content table { width: 100%; margin: .65em 0; border-collapse: collapse; }
|
||||
.markdown-content th, .markdown-content td { padding: .45em .65em; border: 1px solid var(--color-markdown-grid); text-align: left; }
|
||||
.markdown-content th { background: var(--color-markdown-table-header); font-weight: 700; }
|
||||
.markdown-content :is(th, td)[align="center"] { text-align: center; }
|
||||
.markdown-content :is(th, td)[align="right"] { text-align: right; }
|
||||
.markdown-content img { max-width: 100%; }
|
||||
.markdown-content hr { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
|
||||
[data-code-theme='github-light'] .markdown-content .shiki,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// The application has a doctype; happy-dom otherwise reports quirks mode to KaTeX.
|
||||
vi.hoisted(() => { Object.defineProperty(document, 'compatMode', {value:'CSS1Compat',configurable:true}) })
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
|
||||
@@ -49,6 +51,106 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
it('renders the supported format matrix and preserves inline code', async () => {
|
||||
const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6',
|
||||
'正文 **粗体** *斜体* ~~删除~~ `s` 与 ``a`b``', '> 引用', '- 项目\n - 子项', '1. 第一\n2. 第二',
|
||||
'- [x] 完成\n- [ ] 未完成', '[链接](https://example.com)',
|
||||
'| A | B |\n| --- | --- |\n| x | y |', '---', '$x^2$', '$$\nx^2\n$$', '```js\nconst n = 1\n```'].join('\n\n')
|
||||
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:source},attachTo:document.body})
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
expect(wrapper.get('.ProseMirror code').text()).toBe('s')
|
||||
for (const selector of ['h1','h2','h3','h4','h5','h6','strong','em','del','blockquote','ol','ul','table','hr','a']) expect(wrapper.find(`.ProseMirror ${selector}`).exists(), selector).toBe(true)
|
||||
expect(editor.action(getMarkdown())).toContain('`s`')
|
||||
expect(editor.action(getMarkdown())).toContain('``a`b``')
|
||||
})
|
||||
it('converts a typed closing backtick to inline code', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
for (const text of '`s`') {
|
||||
const {from,to} = view.state.selection
|
||||
let handled = false
|
||||
view.someProp('handleTextInput', handler => { if (handler(view,from,to,text, () => view.state.tr.insertText(text,from,to))) { handled = true; return true } })
|
||||
if (!handled) view.dispatch(view.state.tr.insertText(text,from,to))
|
||||
}
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.get('.ProseMirror code').text()).toBe('s')
|
||||
})
|
||||
it('reconciles IME composition text without handleTextInput', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
|
||||
await wrapper.get('.ProseMirror').trigger('compositionend', {data:'`s`'})
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
expect(wrapper.get('.ProseMirror code').text()).toBe('s')
|
||||
expect(editor.action(getMarkdown()).trim()).toBe('`s`')
|
||||
})
|
||||
it.each(['insertText', 'insertCompositionText', 'insertReplacementText'])('reconciles %s without event.data after the DOM update', async (inputType) => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
// Chromium/IME can omit data and commit its DOM change after the input event.
|
||||
await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null})
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
|
||||
await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s'))
|
||||
})
|
||||
it('waits for composition cleanup before converting committed text', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
const view = editor.action(ctx => ctx.get(editorViewCtx))
|
||||
const composing = vi.spyOn(view, 'composing', 'get').mockReturnValue(true)
|
||||
await wrapper.get('.ProseMirror').trigger('compositionstart')
|
||||
view.dispatch(view.state.tr.insertText('`s`'))
|
||||
await wrapper.get('.ProseMirror').trigger('compositionend', {data:'`s`'})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(wrapper.find('.ProseMirror code').exists()).toBe(false)
|
||||
composing.mockReturnValue(false)
|
||||
await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s'))
|
||||
composing.mockRestore()
|
||||
})
|
||||
it('converts content typed between an existing pair of backticks', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
const view = editor.action(ctx => ctx.get(editorViewCtx))
|
||||
view.dispatch(view.state.tr.insertText('``'))
|
||||
await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'`'})
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
// Empty pairs are serialized as escaped literal text, but that must not
|
||||
// prevent recognition after the user moves back and fills in the content.
|
||||
expect(editor.action(getMarkdown())).toContain('\\`')
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)).insertText('s'))
|
||||
await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'s'})
|
||||
await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s'))
|
||||
expect(editor.action(getMarkdown()).trim()).toBe('`s`')
|
||||
expect(view.state.selection.from).toBe(2)
|
||||
view.dispatch(view.state.tr.insertText('tring'))
|
||||
expect(editor.action(getMarkdown()).trim()).toBe('`string`')
|
||||
})
|
||||
it.each(['insertFromPaste', 'historyUndo', 'deleteContentBackward'])('does not reinterpret literals on %s', async (inputType) => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
|
||||
await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null})
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
expect(wrapper.find('.ProseMirror code').exists()).toBe(false)
|
||||
})
|
||||
it('enables inline code from the toolbar at an empty selection', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
await wrapper.get('[aria-label="行内代码"]').trigger('pointerdown')
|
||||
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('value')))
|
||||
expect(editor.action(getMarkdown()).trim()).toBe('`value`')
|
||||
})
|
||||
it.each([['jsonc', 'JSON with Comments', '// comment\n{"answer": 42}'], ['ahk', 'AutoHotkey', 'MsgBox "Hello"']])('persists %s from the language menu and renders it with Shiki', async (id, label, source) => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: `\`\`\`text\n${source}\n\`\`\`` }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
|
||||
import { inlineCodeInputPlugin } from './inlineCodeInput'
|
||||
import { t } from '@/i18n'
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
@@ -93,6 +94,16 @@ type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inli
|
||||
function runCommand(command: ToolbarCommand) {
|
||||
const editor = crepe?.editor
|
||||
if (!editor) return
|
||||
if (command === 'inline-code' && editor.action(ctx => ctx.get(editorViewCtx).state.selection.empty)) {
|
||||
editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const mark = view.state.schema.marks.inlineCode!
|
||||
const active = (view.state.storedMarks ?? view.state.selection.$from.marks()).some(item => item.type === mark)
|
||||
view.dispatch(active ? view.state.tr.removeStoredMark(mark) : view.state.tr.setStoredMarks([mark.create()]))
|
||||
view.focus()
|
||||
})
|
||||
return
|
||||
}
|
||||
// 顶部工具栏复用 Milkdown 命令,因此选区与浮动工具栏共享同一文档事务。
|
||||
const actions = {
|
||||
bold: callCommand(toggleStrongCommand.key),
|
||||
@@ -228,6 +239,7 @@ onMounted(async () => {
|
||||
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
|
||||
})))
|
||||
crepe.editor.use(fontSizeMarkdownPlugin)
|
||||
crepe.editor.use(inlineCodeInputPlugin)
|
||||
crepe.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
||||
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
|
||||
@@ -404,6 +416,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper) { color: var(--color-markdown-marker); font-weight: 700; }
|
||||
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper svg) { fill: var(--color-markdown-marker); }
|
||||
.milkdown-host :deep(code) { font-family: var(--font-editor-mono); }
|
||||
.milkdown-host :deep(.ProseMirror :not(pre) > code) { padding: .12em .35em; border: 1px solid var(--color-code-border); border-radius: var(--radius-sm); background: var(--color-code-background); color: var(--color-code-text); font-size: .9em; box-decoration-break: clone; }
|
||||
:global([data-theme='dark'] .milkdown-host .milkdown) { color-scheme: dark; }
|
||||
@media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { $prose } from '@milkdown/kit/utils'
|
||||
import { Plugin } from '@milkdown/kit/prose/state'
|
||||
import type { EditorView } from '@milkdown/kit/prose/view'
|
||||
|
||||
function reconcile(view: EditorView) {
|
||||
if (view.isDestroyed || view.composing || !view.state.selection.empty) return
|
||||
const { $from } = view.state.selection
|
||||
if (!$from.parent.isTextblock || $from.parent.type.spec.code) return
|
||||
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\ufffc')
|
||||
// Also inspect the closing delimiter AFTER the caret: users commonly type
|
||||
// a pair of backticks first, move left, and then fill in the code.
|
||||
const spans = /(^|[^\\`])`([^`\n\ufffc]+)`(?!`)/g
|
||||
let candidate: { start: number; end: number } | undefined
|
||||
for (const match of text.matchAll(spans)) {
|
||||
const start = match.index! + match[1]!.length
|
||||
const end = match.index! + match[0].length
|
||||
if (match[2]!.trim() && $from.parentOffset > start && $from.parentOffset <= end) {
|
||||
candidate = { start: $from.start() + start, end: $from.start() + end }
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!candidate) return
|
||||
const mark = view.state.schema.marks.inlineCode
|
||||
if (!mark) return
|
||||
const { start, end } = candidate
|
||||
if (view.state.doc.rangeHasMark(start, end, mark)) return
|
||||
const tr = view.state.tr.delete(end - 1, end).delete(start, start + 1)
|
||||
tr.removeMark(start, end - 2).addMark(start, end - 2, mark.create())
|
||||
// Filling an existing pair must keep subsequent letters inside code. Typing
|
||||
// the closing delimiter explicitly should instead leave code as usual.
|
||||
if ($from.pos < end) tr.setStoredMarks([mark.create()])
|
||||
else tr.removeStoredMark(mark)
|
||||
view.dispatch(tr)
|
||||
}
|
||||
|
||||
// DOM input can bypass handleTextInput (IME, replacement text, missing event.data).
|
||||
// Observe it in capture phase, then wait for ProseMirror's DOM observer and its
|
||||
// composition cleanup before inspecting the document. Never reconcile on load,
|
||||
// paste, undo, or a selection change alone.
|
||||
export const inlineCodeInputPlugin = $prose(() => new Plugin({
|
||||
view(view) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let pending = false
|
||||
let composing = false
|
||||
let attempts = 0
|
||||
const cancel = () => { clearTimeout(timer); pending = false }
|
||||
const schedule = () => {
|
||||
clearTimeout(timer)
|
||||
if (!pending || composing) return
|
||||
timer = setTimeout(() => {
|
||||
if (view.isDestroyed) return cancel()
|
||||
if (view.composing) {
|
||||
if (++attempts < 10) schedule()
|
||||
else cancel()
|
||||
return
|
||||
}
|
||||
pending = false
|
||||
reconcile(view)
|
||||
}, 30)
|
||||
}
|
||||
const input = (event: Event) => {
|
||||
const type = (event as InputEvent).inputType
|
||||
if (type && (!type.startsWith('insert') || /paste|drop/i.test(type))) return cancel()
|
||||
pending = true
|
||||
attempts = 0
|
||||
schedule()
|
||||
}
|
||||
const start = () => { composing = true; cancel() }
|
||||
const end = () => { composing = false; pending = true; attempts = 0; schedule() }
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
// ProseMirror handles undo/paste itself, so those actions need not emit input.
|
||||
if (event.ctrlKey || event.metaKey || ['Backspace', 'Delete', 'Escape'].includes(event.key)) cancel()
|
||||
else if (pending && !composing && !view.composing && event.key === 'Enter') {
|
||||
cancel()
|
||||
reconcile(view)
|
||||
}
|
||||
}
|
||||
view.dom.addEventListener('input', input, true)
|
||||
view.dom.addEventListener('keydown', keydown, true)
|
||||
view.dom.addEventListener('paste', cancel, true)
|
||||
view.dom.addEventListener('drop', cancel, true)
|
||||
view.dom.addEventListener('compositionstart', start, true)
|
||||
view.dom.addEventListener('compositionend', end, true)
|
||||
return {
|
||||
update(current, previous) {
|
||||
if (pending && !current.state.doc.eq(previous.doc)) schedule()
|
||||
},
|
||||
destroy() {
|
||||
cancel()
|
||||
view.dom.removeEventListener('input', input, true)
|
||||
view.dom.removeEventListener('keydown', keydown, true)
|
||||
view.dom.removeEventListener('paste', cancel, true)
|
||||
view.dom.removeEventListener('drop', cancel, true)
|
||||
view.dom.removeEventListener('compositionstart', start, true)
|
||||
view.dom.removeEventListener('compositionend', end, true)
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -80,7 +80,7 @@ async function openResult(result: SearchResult) {
|
||||
.search-history { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-bottom: var(--space-md); }
|
||||
.advanced { grid-column: 1 / -1; }
|
||||
.results-header, .result-title, .result-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
||||
.results-header { margin: var(--space-xl) 0 var(--space-md); color: var(--color-text-secondary); }
|
||||
.results-header { margin-block: var(--space-xl) var(--space-md); color: var(--color-text-secondary); }
|
||||
.result-list { display: grid; gap: var(--space-md); }
|
||||
.result-card { position: relative; cursor: pointer; overflow: hidden; }
|
||||
.result-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--color-accent-primary); opacity: 0; transform: scaleY(.45); transition: opacity var(--motion-fast), transform var(--motion-fast); }
|
||||
|
||||
@@ -7,6 +7,27 @@ import githubDark from '@shikijs/themes/github-dark'
|
||||
import githubLight from '@shikijs/themes/github-light'
|
||||
import { renderMermaid } from '@/services/mermaidService'
|
||||
import { appendDiagramControls } from './diagramControls'
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
function mathHtml(source: string, displayMode: boolean) {
|
||||
const result = katex.renderToString(source, {displayMode, throwOnError:false, trust:false, maxExpand:1000, output:'html'})
|
||||
const label = source.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
return `<${displayMode ? 'div' : 'span'} class="markdown-math" role="math" aria-label="${label}">${result}</${displayMode ? 'div' : 'span'}>`
|
||||
}
|
||||
|
||||
marked.use({extensions:[
|
||||
{name:'blockMath',level:'block',tokenizer(source) {
|
||||
const match = /^ {0,3}\$\$\s*\n?([\s\S]+?)\n?\$\$[ \t]*(?:\n|$)/.exec(source)
|
||||
if (match) return {type:'blockMath',raw:match[0],text:match[1]!.trim()}
|
||||
return undefined
|
||||
}, renderer(token) { return mathHtml(token.text, true) }},
|
||||
{name:'inlineMath',level:'inline',start(source) { return source.indexOf('$') },tokenizer(source) {
|
||||
const match = /^\$([^$\n]+?)\$(?!\$)/.exec(source)
|
||||
if (match && !/^\s|\s$/.test(match[1]!)) return {type:'inlineMath',raw:match[0],text:match[1]!}
|
||||
return undefined
|
||||
},renderer(token) { return mathHtml(token.text, false) }},
|
||||
]})
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
|
||||
@@ -72,6 +93,10 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '' })
|
||||
continue
|
||||
}
|
||||
if (requestedLanguage.toLowerCase() === 'latex') {
|
||||
code.parentElement?.replaceWith(document.createRange().createContextualFragment(mathHtml(code.textContent ?? '', true)))
|
||||
continue
|
||||
}
|
||||
const highlighted = await highlightCode(code.textContent ?? '', requestedLanguage)
|
||||
const fragment = document.createRange().createContextualFragment(highlighted)
|
||||
code.parentElement?.replaceWith(fragment)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
import { expect, it } from 'vitest'
|
||||
import { renderMarkdown } from './markdown'
|
||||
|
||||
it('renders CommonMark and GFM formats, escaped literals and safe HTML', async () => {
|
||||
const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6',
|
||||
'**bold** *italic* ~~deleted~~ `inline` ``a`b``', '> quote', '- item\n - nested',
|
||||
'1. first\n2. second', '- [x] done\n- [ ] todo', '[link][ref]\n\n[ref]: https://example.com',
|
||||
'', '| A | B |\n| --- | --- |\n| x | y |', '---',
|
||||
'line \nbreak', '\\`literal\\`', '<script>alert(1)</script><img src="x" onerror="alert(1)">',
|
||||
'```unknown-language\n<script>literal</script>\n```'].join('\n\n')
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = await renderMarkdown(source)
|
||||
for (const selector of ['h1','h2','h3','h4','h5','h6','strong','em','del','blockquote','ul','ol','table','hr','br','a','img','input[type=checkbox]','pre code']) expect(root.querySelector(selector), selector).not.toBeNull()
|
||||
expect(root.querySelector('code')?.textContent).toBe('inline')
|
||||
expect(root.textContent).toContain('`literal`')
|
||||
expect(root.querySelector('pre code')?.textContent).toContain('<script>literal</script>')
|
||||
expect(root.querySelector('script,[onerror]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders inline, display and editor LaTeX fences while leaving code literals alone', async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = await renderMarkdown('$x^2$\n\n$$\nx^2\n$$\n\n```LaTeX\nx^2\n```\n\n`$literal$`\n\n```text\n$literal$\n```')
|
||||
expect(root.querySelectorAll('.katex')).toHaveLength(3)
|
||||
expect(root.querySelector('code')?.textContent).toBe('$literal$')
|
||||
expect(root.querySelector('pre code')?.textContent).toContain('$literal$')
|
||||
})
|
||||
Reference in New Issue
Block a user