feat(M2): FR-E7 块级操作 + FR-E11 查找替换——BlockOps 重写(Alt+↑/↓ 顶层块上移下移+Alt+Backspace 删除); FindReplace 加 区分大小写/整词/正则 复选框 + FindReplaceHighlight 装饰插件(全命中高亮+当前项) + 单次替换/全部替换; styles.css find-match 配色; editor-block-findreplace E2E 3 项

This commit is contained in:
hunter-dev
2026-08-26 13:39:46 +08:00
parent 2f4bcfd1e4
commit 56735f53e6
6 changed files with 295 additions and 42 deletions
@@ -1,13 +1,64 @@
import { Extension } from '@tiptap/core'; 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({ export const BlockOps = Extension.create({
name: 'blockOps', name: 'blockOps',
addKeyboardShortcuts() { addKeyboardShortcuts() {
return { return {
'Alt-ArrowUp': () => this.editor.commands.liftListItem('listItem'), 'Alt-ArrowUp': () => moveBlock(this.editor, 'up'),
'Alt-ArrowDown': () => this.editor.commands.sinkListItem('listItem'), 'Alt-ArrowDown': () => moveBlock(this.editor, 'down'),
'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-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;
}
@@ -1,61 +1,100 @@
import { useState } from 'react'; 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() { export function FindReplace() {
const editor = useEditorRef(); const editor = useEditorRef();
const [find, setFind] = useState(''); const [find, setFind] = useState('');
const [replace, setReplace] = useState(''); const [replace, setReplace] = useState('');
const [matchIdx, setMatchIdx] = useState(0); const [opts, setOpts] = useState<FindOpts>({ caseSensitive: false, wholeWord: false, regex: false });
const [matches, setMatches] = useState<number[]>([]); // 各匹配起始 pos const [matches, setMatches] = useState<Match[]>([]);
const [active, setActive] = useState(0);
const doFind = () => { const sync = (q: string, o: FindOpts): Match[] => {
if (!editor || !find) { setMatches([]); setMatchIdx(0); return; } if (!editor) return [];
const positions: number[] = []; findState.query = q; findState.caseSensitive = o.caseSensitive; findState.wholeWord = o.wholeWord; findState.regex = o.regex;
const text = editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n'); const ms = collectMatches(editor.state.doc, q, o);
// 遍历 doc 收集匹配(基于文本偏移简化) setMatches(ms);
editor.state.doc.descendants((node, pos) => { const a = ms.length ? 0 : -1;
if (node.isText && node.text) { setActive(a); findState.active = a;
let i = 0; refreshFind(editor);
while ((i = node.text.indexOf(find, i)) !== -1) { return ms;
positions.push(pos + i);
i += find.length;
}
}
});
setMatches(positions);
setMatchIdx(0);
if (positions.length > 0) jumpTo(0, positions);
}; };
const jumpTo = (m: Match) => {
const jumpTo = (idx: number, positions = matches) => { if (!editor) return;
if (!editor || positions.length === 0) return; editor.commands.setTextSelection({ from: m.from, to: m.to });
const pos = positions[idx];
editor.commands.setTextSelection({ from: pos, to: pos + find.length });
editor.commands.scrollIntoView(); editor.commands.scrollIntoView();
editor.commands.focus(); editor.commands.focus();
}; };
const doFind = () => { const ms = sync(find, opts); if (ms.length) jumpTo(ms[0]); };
const next = () => { if (matches.length === 0) return; const i = (matchIdx + 1) % matches.length; setMatchIdx(i); jumpTo(i); }; 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 (matches.length === 0) return; const i = (matchIdx - 1 + matches.length) % matches.length; setMatchIdx(i); jumpTo(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 = () => { const doReplace = () => {
if (!editor || matches.length === 0) return; if (!editor || matches.length === 0) return;
const pos = matches[matchIdx]; const m = matches[active];
editor.chain().focus().deleteRange({ from: pos, to: pos + find.length }).insertContentAt(pos, replace).run(); const tr = editor.state.tr.delete(m.from, m.to).insertText(replace, m.from);
doFind(); // 重新查找 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<HTMLInputElement>) => {
const o = { ...opts, [k]: e.target.checked }; setOpts(o); sync(find, o);
}; };
return ( return (
<div style={{ padding: 12, borderBottom: '1px solid #333', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ padding: '6px 12px', borderBottom: '1px solid #333', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<input value={find} onChange={e => setFind(e.target.value)} placeholder="查找" onKeyDown={e => e.key === 'Enter' && doFind()} style={inp} /> <input value={find} onChange={e => setFind(e.target.value)} placeholder="查找" onKeyDown={e => e.key === 'Enter' && doFind()} style={inp} />
<input value={replace} onChange={e => setReplace(e.target.value)} placeholder="替换" style={inp} /> <input value={replace} onChange={e => setReplace(e.target.value)} placeholder="替换" style={inp} />
<label style={lab}><input type="checkbox" data-testid="fr-case" checked={opts.caseSensitive} onChange={toggle('caseSensitive')} /> Aa</label>
<label style={lab}><input type="checkbox" data-testid="fr-word" checked={opts.wholeWord} onChange={toggle('wholeWord')} /> 整词</label>
<label style={lab}><input type="checkbox" data-testid="fr-regex" checked={opts.regex} onChange={toggle('regex')} /> .*</label>
<button style={btn} onClick={doFind}>查找</button> <button style={btn} onClick={doFind}>查找</button>
<button style={btn} onClick={prev}>↑</button> <button style={btn} onClick={prev} title="上一个">↑</button>
<button style={btn} onClick={next}>↓</button> <button style={btn} onClick={next} title="下一个">↓</button>
<button style={btn} onClick={doReplace}>替换</button> <button style={btn} onClick={doReplace}>替换</button>
<span style={{ fontSize: 12, opacity: 0.5 }}>{matches.length > 0 ? `${matchIdx + 1}/${matches.length}` : ''}</span> <button style={btn} onClick={doReplaceAll}>全部替换</button>
<span style={{ fontSize: 12, opacity: 0.5 }}>{matches.length > 0 ? `${active + 1}/${matches.length}` : (find ? '0/0' : '')}</span>
</div> </div>
); );
} }
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 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' };
@@ -18,7 +18,7 @@ import { useStore } from '../../store';
import { invoke } from '../../ipc-client'; import { invoke } from '../../ipc-client';
import { EditorProvider } from './editor-context'; import { EditorProvider } from './editor-context';
import { EditorToolbar } from './EditorToolbar'; import { EditorToolbar } from './EditorToolbar';
import { FindReplace } from './FindReplace'; import { FindReplace, FindReplaceHighlight } from './FindReplace';
import { SlashMenu } from './SlashMenu'; import { SlashMenu } from './SlashMenu';
import { BlockOps } from './BlockOps'; import { BlockOps } from './BlockOps';
import { CodeBlockView } from './CodeBlockView'; import { CodeBlockView } from './CodeBlockView';
@@ -55,6 +55,7 @@ export function TiptapEditor() {
Placeholder.configure({ placeholder: '开始写作…' }), Placeholder.configure({ placeholder: '开始写作…' }),
Markdown.configure({ html: false, breaks: false, linkify: true, transformPastedText: true, transformCopiedText: true }), Markdown.configure({ html: false, breaks: false, linkify: true, transformPastedText: true, transformCopiedText: true }),
BlockOps, BlockOps,
FindReplaceHighlight,
], ],
content: body, content: body,
editorProps: { editorProps: {
@@ -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;
}
+3
View File
@@ -38,6 +38,9 @@ html, body, #root { margin: 0; height: 100%; }
.tiptap-wrap .ProseMirror .hljs-deletion { color: #e06c75; } .tiptap-wrap .ProseMirror .hljs-deletion { color: #e06c75; }
.tiptap-wrap .ProseMirror .hljs-emphasis { font-style: italic; } .tiptap-wrap .ProseMirror .hljs-emphasis { font-style: italic; }
.tiptap-wrap .ProseMirror .hljs-strong { font-weight: 700; } .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 { .tiptap-wrap .ProseMirror blockquote {
border-left: 3px solid #444; padding-left: 12px; color: #aaa; margin: 0.6em 0; border-left: 3px solid #444; padding-left: 12px; color: #aaa; margin: 0.6em 0;
} }
@@ -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();
});