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); + } +});