From 56735f53e6ed8a813767b0cb3f9c8363c930461c Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Wed, 26 Aug 2026 13:39:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(M2):=20FR-E7=20=E5=9D=97=E7=BA=A7=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=20+=20FR-E11=20=E6=9F=A5=E6=89=BE=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E2=80=94=E2=80=94BlockOps=20=E9=87=8D=E5=86=99(Alt+=E2=86=91/?= =?UTF-8?q?=E2=86=93=20=E9=A1=B6=E5=B1=82=E5=9D=97=E4=B8=8A=E7=A7=BB?= =?UTF-8?q?=E4=B8=8B=E7=A7=BB+Alt+Backspace=20=E5=88=A0=E9=99=A4);=20FindR?= =?UTF-8?q?eplace=20=E5=8A=A0=20=E5=8C=BA=E5=88=86=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=86=99/=E6=95=B4=E8=AF=8D/=E6=AD=A3=E5=88=99=20=E5=A4=8D?= =?UTF-8?q?=E9=80=89=E6=A1=86=20+=20FindReplaceHighlight=20=E8=A3=85?= =?UTF-8?q?=E9=A5=B0=E6=8F=92=E4=BB=B6(=E5=85=A8=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E9=AB=98=E4=BA=AE+=E5=BD=93=E5=89=8D=E9=A1=B9)=20+=20=E5=8D=95?= =?UTF-8?q?=E6=AC=A1=E6=9B=BF=E6=8D=A2/=E5=85=A8=E9=83=A8=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2;=20styles.css=20find-match=20=E9=85=8D=E8=89=B2;=20ed?= =?UTF-8?q?itor-block-findreplace=20E2E=203=20=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/renderer/features/editor/BlockOps.ts | 59 ++++++++- .../renderer/features/editor/FindReplace.tsx | 113 ++++++++++------ .../renderer/features/editor/TiptapEditor.tsx | 3 +- .../renderer/features/editor/find-helpers.ts | 37 ++++++ apps/desktop/src/renderer/styles.css | 3 + .../e2e/editor-block-findreplace.spec.ts | 122 ++++++++++++++++++ 6 files changed, 295 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/renderer/features/editor/find-helpers.ts create mode 100644 apps/desktop/tests/e2e/editor-block-findreplace.spec.ts diff --git a/apps/desktop/src/renderer/features/editor/BlockOps.ts b/apps/desktop/src/renderer/features/editor/BlockOps.ts index 936aa17..48389c0 100644 --- a/apps/desktop/src/renderer/features/editor/BlockOps.ts +++ b/apps/desktop/src/renderer/features/editor/BlockOps.ts @@ -1,13 +1,64 @@ import { Extension } from '@tiptap/core'; +import { Fragment, type Node } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; -// 块操作扩展:Alt+↑/↓ 移动当前块,Alt+Backspace 删除当前块(FR-E7) +// 块级操作(FR-E7):Alt+↑/↓ 上移/下移当前顶层块,Alt+Backspace 删除当前块 export const BlockOps = Extension.create({ name: 'blockOps', addKeyboardShortcuts() { return { - 'Alt-ArrowUp': () => this.editor.commands.liftListItem('listItem'), - 'Alt-ArrowDown': () => this.editor.commands.sinkListItem('listItem'), - 'Alt-Backspace': () => { const e = this.editor; const from = e.state.selection.from; const $pos = e.state.doc.resolve(from); const start = $pos.before($pos.depth); const end = $pos.after($pos.depth); if (start >= 0) e.chain().focus().deleteRange({ from: start, to: end }).run(); return true; }, + 'Alt-ArrowUp': () => moveBlock(this.editor, 'up'), + 'Alt-ArrowDown': () => moveBlock(this.editor, 'down'), + 'Alt-Backspace': () => deleteBlock(this.editor), }; }, }); + +// 取光标所在的顶层块(depth=1)范围与节点 +function topBlock(editor: { state: any }): { start: number; end: number; node: Node; idx: number } | null { + const { doc, selection } = editor.state; + const $pos = doc.resolve(selection.from); + if ($pos.depth < 1) return null; + const start = $pos.before(1); + const node = doc.nodeAt(start); + if (!node) return null; + let idx = -1; + for (let i = 0; i < doc.childCount; i++) { if (doc.child(i) === node) { idx = i; break; } } + return { start, end: start + node.nodeSize, node, idx }; +} + +function moveBlock(editor: any, dir: 'up' | 'down'): boolean { + const { doc, tr } = editor.state; + const r = topBlock(editor); + if (!r || r.idx < 0) return false; + const swap = dir === 'up' ? r.idx - 1 : r.idx + 1; + if (swap < 0 || swap >= doc.childCount) return false; + const children: Node[] = []; + for (let i = 0; i < doc.childCount; i++) children.push(doc.child(i)); + const tmp = children[r.idx]; children[r.idx] = children[swap]; children[swap] = tmp; + // 移动后该块的新起始位置 + const movedNewStart = children.slice(0, swap).reduce((s, n) => s + n.nodeSize, 0); + tr.replaceWith(0, doc.content.size, Fragment.from(children)); + tr.setSelection(TextSelection.near(tr.doc.resolve(movedNewStart + 1))); + editor.view.dispatch(tr); + editor.view.focus(); + return true; +} + +function deleteBlock(editor: any): boolean { + const { doc, selection, tr } = editor.state; + const $pos = doc.resolve(selection.from); + if ($pos.depth < 1) return false; + const start = $pos.before($pos.depth); + const end = $pos.after($pos.depth); + if (doc.childCount <= 1 && $pos.depth === 1) { + // 最后一个顶层块:替换为空段落,避免空文档 + const p = doc.type.schema.nodes.paragraph.create(); + tr.replaceWith(start, end, p); + tr.setSelection(TextSelection.near(tr.doc.resolve(start + 1))); + editor.view.dispatch(tr); + } else { + editor.chain().focus().deleteRange({ from: start, to: end }).run(); + } + return true; +} diff --git a/apps/desktop/src/renderer/features/editor/FindReplace.tsx b/apps/desktop/src/renderer/features/editor/FindReplace.tsx index 8bc686e..7317966 100644 --- a/apps/desktop/src/renderer/features/editor/FindReplace.tsx +++ b/apps/desktop/src/renderer/features/editor/FindReplace.tsx @@ -1,61 +1,100 @@ import { useState } from 'react'; -import { useEditorRef } from '../editor/editor-context'; +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { Editor } from '@tiptap/react'; +import { useEditorRef } from './editor-context'; +import { collectMatches, findState, type FindOpts, type Match } from './find-helpers'; + +const findKey = new PluginKey('findReplaceHighlight'); + +// 查找条件变更后触发装饰重算(仅 meta 事务即可) +export function refreshFind(editor: Editor) { editor.view.dispatch(editor.state.tr.setMeta(findKey, true)); } + +// FR-E11:全命中高亮装饰插件 +export const FindReplaceHighlight = Extension.create({ + name: 'findReplaceHighlight', + addProseMirrorPlugins() { + return [new Plugin({ + key: findKey, + state: { + init: () => DecorationSet.empty, + apply: (_tr, _set, _old, newState) => { + const { query, caseSensitive, wholeWord, regex, active } = findState; + if (!query) return DecorationSet.empty; + const ms = collectMatches(newState.doc, query, { caseSensitive, wholeWord, regex }); + const decos = ms.map((m, i) => Decoration.inline(m.from, m.to, { class: i === active ? 'find-match find-match-active' : 'find-match' })); + return DecorationSet.create(newState.doc, decos); + }, + }, + props: { decorations: (state) => findKey.getState(state) }, + })]; + }, +}); export function FindReplace() { const editor = useEditorRef(); const [find, setFind] = useState(''); const [replace, setReplace] = useState(''); - const [matchIdx, setMatchIdx] = useState(0); - const [matches, setMatches] = useState([]); // 各匹配起始 pos + const [opts, setOpts] = useState({ caseSensitive: false, wholeWord: false, regex: false }); + const [matches, setMatches] = useState([]); + const [active, setActive] = useState(0); - const doFind = () => { - if (!editor || !find) { setMatches([]); setMatchIdx(0); return; } - const positions: number[] = []; - const text = editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n'); - // 遍历 doc 收集匹配(基于文本偏移简化) - editor.state.doc.descendants((node, pos) => { - if (node.isText && node.text) { - let i = 0; - while ((i = node.text.indexOf(find, i)) !== -1) { - positions.push(pos + i); - i += find.length; - } - } - }); - setMatches(positions); - setMatchIdx(0); - if (positions.length > 0) jumpTo(0, positions); + const sync = (q: string, o: FindOpts): Match[] => { + if (!editor) return []; + findState.query = q; findState.caseSensitive = o.caseSensitive; findState.wholeWord = o.wholeWord; findState.regex = o.regex; + const ms = collectMatches(editor.state.doc, q, o); + setMatches(ms); + const a = ms.length ? 0 : -1; + setActive(a); findState.active = a; + refreshFind(editor); + return ms; }; - - const jumpTo = (idx: number, positions = matches) => { - if (!editor || positions.length === 0) return; - const pos = positions[idx]; - editor.commands.setTextSelection({ from: pos, to: pos + find.length }); + const jumpTo = (m: Match) => { + if (!editor) return; + editor.commands.setTextSelection({ from: m.from, to: m.to }); editor.commands.scrollIntoView(); editor.commands.focus(); }; - - const next = () => { if (matches.length === 0) return; const i = (matchIdx + 1) % matches.length; setMatchIdx(i); jumpTo(i); }; - const prev = () => { if (matches.length === 0) return; const i = (matchIdx - 1 + matches.length) % matches.length; setMatchIdx(i); jumpTo(i); }; - + const doFind = () => { const ms = sync(find, opts); if (ms.length) jumpTo(ms[0]); }; + const next = () => { if (!editor || matches.length === 0) return; const i = (active + 1) % matches.length; setActive(i); findState.active = i; refreshFind(editor); jumpTo(matches[i]); }; + const prev = () => { if (!editor || matches.length === 0) return; const i = (active - 1 + matches.length) % matches.length; setActive(i); findState.active = i; refreshFind(editor); jumpTo(matches[i]); }; const doReplace = () => { if (!editor || matches.length === 0) return; - const pos = matches[matchIdx]; - editor.chain().focus().deleteRange({ from: pos, to: pos + find.length }).insertContentAt(pos, replace).run(); - doFind(); // 重新查找 + const m = matches[active]; + const tr = editor.state.tr.delete(m.from, m.to).insertText(replace, m.from); + editor.view.dispatch(tr); + editor.commands.focus(); + const ms = sync(find, opts); if (ms.length) jumpTo(ms[Math.min(active, ms.length - 1)]); + }; + const doReplaceAll = () => { + if (!editor || matches.length === 0) return; + const tr = editor.state.tr; + for (const m of [...matches].sort((a, b) => b.from - a.from)) { tr.delete(m.from, m.to); tr.insertText(replace, m.from); } + editor.view.dispatch(tr); + editor.commands.focus(); + sync(find, opts); + }; + const toggle = (k: keyof FindOpts) => (e: React.ChangeEvent) => { + const o = { ...opts, [k]: e.target.checked }; setOpts(o); sync(find, o); }; return ( -
+
setFind(e.target.value)} placeholder="查找" onKeyDown={e => e.key === 'Enter' && doFind()} style={inp} /> setReplace(e.target.value)} placeholder="替换" style={inp} /> + + + - - + + - {matches.length > 0 ? `${matchIdx + 1}/${matches.length}` : ''} + + {matches.length > 0 ? `${active + 1}/${matches.length}` : (find ? '0/0' : '')}
); } -const inp: React.CSSProperties = { background: '#222', border: '1px solid #444', color: '#ddd', borderRadius: 4, padding: '3px 8px', width: 120, fontSize: 13 }; +const inp: React.CSSProperties = { background: '#222', border: '1px solid #444', color: '#ddd', borderRadius: 4, padding: '3px 8px', width: 110, fontSize: 13 }; const btn: React.CSSProperties = { minWidth: 30, height: 26, border: '1px solid #444', background: 'transparent', color: '#ccc', cursor: 'pointer', borderRadius: 4, fontSize: 12 }; +const lab: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 3, fontSize: 12, color: '#aaa', cursor: 'pointer' }; diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index d6ef277..243e293 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -18,7 +18,7 @@ import { useStore } from '../../store'; import { invoke } from '../../ipc-client'; import { EditorProvider } from './editor-context'; import { EditorToolbar } from './EditorToolbar'; -import { FindReplace } from './FindReplace'; +import { FindReplace, FindReplaceHighlight } from './FindReplace'; import { SlashMenu } from './SlashMenu'; import { BlockOps } from './BlockOps'; import { CodeBlockView } from './CodeBlockView'; @@ -55,6 +55,7 @@ export function TiptapEditor() { Placeholder.configure({ placeholder: '开始写作…' }), Markdown.configure({ html: false, breaks: false, linkify: true, transformPastedText: true, transformCopiedText: true }), BlockOps, + FindReplaceHighlight, ], content: body, editorProps: { diff --git a/apps/desktop/src/renderer/features/editor/find-helpers.ts b/apps/desktop/src/renderer/features/editor/find-helpers.ts new file mode 100644 index 0000000..2afbe45 --- /dev/null +++ b/apps/desktop/src/renderer/features/editor/find-helpers.ts @@ -0,0 +1,37 @@ +import type { Node } from '@tiptap/pm/model'; + +export interface FindOpts { caseSensitive: boolean; wholeWord: boolean; regex: boolean } + +// 高亮插件与 React UI 共享的当前查找状态 +export const findState: { query: string; caseSensitive: boolean; wholeWord: boolean; regex: boolean; active: number } = { + query: '', caseSensitive: false, wholeWord: false, regex: false, active: -1, +}; + +export function buildMatcher(query: string, opts: FindOpts): RegExp | null { + if (!query) return null; + try { + if (opts.regex) return new RegExp(query, opts.caseSensitive ? 'g' : 'gi'); + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(opts.wholeWord ? `\\b${escaped}\\b` : escaped, opts.caseSensitive ? 'g' : 'gi'); + } catch { return null; } +} + +export interface Match { from: number; to: number } + +// 收集文档内全部匹配(按文本节点遍历,匹配不跨节点边界) +export function collectMatches(doc: Node, query: string, opts: FindOpts): Match[] { + const re = buildMatcher(query, opts); + if (!re) return []; + const out: Match[] = []; + doc.descendants((node, pos) => { + if (node.isText && node.text) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(node.text)) !== null) { + out.push({ from: pos + m.index, to: pos + m.index + m[0].length }); + if (m[0].length === 0) re.lastIndex++; + } + } + }); + return out; +} diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 9627e40..2b65e78 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -38,6 +38,9 @@ html, body, #root { margin: 0; height: 100%; } .tiptap-wrap .ProseMirror .hljs-deletion { color: #e06c75; } .tiptap-wrap .ProseMirror .hljs-emphasis { font-style: italic; } .tiptap-wrap .ProseMirror .hljs-strong { font-weight: 700; } +/* FR-E11 查找高亮 */ +.tiptap-wrap .ProseMirror .find-match { background: rgba(255, 213, 79, 0.35); border-radius: 2px; } +.tiptap-wrap .ProseMirror .find-match-active { background: rgba(255, 145, 0, 0.65); } .tiptap-wrap .ProseMirror blockquote { border-left: 3px solid #444; padding-left: 12px; color: #aaa; margin: 0.6em 0; } diff --git a/apps/desktop/tests/e2e/editor-block-findreplace.spec.ts b/apps/desktop/tests/e2e/editor-block-findreplace.spec.ts new file mode 100644 index 0000000..9f2d285 --- /dev/null +++ b/apps/desktop/tests/e2e/editor-block-findreplace.spec.ts @@ -0,0 +1,122 @@ +import { test, expect } from '@playwright/test'; +import { _electron as electron } from 'playwright'; +import { promises as fs } from 'node:fs'; +import nodePath from 'node:path'; +import os from 'node:os'; + +const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js'); + +async function launch(userData: string) { + const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] }); + const page = await app.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args }); + return { app, page, invoke }; +} + +async function newDoc(a: { page: any; invoke: any }, title: string) { + await a.page.getByRole('button', { name: /新建文档/ }).click(); + await a.page.getByPlaceholder(/输入文档标题/).fill(title); + await a.page.getByPlaceholder(/输入文档标题/).press('Enter'); + await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 }); +} + +// FR-E7:Alt+↑/↓ 上移/下移当前顶层块 +test('FR-E7:Alt+↑/↓ 上移/下移当前块', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e7-')); + const userData = nodePath.join(tmp, 'userdata'); + const vault = nodePath.join(tmp, 'vault'); + await fs.mkdir(userData, { recursive: true }); + await fs.mkdir(vault, { recursive: true }); + + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + await newDoc(a, '块操作'); + + const editor = a.page.locator('.ProseMirror'); + await editor.click(); + await a.page.keyboard.type('first'); + await a.page.keyboard.press('Enter'); + await a.page.keyboard.type('second'); + + // 光标在第二段 'second' -> Alt+↑ 上移 -> 顺序变 second, first + await a.page.keyboard.press('Alt+ArrowUp'); + let p0 = (await a.page.locator('.ProseMirror p').nth(0).textContent() ?? '').trim(); + let p1 = (await a.page.locator('.ProseMirror p').nth(1).textContent() ?? '').trim(); + expect(p0).toBe('second'); + expect(p1).toBe('first'); + + // Alt+↓ 下移回去 -> first, second + await a.page.keyboard.press('Alt+ArrowDown'); + p0 = (await a.page.locator('.ProseMirror p').nth(0).textContent() ?? '').trim(); + p1 = (await a.page.locator('.ProseMirror p').nth(1).textContent() ?? '').trim(); + expect(p0).toBe('first'); + expect(p1).toBe('second'); + + await a.app.close(); +}); + +// FR-E11:查找全命中高亮 + 替换 + 全部替换 +test('FR-E11:查找高亮 + 替换 + 全部替换', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e11a-')); + const userData = nodePath.join(tmp, 'userdata'); + const vault = nodePath.join(tmp, 'vault'); + await fs.mkdir(userData, { recursive: true }); + await fs.mkdir(vault, { recursive: true }); + + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + await newDoc(a, '查找替换'); + + await a.page.locator('.ProseMirror').click(); + await a.page.keyboard.type('foo bar foo baz foo'); + + await a.page.getByPlaceholder('查找').fill('foo'); + await a.page.getByRole('button', { name: '查找' }).click(); + await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(3, { timeout: 5000 }); + await expect(a.page.locator('.ProseMirror .find-match-active')).toHaveCount(1); + + await a.page.getByPlaceholder('替换').fill('Y'); + await a.page.getByRole('button', { name: '替换', exact: true }).click(); + await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(2, { timeout: 5000 }); + + await a.page.getByPlaceholder('替换').fill('X'); + await a.page.getByRole('button', { name: '全部替换' }).click(); + await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(0, { timeout: 5000 }); + const body = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.textContent ?? ''); + expect(body).not.toContain('foo'); + + await a.app.close(); +}); + +// FR-E11:整词 + 区分大小写选项 +test('FR-E11:整词 + 区分大小写选项', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e11b-')); + const userData = nodePath.join(tmp, 'userdata'); + const vault = nodePath.join(tmp, 'vault'); + await fs.mkdir(userData, { recursive: true }); + await fs.mkdir(vault, { recursive: true }); + + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + await newDoc(a, '查找选项'); + + await a.page.locator('.ProseMirror').click(); + await a.page.keyboard.type('foo foobar foo'); + + await a.page.getByPlaceholder('查找').fill('foo'); + await a.page.getByRole('button', { name: '查找' }).click(); + await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(3, { timeout: 5000 }); + + // 整词 -> 排除 foobar -> 2 + await a.page.getByTestId('fr-word').check(); + await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(2, { timeout: 5000 }); + + // 区分大小写 + 查 'Foo' -> 0 + await a.page.getByTestId('fr-case').check(); + await a.page.getByPlaceholder('查找').fill('Foo'); + await a.page.getByRole('button', { name: '查找' }).click(); + await expect(a.page.locator('.ProseMirror .find-match')).toHaveCount(0, { timeout: 5000 }); + + await a.app.close(); +});