diff --git a/README.md b/README.md index 3adad46..9463085 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Tauri 2 + React + TypeScript + Milkdown(ProseMirror)。 ## 当前进度 -**阶段 0–5 已完成(自动化验证全绿),待 Windows 真机验收清单确认。** +**阶段 0–6 已完成(自动化验证全绿),待 Windows 真机验收清单确认。** 阶段 5:7 套文档主题、自定义 CSS、导出 HTML、打印/PDF、导入 HTML/Word,报告见 `docs/09-stage5-report.md`。 阶段 1:Tauri 桌面骨架 + 打开/编辑/保存全链路,报告见 `docs/05-stage1-report.md` §4 清单。 diff --git a/docs/10-stage6-report.md b/docs/10-stage6-report.md new file mode 100644 index 0000000..e4896c0 --- /dev/null +++ b/docs/10-stage6-report.md @@ -0,0 +1,64 @@ +# 阶段 6 报告:体验与性能 + +完成日期:2026-11-13。本阶段交付专注/打字机模式、文档内查找替换、工作区全文搜索、快捷键自定义中心、多窗口与大文档性能实测。 + +## 1. 实现内容 + +### 专注模式与打字机模式(视图菜单开关,随偏好持久化) + +- **专注模式**:自研 ProseMirror 装饰插件(`createFocusDimPlugin`)——选区/文档变化时给「非当前顶层块」加节点装饰类 `focus-dim`(CSS 透明度 0.32 + 0.25s 过渡);选区所在顶层块保持全亮。装饰是纯呈现层,不入模型、不入撤销历史。开关切换时经插件 meta 立即重算,编辑器创建完成后也会按当前开关状态应用一次。 +- **打字机模式**:EditorPane 的光标回调里用 `coordsAtPos` 计算光标行位置,当其偏离编辑区垂直中央超过 6px 时平滑滚动使光标行回到中央;仅滚动容器操作,不改文档。 + +### 文档内查找替换(编辑菜单/Ctrl+F、Ctrl+H) + +- 引擎:`src/editor/search.ts` 把文档拍平成「字符 → 文档位置」索引(块间放分隔符),在此之上做明文/正则匹配——命中可跨文本节点(如粗体边界)精确映射回文档位置,**跨块命中自动跳过**(避免替换合并段落)。选项:区分大小写、全词匹配(ASCII 词边界)、正则(非法正则给出中文错误提示),命中上限 5000。 +- 渲染模式走 ProseMirror 事务(选中命中并滚动到可见处;替换为单事务 `insertText`,可撤销;全部替换倒序单事务完成);源码模式在 CodeMirror 文档文本上复用同一套语义实现偏移版查找/替换。 +- UI:右上角浮动 FindBar(查找/替换两行可折叠、命中计数 `当前 / 总数`、Enter 下一个/Shift+Enter 上一个、Esc 关闭);打开时自动带入编辑器选区文本作为查询词;切换标签/模式时对新上下文自动重算;同一文档的编辑会经替换后自动刷新命中。 + +### 工作区全文搜索(侧边栏第三页签「搜索」) + +- Rust 纯逻辑模块 `search.rs`:递归扫描工作区内的 `.md/.markdown/.txt`(跳过隐藏目录与 node_modules,单文件上限 2MB),逐行包含匹配,返回行号 + 片段(≤160 字)。保护上限:有命中的文件 ≤300、单文件命中 ≤20。命令 `workspace_search` 转发;逻辑已用独立 crate 对临时目录树实跑断言(大小写敏感/不敏感、跳过隐藏目录与图片、空查询)。 +- 前端页签含查询框、区分大小写开关、结果按文件分组(文件名 + 行号 + 片段);点击任一命中在主窗口打开文件并自动打开查找条定位该查询词。Web 降级模式与未打开文件夹时给出明确提示。 + +### 快捷键自定义中心(帮助 → 快捷键设置…) + +- 应用级快捷键从 App 的硬编码分支重构为注册表(`src/utils/shortcuts.ts`):22 个动作(新建/打开/保存/另存为/关闭标签/查找/替换/侧边栏/链接/图片/表格/引用/代码块/段落/六级标题/打印/缩放),全局键盘处理先归一化组合键再查生效绑定分发,原有语义保持不变(IME 守卫、输入框内不触发内容类快捷键、保存走保存/另存为两条独立绑定)。 +- 设置中心支持改绑(点击「修改」后按任意组合键录入,Esc 取消)、解绑、恢复单个默认;改绑命中他人组合键时弹出冲突提示,可选择「覆盖并解绑对方」;编辑器内置键(Ctrl+B/I、Tab 导航等)单列只读展示并注明不可改绑。覆盖随 UI 偏好持久化(SQLite/localStorage),即时生效。菜单上的快捷键标注仍为默认组合,改绑后以设置中心为准。 + +### 多窗口打开文档 + +- 文件菜单「在新窗口打开当前文档」:Tauri `WebviewWindow` 以 `doc-*` 标签创建新窗口,URL 带 `?file=<路径>` 参数;应用启动初始化时读取该参数自动打开对应文档。新窗口与主窗口共用 SQLite(最近文件/偏好/恢复记录一致),同文档双开编辑时由阶段 4 的外部修改基线检测兜底冲突提醒。 +- 能力配置:`default.json` 的 windows 扩展为 `["main", "doc-*"]`,新增 `core:webview:allow-create-webview-window` 权限;新窗口同样使用无边框装饰设置。 + +## 2. 性能实测(沙箱口径) + +`tests/perf.test.ts`(`PERF_BENCH=1` 时运行,默认跳过)用结构化文档(标题/段落/列表/表格/代码块混合)实测「建模 + 序列化」: + +| 目标大小 | 实际字符 | 建模(create) | 全量序列化 | +| --- | --- | --- | --- | +| 100 KB | 61,342 | 2.2 s | 0.46 s | +| 1 MB | 625,870 | 29.8 s | 2.6 s | + +口径说明(重要):这是 **jsdom + 沙箱共享 CPU(1.5 核)** 的数字——jsdom 的 DOM 操作比真实浏览器慢一个量级,属悲观上限,不是 WebView2 真机表现;可得的结论是成本随体积近似线性、序列化远快于建模。输入延迟在 jsdom 里无法测(无真实渲染管线),**大文档真机手感列入 Windows 验收清单**:重点验 100KB 文档输入跟手度与 1MB 文档打开等待;若真机 1MB 建模仍明显卡顿,阶段 7 前可再做「打开时占位 + 分片解析」优化,本阶段未预先做该改造(避免无真机数据拍脑袋)。 + +## 3. 验证 + +- 前端:`npx tsc -b` 0 错误;vitest **61 通过 + 2 跳过**(跳过为性能基准门控),新增阶段 6 共 8 例: + - 查找索引:跨文本节点命中映射、跨块命中跳过、大小写/整词/正则与非法正则报错;真实编辑器里查到 2 处、选中文本核对、替换后序列化文本正确; + - 快捷键注册表:默认绑定无冲突、覆盖/解绑/覆盖优先、冲突占用查询、事件归一化与展示格式; + - 专注模式:真实编辑器开启后非当前块 2 个段落带 `focus-dim`、关闭后消失; + - 其余 53 例(阶段 1–5)无回归。 +- Rust:`search.rs` 独立 crate 断言全过(上文);`commands.rs` 通过 rustfmt 语法解析;workspace_search 命令与 lib 注册完成。Tauri 完整构建仍受沙箱系统库限制,Rust 命令签名类型待 Windows 构建确认。 +- 生产构建通过;dev 冒烟 HTTP 200。 + +## 4. 已知边界(如实登记) + +- 查找不做「全部命中高亮」,只有当前命中以选区形式高亮;源码模式替换走 CodeMirror changes(单次 dispatch,可撤销)。 +- 全词匹配只对 ASCII 词边界有效(中文无词边界概念,与主流编辑器行为一致)。 +- 全文搜索为同步命令:超大工作区(数千文件)可能有短暂等待,已有上限保护;结果点击定位到「该文件的第一个命中」而非具体行(打开后查找条定位首个命中,随后可逐个跳转)。 +- 多窗口共用恢复记录:两个窗口同时退出时未保存草稿的恢复条目可能重复提示,以先处理为准(记录处理后即删除)。 +- 菜单快捷键标注为默认组合键,改绑后不动态更新菜单文案(设置中心显示生效值)。 + +## 5. 下一阶段(阶段 7:测试与发布准备) + +Win10/11 真机测试与验收清单总销账(阶段 0–6 累积清单)、安装包(nsis/msi)与便携版构建验证、自动更新通道设计与接入、版本号与发布说明、最终全量回归。 diff --git a/src/App.tsx b/src/App.tsx index 16fa857..2ee891a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { Suspense, lazy, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import type { Editor } from '@milkdown/kit/core' +import { editorViewCtx } from '@milkdown/kit/core' import TitleBar from './components/TitleBar' import MenuBar, { type MenuDef } from './components/MenuBar' import TabBar from './components/TabBar' @@ -7,6 +8,10 @@ import Sidebar from './components/Sidebar' import StatusBar from './components/StatusBar' import EditorPane from './components/EditorPane' import type { SourceEditorApi } from './components/SourceEditor' +import FindBar from './components/FindBar' +import type { EditorPaneApi } from './components/EditorPane' +import type { FindOptions } from './editor/search' +import { comboFromEvent, resolveShortcuts } from './utils/shortcuts' // 源码编辑器(CodeMirror)懒加载:不进首屏主包,仅切到源码模式时加载 const SourceEditor = lazy(() => import('./components/SourceEditor')) import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync' @@ -51,9 +56,9 @@ const UI_KEY = 'new-markdown:ui' function loadRecent(): string[] { try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] } } -interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string } +interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record } function loadUi(): UiState { - const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '' } + const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {} } try { return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') } } catch { return defaults } @@ -71,7 +76,7 @@ export default function App() { const tab = activeTab(state) const [workspaceRoot, setWorkspaceRoot] = useState(null) - const [sidebarMode, setSidebarMode] = useState<'files' | 'outline'>('outline') + const [sidebarMode, setSidebarMode] = useState<'files' | 'outline' | 'search'>('outline') const [recent, setRecent] = useState(loadRecent) const [cursor, setCursor] = useState({ line: 1, col: 1 }) const [sourceMode, setSourceMode] = useState(false) @@ -108,8 +113,52 @@ export default function App() { const jumpRef = useRef<(text: string) => void>(() => undefined) const anchorApiRef = useRef<{ getAnchor: () => CursorAnchor | null; setCursorByAnchor: (a: CursorAnchor) => void } | null>(null) const sourceApiRef = useRef(null) + const sourceModeRef = useRef(sourceMode) + sourceModeRef.current = sourceMode const pendingAnchorRef = useRef(null) const [sourceCursorOffset, setSourceCursorOffset] = useState(0) + const paneFindRef = useRef(null) + const [findUi, setFindUi] = useState<{ open: boolean; replace: boolean; query: string; replacement: string; opts: FindOptions; count: number; current: number; error: string | null }>({ + open: false, replace: false, query: '', replacement: '', + opts: { caseSensitive: false, wholeWord: false, regex: false }, count: 0, current: -1, error: null, + }) + const findUiRef = useRef(findUi) + findUiRef.current = findUi + const pendingFindRef = useRef(null) + + const activeFindApi = useCallback((): Pick | SourceEditorApi | null => { + return sourceModeRef.current ? sourceApiRef.current : paneFindRef.current + }, []) + + const runFind = useCallback((query: string, opts: FindOptions, goFirst = true) => { + const api = activeFindApi() + if (!api || !query) { + setFindUi((f) => ({ ...f, count: 0, current: -1, error: null })) + return + } + try { + const count = api.findAll(query, opts) + const current = count > 0 && goFirst ? api.gotoMatch(0) : -1 + setFindUi((f) => ({ ...f, count, current, error: null })) + } catch (e) { + setFindUi((f) => ({ ...f, count: 0, current: -1, error: e instanceof Error ? e.message : String(e) })) + } + }, [activeFindApi]) + + const openFind = useCallback((replace: boolean, preset?: string) => { + const selection = !sourceModeRef.current && editorRef.current + ? (() => { try { const st = editorRef.current?.action((ctx) => { const v = ctx.get(editorViewCtx); return v.state.doc.textBetween(v.state.selection.from, v.state.selection.to) }); return typeof st === 'string' ? st : '' } catch { return '' } })() + : '' + const query = preset ?? (selection && !selection.includes('\n') ? selection : findUiRef.current.query) + setFindUi((f) => ({ ...f, open: true, replace, query })) + window.setTimeout(() => runFind(query, findUiRef.current.opts), 30) + }, [runFind]) + + const closeFind = useCallback(() => { + activeFindApi()?.clearFind() + setFindUi((f) => ({ ...f, open: false, count: 0, current: -1, error: null })) + }, [activeFindApi]) + const tauriMode = isTauri() useEffect(() => { @@ -117,6 +166,27 @@ export default function App() { store.saveUiPref(JSON.stringify(ui)) }, [ui]) + // 工作区搜索点击结果:文件打开后自动打开查找条并定位 + useEffect(() => { + if (!tab || !pendingFindRef.current) return + const query = pendingFindRef.current + pendingFindRef.current = null + const timer = window.setTimeout(() => openFind(false, query), 350) + return () => window.clearTimeout(timer) + }, [tab?.id, openFind]) + + // 查找条开着时切换标签/模式:对新上下文重算命中 + const findContextKey = `${tab?.id ?? ''}:${sourceMode ? 'src' : 'doc'}` + const lastFindContextRef = useRef(findContextKey) + useEffect(() => { + if (lastFindContextRef.current === findContextKey) return + lastFindContextRef.current = findContextKey + if (findUiRef.current.open && findUiRef.current.query) { + const timer = window.setTimeout(() => runFind(findUiRef.current.query, findUiRef.current.opts), 60) + return () => window.clearTimeout(timer) + } + }, [findContextKey, runFind]) + const effectiveDocTheme = (tab ? docOverrides[tab.id] : undefined) ?? ui.docTheme useEffect(() => { document.documentElement.dataset.docTheme = effectiveDocTheme @@ -170,6 +240,28 @@ export default function App() { } }, [tauriMode, openPath]) + const openSearchResult = useCallback((path: string, query: string) => { + pendingFindRef.current = query + void openPath(path) + }, [openPath ]) + + const openInNewWindow = useCallback(async (path: string) => { + if (!isTauri()) return + try { + const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') + const label = `doc-${Date.now()}` + new WebviewWindow(label, { + url: `index.html?file=${encodeURIComponent(path)}`, + title: baseName(path), + width: 1280, + height: 820, + minWidth: 920, + minHeight: 600, + decorations: false, + }) + } catch (e) { setError(`打开新窗口失败:${String(e)}`) } + }, []) + const openFolder = useCallback(async () => { const folder = await pickFolder() if (folder) { @@ -281,6 +373,9 @@ export default function App() { const plan = planRecovery(records, (path) => disk.get(path) ?? null) for (const key of plan.purgeKeys) void store.deleteRecovery(key) if (!cancelled && plan.offer.length > 0) setRecoveryOffer(plan.offer) + // 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6) + const startupFile = new URLSearchParams(window.location.search).get('file') + if (!cancelled && startupFile) void openPath(startupFile) })() return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -498,32 +593,43 @@ export default function App() { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (isImeComposing(e)) return // IME 组合期间全局快捷键一律不触发(阶段 2 守卫) - const mod = e.ctrlKey || e.metaKey - if (!mod) return - const key = e.key.toLowerCase() - const inTextField = (e.target as HTMLElement)?.tagName === 'TEXTAREA' || (e.target as HTMLElement)?.tagName === 'INPUT' - if (key === 'n' && !inTextField) { e.preventDefault(); newDoc() } - else if (key === 'o' && !inTextField) { e.preventDefault(); void openFile() } - else if (key === 's' && tab) { e.preventDefault(); void saveTab(tab, e.shiftKey) } - else if (key === 'w' && tab) { e.preventDefault(); closeTab(tab.id) } - else if (key === 'l' && e.shiftKey) { e.preventDefault(); setUi((u) => ({ ...u, sidebar: !u.sidebar })) } - else if (key === 'k' && !e.shiftKey && !inTextField) { e.preventDefault(); setModal('link') } - else if (key === 'p' && !e.shiftKey) { e.preventDefault(); printRef.current() } - else if (key === 't' && !e.shiftKey && !inTextField) { e.preventDefault(); withEditor((ed) => editorCommands.insertTable(ed)) } - else if (key === 'i' && e.shiftKey) { e.preventDefault(); void insertImage() } - else if (key === 'q' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.blockquote) } - else if (key === 'k' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.codeBlock) } - else if ((key === '=' || key === '+')) { e.preventDefault(); setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2))) } - else if (key === '-') { e.preventDefault(); setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2))) } - else if (/^[0-6]$/.test(key) && !inTextField) { - e.preventDefault() - const level = Number(key) - withEditor((ed) => (level === 0 ? editorCommands.paragraph(ed) : editorCommands.heading(ed, level))) + if (e.key === 'Escape' && findUiRef.current.open) { e.preventDefault(); closeFind(); return } + if (!e.ctrlKey && !e.metaKey && !e.altKey) return + const combo = comboFromEvent(e) + if (!combo) return + const actionId = resolveShortcuts(ui.shortcuts).get(combo) + if (!actionId) return + const target = e.target as HTMLElement | null + const inTextField = target?.tagName === 'TEXTAREA' || target?.tagName === 'INPUT' + const run = (fn: () => void) => { e.preventDefault(); fn() } + switch (actionId) { + case 'new': if (!inTextField) run(newDoc); break + case 'open': if (!inTextField) run(() => void openFile()); break + case 'save': if (tab) run(() => void saveTab(tab, false)); break + case 'saveAs': if (tab) run(() => void saveTab(tab, true)); break + case 'closeTab': if (tab) run(() => closeTab(tab.id)); break + case 'sidebar': run(() => setUi((u) => ({ ...u, sidebar: !u.sidebar }))); break + case 'find': if (tab) run(() => openFind(false)); break + case 'replace': if (tab) run(() => openFind(true)); break + case 'link': if (!inTextField) run(() => setModal('link')); break + case 'print': run(() => printRef.current()); break + case 'table': if (!inTextField) run(() => withEditor((ed) => editorCommands.insertTable(ed))); break + case 'image': run(() => void insertImage()); break + case 'blockquote': run(() => withEditor(editorCommands.blockquote)); break + case 'codeBlock': run(() => withEditor(editorCommands.codeBlock)); break + case 'paragraph': if (!inTextField) run(() => withEditor(editorCommands.paragraph)); break + case 'zoomIn': run(() => setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2)))); break + case 'zoomOut': run(() => setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2)))); break + default: + if (/^heading[1-6]$/.test(actionId) && !inTextField) { + const level = Number(actionId.slice('heading'.length)) + run(() => withEditor((ed) => editorCommands.heading(ed, level))) + } } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) - }, [tab, newDoc, openFile, saveTab, closeTab, withEditor]) + }, [tab, newDoc, openFile, saveTab, closeTab, withEditor, ui.shortcuts, openFind, closeFind]) const pasteFromClipboard = useCallback(async () => { if (sourceMode) { @@ -701,6 +807,7 @@ export default function App() { { label: '保存', shortcut: 'Ctrl+S', disabled: !tab, onClick: () => tab && void saveTab(tab, false) }, { label: '另存为…', shortcut: 'Ctrl+Shift+S', disabled: !tab, onClick: () => tab && void saveTab(tab, true) }, { label: '关闭标签页', shortcut: 'Ctrl+W', disabled: !tab, onClick: () => tab && closeTab(tab.id) }, + { label: '在新窗口打开当前文档', disabled: !tab?.path || !tauriMode, onClick: () => tab?.path && void openInNewWindow(tab.path) }, { divider: true }, { label: '导入 HTML / Word…', onClick: () => void importFileFlow() }, { label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) }, @@ -717,6 +824,9 @@ export default function App() { { label: '复制', onClick: () => document.execCommand('copy') }, { label: '粘贴', onClick: () => { void pasteFromClipboard() } }, { label: '全选', onClick: () => document.execCommand('selectAll') }, + { divider: true }, + { label: '查找', shortcut: 'Ctrl+F', disabled: !tab, onClick: () => openFind(false) }, + { label: '替换', shortcut: 'Ctrl+H', disabled: !tab, onClick: () => openFind(true) }, ], }, { @@ -760,6 +870,8 @@ export default function App() { { label: '源代码模式', checked: sourceMode, disabled: !tab, onClick: toggleSourceMode }, { label: '深色主题', checked: ui.theme === 'dark', onClick: () => setUi((u) => ({ ...u, theme: u.theme === 'dark' ? 'light' : 'dark' })) }, { label: `自动保存(${ui.autosaveSeconds} 秒)`, checked: ui.autosave, onClick: () => setUi((u) => ({ ...u, autosave: !u.autosave })) }, + { label: '专注模式', checked: ui.focusMode, disabled: sourceMode, onClick: () => setUi((u) => ({ ...u, focusMode: !u.focusMode })) }, + { label: '打字机模式', checked: ui.typewriter, disabled: sourceMode, onClick: () => setUi((u) => ({ ...u, typewriter: !u.typewriter })) }, { divider: true }, { label: '放大', shortcut: 'Ctrl+=', onClick: () => setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2))) }, { label: '缩小', shortcut: 'Ctrl+-', onClick: () => setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2))) }, @@ -780,11 +892,11 @@ export default function App() { { label: '帮助', items: [ - { label: '快捷键说明', onClick: () => setModal('shortcuts') }, + { label: '快捷键设置…', onClick: () => setModal('shortcuts') }, { label: '关于 new-markdown', onClick: () => setModal('about') }, ], }, - ], [tab, recent, sourceMode, ui, sidebarMode, tauriMode, newDoc, openFile, openFolder, openPath, saveTab, closeTab, withEditor, toggleSourceMode, insertImage, insertAttachment, pasteFromClipboard, importFileFlow, printDoc]) + ], [tab, recent, sourceMode, ui, sidebarMode, tauriMode, newDoc, openFile, openFolder, openPath, saveTab, closeTab, withEditor, toggleSourceMode, insertImage, insertAttachment, pasteFromClipboard, importFileFlow, printDoc, openFind, openInNewWindow]) const pendingTitles = pendingClose ? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、') @@ -823,6 +935,7 @@ export default function App() { refreshSignal={treeRefresh} outline={outline} onJumpHeading={jumpHeading} + onOpenSearchResult={openSearchResult} tauriMode={tauriMode} /> @@ -856,8 +969,11 @@ export default function App() { initialMarkdown={tab.content} onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })} onCursor={setCursor} + focusMode={ui.focusMode} + typewriter={ui.typewriter} onImageFile={handleImageFile} onReady={(api) => { + paneFindRef.current = api.editor ? api : null editorRef.current = api.editor jumpRef.current = api.jumpToHeading anchorApiRef.current = api.editor ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null @@ -868,6 +984,38 @@ export default function App() { }} /> )} + {findUi.open && tab && ( + { setFindUi((f) => ({ ...f, query: value })); runFind(value, findUiRef.current.opts) }} + onReplacementChange={(value) => setFindUi((f) => ({ ...f, replacement: value }))} + onToggleReplace={() => setFindUi((f) => ({ ...f, replace: !f.replace }))} + onOptsChange={(opts) => { setFindUi((f) => ({ ...f, opts })); runFind(findUiRef.current.query, opts) }} + onNext={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current + 1); setFindUi((f) => ({ ...f, current: cur })) } }} + onPrev={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current - 1); setFindUi((f) => ({ ...f, current: cur })) } }} + onReplace={() => { + const api = activeFindApi() + if (!api || !api.replaceCurrent(findUiRef.current.replacement)) return + const count = api.findAll(findUiRef.current.query, findUiRef.current.opts) + const current = count > 0 ? api.gotoMatch(Math.min(findUiRef.current.current, count - 1)) : -1 + setFindUi((f) => ({ ...f, count, current })) + }} + onReplaceAll={() => { + const api = activeFindApi() + if (!api) return + const replaced = api.replaceAll(findUiRef.current.replacement) + if (replaced > 0) flash(`已替换 ${replaced} 处`) + setFindUi((f) => ({ ...f, count: 0, current: -1 })) + }} + onClose={closeFind} + /> + )} )} {modal === 'about' && setModal(null)} />} - {modal === 'shortcuts' && setModal(null)} />} + {modal === 'shortcuts' && setUi((u) => ({ ...u, shortcuts }))} onClose={() => setModal(null)} />} {modal === 'link' && ( setModal(null)} diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index 08db6e2..76e4cc4 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -5,12 +5,19 @@ import { editorViewCtx } from '@milkdown/kit/core' import { TextSelection } from '@milkdown/kit/prose/state' import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/editor' import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync' +import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search' +import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins' export interface EditorPaneApi { editor: Editor | null jumpToHeading: (text: string) => void getAnchor: () => CursorAnchor | null setCursorByAnchor: (anchor: CursorAnchor) => void + findAll: (query: string, opts: FindOptions) => number + gotoMatch: (index: number) => number + replaceCurrent: (replacement: string) => boolean + replaceAll: (replacement: string) => number + clearFind: () => void } interface Props { @@ -20,6 +27,8 @@ interface Props { onCursor: (cursor: CursorInfo) => void onReady: (api: EditorPaneApi) => void onImageFile?: (file: File) => Promise + focusMode: boolean + typewriter: boolean } const EMPTY_API: EditorPaneApi = { @@ -27,11 +36,21 @@ const EMPTY_API: EditorPaneApi = { jumpToHeading: () => undefined, getAnchor: () => null, setCursorByAnchor: () => undefined, + findAll: () => 0, + gotoMatch: () => -1, + replaceCurrent: () => false, + replaceAll: () => 0, + clearFind: () => undefined, } /** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */ -export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCursor, onReady, onImageFile }: Props) { +export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCursor, onReady, onImageFile, 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, + }) + const modesRef = useRef({ focusMode, typewriter }) + modesRef.current = { focusMode, typewriter } const editorObjRef = useRef(null) const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null) @@ -51,9 +70,31 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso useEffect(() => { if (!hostRef.current) return + const centerCursorLine = () => { + // 打字机模式:把光标行滚到编辑区垂直中央附近 + window.requestAnimationFrame(() => { + const container = wrapRef.current?.closest('.editor-scroll') as HTMLElement | null + if (!container) return + try { + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + const coords = view.coordsAtPos(view.state.selection.from) + const rect = container.getBoundingClientRect() + const lineCenter = (coords.top + coords.bottom) / 2 - rect.top + container.scrollTop + const target = lineCenter - container.clientHeight / 2 + if (Math.abs(target - container.scrollTop) > 6) { + container.scrollTo({ top: target, behavior: 'smooth' }) + } + }) + } catch { /* 视图未就绪时跳过 */ } + }) + } const editor = createMarkdownEditor(hostRef.current, initialRef.current, { onMarkdown: (md) => callbacksRef.current.onMarkdown(tabId, md), - onCursor: (c) => callbacksRef.current.onCursor(c), + onCursor: (c) => { + callbacksRef.current.onCursor(c) + if (modesRef.current.typewriter) centerCursorLine() + }, onTableContext: (inTable, el) => { tableElRef.current = el; if (inTable) updateTableBar(); else setTableBar(null) }, }, { onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)) }) editorObjRef.current = editor @@ -94,8 +135,72 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso }) } catch { /* 定位失败静默:不打断模式切换 */ } } + const findAll = (query: string, opts: FindOptions): number => { + try { + const matches = editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + return findInIndex(buildTextIndex(view.state.doc), query, opts) + }) + findRef.current = { matches, current: matches.length ? 0 : -1, query, opts } + return matches.length + } catch (e) { + throw e instanceof Error ? e : new Error(String(e)) + } + } + const gotoMatch = (index: number): number => { + const found = findRef.current + if (found.matches.length === 0) return -1 + const wrapped = ((index % found.matches.length) + found.matches.length) % found.matches.length + found.current = wrapped + const m = found.matches[wrapped] + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, m.from, m.to)).scrollIntoView()) + view.focus() + }) + return wrapped + } + const replaceCurrent = (replacement: string): boolean => { + const found = findRef.current + if (found.current < 0 || !found.opts) return false + const m = found.matches[found.current] + if (!m) return false + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + view.dispatch(view.state.tr.insertText(replacement, m.from, m.to)) + view.focus() + }) + const previous = found.current + const count = findAll(found.query, found.opts) + if (count > 0) gotoMatch(Math.min(previous, count - 1)) + return true + } + const replaceAll = (replacement: string): number => { + const found = findRef.current + if (found.matches.length === 0) return 0 + const total = found.matches.length + const reversed = [...found.matches].reverse() + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + const tr = view.state.tr + for (const m of reversed) tr.insertText(replacement, m.from, m.to) + view.dispatch(tr) + view.focus() + }) + findRef.current = { matches: [], current: -1, query: found.query, opts: found.opts } + return total + } + const clearFind = () => { + findRef.current = { matches: [], current: -1, query: '', opts: null } + } editor.create() - .then(() => callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor })) + .then(() => { + setFocusModeEnabled(modesRef.current.focusMode) + try { + editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) + } catch { /* 忽略 */ } + callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind }) + }) .catch((e) => console.error('编辑器创建失败', e)) const scrollParent = wrapRef.current?.closest('.editor-scroll') const onScroll = () => { if (editorObjRef.current) updateTableBar() } @@ -112,6 +217,16 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso } }, [tabId, updateTableBar]) + // 专注模式切换时立即刷新当前编辑器装饰 + useEffect(() => { + setFocusModeEnabled(focusMode) + const editor = editorObjRef.current + if (!editor) return + try { + editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) + } catch { /* 编辑器未就绪时跳过,创建完成时会再应用 */ } + }, [focusMode]) + return (
{tableBar && editorObjRef.current && ( diff --git a/src/components/FindBar.tsx b/src/components/FindBar.tsx new file mode 100644 index 0000000..0e4af85 --- /dev/null +++ b/src/components/FindBar.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef } from 'react' +import type { FindOptions } from '../editor/search' + +interface Props { + query: string + replacement: string + showReplace: boolean + opts: FindOptions + count: number + current: number // -1 表示无当前命中 + error: string | null + onQueryChange: (value: string) => void + onReplacementChange: (value: string) => void + onToggleReplace: () => void + onOptsChange: (opts: FindOptions) => void + onNext: () => void + onPrev: () => void + onReplace: () => void + onReplaceAll: () => void + onClose: () => void +} + +/** 查找/替换条(阶段 6):渲染与源码双模式共用,命中/替换由外部 API 执行。 */ +export default function FindBar(p: Props) { + const queryRef = useRef(null) + useEffect(() => { + queryRef.current?.focus() + queryRef.current?.select() + }, []) + + const toggle = (key: keyof FindOptions, label: string, title: string) => ( + + ) + + return ( +
+
+ + p.onQueryChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? p.onPrev() : p.onNext() } + if (e.key === 'Escape') { e.preventDefault(); p.onClose() } + }} + /> + {toggle('caseSensitive', 'Aa', '区分大小写')} + {toggle('wholeWord', '整词', '全词匹配')} + {toggle('regex', '.*', '正则表达式')} + + {p.error ? p.error : p.query ? (p.count === 0 ? '无结果' : `${p.current + 1} / ${p.count}`) : ''} + + + + +
+ {p.showReplace && ( +
+ + p.onReplacementChange(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); p.onClose() } }} + /> + + +
+ )} +
+ ) +} diff --git a/src/components/Modals.tsx b/src/components/Modals.tsx index 41dd1b3..cd35711 100644 --- a/src/components/Modals.tsx +++ b/src/components/Modals.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { SHORTCUT_DEFS, BUILTIN_SHORTCUTS, comboFromEvent, comboOwner, effectiveCombos, prettyCombo } from '../utils/shortcuts' function Shell({ children, onCancel }: { children: React.ReactNode; onCancel?: () => void }) { return ( @@ -38,27 +39,76 @@ export function AboutModal({ onClose }: { onClose: () => void }) { ) } -const SHORTCUTS: [string, string][] = [ - ['新建文档', 'Ctrl+N'], ['打开文件', 'Ctrl+O'], ['保存', 'Ctrl+S'], ['另存为', 'Ctrl+Shift+S'], - ['关闭标签页', 'Ctrl+W'], ['撤销 / 重做', 'Ctrl+Z / Ctrl+Y'], - ['加粗 / 斜体', 'Ctrl+B / Ctrl+I'], ['插入链接', 'Ctrl+K'], - ['段落 / 一级标题', 'Ctrl+0 / Ctrl+1'], ['二级 ~ 六级标题', 'Ctrl+2 ~ Ctrl+6'], - ['引用', 'Ctrl+Shift+Q'], ['代码块', 'Ctrl+Shift+K'], - ['显示/隐藏侧边栏', 'Ctrl+Shift+L'], ['放大 / 缩小', 'Ctrl+= / Ctrl+-'], -] +/** 快捷键设置中心(阶段 6):应用级快捷键可改绑/解绑/恢复默认,改绑时检测冲突。 */ +export function ShortcutsModal({ overrides, onChange, onClose }: { + overrides: Record + onChange: (overrides: Record) => void + onClose: () => void +}) { + const [recordingId, setRecordingId] = useState(null) + const [conflict, setConflict] = useState<{ id: string; combo: string; ownerLabel: string; ownerId: string } | null>(null) + const combos = effectiveCombos(overrides) + + useEffect(() => { + if (!recordingId) return + const onKey = (e: KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.key === 'Escape') { setRecordingId(null); return } + const combo = comboFromEvent(e) + if (!combo) return + const owner = comboOwner(combo, overrides, recordingId) + setRecordingId(null) + if (owner) { + setConflict({ id: recordingId, combo, ownerLabel: owner.label, ownerId: owner.id }) + } else { + onChange({ ...overrides, [recordingId]: combo }) + } + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [recordingId, overrides, onChange]) + + const applyConflict = () => { + if (!conflict) return + onChange({ ...overrides, [conflict.id]: conflict.combo, [conflict.ownerId]: '' }) + setConflict(null) + } -export function ShortcutsModal({ onClose }: { onClose: () => void }) { return ( -

快捷键

+

快捷键设置

+
+ {SHORTCUT_DEFS.map((def) => { + const combo = combos.get(def.id) ?? '' + const customized = overrides[def.id] !== undefined + return ( +
+ {def.label} + {recordingId === def.id ? '请按键…' : combo ? prettyCombo(combo) : '(未绑定)'} + + + +
+ ) + })} +
+ {conflict && ( +

+ {prettyCombo(conflict.combo)} 已被「{conflict.ownerLabel}」占用。 + + +

+ )} +

编辑器内置(不可改绑)

- {SHORTCUTS.map(([name, key]) => ( + {BUILTIN_SHORTCUTS.map(([name, key]) => ( ))}
{name}{key}
-

需求表里 Ctrl+0 同时挂了「段落」与「恢复默认缩放」,本实现取「段落」(与 Typora 一致),恢复缩放走视图菜单,避免同键双义。阶段 6 设置中心提供快捷键自定义与冲突检测。

+

Ctrl+0 取「段落」(与 Typora 一致),恢复默认缩放走视图菜单。设置即时生效并随偏好保存。

) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7f91c37..226a365 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useState } from 'react' -import { listDir, type DirEntry } from '../services/platform' +import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform' import type { OutlineItem } from '../utils/text' import { baseName } from '../utils/text' interface Props { - mode: 'files' | 'outline' - onModeChange: (mode: 'files' | 'outline') => void + mode: 'files' | 'outline' | 'search' + onModeChange: (mode: 'files' | 'outline' | 'search') => void + onOpenSearchResult?: (path: string, query: string) => void workspaceRoot: string | null activePath: string | null onOpenFile: (path: string) => void @@ -25,8 +26,12 @@ export default function Sidebar(p: Props) {
+
- {p.mode === 'outline' ? ( + {p.mode === 'search' ? ( + + ) : null} + {p.mode === 'search' ? null : p.mode === 'outline' ? (
{p.outline.length === 0 &&
当前文档暂无标题
} {p.outline.map((o, i) => ( @@ -162,3 +167,74 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
) } + +function WorkspaceSearch({ root, tauriMode, onOpen }: { + root: string | null + tauriMode: boolean + onOpen?: (path: string, query: string) => void +}) { + const [query, setQuery] = useState('') + const [caseSensitive, setCaseSensitive] = useState(false) + const [results, setResults] = useState(null) + const [searching, setSearching] = useState(false) + const [error, setError] = useState(null) + + const run = async () => { + if (!root || !query.trim()) return + setSearching(true) + setError(null) + try { + setResults(await workspaceSearch(root, query.trim(), caseSensitive)) + } catch (e) { + setError(String(e)) + } finally { + setSearching(false) + } + } + + if (!tauriMode) return
Web 降级模式不支持工作区搜索。
+ if (!root) return
先打开一个文件夹,再搜索其中的文档。
+ const total = results?.reduce((acc, f) => acc + f.matches.length, 0) ?? 0 + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') void run() }} + /> + +
+ + {error &&
{error}
} + {results && ( +
{results.length === 0 ? '没有找到匹配。' : `${results.length} 个文件 · ${total} 处匹配`}
+ )} +
+ {results?.map((file) => ( +
+
{file.name}
+ {file.matches.map((m, i) => ( + + ))} +
+ ))} +
+
+ ) +} diff --git a/src/components/SourceEditor.tsx b/src/components/SourceEditor.tsx index 8281d38..eaa680e 100644 --- a/src/components/SourceEditor.tsx +++ b/src/components/SourceEditor.tsx @@ -7,11 +7,52 @@ import { languages } from '@codemirror/language-data' import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language' import { oneDark } from '@codemirror/theme-one-dark' import { anchorFromMarkdown, type CursorAnchor } from '../editor/cursorSync' +import type { FindOptions } from '../editor/search' export interface SourceEditorApi { jumpToLine: (line: number) => void getAnchor: () => CursorAnchor | null focus: () => void + findAll: (query: string, opts: FindOptions) => number + gotoMatch: (index: number) => number + replaceCurrent: (replacement: string) => boolean + replaceAll: (replacement: string) => number + clearFind: () => void +} + +/** 在纯文本上复用同一套查找语义(明文/整词/正则),返回偏移命中。 */ +function findInText(text: string, query: string, opts: FindOptions): { from: number; to: number }[] { + if (!query) return [] + const hits: { from: number; to: number }[] = [] + if (opts.regex) { + let re: RegExp + try { + re = new RegExp(query, opts.caseSensitive ? 'g' : 'gi') + } catch (e) { + throw new Error(`正则表达式无效:${e instanceof Error ? e.message : String(e)}`) + } + let m: RegExpExecArray | null + while ((m = re.exec(text)) !== null) { + if (m[0].length === 0) { re.lastIndex += 1; continue } + hits.push({ from: m.index, to: m.index + m[0].length }) + if (hits.length >= 5000) break + } + return hits + } + const haystack = opts.caseSensitive ? text : text.toLowerCase() + const needle = opts.caseSensitive ? query : query.toLowerCase() + let from = 0 + while (from <= haystack.length - needle.length) { + const hit = haystack.indexOf(needle, from) + if (hit < 0) break + const end = hit + needle.length + const wholeOk = !opts.wholeWord + || (!/[A-Za-z0-9_]/.test(text[hit - 1] ?? '') && !/[A-Za-z0-9_]/.test(text[end] ?? '')) + if (wholeOk) hits.push({ from: hit, to: end }) + from = hit + Math.max(1, needle.length) + if (hits.length >= 5000) break + } + return hits } interface Props { @@ -64,6 +105,29 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset, }) viewRef.current = view view.focus() + let matches: { from: number; to: number }[] = [] + let current = -1 + let lastQuery = '' + let lastOpts: FindOptions | null = null + const findAll = (query: string, opts: FindOptions): number => { + matches = findInText(view.state.doc.toString(), query, opts) + current = matches.length ? 0 : -1 + lastQuery = query + lastOpts = opts + return matches.length + } + const gotoMatch = (index: number): number => { + if (matches.length === 0) return -1 + const wrapped = ((index % matches.length) + matches.length) % matches.length + current = wrapped + const m = matches[wrapped] + view.dispatch({ + selection: { anchor: m.from, head: m.to }, + effects: EditorView.scrollIntoView(m.from, { y: 'center' }), + }) + view.focus() + return wrapped + } callbacksRef.current.onReady({ jumpToLine: (line) => { const target = Math.max(1, Math.min(line + 1, view.state.doc.lines)) @@ -73,6 +137,30 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset, }, getAnchor: () => anchorFromMarkdown(view.state.doc.toString(), view.state.selection.main.head), focus: () => view.focus(), + findAll, + gotoMatch, + replaceCurrent: (replacement) => { + if (current < 0 || !lastOpts) return false + const m = matches[current] + if (!m) return false + view.dispatch({ changes: { from: m.from, to: m.to, insert: replacement } }) + view.focus() + const previous = current + const count = findAll(lastQuery, lastOpts) + if (count > 0) gotoMatch(Math.min(previous, count - 1)) + return true + }, + replaceAll: (replacement) => { + if (matches.length === 0) return 0 + const total = matches.length + const changes = [...matches].reverse().map((m) => ({ from: m.from, to: m.to, insert: replacement })) + view.dispatch({ changes }) + view.focus() + matches = [] + current = -1 + return total + }, + clearFind: () => { matches = []; current = -1 }, }) return () => { // 卸载前回写最终文本,避免切换瞬间丢失最后一次输入 diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 6038026..db756b2 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -12,7 +12,7 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard' import { indent } from '@milkdown/kit/plugin/indent' import { math } from '@milkdown/plugin-math' import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload' -import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, selectionInTable } from './plugins' +import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, selectionInTable } from './plugins' import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews' export interface CursorInfo { line: number; col: number } @@ -81,6 +81,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string, .use(createSyntaxRevealPlugin()) .use(createCodeBlockTabPlugin()) .use(createTableTabPlugin()) + .use(createFocusDimPlugin()) .use(createListItemViewPlugin()) .use(createCodeBlockViewPlugin()) .use(math) diff --git a/src/editor/plugins.ts b/src/editor/plugins.ts index 755911b..eacc230 100644 --- a/src/editor/plugins.ts +++ b/src/editor/plugins.ts @@ -166,6 +166,58 @@ export function applyCodeBlockIndent(view: MinimalView, outdent: boolean): boole return true } +// ————————————————— 专注模式(阶段 6):非当前顶层块变淡 ————————————————— + +let focusModeEnabled = false +export function setFocusModeEnabled(value: boolean) { + focusModeEnabled = value +} +export function isFocusModeEnabled() { + return focusModeEnabled +} + +const focusKey = new PluginKey('new-markdown-focus') + +export function createFocusDimPlugin() { + return $prose(() => new Plugin({ + key: focusKey, + state: { + init: () => DecorationSet.empty, + apply: (tr, old, _oldState, newState) => { + if (!focusModeEnabled) return DecorationSet.empty + if (!tr.selectionSet && !tr.docChanged && !tr.getMeta(focusKey)) return old + const { doc, selection } = newState + const anchor = selection.$from + // 找选区所在的顶层块范围 + let activeFrom = -1 + let activeTo = -1 + if (anchor.depth >= 1) { + activeFrom = anchor.before(1) + activeTo = anchor.after(1) + } + const decorations: Decoration[] = [] + doc.forEach((node, offset) => { + const from = offset + const to = offset + node.nodeSize + if (from !== activeFrom || to !== activeTo) { + decorations.push(Decoration.node(from, to, { class: 'focus-dim' })) + } + }) + return DecorationSet.create(doc, decorations) + }, + }, + props: { + decorations: (state) => focusKey.getState(state) as DecorationSet, + }, + })) +} + +/** 触发专注装饰立即重算(开关切换时调用)。 */ +export function refreshFocusDecorations(view: { state: never; dispatch: (tr: never) => void }) { + const v = view as unknown as { state: { tr: { setMeta: (k: unknown, val: unknown) => unknown } }; dispatch: (tr: unknown) => void } + v.dispatch(v.state.tr.setMeta(focusKey, true)) +} + // ————————————————— 表格 Tab 导航(阶段 3) ————————————————— interface AncestorLike { diff --git a/src/editor/search.ts b/src/editor/search.ts new file mode 100644 index 0000000..f121008 --- /dev/null +++ b/src/editor/search.ts @@ -0,0 +1,102 @@ +// 文档内查找(阶段 6):把 ProseMirror 文档拍平成「字符 → 文档位置」索引, +// 在索引上做纯字符串/正则匹配,再把命中映射回文档位置(含跨文本节点、避开跨块命中)。 +// 纯逻辑与索引构建分离,便于单测;编辑器侧的选区/替换在 EditorPane 里走事务。 + +export interface FindOptions { + caseSensitive: boolean + wholeWord: boolean + regex: boolean +} + +export interface TextIndex { + text: string + /** 与 text 等长:每字符的文档位置;块间分隔符为 -1 */ + positions: number[] +} + +interface PmTextNode { + isText: boolean + text?: string +} +interface PmDocLike { + descendants: (fn: (node: PmTextNode, pos: number) => void | boolean) => void +} + +export function buildTextIndex(doc: PmDocLike): TextIndex { + const chars: string[] = [] + const positions: number[] = [] + let lastEnd = -1 + doc.descendants((node, pos) => { + if (!node.isText || !node.text) return + if (lastEnd >= 0 && pos > lastEnd) { + // 与上一文本段之间隔着块边界:放一个分隔符,跨块命中会被过滤掉 + chars.push('\n') + positions.push(-1) + } + for (let i = 0; i < node.text.length; i++) { + chars.push(node.text[i]) + positions.push(pos + i) + } + lastEnd = pos + node.text.length + }) + return { text: chars.join(''), positions } +} + +export interface MatchRange { + /** 索引内起止(映射前) */ + start: number + end: number + /** 文档位置 */ + from: number + to: number +} + +const WORD_CHAR = /[A-Za-z0-9_]/ + +function isWholeWordAt(text: string, start: number, end: number): boolean { + const before = start > 0 ? text[start - 1] : '' + const after = end < text.length ? text[end] : '' + return !WORD_CHAR.test(before) && !WORD_CHAR.test(after) +} + +export function findInIndex(index: TextIndex, query: string, opts: FindOptions): MatchRange[] { + if (!query) return [] + const ranges: { start: number; end: number }[] = [] + if (opts.regex) { + let re: RegExp + try { + re = new RegExp(query, opts.caseSensitive ? 'g' : 'gi') + } catch (e) { + throw new Error(`正则表达式无效:${e instanceof Error ? e.message : String(e)}`) + } + let m: RegExpExecArray | null + while ((m = re.exec(index.text)) !== null) { + if (m[0].length === 0) { + re.lastIndex += 1 + continue + } + ranges.push({ start: m.index, end: m.index + m[0].length }) + if (ranges.length >= 5000) break + } + } else { + const haystack = opts.caseSensitive ? index.text : index.text.toLowerCase() + const needle = opts.caseSensitive ? query : query.toLowerCase() + let from = 0 + while (from <= haystack.length - needle.length) { + const hit = haystack.indexOf(needle, from) + if (hit < 0) break + ranges.push({ start: hit, end: hit + needle.length }) + from = hit + Math.max(1, needle.length) + if (ranges.length >= 5000) break + } + } + const out: MatchRange[] = [] + for (const r of ranges) { + if (opts.wholeWord && !opts.regex && !isWholeWordAt(index.text, r.start, r.end)) continue + const fromPos = index.positions[r.start] + const toPos = index.positions[r.end - 1] + if (fromPos < 0 || toPos < 0) continue // 跨块命中,跳过 + out.push({ ...r, from: fromPos, to: toPos + 1 }) + } + return out +} diff --git a/src/services/platform.ts b/src/services/platform.ts index 88019c7..854a9ad 100644 --- a/src/services/platform.ts +++ b/src/services/platform.ts @@ -274,3 +274,20 @@ export function pickAndReadWebImport(): Promise<{ name: string; text?: string; b input.click() }) } + +// ————————————————— 阶段 6:工作区全文搜索 ————————————————— + +export interface WorkspaceSearchMatch { + line: number + text: string +} + +export interface WorkspaceSearchFile { + path: string + name: string + matches: WorkspaceSearchMatch[] +} + +export async function workspaceSearch(root: string, query: string, caseSensitive: boolean): Promise { + return invoke('workspace_search', { root, query, caseSensitive }) +} diff --git a/src/styles.css b/src/styles.css index 15345f1..0ca4358 100644 --- a/src/styles.css +++ b/src/styles.css @@ -266,3 +266,36 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius .seconds-input { width: 72px; margin: 0 6px; } .modal-actions.left { align-items: stretch; } .modal-actions.left button { text-align: left; } +.ws-search { display: flex; flex-direction: column; gap: 8px; padding: 10px; min-height: 0; } +.ws-search-box { display: flex; gap: 6px; } +.ws-search-box .input { flex: 1; min-width: 0; } +.ws-search-case { font-size: 12px; color: var(--text-dim); display: flex; gap: 5px; align-items: center; } +.ws-search-summary { font-size: 12px; color: var(--text-dim); } +.ws-search-results { overflow-y: auto; display: flex; flex-direction: column; gap: 10px; } +.ws-search-name { font-weight: 600; font-size: 12.5px; margin-bottom: 3px; } +.ws-search-hit { display: flex; gap: 7px; width: 100%; text-align: left; border: 0; background: transparent; padding: 3px 6px; border-radius: 6px; cursor: pointer; font-size: 12.5px; align-items: baseline; } +.ws-search-hit:hover { background: var(--accent-weak); } +.ws-search-line { color: var(--text-dim); flex: none; min-width: 26px; text-align: right; } +.ws-search-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.findbar { position: absolute; top: 8px; right: 14px; z-index: 40; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14); padding: 6px 8px; display: flex; flex-direction: column; gap: 6px; min-width: 380px; } +.findbar-row { display: flex; align-items: center; gap: 5px; } +.find-input { flex: 1; min-width: 130px; height: 28px; } +.find-toggle { border: 1px solid transparent; background: transparent; border-radius: 6px; padding: 3px 7px; font-size: 12px; cursor: pointer; color: var(--text-dim); } +.find-toggle.on { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); } +.find-icon { border: 0; background: transparent; border-radius: 6px; padding: 3px 7px; cursor: pointer; color: var(--text-dim); } +.find-icon:hover { background: var(--accent-weak); } +.find-count { font-size: 12px; color: var(--text-dim); min-width: 74px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.find-action { border: 1px solid var(--border); background: var(--panel); border-radius: 6px; padding: 4px 10px; font-size: 12.5px; cursor: pointer; } +.find-action:hover { background: var(--accent-weak); } +.find-spacer { width: 26px; flex: none; } +.focus-dim { opacity: 0.32; transition: opacity 0.25s ease; } +.shortcut-list { max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; } +.shortcut-row { display: flex; align-items: center; gap: 7px; padding: 4px 2px; border-bottom: 1px solid var(--border); font-size: 13px; } +.shortcut-row .shortcut-name { flex: 1; } +.shortcut-row kbd { min-width: 118px; text-align: center; } +.shortcut-row button { border: 1px solid var(--border); background: var(--panel); border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer; } +.shortcut-row button:disabled { opacity: 0.4; cursor: default; } +.shortcut-subtitle { margin: 12px 0 4px; font-size: 13px; } +.conflict-note { display: flex; align-items: center; gap: 8px; } +.findbar { position: absolute; } +.editor-scroll { position: relative; } diff --git a/src/utils/shortcuts.ts b/src/utils/shortcuts.ts new file mode 100644 index 0000000..8fb49b7 --- /dev/null +++ b/src/utils/shortcuts.ts @@ -0,0 +1,113 @@ +// 快捷键注册表(阶段 6):应用级快捷键集中定义,支持用户覆盖与冲突处理。 +// 组合键统一格式:ctrl/alt/shift 以 '+' 连接 + 小写主键,如 'ctrl+shift+s'。 + +export interface ShortcutDef { + id: string + label: string + defaultCombo: string +} + +export const SHORTCUT_DEFS: ShortcutDef[] = [ + { id: 'new', label: '新建文档', defaultCombo: 'ctrl+n' }, + { id: 'open', label: '打开文件', defaultCombo: 'ctrl+o' }, + { id: 'save', label: '保存', defaultCombo: 'ctrl+s' }, + { id: 'saveAs', label: '另存为', defaultCombo: 'ctrl+shift+s' }, + { id: 'closeTab', label: '关闭标签页', defaultCombo: 'ctrl+w' }, + { id: 'find', label: '查找', defaultCombo: 'ctrl+f' }, + { id: 'replace', label: '替换', defaultCombo: 'ctrl+h' }, + { id: 'sidebar', label: '显示/隐藏侧边栏', defaultCombo: 'ctrl+shift+l' }, + { id: 'link', label: '插入链接', defaultCombo: 'ctrl+k' }, + { id: 'image', label: '插入图片', defaultCombo: 'ctrl+shift+i' }, + { id: 'table', label: '插入表格', defaultCombo: 'ctrl+t' }, + { id: 'blockquote', label: '引用', defaultCombo: 'ctrl+shift+q' }, + { id: 'codeBlock', label: '代码块', defaultCombo: 'ctrl+shift+k' }, + { id: 'paragraph', label: '段落', defaultCombo: 'ctrl+0' }, + ...[1, 2, 3, 4, 5, 6].map((lv) => ({ + id: `heading${lv}`, + label: `${'一二三四五六'[lv - 1]}级标题`, + defaultCombo: `ctrl+${lv}`, + })), + { id: 'print', label: '打印 / 导出 PDF', defaultCombo: 'ctrl+p' }, + { id: 'zoomIn', label: '放大', defaultCombo: 'ctrl+=' }, + { id: 'zoomOut', label: '缩小', defaultCombo: 'ctrl+-' }, +] + +/** 编辑器内置快捷键(Milkdown/ProseMirror 提供,本中心只展示、不可改绑)。 */ +export const BUILTIN_SHORTCUTS: [string, string][] = [ + ['加粗', 'Ctrl+B'], + ['斜体', 'Ctrl+I'], + ['撤销 / 重做', 'Ctrl+Z / Ctrl+Y'], + ['表格内跳转单元格', 'Tab / Shift+Tab'], + ['代码块缩进', 'Tab / Shift+Tab'], + ['源码模式切换', '菜单/工具栏按钮'], +] + +export function comboFromEvent(e: { ctrlKey: boolean; metaKey: boolean; altKey: boolean; shiftKey: boolean; key: string }): string | null { + const key = e.key + if (['Control', 'Meta', 'Alt', 'Shift'].includes(key)) return null + let main = key.length === 1 ? key.toLowerCase() : key.toLowerCase() + if (main === ' ') main = 'space' + if (main === '+') main = '=' + const parts: string[] = [] + if (e.ctrlKey || e.metaKey) parts.push('ctrl') + if (e.altKey) parts.push('alt') + if (e.shiftKey) parts.push('shift') + parts.push(main) + return parts.join('+') +} + +/** 计算生效绑定:动作 id -> 组合键(覆盖值 '' 表示解绑)。 */ +export function effectiveCombos(overrides: Record): Map { + const map = new Map() + for (const def of SHORTCUT_DEFS) { + const override = overrides[def.id] + const combo = override === undefined ? def.defaultCombo : override + if (combo) map.set(def.id, combo) + } + return map +} + +/** 组合键 -> 动作 id;冲突时后出现的覆盖(用户覆盖优先于默认)。 */ +export function resolveShortcuts(overrides: Record): Map { + const byId = effectiveCombos(overrides) + const byCombo = new Map() + // 先放默认值,再放用户覆盖,保证覆盖胜出 + for (const def of SHORTCUT_DEFS) { + if (overrides[def.id] === undefined) { + const combo = byId.get(def.id) + if (combo) byCombo.set(combo, def.id) + } + } + for (const def of SHORTCUT_DEFS) { + if (overrides[def.id] !== undefined) { + const combo = byId.get(def.id) + if (combo) byCombo.set(combo, def.id) + } + } + return byCombo +} + +/** 组合键的展示形式:'ctrl+shift+s' -> 'Ctrl+Shift+S'。 */ +export function prettyCombo(combo: string): string { + if (!combo) return '(未绑定)' + return combo + .split('+') + .map((part) => { + if (part === 'ctrl') return 'Ctrl' + if (part === 'alt') return 'Alt' + if (part === 'shift') return 'Shift' + if (part === 'space') return 'Space' + return part.length === 1 ? part.toUpperCase() : part + }) + .join('+') +} + +/** 查组合键当前被哪个动作占用(排除指定动作自身)。 */ +export function comboOwner(combo: string, overrides: Record, excludeId?: string): ShortcutDef | null { + const byId = effectiveCombos(overrides) + for (const def of SHORTCUT_DEFS) { + if (def.id === excludeId) continue + if (byId.get(def.id) === combo) return def + } + return null +} diff --git a/tests/perf.test.ts b/tests/perf.test.ts new file mode 100644 index 0000000..6274e38 --- /dev/null +++ b/tests/perf.test.ts @@ -0,0 +1,56 @@ +// 性能基准(阶段 6):默认跳过,PERF_BENCH=1 时运行。 +// 口径:jsdom + Node(无真实浏览器渲染管线),只测「解析建模 + 序列化」耗时, +// 不是 WebView2 真机输入延迟;真机口径见 docs/10 §3 的 Windows 清单。 +declare const process: { env: Record } +import { describe, it, expect } from 'vitest' +import { createMarkdownEditor, getMarkdown } from '../src/editor/editor' + +const BLOCK = `## 性能小节标题 + +这是一个包含**粗体**、*斜体*、[链接](https://example.com) 与 \`行内代码\` 的段落, +用来模拟真实写作内容。第二句继续填充文本,让段落长度接近真实文档。 + +- 列表项一 +- 列表项二 +- 任务 [x] 已完成 + +\`\`\`ts +const answer: number = 42 +console.log(answer) +\`\`\` + +| 列 A | 列 B | +| --- | --- | +| 1 | 2 | + +` + +function makeDoc(targetBytes: number): string { + let out = '# 性能基准文档\n\n' + while (new TextEncoder().encode(out).length < targetBytes) out += BLOCK + return out +} + +const enabled = process.env.PERF_BENCH === '1' + +describe.skipIf(!enabled)('性能基准(PERF_BENCH=1)', () => { + it.each([100 * 1024, 1024 * 1024])('打开并序列化 %i 字节文档', async (size) => { + const md = makeDoc(size) + const host = document.createElement('div') + document.body.appendChild(host) + const t0 = performance.now() + let serialized = '' + const editor = createMarkdownEditor(host, md, { + onMarkdown: (text) => { serialized = text }, + }) + await editor.create() + const createMs = performance.now() - t0 + const t1 = performance.now() + const out = getMarkdown(editor) + const serializeMs = performance.now() - t1 + console.table([{ 目标大小KB: Math.round(size / 1024), 实际字符: md.length, 建模ms: Math.round(createMs), 序列化ms: Math.round(serializeMs), 首轮回调字符: serialized.length }]) + expect(out.length).toBeGreaterThan(md.length * 0.9) + await editor.destroy() + host.remove() + }, 300_000) +}) diff --git a/tests/stage6.test.ts b/tests/stage6.test.ts new file mode 100644 index 0000000..63b00f4 --- /dev/null +++ b/tests/stage6.test.ts @@ -0,0 +1,126 @@ +// 阶段 6 测试:文档查找索引与匹配(桩文档 + 真实编辑器)、快捷键注册表、专注模式装饰。 +import { describe, it, expect } from 'vitest' +import { buildTextIndex, findInIndex, type FindOptions } from '../src/editor/search' +import { comboFromEvent, effectiveCombos, resolveShortcuts, prettyCombo, comboOwner, SHORTCUT_DEFS } from '../src/utils/shortcuts' +import { createMarkdownEditor, getMarkdown } from '../src/editor/editor' +import { setFocusModeEnabled } from '../src/editor/plugins' + +const OPTS: FindOptions = { caseSensitive: false, wholeWord: false, regex: false } + +/** 桩文档:按 ProseMirror descendants 语义回放文本节点与位置。 */ +function stubDoc(segments: { text: string; pos: number }[]) { + return { + descendants: (fn: (node: { isText: boolean; text?: string }, pos: number) => void) => { + for (const seg of segments) fn({ isText: true, text: seg.text }, seg.pos) + }, + } +} + +describe('查找索引与匹配', () => { + it('跨文本节点命中能映射回连续文档位置', () => { + // "Hello " 与 "World" 是两个文本节点(如中间有格式标记) + const index = buildTextIndex(stubDoc([{ text: 'Hello ', pos: 1 }, { text: 'World', pos: 7 }])) + const hits = findInIndex(index, 'o w', OPTS) + expect(hits).toHaveLength(1) + expect(hits[0].from).toBe(5) + expect(hits[0].to).toBe(8) + }) + + it('跨块命中被跳过(块间分隔符)', () => { + const index = buildTextIndex(stubDoc([{ text: 'abc', pos: 1 }, { text: 'def', pos: 6 }])) + expect(findInIndex(index, 'cdef', OPTS)).toHaveLength(0) + expect(findInIndex(index, 'def', OPTS)).toHaveLength(1) + }) + + it('大小写/整词/正则选项', () => { + const index = buildTextIndex(stubDoc([{ text: 'Cat cat scatter CAT', pos: 1 }])) + expect(findInIndex(index, 'cat', OPTS)).toHaveLength(4) + expect(findInIndex(index, 'cat', { ...OPTS, caseSensitive: true })).toHaveLength(2) + expect(findInIndex(index, 'cat', { ...OPTS, wholeWord: true })).toHaveLength(3) + expect(findInIndex(index, 'c.t', { ...OPTS, regex: true })).toHaveLength(4) + expect(() => findInIndex(index, '(', { ...OPTS, regex: true })).toThrow(/正则表达式无效/) + }) + + it('真实编辑器文档可查且替换后文本正确', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + const editor = createMarkdownEditor(host, '# 标题\n\nhello 世界 hello\n') + await editor.create() + const { editorViewCtx } = await import('@milkdown/kit/core') + const { TextSelection } = await import('@milkdown/kit/prose/state') + const matches = editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + return findInIndex(buildTextIndex(view.state.doc), 'hello', OPTS) + }) + expect(matches).toHaveLength(2) + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + const m = matches[0] + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, m.from, m.to))) + expect(view.state.doc.textBetween(m.from, m.to)).toBe('hello') + view.dispatch(view.state.tr.insertText('HELLO', m.from, m.to)) + }) + expect(getMarkdown(editor)).toContain('HELLO 世界 hello') + await editor.destroy() + host.remove() + }) +}) + +describe('快捷键注册表', () => { + it('默认绑定完整且无组合键冲突', () => { + const resolved = resolveShortcuts({}) + expect(resolved.get('ctrl+s')).toBe('save') + expect(resolved.get('ctrl+f')).toBe('find') + expect(resolved.get('ctrl+1')).toBe('heading1') + const combos = SHORTCUT_DEFS.map((d) => d.defaultCombo) + expect(new Set(combos).size).toBe(combos.length) + }) + + it('覆盖、解绑与冲突查询', () => { + const overrides = { find: 'ctrl+shift+f', save: '' } + const resolved = resolveShortcuts(overrides) + expect(resolved.get('ctrl+shift+f')).toBe('find') + expect(resolved.get('ctrl+f')).toBeUndefined() + expect(resolved.get('ctrl+s')).toBeUndefined() + expect(effectiveCombos(overrides).has('save')).toBe(false) // 解绑后不在生效表内 + expect(comboOwner('ctrl+shift+f', {}, 'find')?.id).toBeUndefined + expect(comboOwner('ctrl+s', {})?.id).toBe('save') + // 改绑到他人组合键时覆盖优先 + expect(resolveShortcuts({ find: 'ctrl+s' }).get('ctrl+s')).toBe('find') + }) + + it('事件组合键归一化与展示', () => { + expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: 'S' })).toBe('ctrl+shift+s') + expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: '+' })).toBe('ctrl+shift+=') + expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: false, key: 'Control' })).toBeNull() + expect(prettyCombo('ctrl+shift+s')).toBe('Ctrl+Shift+S') + expect(prettyCombo('')).toBe('(未绑定)') + }) +}) + +describe('专注模式装饰', () => { + it('开启后非当前块获得 focus-dim 类,关闭后消失', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + setFocusModeEnabled(true) + const editor = createMarkdownEditor(host, '第一段文字。\n\n第二段文字。\n\n第三段文字。\n') + await editor.create() + const { editorViewCtx } = await import('@milkdown/kit/core') + const { TextSelection } = await import('@milkdown/kit/prose/state') + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + // 把光标放到第一段,触发一次选区变化让装饰重算 + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2))) + }) + const dimmed = host.querySelectorAll('.focus-dim') + expect(dimmed.length).toBe(2) + setFocusModeEnabled(false) + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 3))) + }) + expect(host.querySelectorAll('.focus-dim').length).toBe(0) + await editor.destroy() + host.remove() + }) +})