feat(stage6): 专注/打字机模式、查找替换、全文搜索 UI、快捷键中心、多窗口
This commit is contained in:
@@ -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
64
docs/10-stage6-report.md
Normal 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)与便携版构建验证、自动更新通道设计与接入、版本号与发布说明、最终全量回归。
|
||||
204
src/App.tsx
204
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<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)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
84
src/components/FindBar.tsx
Normal file
84
src/components/FindBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
// 卸载前回写最终文本,避免切换瞬间丢失最后一次输入
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
102
src/editor/search.ts
Normal 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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
113
src/utils/shortcuts.ts
Normal 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
56
tests/perf.test.ts
Normal 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
126
tests/stage6.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user