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`。
|
阶段 5:7 套文档主题、自定义 CSS、导出 HTML、打印/PDF、导入 HTML/Word,报告见 `docs/09-stage5-report.md`。
|
||||||
阶段 1:Tauri 桌面骨架 + 打开/编辑/保存全链路,报告见 `docs/05-stage1-report.md` §4 清单。
|
阶段 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 { Suspense, lazy, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||||
import type { Editor } from '@milkdown/kit/core'
|
import type { Editor } from '@milkdown/kit/core'
|
||||||
|
import { editorViewCtx } from '@milkdown/kit/core'
|
||||||
import TitleBar from './components/TitleBar'
|
import TitleBar from './components/TitleBar'
|
||||||
import MenuBar, { type MenuDef } from './components/MenuBar'
|
import MenuBar, { type MenuDef } from './components/MenuBar'
|
||||||
import TabBar from './components/TabBar'
|
import TabBar from './components/TabBar'
|
||||||
@@ -7,6 +8,10 @@ import Sidebar from './components/Sidebar'
|
|||||||
import StatusBar from './components/StatusBar'
|
import StatusBar from './components/StatusBar'
|
||||||
import EditorPane from './components/EditorPane'
|
import EditorPane from './components/EditorPane'
|
||||||
import type { SourceEditorApi } from './components/SourceEditor'
|
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)懒加载:不进首屏主包,仅切到源码模式时加载
|
// 源码编辑器(CodeMirror)懒加载:不进首屏主包,仅切到源码模式时加载
|
||||||
const SourceEditor = lazy(() => import('./components/SourceEditor'))
|
const SourceEditor = lazy(() => import('./components/SourceEditor'))
|
||||||
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
|
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
|
||||||
@@ -51,9 +56,9 @@ const UI_KEY = 'new-markdown:ui'
|
|||||||
function loadRecent(): string[] {
|
function loadRecent(): string[] {
|
||||||
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] }
|
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 {
|
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 {
|
try {
|
||||||
return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') }
|
return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') }
|
||||||
} catch { return defaults }
|
} catch { return defaults }
|
||||||
@@ -71,7 +76,7 @@ export default function App() {
|
|||||||
const tab = activeTab(state)
|
const tab = activeTab(state)
|
||||||
|
|
||||||
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null)
|
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 [recent, setRecent] = useState<string[]>(loadRecent)
|
||||||
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
|
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
|
||||||
const [sourceMode, setSourceMode] = useState(false)
|
const [sourceMode, setSourceMode] = useState(false)
|
||||||
@@ -108,8 +113,52 @@ export default function App() {
|
|||||||
const jumpRef = useRef<(text: string) => void>(() => undefined)
|
const jumpRef = useRef<(text: string) => void>(() => undefined)
|
||||||
const anchorApiRef = useRef<{ getAnchor: () => CursorAnchor | null; setCursorByAnchor: (a: CursorAnchor) => void } | null>(null)
|
const anchorApiRef = useRef<{ getAnchor: () => CursorAnchor | null; setCursorByAnchor: (a: CursorAnchor) => void } | null>(null)
|
||||||
const sourceApiRef = useRef<SourceEditorApi | null>(null)
|
const sourceApiRef = useRef<SourceEditorApi | null>(null)
|
||||||
|
const sourceModeRef = useRef(sourceMode)
|
||||||
|
sourceModeRef.current = sourceMode
|
||||||
const pendingAnchorRef = useRef<CursorAnchor | null>(null)
|
const pendingAnchorRef = useRef<CursorAnchor | null>(null)
|
||||||
const [sourceCursorOffset, setSourceCursorOffset] = useState(0)
|
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()
|
const tauriMode = isTauri()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,6 +166,27 @@ export default function App() {
|
|||||||
store.saveUiPref(JSON.stringify(ui))
|
store.saveUiPref(JSON.stringify(ui))
|
||||||
}, [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
|
const effectiveDocTheme = (tab ? docOverrides[tab.id] : undefined) ?? ui.docTheme
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.docTheme = effectiveDocTheme
|
document.documentElement.dataset.docTheme = effectiveDocTheme
|
||||||
@@ -170,6 +240,28 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [tauriMode, openPath])
|
}, [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 openFolder = useCallback(async () => {
|
||||||
const folder = await pickFolder()
|
const folder = await pickFolder()
|
||||||
if (folder) {
|
if (folder) {
|
||||||
@@ -281,6 +373,9 @@ export default function App() {
|
|||||||
const plan = planRecovery(records, (path) => disk.get(path) ?? null)
|
const plan = planRecovery(records, (path) => disk.get(path) ?? null)
|
||||||
for (const key of plan.purgeKeys) void store.deleteRecovery(key)
|
for (const key of plan.purgeKeys) void store.deleteRecovery(key)
|
||||||
if (!cancelled && plan.offer.length > 0) setRecoveryOffer(plan.offer)
|
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 }
|
return () => { cancelled = true }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -498,32 +593,43 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (isImeComposing(e)) return // IME 组合期间全局快捷键一律不触发(阶段 2 守卫)
|
if (isImeComposing(e)) return // IME 组合期间全局快捷键一律不触发(阶段 2 守卫)
|
||||||
const mod = e.ctrlKey || e.metaKey
|
if (e.key === 'Escape' && findUiRef.current.open) { e.preventDefault(); closeFind(); return }
|
||||||
if (!mod) return
|
if (!e.ctrlKey && !e.metaKey && !e.altKey) return
|
||||||
const key = e.key.toLowerCase()
|
const combo = comboFromEvent(e)
|
||||||
const inTextField = (e.target as HTMLElement)?.tagName === 'TEXTAREA' || (e.target as HTMLElement)?.tagName === 'INPUT'
|
if (!combo) return
|
||||||
if (key === 'n' && !inTextField) { e.preventDefault(); newDoc() }
|
const actionId = resolveShortcuts(ui.shortcuts).get(combo)
|
||||||
else if (key === 'o' && !inTextField) { e.preventDefault(); void openFile() }
|
if (!actionId) return
|
||||||
else if (key === 's' && tab) { e.preventDefault(); void saveTab(tab, e.shiftKey) }
|
const target = e.target as HTMLElement | null
|
||||||
else if (key === 'w' && tab) { e.preventDefault(); closeTab(tab.id) }
|
const inTextField = target?.tagName === 'TEXTAREA' || target?.tagName === 'INPUT'
|
||||||
else if (key === 'l' && e.shiftKey) { e.preventDefault(); setUi((u) => ({ ...u, sidebar: !u.sidebar })) }
|
const run = (fn: () => void) => { e.preventDefault(); fn() }
|
||||||
else if (key === 'k' && !e.shiftKey && !inTextField) { e.preventDefault(); setModal('link') }
|
switch (actionId) {
|
||||||
else if (key === 'p' && !e.shiftKey) { e.preventDefault(); printRef.current() }
|
case 'new': if (!inTextField) run(newDoc); break
|
||||||
else if (key === 't' && !e.shiftKey && !inTextField) { e.preventDefault(); withEditor((ed) => editorCommands.insertTable(ed)) }
|
case 'open': if (!inTextField) run(() => void openFile()); break
|
||||||
else if (key === 'i' && e.shiftKey) { e.preventDefault(); void insertImage() }
|
case 'save': if (tab) run(() => void saveTab(tab, false)); break
|
||||||
else if (key === 'q' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.blockquote) }
|
case 'saveAs': if (tab) run(() => void saveTab(tab, true)); break
|
||||||
else if (key === 'k' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.codeBlock) }
|
case 'closeTab': if (tab) run(() => closeTab(tab.id)); break
|
||||||
else if ((key === '=' || key === '+')) { e.preventDefault(); setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2))) }
|
case 'sidebar': run(() => setUi((u) => ({ ...u, sidebar: !u.sidebar }))); break
|
||||||
else if (key === '-') { e.preventDefault(); setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2))) }
|
case 'find': if (tab) run(() => openFind(false)); break
|
||||||
else if (/^[0-6]$/.test(key) && !inTextField) {
|
case 'replace': if (tab) run(() => openFind(true)); break
|
||||||
e.preventDefault()
|
case 'link': if (!inTextField) run(() => setModal('link')); break
|
||||||
const level = Number(key)
|
case 'print': run(() => printRef.current()); break
|
||||||
withEditor((ed) => (level === 0 ? editorCommands.paragraph(ed) : editorCommands.heading(ed, level)))
|
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)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('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 () => {
|
const pasteFromClipboard = useCallback(async () => {
|
||||||
if (sourceMode) {
|
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+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+Shift+S', disabled: !tab, onClick: () => tab && void saveTab(tab, true) },
|
||||||
{ label: '关闭标签页', shortcut: 'Ctrl+W', disabled: !tab, onClick: () => tab && closeTab(tab.id) },
|
{ 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 },
|
{ divider: true },
|
||||||
{ label: '导入 HTML / Word…', onClick: () => void importFileFlow() },
|
{ label: '导入 HTML / Word…', onClick: () => void importFileFlow() },
|
||||||
{ label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) },
|
{ label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) },
|
||||||
@@ -717,6 +824,9 @@ export default function App() {
|
|||||||
{ label: '复制', onClick: () => document.execCommand('copy') },
|
{ label: '复制', onClick: () => document.execCommand('copy') },
|
||||||
{ label: '粘贴', onClick: () => { void pasteFromClipboard() } },
|
{ label: '粘贴', onClick: () => { void pasteFromClipboard() } },
|
||||||
{ label: '全选', onClick: () => document.execCommand('selectAll') },
|
{ 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: sourceMode, disabled: !tab, onClick: toggleSourceMode },
|
||||||
{ label: '深色主题', checked: ui.theme === 'dark', onClick: () => setUi((u) => ({ ...u, theme: u.theme === 'dark' ? 'light' : 'dark' })) },
|
{ 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: `自动保存(${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 },
|
{ divider: true },
|
||||||
{ label: '放大', shortcut: 'Ctrl+=', onClick: () => setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2))) },
|
{ 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))) },
|
{ label: '缩小', shortcut: 'Ctrl+-', onClick: () => setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2))) },
|
||||||
@@ -780,11 +892,11 @@ export default function App() {
|
|||||||
{
|
{
|
||||||
label: '帮助',
|
label: '帮助',
|
||||||
items: [
|
items: [
|
||||||
{ label: '快捷键说明', onClick: () => setModal('shortcuts') },
|
{ label: '快捷键设置…', onClick: () => setModal('shortcuts') },
|
||||||
{ label: '关于 new-markdown', onClick: () => setModal('about') },
|
{ 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
|
const pendingTitles = pendingClose
|
||||||
? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、')
|
? 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}
|
refreshSignal={treeRefresh}
|
||||||
outline={outline}
|
outline={outline}
|
||||||
onJumpHeading={jumpHeading}
|
onJumpHeading={jumpHeading}
|
||||||
|
onOpenSearchResult={openSearchResult}
|
||||||
tauriMode={tauriMode}
|
tauriMode={tauriMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -856,8 +969,11 @@ export default function App() {
|
|||||||
initialMarkdown={tab.content}
|
initialMarkdown={tab.content}
|
||||||
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })}
|
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })}
|
||||||
onCursor={setCursor}
|
onCursor={setCursor}
|
||||||
|
focusMode={ui.focusMode}
|
||||||
|
typewriter={ui.typewriter}
|
||||||
onImageFile={handleImageFile}
|
onImageFile={handleImageFile}
|
||||||
onReady={(api) => {
|
onReady={(api) => {
|
||||||
|
paneFindRef.current = api.editor ? api : null
|
||||||
editorRef.current = api.editor
|
editorRef.current = api.editor
|
||||||
jumpRef.current = api.jumpToHeading
|
jumpRef.current = api.jumpToHeading
|
||||||
anchorApiRef.current = api.editor ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null
|
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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
@@ -1005,7 +1153,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{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' && (
|
{modal === 'link' && (
|
||||||
<LinkModal
|
<LinkModal
|
||||||
onCancel={() => setModal(null)}
|
onCancel={() => setModal(null)}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import { editorViewCtx } from '@milkdown/kit/core'
|
|||||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||||
import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/editor'
|
import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/editor'
|
||||||
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
|
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 {
|
export interface EditorPaneApi {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
jumpToHeading: (text: string) => void
|
jumpToHeading: (text: string) => void
|
||||||
getAnchor: () => CursorAnchor | null
|
getAnchor: () => CursorAnchor | null
|
||||||
setCursorByAnchor: (anchor: CursorAnchor) => void
|
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 {
|
interface Props {
|
||||||
@@ -20,6 +27,8 @@ interface Props {
|
|||||||
onCursor: (cursor: CursorInfo) => void
|
onCursor: (cursor: CursorInfo) => void
|
||||||
onReady: (api: EditorPaneApi) => void
|
onReady: (api: EditorPaneApi) => void
|
||||||
onImageFile?: (file: File) => Promise<string>
|
onImageFile?: (file: File) => Promise<string>
|
||||||
|
focusMode: boolean
|
||||||
|
typewriter: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_API: EditorPaneApi = {
|
const EMPTY_API: EditorPaneApi = {
|
||||||
@@ -27,11 +36,21 @@ const EMPTY_API: EditorPaneApi = {
|
|||||||
jumpToHeading: () => undefined,
|
jumpToHeading: () => undefined,
|
||||||
getAnchor: () => null,
|
getAnchor: () => null,
|
||||||
setCursorByAnchor: () => undefined,
|
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 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 editorObjRef = useRef<Editor | null>(null)
|
||||||
const [tableBar, setTableBar] = useState<{ top: number; left: number } | 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(() => {
|
useEffect(() => {
|
||||||
if (!hostRef.current) return
|
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, {
|
const editor = createMarkdownEditor(hostRef.current, initialRef.current, {
|
||||||
onMarkdown: (md) => callbacksRef.current.onMarkdown(tabId, md),
|
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) },
|
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)) })
|
}, { onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)) })
|
||||||
editorObjRef.current = editor
|
editorObjRef.current = editor
|
||||||
@@ -94,8 +135,72 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
|
|||||||
})
|
})
|
||||||
} catch { /* 定位失败静默:不打断模式切换 */ }
|
} 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()
|
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))
|
.catch((e) => console.error('编辑器创建失败', e))
|
||||||
const scrollParent = wrapRef.current?.closest('.editor-scroll')
|
const scrollParent = wrapRef.current?.closest('.editor-scroll')
|
||||||
const onScroll = () => { if (editorObjRef.current) updateTableBar() }
|
const onScroll = () => { if (editorObjRef.current) updateTableBar() }
|
||||||
@@ -112,6 +217,16 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
|
|||||||
}
|
}
|
||||||
}, [tabId, updateTableBar])
|
}, [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 (
|
return (
|
||||||
<div className="editor-pane-wrap" ref={wrapRef}>
|
<div className="editor-pane-wrap" ref={wrapRef}>
|
||||||
{tableBar && editorObjRef.current && (
|
{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 }) {
|
function Shell({ children, onCancel }: { children: React.ReactNode; onCancel?: () => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -38,27 +39,76 @@ export function AboutModal({ onClose }: { onClose: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHORTCUTS: [string, string][] = [
|
/** 快捷键设置中心(阶段 6):应用级快捷键可改绑/解绑/恢复默认,改绑时检测冲突。 */
|
||||||
['新建文档', 'Ctrl+N'], ['打开文件', 'Ctrl+O'], ['保存', 'Ctrl+S'], ['另存为', 'Ctrl+Shift+S'],
|
export function ShortcutsModal({ overrides, onChange, onClose }: {
|
||||||
['关闭标签页', 'Ctrl+W'], ['撤销 / 重做', 'Ctrl+Z / Ctrl+Y'],
|
overrides: Record<string, string>
|
||||||
['加粗 / 斜体', 'Ctrl+B / Ctrl+I'], ['插入链接', 'Ctrl+K'],
|
onChange: (overrides: Record<string, string>) => void
|
||||||
['段落 / 一级标题', 'Ctrl+0 / Ctrl+1'], ['二级 ~ 六级标题', 'Ctrl+2 ~ Ctrl+6'],
|
onClose: () => void
|
||||||
['引用', 'Ctrl+Shift+Q'], ['代码块', 'Ctrl+Shift+K'],
|
}) {
|
||||||
['显示/隐藏侧边栏', 'Ctrl+Shift+L'], ['放大 / 缩小', 'Ctrl+= / Ctrl+-'],
|
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 (
|
return (
|
||||||
<Shell onCancel={onClose}>
|
<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">
|
<table className="shortcuts-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
{SHORTCUTS.map(([name, key]) => (
|
{BUILTIN_SHORTCUTS.map(([name, key]) => (
|
||||||
<tr key={name}><td>{name}</td><td><kbd>{key}</kbd></td></tr>
|
<tr key={name}><td>{name}</td><td><kbd>{key}</kbd></td></tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
<div className="modal-actions"><button className="primary" onClick={onClose}>关闭</button></div>
|
||||||
</Shell>
|
</Shell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
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 type { OutlineItem } from '../utils/text'
|
||||||
import { baseName } from '../utils/text'
|
import { baseName } from '../utils/text'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'files' | 'outline'
|
mode: 'files' | 'outline' | 'search'
|
||||||
onModeChange: (mode: 'files' | 'outline') => void
|
onModeChange: (mode: 'files' | 'outline' | 'search') => void
|
||||||
|
onOpenSearchResult?: (path: string, query: string) => void
|
||||||
workspaceRoot: string | null
|
workspaceRoot: string | null
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
onOpenFile: (path: string) => void
|
onOpenFile: (path: string) => void
|
||||||
@@ -25,8 +26,12 @@ export default function Sidebar(p: Props) {
|
|||||||
<div className="sidebar-tabs">
|
<div className="sidebar-tabs">
|
||||||
<button className={p.mode === 'files' ? 'active' : ''} onClick={() => p.onModeChange('files')}>文件</button>
|
<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 === 'outline' ? 'active' : ''} onClick={() => p.onModeChange('outline')}>大纲</button>
|
||||||
|
<button className={p.mode === 'search' ? 'active' : ''} onClick={() => p.onModeChange('search')}>搜索</button>
|
||||||
</div>
|
</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">
|
<div className="outline">
|
||||||
{p.outline.length === 0 && <div className="sidebar-empty">当前文档暂无标题</div>}
|
{p.outline.length === 0 && <div className="sidebar-empty">当前文档暂无标题</div>}
|
||||||
{p.outline.map((o, i) => (
|
{p.outline.map((o, i) => (
|
||||||
@@ -162,3 +167,74 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
|
|||||||
</div>
|
</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 { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language'
|
||||||
import { oneDark } from '@codemirror/theme-one-dark'
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
import { anchorFromMarkdown, type CursorAnchor } from '../editor/cursorSync'
|
import { anchorFromMarkdown, type CursorAnchor } from '../editor/cursorSync'
|
||||||
|
import type { FindOptions } from '../editor/search'
|
||||||
|
|
||||||
export interface SourceEditorApi {
|
export interface SourceEditorApi {
|
||||||
jumpToLine: (line: number) => void
|
jumpToLine: (line: number) => void
|
||||||
getAnchor: () => CursorAnchor | null
|
getAnchor: () => CursorAnchor | null
|
||||||
focus: () => void
|
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 {
|
interface Props {
|
||||||
@@ -64,6 +105,29 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
|
|||||||
})
|
})
|
||||||
viewRef.current = view
|
viewRef.current = view
|
||||||
view.focus()
|
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({
|
callbacksRef.current.onReady({
|
||||||
jumpToLine: (line) => {
|
jumpToLine: (line) => {
|
||||||
const target = Math.max(1, Math.min(line + 1, view.state.doc.lines))
|
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),
|
getAnchor: () => anchorFromMarkdown(view.state.doc.toString(), view.state.selection.main.head),
|
||||||
focus: () => view.focus(),
|
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 () => {
|
return () => {
|
||||||
// 卸载前回写最终文本,避免切换瞬间丢失最后一次输入
|
// 卸载前回写最终文本,避免切换瞬间丢失最后一次输入
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard'
|
|||||||
import { indent } from '@milkdown/kit/plugin/indent'
|
import { indent } from '@milkdown/kit/plugin/indent'
|
||||||
import { math } from '@milkdown/plugin-math'
|
import { math } from '@milkdown/plugin-math'
|
||||||
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload'
|
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'
|
import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
|
||||||
|
|
||||||
export interface CursorInfo { line: number; col: number }
|
export interface CursorInfo { line: number; col: number }
|
||||||
@@ -81,6 +81,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
|
|||||||
.use(createSyntaxRevealPlugin())
|
.use(createSyntaxRevealPlugin())
|
||||||
.use(createCodeBlockTabPlugin())
|
.use(createCodeBlockTabPlugin())
|
||||||
.use(createTableTabPlugin())
|
.use(createTableTabPlugin())
|
||||||
|
.use(createFocusDimPlugin())
|
||||||
.use(createListItemViewPlugin())
|
.use(createListItemViewPlugin())
|
||||||
.use(createCodeBlockViewPlugin())
|
.use(createCodeBlockViewPlugin())
|
||||||
.use(math)
|
.use(math)
|
||||||
|
|||||||
@@ -166,6 +166,58 @@ export function applyCodeBlockIndent(view: MinimalView, outdent: boolean): boole
|
|||||||
return true
|
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) —————————————————
|
// ————————————————— 表格 Tab 导航(阶段 3) —————————————————
|
||||||
|
|
||||||
interface AncestorLike {
|
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()
|
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; }
|
.seconds-input { width: 72px; margin: 0 6px; }
|
||||||
.modal-actions.left { align-items: stretch; }
|
.modal-actions.left { align-items: stretch; }
|
||||||
.modal-actions.left button { text-align: left; }
|
.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