fix(acceptance): 首轮 Windows 验收 7 项缺陷回修(docs/14)
P0 快捷键让行判定补 recoveryDismissed,点过「稍后再说」不再全失效; P1 冲突重载加 force 绕过防竞态守卫;路径分隔符 joinPath 原生化 + samePath 归一化比较;.source-cm 补文档字色; P2 turndown 启用 GFM 插件(HTML 导入不再丢表格);任务复选框去掉 click preventDefault(浏览器激活撤销覆盖视觉同步); P3 新增 syncEditorContent:干净标签的挂载/卸载序列化回写同步基线, 切标签不再误标未保存。vitest 74 通过 + 2 跳过。
This commit is contained in:
49
docs/14-acceptance-fix-report.md
Normal file
49
docs/14-acceptance-fix-report.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 验收缺陷回修报告(对应 docs/13 首轮 Windows 验收,2026-11-14)
|
||||
|
||||
用户真机验收结论「未通过」,报回 7 项缺陷(P0×1、P1×3、P2×2、P3×1),全部已修。
|
||||
验证:`npx tsc -b` 0 错误;`npx vitest run` **74 通过 + 2 跳过**(新增回归 3 例:tabs 基线同步、复选框点击同步、HTML 表格导入);`npm run build` 通过。本地未推送,待复验。
|
||||
|
||||
## P0 全局快捷键全失效(恢复弹窗点过「稍后再说」后)
|
||||
|
||||
- 根因:审计修复时给全局快捷键加的「任一弹窗打开时让行」判定用了 `recoveryOffer.length > 0`,没算「已点稍后再说」的状态——草稿记录还在库里,offer 就一直在,判定恒真,Ctrl+N/O/S/F/W 全部被让行。
|
||||
- 修复:判定改为 `recoveryOffer.length > 0 && !recoveryDismissed`,与渲染条件一致。
|
||||
|
||||
## P1 B3 冲突「重新加载磁盘版本」无效
|
||||
|
||||
- 根因:审计 A3 的防竞态守卫(脏标签不许自动重载)把冲突弹窗这条「用户主动丢弃本地修改」的路径也挡了——弹窗关了、正文没换。
|
||||
- 修复:reloadTabFromDisk 增加 `force` 参数;自动轮询走守卫路径,冲突弹窗的「重新加载」传 force 绕过守卫(卸载回写闸门仍生效,不会被旧编辑器盖回)。
|
||||
|
||||
## P1 D3/B12 路径分隔符不一致
|
||||
|
||||
- 根因:`joinPath` 恒用 `/` 拼在 Windows 反斜杠目录后面,造出 `...\ws/d3-new.md`,与 Rust/对话框返回的 `...\ws\d3-new.md` 字符串比较对不上:重命名后已开标签不跟随、删除后标签不关(进而又触发「已被外部删除」的误报提示,盖掉删除成功的提示;B12 本身已进回收站)。
|
||||
- 修复:双管——`joinPath` 按父目录既有分隔符拼接(Windows 生成原生反斜杠路径);同时新增 `samePath` 归一化比较,用在打开去重、重命名查标签、恢复查标签、删除关标签(含子路径前缀判定)全部路径比较点。
|
||||
|
||||
## P1 E2 源码模式浅纸浅字
|
||||
|
||||
- 根因:`.source-cm` 没设文字色,继承了 UI 的 `--text`(深色 UI 下近白色),而纸面是文档主题的浅色(Notion #fbfbfa),浅字压浅纸。
|
||||
- 修复:按建议 `.source-cm { color: var(--doc-ink); caret-color: var(--doc-ink) }`,源码明暗此前已按 paperTone 选过 CodeMirror 主题,这次把容器继承色也对齐到文档字色。
|
||||
|
||||
## P2 E6 HTML 导入丢表格
|
||||
|
||||
- 根因:turndown 未启用 GFM 插件,表格整段丢失(标题/列表正常)。
|
||||
- 修复:新增依赖 turndown-plugin-gfm@1.0.2(MIT),htmlToMarkdown 启用 `gfm`(表格/删除线/任务列表一并补齐),附本地类型声明。回归测试:HTML 表格导入后生成标准 GFM 表格。
|
||||
|
||||
## P2 C3 任务复选框视觉不同步
|
||||
|
||||
- 根因:click 处理里 `e.preventDefault()`——浏览器对 checkbox 的激活语义是「点击先翻转、事件被取消则退回原值」,退回动作发生在事件结束后,正好把 NodeView update() 刚同步好的 `input.checked` 又退回去;`data-checked` 是普通属性不受影响,症状正是「数据变了、勾没变」。
|
||||
- 修复:click 不再 preventDefault(原生翻转与文档翻转方向一致,update 再按文档值对齐);取不到节点时手工把视觉退回文档值。回归测试:真实 click 后 `input.checked` 与序列化结果一致。
|
||||
|
||||
## P3 切标签后旧文档偶发误标未保存
|
||||
|
||||
- 根因:编辑器挂载/卸载的序列化回写与磁盘原文存在规范化差异(表格对齐冒号、欢迎页内容最典型),回写经普通 updateContent 落地后 content≠savedContent,干净文档被标脏,进而触发自动保存与恢复记录。
|
||||
- 修复:新增 reducer 动作 `syncEditorContent`——标签原本干净时,正文与基线一起对齐(规范化不算编辑);已脏时与普通更新无异。只走两条通道:挂载完成后的首次序列化对齐(EditorPane onSyncInitial)、卸载最终回写(onFinalMarkdown,含原有的重载闸门)。实时输入仍走 updateContent,脏标记语义不变。回归测试已加。
|
||||
|
||||
## 复验建议(对应 docs/13 条目)
|
||||
|
||||
P0 复验:留一份草稿 → 点「稍后再说」→ Ctrl+N 应能新建。
|
||||
B3 复验:按原步骤点「重新加载」应换成磁盘版本且标签变干净。
|
||||
D3/B12 复验:工作区里新建的文档重命名/删除,已开标签应跟随/关闭,提示不再被「已被外部删除」覆盖。
|
||||
E2 复验:深色 UI + Notion 切源码模式,文字应为深色(rgb 55,53,47 量级)。
|
||||
E6 复验:导含表格的 HTML,表格应以 GFM 表格出现。
|
||||
C3 复验:点任务复选框,勾选视觉立即跟随。
|
||||
P3 复验:打开欢迎页/含表格的文档来回切标签,不应出现未保存圆点。
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -29,6 +29,7 @@
|
||||
"remark-rehype": "11.1.2",
|
||||
"shiki": "4.6.0",
|
||||
"turndown": "7.2.2",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"unified": "11.0.5",
|
||||
"unist-util-visit": "5.1.0"
|
||||
},
|
||||
@@ -7239,6 +7240,12 @@
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/turndown-plugin-gfm": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
|
||||
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
|
||||
33
package.json
33
package.json
@@ -1,40 +1,41 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@milkdown/kit": "7.23.0",
|
||||
"@tauri-apps/api": "^2.12.4",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"codemirror": "6.0.2",
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/language-data": "6.5.2",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@milkdown/kit": "7.23.0",
|
||||
"@milkdown/plugin-math": "7.5.9",
|
||||
"@tauri-apps/api": "^2.12.4",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"codemirror": "6.0.2",
|
||||
"katex": "0.16.11",
|
||||
"mammoth": "1.11.0",
|
||||
"mermaid": "12.3.0",
|
||||
"unified": "11.0.5",
|
||||
"remark-parse": "11.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"rehype-katex": "7.0.1",
|
||||
"rehype-stringify": "10.0.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-math": "6.0.0",
|
||||
"remark-parse": "11.0.0",
|
||||
"remark-rehype": "11.1.2",
|
||||
"rehype-stringify": "10.0.1",
|
||||
"rehype-katex": "7.0.1",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"shiki": "4.6.0",
|
||||
"turndown": "7.2.2",
|
||||
"mammoth": "1.11.0"
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"unified": "11.0.5",
|
||||
"unist-util-visit": "5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.2.0",
|
||||
"@types/hast": "3.0.5",
|
||||
"@types/react": "18.3.11",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/turndown": "5.0.6",
|
||||
"@vitejs/plugin-react": "4.3.3",
|
||||
"jsdom": "25.0.1",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.11",
|
||||
"vitest": "2.1.5",
|
||||
"@types/turndown": "5.0.6",
|
||||
"@types/hast": "3.0.5"
|
||||
"vitest": "2.1.5"
|
||||
},
|
||||
"name": "new-markdown",
|
||||
"private": true,
|
||||
@@ -47,4 +48,4 @@
|
||||
},
|
||||
"type": "module",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -247,7 +247,7 @@ export default function App() {
|
||||
path, title: baseName(path), content: file.content,
|
||||
encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom,
|
||||
})
|
||||
const existing = stateRef.current.tabs.find((t) => t.path === path)
|
||||
const existing = stateRef.current.tabs.find((t) => samePath(t.path, path))
|
||||
dispatch({ type: 'open', tab: created })
|
||||
const stat = await statFile(path)
|
||||
if (stat.exists) baselinesRef.current.set(existing?.id ?? created.id, { mtimeMs: stat.mtimeMs, size: stat.size })
|
||||
@@ -352,7 +352,7 @@ export default function App() {
|
||||
}, [requestClose])
|
||||
|
||||
// ——— 阶段 4:存储初始化与启动恢复 ———
|
||||
const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string) => {
|
||||
const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string, force = false) => {
|
||||
if (!target.path) return
|
||||
try {
|
||||
const file = await readTextFile(target.path)
|
||||
@@ -360,8 +360,12 @@ export default function App() {
|
||||
// 交给下一轮轮询按冲突流程处理,绝不用磁盘内容整表覆盖新输入。
|
||||
const current = stateRef.current.tabs.find((t) => t.id === target.id)
|
||||
if (!current) return
|
||||
if (expectedContent !== undefined && current.content !== expectedContent) return
|
||||
if (isDirty(current)) return
|
||||
// force=true 是冲突弹窗里用户主动选「重新加载磁盘版本」:本地是脏的正是前提,
|
||||
// 不能被下面的防竞态守卫挡掉(验收 B3 实测踩中)。
|
||||
if (!force) {
|
||||
if (expectedContent !== undefined && current.content !== expectedContent) return
|
||||
if (isDirty(current)) return
|
||||
}
|
||||
const isActive = stateRef.current.activeId === target.id
|
||||
// 审计修复(卸载回写覆盖):活动标签重挂时,旧 EditorPane 的卸载回写会把旧正文
|
||||
// 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。
|
||||
@@ -391,7 +395,7 @@ export default function App() {
|
||||
path: item.path, title: baseName(item.path), content: file.content,
|
||||
encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom,
|
||||
})
|
||||
const existing = stateRef.current.tabs.find((t) => t.path === item.path)
|
||||
const existing = stateRef.current.tabs.find((t) => samePath(t.path, item.path))
|
||||
dispatch({ type: 'open', tab: created })
|
||||
const targetId = existing?.id ?? created.id
|
||||
dispatch({ type: 'updateContent', id: targetId, content: item.content })
|
||||
@@ -563,7 +567,13 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
// ——— 阶段 4:工作区文件管理 ———
|
||||
const joinPath = (dir: string, name: string) => `${dir.replace(/[\\/]+$/, '')}/${name}`
|
||||
const joinPath = (dir: string, name: string) => {
|
||||
const sep = dir.includes('\\') ? '\\' : '/'
|
||||
return `${dir.replace(/[\\/]+$/, '')}${sep}${name}`
|
||||
}
|
||||
/** 路径比较归一化:不同来源(对话框/Rust/手拼)的分隔符混用时也能对上 */
|
||||
const samePath = (a?: string | null, b?: string | null) =>
|
||||
Boolean(a && b && a.replace(/\\/g, '/') === b.replace(/\\/g, '/'))
|
||||
const dirOf = (path: string) => path.replace(/[\\/][^\\/]*$/, '')
|
||||
|
||||
const createFileInWorkspace = useCallback(() => {
|
||||
@@ -595,7 +605,7 @@ export default function App() {
|
||||
void (async () => {
|
||||
try {
|
||||
await renamePath(entry.path, target)
|
||||
const open = stateRef.current.tabs.find((t) => t.path === entry.path)
|
||||
const open = stateRef.current.tabs.find((t) => samePath(t.path, entry.path))
|
||||
if (open) {
|
||||
dispatch({ type: 'setPath', id: open.id, path: target, title: baseName(target) })
|
||||
store.removeRecent(entry.path)
|
||||
@@ -622,7 +632,9 @@ export default function App() {
|
||||
try {
|
||||
const dest = await deleteToTrash(entry.path)
|
||||
for (const t of stateRef.current.tabs) {
|
||||
if (t.path && (t.path === entry.path || t.path.startsWith(`${entry.path}/`) || t.path.startsWith(`${entry.path}\\`))) {
|
||||
const tabPath = t.path?.replace(/\\/g, '/')
|
||||
const entryPath = entry.path.replace(/\\/g, '/')
|
||||
if (tabPath && (tabPath === entryPath || tabPath.startsWith(`${entryPath}/`))) {
|
||||
dispatch({ type: 'close', id: t.id })
|
||||
const key = recoveryKey(t)
|
||||
if (recoveryWrittenRef.current.delete(key)) void store.deleteRecovery(key)
|
||||
@@ -884,7 +896,7 @@ export default function App() {
|
||||
|
||||
anyModalRef.current = Boolean(
|
||||
modal || appearanceOpen || exportOpen || pendingClose || conflict
|
||||
|| promptSpec || confirmSpec || quitOpen || recoveryOffer.length > 0,
|
||||
|| promptSpec || confirmSpec || quitOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
||||
)
|
||||
|
||||
// 大纲与状态栏计数只随正文变化重算(原先每次光标移动/击键都对全文重算);
|
||||
@@ -1078,8 +1090,9 @@ export default function App() {
|
||||
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })}
|
||||
onFinalMarkdown={(id, md) => {
|
||||
if (suppressFinalWriteRef.current.delete(id)) return // 外部重载的卸载回写,丢弃
|
||||
dispatch({ type: 'updateContent', id, content: md })
|
||||
dispatch({ type: 'syncEditorContent', id, content: md })
|
||||
}}
|
||||
onSyncInitial={(id, md) => dispatch({ type: 'syncEditorContent', id, content: md })}
|
||||
onCursor={setCursor}
|
||||
focusMode={ui.focusMode}
|
||||
typewriter={ui.typewriter}
|
||||
@@ -1193,7 +1206,7 @@ export default function App() {
|
||||
return (
|
||||
<ConflictModal
|
||||
title={target.title}
|
||||
onReload={() => { setConflict(null); void reloadTabFromDisk(target) }}
|
||||
onReload={() => { setConflict(null); void reloadTabFromDisk(target, undefined, true) }}
|
||||
onKeepMine={() => {
|
||||
setConflict(null)
|
||||
if (target.path) {
|
||||
|
||||
@@ -26,6 +26,8 @@ interface Props {
|
||||
onMarkdown: (tabId: string, markdown: string) => void
|
||||
/** 卸载前的最终回写(与实时 onMarkdown 分开,供外部重载时拦截旧内容回写) */
|
||||
onFinalMarkdown?: (tabId: string, markdown: string) => void
|
||||
/** 挂载后首次序列化与初始文本不一致时的一次性对齐(规范化差异不标脏) */
|
||||
onSyncInitial?: (tabId: string, markdown: string) => void
|
||||
onCursor: (cursor: CursorInfo) => void
|
||||
onReady: (api: EditorPaneApi) => void
|
||||
onImageFile?: (file: File) => Promise<string>
|
||||
@@ -47,7 +49,7 @@ const EMPTY_API: EditorPaneApi = {
|
||||
}
|
||||
|
||||
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
|
||||
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError, focusMode, typewriter }: Props) {
|
||||
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinalMarkdown, onSyncInitial, onCursor, onReady, onImageFile, onImageError, focusMode, typewriter }: Props) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const findRef = useRef<{ matches: MatchRange[]; current: number; query: string; opts: FindOptions | null }>({
|
||||
matches: [], current: -1, query: '', opts: null,
|
||||
@@ -68,8 +70,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
}, [])
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const initialRef = useRef(initialMarkdown)
|
||||
const callbacksRef = useRef({ onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError })
|
||||
callbacksRef.current = { onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError }
|
||||
const callbacksRef = useRef({ onMarkdown, onFinalMarkdown, onSyncInitial, onCursor, onReady, onImageFile, onImageError })
|
||||
callbacksRef.current = { onMarkdown, onFinalMarkdown, onSyncInitial, onCursor, onReady, onImageFile, onImageError }
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return
|
||||
|
||||
@@ -28,12 +28,18 @@ const listItemView: NodeViewConstructor = (node, view, getPos) => {
|
||||
checkbox.checked = Boolean(item.attrs.checked)
|
||||
checkbox.contentEditable = 'false'
|
||||
checkbox.addEventListener('mousedown', (e) => e.preventDefault())
|
||||
checkbox.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
checkbox.addEventListener('click', () => {
|
||||
// 不要在 click 里 preventDefault:浏览器对 checkbox 的激活行为是「先翻转、
|
||||
// 事件被取消则退回原值」,会把 update() 同步好的 checked 又退回去(验收 C3)。
|
||||
// 原生翻转与文档翻转方向一致,update() 再按文档值对齐即可;取不到节点时手工回退。
|
||||
const el = checkbox
|
||||
if (!el) return
|
||||
const pos = getPos()
|
||||
if (pos == null) return
|
||||
const current = view.state.doc.nodeAt(pos)
|
||||
if (!current) return
|
||||
const current = pos == null ? null : view.state.doc.nodeAt(pos)
|
||||
if (pos == null || !current) {
|
||||
el.checked = Boolean(item.attrs.checked)
|
||||
return
|
||||
}
|
||||
const next = !current.attrs.checked
|
||||
view.dispatch(view.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, checked: next }))
|
||||
})
|
||||
|
||||
@@ -221,6 +221,9 @@ function escapeHtml(text: string): string {
|
||||
export async function htmlToMarkdown(html: string): Promise<string> {
|
||||
const TurndownService = (await import('turndown')).default
|
||||
const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' })
|
||||
// GFM 扩展(表格/删除线/任务列表):原先没启用,导入 HTML 时表格整段丢失(验收 E6)
|
||||
const { gfm } = await import('turndown-plugin-gfm')
|
||||
service.use(gfm)
|
||||
return service.turndown(html)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export type TabsAction =
|
||||
| { type: 'activate'; id: string }
|
||||
| { type: 'updateContent'; id: string; content: string }
|
||||
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string }
|
||||
| { type: 'syncEditorContent'; id: string; content: string }
|
||||
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
|
||||
| { type: 'setPath'; id: string; path: string; title: string }
|
||||
| { type: 'close'; id: string }
|
||||
@@ -75,6 +76,18 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
|
||||
tabs: state.tabs.map((t) => (t.id === action.id ? { ...t, content: action.content } : t)),
|
||||
}
|
||||
}
|
||||
case 'syncEditorContent':
|
||||
// 编辑器挂载/卸载时的序列化回写:标签若原本干净,这份差异只是解析→序列化的
|
||||
// 规范化(表格对齐冒号等),不是用户编辑——正文与基线一起对齐,不标未保存。
|
||||
// 标签若已脏,则与普通 updateContent 无异。(验收 P3:切标签后旧文档误标脏)
|
||||
return {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) => {
|
||||
if (t.id !== action.id || t.content === action.content) return t
|
||||
const wasClean = t.content === t.savedContent
|
||||
return { ...t, content: action.content, savedContent: wasClean ? action.content : t.savedContent }
|
||||
}),
|
||||
}
|
||||
case 'saved':
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -91,7 +91,7 @@ button { font: inherit; color: inherit; }
|
||||
|
||||
.editor-scroll { flex: 1; overflow-y: auto; background: var(--bg-editor); min-width: 0; position: relative; }
|
||||
.editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; }
|
||||
.source-cm { height: 100%; max-width: 980px; margin: 0 auto; }
|
||||
.source-cm { height: 100%; max-width: 980px; margin: 0 auto; color: var(--doc-ink); caret-color: var(--doc-ink); }
|
||||
.source-cm .cm-editor { height: 100%; outline: none; }
|
||||
.md-marker { color: var(--text-dim); opacity: 0.55; font-size: 0.9em; pointer-events: none; user-select: none; white-space: pre; }
|
||||
.empty-state { max-width: 520px; margin: 18vh auto 0; text-align: center; color: var(--text-dim); }
|
||||
|
||||
9
src/types/turndown-plugin-gfm.d.ts
vendored
Normal file
9
src/types/turndown-plugin-gfm.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// turndown-plugin-gfm 无官方类型声明(运行时为标准 turndown 插件对象)。
|
||||
declare module 'turndown-plugin-gfm' {
|
||||
import type TurndownService from 'turndown'
|
||||
export const gfm: TurndownService.Plugin
|
||||
export const tables: TurndownService.Plugin
|
||||
export const strikethrough: TurndownService.Plugin
|
||||
export const taskListItems: TurndownService.Plugin
|
||||
export const highlightedCodeBlock: TurndownService.Plugin
|
||||
}
|
||||
@@ -100,3 +100,26 @@ describe('粘贴与锚点修复(审计)', () => {
|
||||
expect(findBlockIndex(blocks, { blockText: '目标文本', offsetInBlock: 0 })).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('验收回修回归', () => {
|
||||
it('任务复选框点击后视觉 checked 与文档同步(C3)', async () => {
|
||||
const { host, editor } = await makeEditor('- [ ] 待办一\n- [x] 已办')
|
||||
const box = host.querySelector<HTMLInputElement>('input.task-checkbox')
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.checked).toBe(false)
|
||||
box!.click()
|
||||
expect(box!.checked).toBe(true)
|
||||
expect(getMarkdown(editor)).toContain('- [x] 待办一')
|
||||
await editor.destroy()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('HTML 导入保留 GFM 表格(E6)', async () => {
|
||||
const { htmlToMarkdown } = await import('../src/services/exportHtml')
|
||||
const md = await htmlToMarkdown('<h1>标题</h1><table><thead><tr><th>列一</th><th>列二</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
|
||||
expect(md).toContain('# 标题')
|
||||
expect(md).toContain('| 列一 | 列二 |')
|
||||
expect(md).toMatch(/\| -+ \| -+ \|/)
|
||||
expect(md).toContain('| 1 | 2 |')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,3 +61,20 @@ describe('标签页会话模型', () => {
|
||||
expect(s.tabs[0].id).toBe(t3.id === s.tabs[0].id ? t3.id : s.tabs[0].id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncEditorContent(验收 P3 回归)', () => {
|
||||
it('干净标签的规范化回写同步基线、不标脏;脏标签只更新正文', () => {
|
||||
const t = createTab({ content: '| A |\n|---|\n| 1 |' })
|
||||
let s: TabsState = { tabs: [t], activeId: t.id }
|
||||
// 挂载序列化把对齐冒号规范化了:干净标签基线跟随,仍不脏
|
||||
s = tabsReducer(s, { type: 'syncEditorContent', id: t.id, content: '| A |\n|:---|\n| 1 |' })
|
||||
expect(isDirty(s.tabs[0])).toBe(false)
|
||||
expect(s.tabs[0].content).toContain(':---')
|
||||
// 脏标签:只更新正文,基线不动
|
||||
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '改过' })
|
||||
expect(isDirty(s.tabs[0])).toBe(true)
|
||||
s = tabsReducer(s, { type: 'syncEditorContent', id: t.id, content: '改过又回写' })
|
||||
expect(s.tabs[0].savedContent).toContain(':---')
|
||||
expect(isDirty(s.tabs[0])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user