From 71e2395e28a9f8de2b4b036a09eaaacc21b0f4e5 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 15 Oct 2026 11:43:34 +0800 Subject: [PATCH] fix(editor): search and replace in the active editor mode Search raw Markdown in source mode with literal character matching, batched undoable replacement, and matching highlights. Preserve query whitespace and keep replacement focus stable. Route source editor search shortcuts through the workspace controller. Validation: 141 editor tests and 2 Chrome end-to-end cases passed; web and mobile typechecks and the web build passed. Refs #574 --- apps/web/package.json | 3 + apps/web/src/components/EditorPane.tsx | 7 ++ apps/web/src/components/WorkspaceApp.tsx | 2 +- .../components/editor/EditorNoteSearchBar.tsx | 2 +- .../editor/MarkdownSourceEditor.tsx | 34 +++++- .../editor/markdown-source-search.test.mjs | 50 ++++++++ .../editor/markdown-source-search.ts | 17 +++ .../components/editor/note-search.test.mjs | 10 +- apps/web/src/components/editor/note-search.ts | 4 +- .../editor/useEditorNoteSearchController.ts | 52 +++++--- apps/web/src/styles/globals.css | 6 +- bun.lock | 3 + tests/e2e/note-search.spec.ts | 112 ++++++++++++++++++ 13 files changed, 279 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/editor/markdown-source-search.test.mjs create mode 100644 apps/web/src/components/editor/markdown-source-search.ts create mode 100644 tests/e2e/note-search.spec.ts diff --git a/apps/web/package.json b/apps/web/package.json index d214ea50..162e5df1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,8 +6,11 @@ "dependencies": { "@antv/infographic": "0.2.20", "@antv/x6": "3.1.8", + "@codemirror/commands": "6.11.0", "@codemirror/lang-css": "6.3.1", "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/search": "6.7.1", + "@codemirror/state": "6.7.1", "@edgeever/client": "workspace:*", "@floating-ui/react-dom": "^2.1.8", "@fontsource-variable/noto-sans-sc": "^5.2.8", diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 849da2b3..f82e6567 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -1985,6 +1985,8 @@ const RichEditorPane = ({ const { closeReplace: closeNoteReplace, + highlightQuery: noteSearchHighlightQuery, + highlightIndex: noteSearchHighlightIndex, closeSearch: closeNoteSearch, inputRef: noteSearchInputRef, matchLabel: noteSearchMatchLabel, @@ -2002,6 +2004,9 @@ const RichEditorPane = ({ setQuery: setNoteSearchQuery, setReplacement: setNoteSearchReplacement, } = useEditorNoteSearchController({ + markdownMode: useMarkdownSourceEditor, + markdownSource, + markdownSourceEditorRef, contentSearchQuery, dirtyVersion, editor, @@ -4425,6 +4430,8 @@ const RichEditorPane = ({ 0 && matchCount === 0 ? "text-rose-500" : "text-slate-500", )} aria-live="polite" > diff --git a/apps/web/src/components/editor/MarkdownSourceEditor.tsx b/apps/web/src/components/editor/MarkdownSourceEditor.tsx index 6b81bfdc..3a898efd 100644 --- a/apps/web/src/components/editor/MarkdownSourceEditor.tsx +++ b/apps/web/src/components/editor/MarkdownSourceEditor.tsx @@ -1,9 +1,15 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, type ClipboardEvent as ReactClipboardEvent } from "react"; import CodeMirror, { EditorView, + Decoration, + ViewPlugin, + type DecorationSet, + type ViewUpdate, type ReactCodeMirrorRef, type Extension, } from "@uiw/react-codemirror"; +import { isolateHistory } from "@codemirror/commands"; +import { getMarkdownReplacementChanges, getMarkdownSearchMatches } from "./markdown-source-search"; import { markdown } from "@codemirror/lang-markdown"; import { githubLightInit, @@ -36,6 +42,7 @@ export interface MarkdownSourceEditorRef { getSelection: () => { from: number; to: number }; setSelection: (from: number, to: number) => void; replaceDocument: (value: string) => void; + replaceSearchMatches: (query: string, replacement: string) => void; focus: () => void; insertText: (text: string, from?: number, to?: number) => void; sliceText: (from: number, to: number) => string; @@ -57,6 +64,8 @@ export interface MarkdownSourceEditorProps { onLinkShortcut?: () => void; onSelectionChange?: () => void; onReady?: () => void; + searchQuery?: string; + searchIndex?: number; } export const CODE_MIRROR_THEME_MAP: Record = { @@ -129,6 +138,8 @@ export const MarkdownSourceEditor = forwardRef { @@ -180,6 +191,12 @@ export const MarkdownSourceEditor = forwardRef { + const view = cmRef.current?.view; + if (!view || view.state.readOnly) return; + const changes = getMarkdownReplacementChanges(view.state.doc, query, replacement); + if (changes.length) view.dispatch({ changes, userEvent: "input.replace", annotations: isolateHistory.of("full") }); + }, replaceDocument: (value: string) => { const view = cmRef.current?.view; if (!view || view.state.doc.toString() === value) return; @@ -236,6 +253,19 @@ export const MarkdownSourceEditor = forwardRef { return [ + ViewPlugin.fromClass(class { + decorations: DecorationSet; + constructor(view: EditorView) { this.decorations = this.highlight(view); } + update(update: ViewUpdate) { + if (update.docChanged) this.decorations = this.highlight(update.view); + } + highlight(view: EditorView) { + return Decoration.set(getMarkdownSearchMatches(view.state.doc, searchQuery).map((match, index) => + Decoration.mark({ class: index === searchIndex + ? "edgeever-search-match edgeever-search-match-active" + : "edgeever-search-match" }).range(match.from, match.to)), true); + } + }, { decorations: (plugin) => plugin.decorations }), markdown(), EditorView.lineWrapping, baseEditorTheme, @@ -249,7 +279,7 @@ export const MarkdownSourceEditor = forwardRef) => { if (readOnly || !onPasteFiles) return; @@ -378,7 +408,7 @@ export const MarkdownSourceEditor = forwardRef EditorState.create({ doc: source }).update({ + changes: getMarkdownReplacementChanges(source, query, replacement), +}).state.doc.toString(); + +describe("Markdown source search", () => { + test("matches raw escape characters and Markdown syntax literally", () => { + const source = String.raw`\\alpha \[link\](url) **bold** $& \\beta`; + expect(getMarkdownSearchMatches(source, "\\\\")).toEqual([{ from: 0, to: 2 }, { from: 34, to: 36 }]); + expect(replace(source, "\\", "")).toBe("alpha [link](url) **bold** $& beta"); + expect(replace("a\\nb\na\\nb", String.raw`\n`, "!")).toBe("a!b\na!b"); + expect(replace("**bold**", "**", "")).toBe("bold"); + }); + + test("keeps query whitespace and replacement special characters", () => { + expect(replace(" a b ", " ", "")).toBe("ab"); + expect(replace(" a a ", " a ", "$&\\n")).toBe("$&\\na "); + expect(replace("Aa aa", "aa", "$1\\")).toBe("$1\\ $1\\"); + expect(getMarkdownSearchMatches("anything", "")).toEqual([]); + expect(replace("anything", "", "x")).toBe("anything"); + }); + + test("uses non-overlapping matches and maintains source offsets after Unicode text", () => { + expect(getMarkdownSearchMatches("😀aaaa", "aa")).toEqual([{ from: 2, to: 4 }, { from: 4, to: 6 }]); + expect(replace("😀aaaa", "aa", "b")).toBe("😀bb"); + }); + + test("undoes and redoes a whole replacement separately from adjacent typing", () => { + let state = EditorState.create({ doc: "a\\b\\c", extensions: [history()] }); + const dispatch = (tr) => { state = tr.state; }; + state = state.update({ changes: { from: state.doc.length, insert: "!" }, userEvent: "input.type" }).state; + state = state.update({ + changes: getMarkdownReplacementChanges(state.doc, "\\", ""), + annotations: isolateHistory.of("full"), + userEvent: "input.replace", + }).state; + expect(state.doc.toString()).toBe("abc!"); + expect(undo({ state, dispatch })).toBe(true); + expect(state.doc.toString()).toBe("a\\b\\c!"); + expect(redo({ state, dispatch })).toBe(true); + expect(state.doc.toString()).toBe("abc!"); + expect(undo({ state, dispatch })).toBe(true); + expect(undo({ state, dispatch })).toBe(true); + expect(state.doc.toString()).toBe("a\\b\\c"); + }); +}); diff --git a/apps/web/src/components/editor/markdown-source-search.ts b/apps/web/src/components/editor/markdown-source-search.ts new file mode 100644 index 00000000..96926eb0 --- /dev/null +++ b/apps/web/src/components/editor/markdown-source-search.ts @@ -0,0 +1,17 @@ +import { SearchQuery } from "@codemirror/search"; +import { Text } from "@codemirror/state"; +import type { NoteSearchMatch } from "./note-search"; + +export const getMarkdownSearchMatches = (source: string | Text, query: string): NoteSearchMatch[] => { + if (!query.length) return []; + const doc = typeof source === "string" ? Text.of(source.split("\n")) : source; + const cursor = new SearchQuery({ search: query, literal: true, caseSensitive: false }).getCursor(doc); + const matches: NoteSearchMatch[] = []; + for (let next = cursor.next(); !next.done; next = cursor.next()) { + matches.push({ from: next.value.from, to: next.value.to }); + } + return matches; +}; + +export const getMarkdownReplacementChanges = (source: string | Text, query: string, replacement: string) => + getMarkdownSearchMatches(source, query).map((match) => ({ ...match, insert: replacement })); diff --git a/apps/web/src/components/editor/note-search.test.mjs b/apps/web/src/components/editor/note-search.test.mjs index 15fe48d2..6133c20d 100644 --- a/apps/web/src/components/editor/note-search.test.mjs +++ b/apps/web/src/components/editor/note-search.test.mjs @@ -36,6 +36,13 @@ describe("note search", () => { ]); }); + test("matches literal backslashes and preserves query whitespace", () => { + const doc = documentWithParagraphs("a\\b c"); + expect(getSearchMatchesFromDocument(doc, "\\")).toEqual([{ from: 2, to: 3 }]); + expect(getSearchMatchesFromDocument(doc, " ")).toEqual([{ from: 4, to: 6 }]); + expect(getSearchMatchesFromDocument(doc, " c")).toEqual([{ from: 5, to: 7 }]); + }); + test("does not match across block boundaries", () => { const doc = documentWithParagraphs("beta", "alpha"); expect(getSearchMatchesFromDocument(doc, "betaalpha")).toEqual([]); @@ -55,7 +62,8 @@ describe("note search", () => { expect(getNextSearchMatchIndex(4, 1, 0)).toBe(0); expect(formatNoteSearchMatchLabel("alpha", 1, 3)).toBe("2/3"); expect(formatNoteSearchMatchLabel("alpha", 0, 0)).toBe("0/0"); - expect(formatNoteSearchMatchLabel(" ", 0, 3)).toBe("0/0"); + expect(formatNoteSearchMatchLabel(" ", 0, 3)).toBe("1/3"); + expect(formatNoteSearchMatchLabel("", 0, 0)).toBe("0/0"); }); test("resets in-note search when switching notes, including leftover plugin jumps", () => { diff --git a/apps/web/src/components/editor/note-search.ts b/apps/web/src/components/editor/note-search.ts index 19a3b4ad..ce87fda1 100644 --- a/apps/web/src/components/editor/note-search.ts +++ b/apps/web/src/components/editor/note-search.ts @@ -37,7 +37,7 @@ export const getSearchMatchesFromDocument = ( doc: Editor["state"]["doc"], query: string, ): NoteSearchMatch[] => { - const needle = query.trim().toLocaleLowerCase(); + const needle = query.toLocaleLowerCase(); if (needle.length === 0) return []; const characters: Array<{ char: string; pos: number }> = []; @@ -99,6 +99,6 @@ export const formatNoteSearchMatchLabel = ( query: string, activeIndex: number, matchCount: number, -) => query.trim() +) => query.length > 0 ? `${matchCount > 0 ? activeIndex + 1 : 0}/${matchCount}` : "0/0"; diff --git a/apps/web/src/components/editor/useEditorNoteSearchController.ts b/apps/web/src/components/editor/useEditorNoteSearchController.ts index 9dde8b3e..b07de1ae 100644 --- a/apps/web/src/components/editor/useEditorNoteSearchController.ts +++ b/apps/web/src/components/editor/useEditorNoteSearchController.ts @@ -19,7 +19,13 @@ import { type NoteSearchMatch, } from "./note-search"; +import type { MarkdownSourceEditorRef } from "./MarkdownSourceEditor"; +import { getMarkdownSearchMatches } from "./markdown-source-search"; + type EditorNoteSearchControllerOptions = { + markdownMode: boolean; + markdownSource: string; + markdownSourceEditorRef: RefObject; contentSearchQuery: string; dirtyVersion: number; editor: Editor | null; @@ -43,6 +49,9 @@ const getEditorSearchMatches = (editor: Editor | null, query: string): NoteSearc }; export const useEditorNoteSearchController = ({ + markdownMode, + markdownSource, + markdownSourceEditorRef, contentSearchQuery, dirtyVersion, editor, @@ -73,18 +82,22 @@ export const useEditorNoteSearchController = ({ } const noteSearchMatches = useMemo( - () => getEditorSearchMatches(editor, noteSearchQuery), - [dirtyVersion, editor, memoId, noteSearchQuery], + () => markdownMode ? getMarkdownSearchMatches(markdownSource, noteSearchQuery) : getEditorSearchMatches(editor, noteSearchQuery), + [dirtyVersion, editor, markdownMode, markdownSource, memoId, noteSearchQuery], ); const contentSearchMatches = useMemo( - () => getEditorSearchMatches(editor, contentSearchQuery), - [contentSearchQuery, dirtyVersion, editor, memoId], + () => markdownMode ? getMarkdownSearchMatches(markdownSource, contentSearchQuery) : getEditorSearchMatches(editor, contentSearchQuery), + [contentSearchQuery, dirtyVersion, editor, markdownMode, markdownSource, memoId], ); const selectMatch = useCallback( (index: number, matches: NoteSearchMatch[]) => { const match = matches[index]; + if (markdownMode) { + if (match) markdownSourceEditorRef.current?.setSelection(match.from, match.to); + return; + } if (!isEditorReady(editor) || !match) { return; } @@ -117,11 +130,11 @@ export const useEditorNoteSearchController = ({ } }); }, - [editor, editorScrollContainerRef], + [editor, editorScrollContainerRef, markdownMode, markdownSourceEditorRef], ); useEffect(() => { - if (!isEditorReady(editor)) { + if (markdownMode || !isEditorReady(editor)) { return; } @@ -137,7 +150,7 @@ export const useEditorNoteSearchController = ({ editor.unregisterPlugin(NOTE_SEARCH_HIGHLIGHT_PLUGIN_KEY); } }; - }, [contentSearchQuery, editor, noteSearchIndex, noteSearchOpen, noteSearchQuery]); + }, [contentSearchQuery, editor, markdownMode, noteSearchIndex, noteSearchOpen, noteSearchQuery]); const focusSearchInput = useCallback(() => { window.requestAnimationFrame(() => { @@ -178,10 +191,12 @@ export const useEditorNoteSearchController = ({ const closeSearch = useCallback(() => { setNoteSearchOpen(false); - if (isEditorReady(editor)) { + if (markdownMode) { + markdownSourceEditorRef.current?.focus(); + } else if (isEditorReady(editor)) { editor.commands.focus(); } - }, [editor, setNoteSearchOpen]); + }, [editor, markdownMode, markdownSourceEditorRef, setNoteSearchOpen]); useEffect(() => { if (!noteSearchOpen) { @@ -234,7 +249,7 @@ export const useEditorNoteSearchController = ({ const source = noteSearchOpen ? "note" : "content"; const query = noteSearchOpen ? noteSearchQuery : contentSearchQuery; const matches = noteSearchOpen ? noteSearchMatches : contentSearchMatches; - const identity = getSearchNavigationIdentity(memoId, source, query); + const identity = JSON.stringify([markdownMode, getSearchNavigationIdentity(memoId, source, query)]); const previousSelection = automaticSelectionRef.current; if (previousSelection?.editor === editor && previousSelection.identity === identity) { @@ -250,6 +265,7 @@ export const useEditorNoteSearchController = ({ }, [ contentSearchMatches, contentSearchQuery, + markdownMode, editor, memoId, noteSearchMatches, @@ -266,13 +282,18 @@ export const useEditorNoteSearchController = ({ }, [noteSearchMatches.length, setNoteSearchIndex]); const replaceAllMatches = useCallback(() => { - if (!isEditorReady(editor) || readOnly || noteSearchMatches.length === 0) { + if (readOnly || noteSearchMatches.length === 0) return; + if (markdownMode) { + markdownSourceEditorRef.current?.replaceSearchMatches(noteSearchQuery, noteSearchReplacement); + setNoteSearchIndex(0); + return; + } + if (!isEditorReady(editor)) { return; } editor .chain() - .focus() .command(({ tr, dispatch }) => { for (const match of [...noteSearchMatches].reverse()) { tr.insertText(noteSearchReplacement, match.from, match.to); @@ -284,10 +305,11 @@ export const useEditorNoteSearchController = ({ .run(); setNoteSearchIndex(0); - window.requestAnimationFrame(() => noteSearchInputRef.current?.focus()); }, [ editor, - noteSearchInputRef, + markdownMode, + markdownSourceEditorRef, + noteSearchQuery, noteSearchMatches, noteSearchReplacement, readOnly, @@ -295,6 +317,8 @@ export const useEditorNoteSearchController = ({ ]); return { + highlightQuery: noteSearchOpen ? noteSearchQuery : contentSearchQuery, + highlightIndex: noteSearchOpen ? noteSearchIndex : 0, closeReplace, closeSearch, inputRef: noteSearchInputRef, diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 9f9ad43d..f422c730 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -1334,13 +1334,15 @@ html[data-edgeever-extension-theme] .edgeever-memo-divider { } } -.ProseMirror .edgeever-search-match { +.ProseMirror .edgeever-search-match, +.edgeever-markdown-source-editor .edgeever-search-match { border-radius: 0.2rem; background-color: var(--search-match); box-shadow: 0 0 0 1px rgb(var(--brand-green-rgb) / 0.22); } -.ProseMirror .edgeever-search-match-active { +.ProseMirror .edgeever-search-match-active, +.edgeever-markdown-source-editor .edgeever-search-match-active { background-color: var(--search-match-active); box-shadow: 0 0 0 2px rgb(var(--brand-green-rgb) / 0.4); } diff --git a/bun.lock b/bun.lock index 708f685c..43665c03 100644 --- a/bun.lock +++ b/bun.lock @@ -228,8 +228,11 @@ "dependencies": { "@antv/infographic": "0.2.20", "@antv/x6": "3.1.8", + "@codemirror/commands": "6.11.0", "@codemirror/lang-css": "6.3.1", "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/search": "6.7.1", + "@codemirror/state": "6.7.1", "@edgeever/client": "workspace:*", "@floating-ui/react-dom": "^2.1.8", "@fontsource-variable/noto-sans-sc": "^5.2.8", diff --git a/tests/e2e/note-search.spec.ts b/tests/e2e/note-search.spec.ts new file mode 100644 index 00000000..987fdc4c --- /dev/null +++ b/tests/e2e/note-search.spec.ts @@ -0,0 +1,112 @@ +import { expect, test, type Page } from "@playwright/test"; + +const createMemo = async (page: Page, contentMarkdown: string) => { + const notebooks = await (await page.request.get("/api/v1/notebooks")).json() as { notebooks: Array<{ id: string }> }; + const title = `Note search regression ${Date.now()}`; + const response = await page.request.post("/api/v1/memos", { + data: { notebookId: notebooks.notebooks[0]?.id, title, contentMarkdown }, + }); + expect(response.status()).toBe(201); + const { memo } = await response.json() as { memo: { id: string } }; + await page.goto("/"); + await expect(page.locator("[data-memo-id]").first()).toBeVisible(); + await page.getByRole("button", { name: "全部笔记", exact: true }).click(); + await page.getByPlaceholder("搜索笔记").fill(title); + await page.locator(`[data-memo-id="${memo.id}"]`).locator("button").first().click(); + await expect(page.getByPlaceholder("无标题笔记")).toHaveValue(title); + return { ...memo, title }; +}; + +const deleteMemo = async (page: Page, id: string) => { + await page.request.delete(`/api/v1/memos/${id}`); + await page.request.delete(`/api/v1/memos/${id}?permanent=1`); +}; + +const ensureSourceMode = async (page: Page) => { + const toggle = page.getByRole("button", { name: "切换到 Markdown 源码", exact: true }); + if (await toggle.count()) await toggle.click(); + await expect(page.locator(".cm-content")).toBeVisible(); +}; + +const openReplace = async (page: Page, selector: string) => { + await page.locator(selector).click(); + await page.keyboard.press("ControlOrMeta+h"); + await expect(page.getByPlaceholder("替换为", { exact: true })).toBeVisible(); +}; + +const searchQuery = (page: Page) => page.getByPlaceholder("在当前笔记内搜索", { exact: true }); +const replaceAll = (page: Page) => page.getByRole("button", { name: "全部替换", exact: true }); +const readSource = async (page: Page) => (await page.locator(".cm-content .cm-line").allTextContents()).join("\n"); + +test("source search replaces literal escapes with empty text, supports undo, and persists the result", async ({ page }) => { + const memo = await createMemo(page, "# Search regression\n\n**bold** \\\\alpha \\[literal\\] $& \\\\beta\n\nspace end"); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + try { + await ensureSourceMode(page); + const original = await readSource(page); + await openReplace(page, ".cm-content"); + await searchQuery(page).fill("\\"); + await expect(page.locator(".cm-content .edgeever-search-match")).toHaveCount(6); + await expect(page.locator('[aria-live="polite"]').filter({ hasText: "1/6" })).toBeVisible(); + await page.getByRole("button", { name: "下一个搜索结果", exact: true }).click(); + await expect(page.locator('[aria-live="polite"]').filter({ hasText: "2/6" })).toBeVisible(); + await expect(page.locator(".cm-search")).toHaveCount(0); + await replaceAll(page).click(); + await expect.poll(() => readSource(page)).toBe(original.replaceAll("\\", "")); + await page.getByRole("button", { name: "关闭搜索", exact: true }).click(); + await expect(page.locator(".cm-content")).toBeFocused(); + await page.keyboard.press("ControlOrMeta+z"); + await expect.poll(() => readSource(page)).toBe(original); + + await openReplace(page, ".cm-content"); + await searchQuery(page).fill("\\"); + await page.getByPlaceholder("替换为", { exact: true }).fill("$1\\n"); + await replaceAll(page).click(); + const expected = original.replaceAll("\\", "$1\\n"); + await expect.poll(() => readSource(page)).toBe(expected); + await page.keyboard.press("ControlOrMeta+s"); + await expect.poll(async () => { + const result = await (await page.request.get(`/api/v1/memos/${memo.id}`)).json() as { memo: { contentMarkdown: string } }; + return result.memo.contentMarkdown; + }, { timeout: 20000 }).toBe(expected); + await page.reload(); + await expect(page.locator("[data-memo-id]").first()).toBeVisible(); + await page.getByRole("button", { name: "全部笔记", exact: true }).click(); + await page.getByPlaceholder("搜索笔记").fill(memo.title); + await page.locator(`[data-memo-id="${memo.id}"]`).locator("button").first().click(); + await expect(page.getByPlaceholder("无标题笔记")).toHaveValue(memo.title); + await ensureSourceMode(page); + await expect.poll(() => readSource(page)).toBe(expected); + expect(errors).toEqual([]); + } finally { + await deleteMemo(page, memo.id); + } +}); + +test("search follows the active mode and rich text supports whitespace deletion without moving focus into the body", async ({ page }) => { + const memo = await createMemo(page, "**bold** alpha beta"); + try { + await ensureSourceMode(page); + await openReplace(page, ".cm-content"); + await searchQuery(page).fill("**"); + await expect(replaceAll(page)).toBeEnabled(); + await page.getByRole("button", { name: "切换到富文本编辑", exact: true }).click(); + await expect(replaceAll(page)).toBeDisabled(); + await searchQuery(page).fill(" "); + const rich = page.locator(".ProseMirror[contenteditable='true']"); + await expect(rich.locator(".edgeever-search-match")).toHaveCount(3); + await rich.evaluate((element) => { + element.setAttribute("data-search-focus-count", "0"); + element.addEventListener("focus", () => { + element.setAttribute("data-search-focus-count", String(Number(element.getAttribute("data-search-focus-count")) + 1)); + }); + }); + await page.getByPlaceholder("替换为", { exact: true }).press("Enter"); + await expect(rich).toHaveText("boldalphabeta"); + await expect(rich).toHaveAttribute("data-search-focus-count", "0"); + await expect(page.getByPlaceholder("替换为", { exact: true })).toBeFocused(); + } finally { + await deleteMemo(page, memo.id); + } +});