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:
Muse
2026-11-14 11:08:24 +08:00
parent 947154892c
commit 9bd3f3de97
12 changed files with 179 additions and 36 deletions

View 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
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 }))
})

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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
View 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
}

View File

@@ -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 |')
})
})

View File

@@ -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)
})
})