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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user