feat(stage6): 专注/打字机模式、查找替换、全文搜索 UI、快捷键中心、多窗口

This commit is contained in:
Muse
2026-11-13 23:09:32 +08:00
parent db158fa4da
commit c251903fa2
16 changed files with 1175 additions and 50 deletions

View File

@@ -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 清单。

64
docs/10-stage6-report.md Normal file
View File

@@ -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)与便携版构建验证、自动更新通道设计与接入、版本号与发布说明、最终全量回归。

View File

@@ -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<string, string> }
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<string | null>(null)
const [sidebarMode, setSidebarMode] = useState<'files' | 'outline'>('outline')
const [sidebarMode, setSidebarMode] = useState<'files' | 'outline' | 'search'>('outline')
const [recent, setRecent] = useState<string[]>(loadRecent)
const [cursor, setCursor] = useState<CursorInfo>({ 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<SourceEditorApi | null>(null)
const sourceModeRef = useRef(sourceMode)
sourceModeRef.current = sourceMode
const pendingAnchorRef = useRef<CursorAnchor | null>(null)
const [sourceCursorOffset, setSourceCursorOffset] = useState(0)
const paneFindRef = useRef<EditorPaneApi | null>(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<string | null>(null)
const activeFindApi = useCallback((): Pick<EditorPaneApi, 'findAll' | 'gotoMatch' | 'replaceCurrent' | 'replaceAll' | 'clearFind'> | 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}
/>
</div>
@@ -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 && (
<FindBar
query={findUi.query}
replacement={findUi.replacement}
showReplace={findUi.replace}
opts={findUi.opts}
count={findUi.count}
current={findUi.current}
error={findUi.error}
onQueryChange={(value) => { 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}
/>
)}
</section>
</div>
<StatusBar
@@ -1005,7 +1153,7 @@ export default function App() {
/>
)}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'shortcuts' && <ShortcutsModal onClose={() => setModal(null)} />}
{modal === 'shortcuts' && <ShortcutsModal overrides={ui.shortcuts} onChange={(shortcuts) => setUi((u) => ({ ...u, shortcuts }))} onClose={() => setModal(null)} />}
{modal === 'link' && (
<LinkModal
onCancel={() => setModal(null)}

View File

@@ -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<string>
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<HTMLDivElement>(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<Editor | null>(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 (
<div className="editor-pane-wrap" ref={wrapRef}>
{tableBar && editorObjRef.current && (

View File

@@ -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<HTMLInputElement>(null)
useEffect(() => {
queryRef.current?.focus()
queryRef.current?.select()
}, [])
const toggle = (key: keyof FindOptions, label: string, title: string) => (
<button
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
title={title}
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
>
{label}
</button>
)
return (
<div className="findbar">
<div className="findbar-row">
<button className="find-icon" title={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
{p.showReplace ? '▾' : '▸'}
</button>
<input
ref={queryRef}
className="input find-input"
placeholder="查找"
value={p.query}
onChange={(e) => 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', '.*', '正则表达式')}
<span className="find-count">
{p.error ? p.error : p.query ? (p.count === 0 ? '无结果' : `${p.current + 1} / ${p.count}`) : ''}
</span>
<button className="find-icon" title="上一个 (Shift+Enter)" onClick={p.onPrev}>↑</button>
<button className="find-icon" title="下一个 (Enter)" onClick={p.onNext}>↓</button>
<button className="find-icon" title="关闭 (Esc)" onClick={p.onClose}>✕</button>
</div>
{p.showReplace && (
<div className="findbar-row">
<span className="find-spacer" />
<input
className="input find-input"
placeholder="替换为"
value={p.replacement}
onChange={(e) => p.onReplacementChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); p.onClose() } }}
/>
<button className="find-action" onClick={p.onReplace}>替换</button>
<button className="find-action" onClick={p.onReplaceAll}>全部替换</button>
</div>
)}
</div>
)
}

View File

@@ -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<string, string>
onChange: (overrides: Record<string, string>) => void
onClose: () => void
}) {
const [recordingId, setRecordingId] = useState<string | null>(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 (
<Shell onCancel={onClose}>
<h3>快捷键</h3>
<h3>快捷键设置</h3>
<div className="shortcut-list">
{SHORTCUT_DEFS.map((def) => {
const combo = combos.get(def.id) ?? ''
const customized = overrides[def.id] !== undefined
return (
<div className="shortcut-row" key={def.id}>
<span className="shortcut-name">{def.label}</span>
<kbd>{recordingId === def.id ? '请按键…' : combo ? prettyCombo(combo) : '(未绑定)'}</kbd>
<button onClick={() => { setConflict(null); setRecordingId(def.id) }}>修改</button>
<button disabled={!combo} onClick={() => onChange({ ...overrides, [def.id]: '' })}>解绑</button>
<button disabled={!customized} onClick={() => { const next = { ...overrides }; delete next[def.id]; onChange(next) }}>默认</button>
</div>
)
})}
</div>
{conflict && (
<p className="modal-note conflict-note">
<b>{prettyCombo(conflict.combo)}</b> 已被「{conflict.ownerLabel}」占用。
<button onClick={applyConflict}>覆盖并解绑对方</button>
<button onClick={() => setConflict(null)}>取消</button>
</p>
)}
<h4 className="shortcut-subtitle">编辑器内置(不可改绑)</h4>
<table className="shortcuts-table">
<tbody>
{SHORTCUTS.map(([name, key]) => (
{BUILTIN_SHORTCUTS.map(([name, key]) => (
<tr key={name}><td>{name}</td><td><kbd>{key}</kbd></td></tr>
))}
</tbody>
</table>
<p className="modal-note">需求表里 Ctrl+0 同时挂了「段落」与「恢复默认缩放」,本实现取「段落」(与 Typora 一致),恢复缩放走视图菜单,避免同键双义。阶段 6 设置中心提供快捷键自定义与冲突检测。</p>
<p className="modal-note">Ctrl+0 取「段落」(与 Typora 一致),恢复默认缩放走视图菜单。设置即时生效并随偏好保存。</p>
<div className="modal-actions"><button className="primary" onClick={onClose}>关闭</button></div>
</Shell>
)

View File

@@ -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) {
<div className="sidebar-tabs">
<button className={p.mode === 'files' ? 'active' : ''} onClick={() => p.onModeChange('files')}>文件</button>
<button className={p.mode === 'outline' ? 'active' : ''} onClick={() => p.onModeChange('outline')}>大纲</button>
<button className={p.mode === 'search' ? 'active' : ''} onClick={() => p.onModeChange('search')}>搜索</button>
</div>
{p.mode === 'outline' ? (
{p.mode === 'search' ? (
<WorkspaceSearch root={p.workspaceRoot} tauriMode={p.tauriMode} onOpen={p.onOpenSearchResult} />
) : null}
{p.mode === 'search' ? null : p.mode === 'outline' ? (
<div className="outline">
{p.outline.length === 0 && <div className="sidebar-empty">当前文档暂无标题</div>}
{p.outline.map((o, i) => (
@@ -162,3 +167,74 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
</div>
)
}
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<WorkspaceSearchFile[] | null>(null)
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(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 <div className="sidebar-empty">Web 降级模式不支持工作区搜索。</div>
if (!root) return <div className="sidebar-empty">先打开一个文件夹,再搜索其中的文档。</div>
const total = results?.reduce((acc, f) => acc + f.matches.length, 0) ?? 0
return (
<div className="ws-search">
<div className="ws-search-box">
<input
className="input"
placeholder="搜索工作区文档…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void run() }}
/>
<button className="primary" disabled={searching || !query.trim()} onClick={() => void run()}>
{searching ? '搜索中…' : '搜索'}
</button>
</div>
<label className="ws-search-case">
<input type="checkbox" checked={caseSensitive} onChange={(e) => setCaseSensitive(e.target.checked)} />
区分大小写
</label>
{error && <div className="sidebar-empty">{error}</div>}
{results && (
<div className="ws-search-summary">{results.length === 0 ? '没有找到匹配。' : `${results.length} 个文件 · ${total} 处匹配`}</div>
)}
<div className="ws-search-results">
{results?.map((file) => (
<div key={file.path} className="ws-search-file">
<div className="ws-search-name" title={file.path}>{file.name}</div>
{file.matches.map((m, i) => (
<button
key={i}
className="ws-search-hit"
onClick={() => onOpen?.(file.path, query.trim())}
title={`${file.path}:${m.line}`}
>
<span className="ws-search-line">{m.line}</span>
<span className="ws-search-text">{m.text}</span>
</button>
))}
</div>
))}
</div>
</div>
)
}

View File

@@ -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 () => {
// 卸载前回写最终文本,避免切换瞬间丢失最后一次输入

View File

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

View File

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

102
src/editor/search.ts Normal file
View File

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

View File

@@ -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<WorkspaceSearchFile[]> {
return invoke<WorkspaceSearchFile[]>('workspace_search', { root, query, caseSensitive })
}

View File

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

113
src/utils/shortcuts.ts Normal file
View File

@@ -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<string, string>): Map<string, string> {
const map = new Map<string, string>()
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<string, string>): Map<string, string> {
const byId = effectiveCombos(overrides)
const byCombo = new Map<string, string>()
// 先放默认值,再放用户覆盖,保证覆盖胜出
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<string, string>, 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
}

56
tests/perf.test.ts Normal file
View File

@@ -0,0 +1,56 @@
// 性能基准(阶段 6):默认跳过,PERF_BENCH=1 时运行。
// 口径:jsdom + Node(无真实浏览器渲染管线),只测「解析建模 + 序列化」耗时,
// 不是 WebView2 真机输入延迟;真机口径见 docs/10 §3 的 Windows 清单。
declare const process: { env: Record<string, string | undefined> }
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)
})

126
tests/stage6.test.ts Normal file
View File

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