diff --git a/docs/14-acceptance-fix-report.md b/docs/14-acceptance-fix-report.md new file mode 100644 index 0000000..a48dd7f --- /dev/null +++ b/docs/14-acceptance-fix-report.md @@ -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 复验:打开欢迎页/含表格的文档来回切标签,不应出现未保存圆点。 diff --git a/package-lock.json b/package-lock.json index 2949138..7a810f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 32b0a59..13a8b13 100644 --- a/package.json +++ b/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" -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 40b0d20..6256eed 100644 --- a/src/App.tsx +++ b/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 ( { setConflict(null); void reloadTabFromDisk(target) }} + onReload={() => { setConflict(null); void reloadTabFromDisk(target, undefined, true) }} onKeepMine={() => { setConflict(null) if (target.path) { diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index 352dcea..cf32536 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -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 @@ -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(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(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 diff --git a/src/editor/nodeViews.ts b/src/editor/nodeViews.ts index fbd1cc3..885b5b5 100644 --- a/src/editor/nodeViews.ts +++ b/src/editor/nodeViews.ts @@ -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 })) }) diff --git a/src/services/exportHtml.ts b/src/services/exportHtml.ts index b6175f1..54039c7 100644 --- a/src/services/exportHtml.ts +++ b/src/services/exportHtml.ts @@ -221,6 +221,9 @@ function escapeHtml(text: string): string { export async function htmlToMarkdown(html: string): Promise { 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) } diff --git a/src/stores/tabs.ts b/src/stores/tabs.ts index 2c562dd..04050d3 100644 --- a/src/stores/tabs.ts +++ b/src/stores/tabs.ts @@ -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, diff --git a/src/styles.css b/src/styles.css index 0489fd5..e092204 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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); } diff --git a/src/types/turndown-plugin-gfm.d.ts b/src/types/turndown-plugin-gfm.d.ts new file mode 100644 index 0000000..29f4fa6 --- /dev/null +++ b/src/types/turndown-plugin-gfm.d.ts @@ -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 +} diff --git a/tests/audit-fix.test.ts b/tests/audit-fix.test.ts index a7e6934..8d897d2 100644 --- a/tests/audit-fix.test.ts +++ b/tests/audit-fix.test.ts @@ -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('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('

标题

列一列二
12
') + expect(md).toContain('# 标题') + expect(md).toContain('| 列一 | 列二 |') + expect(md).toMatch(/\| -+ \| -+ \|/) + expect(md).toContain('| 1 | 2 |') + }) +}) diff --git a/tests/tabs.test.ts b/tests/tabs.test.ts index 666a383..c0f9dd4 100644 --- a/tests/tabs.test.ts +++ b/tests/tabs.test.ts @@ -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) + }) +})