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
This commit is contained in:
1 parent
f4b36fc64f
commit
71e2395e28
13 files changed
+279
-23
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 = ({
|
||||
<MarkdownSourceEditor
|
||||
ref={markdownSourceEditorRef}
|
||||
memoId={memo?.id ?? ""}
|
||||
searchQuery={noteSearchHighlightQuery}
|
||||
searchIndex={noteSearchHighlightIndex}
|
||||
value={markdownSource}
|
||||
onChange={handleMarkdownSourceChange}
|
||||
onPasteFiles={uploadMarkdownPasteFiles}
|
||||
|
||||
@@ -2800,7 +2800,7 @@ export const WorkspaceApp = ({
|
||||
}
|
||||
|
||||
const targetElement = event.target instanceof Element ? event.target : null;
|
||||
const isEditorTextTarget = Boolean(targetElement?.closest(".ProseMirror"));
|
||||
const isEditorTextTarget = Boolean(targetElement?.closest(".ProseMirror, .edgeever-markdown-source-editor"));
|
||||
|
||||
const transientLayerOpen = Boolean(
|
||||
appNoticeDialog ||
|
||||
|
||||
@@ -76,7 +76,7 @@ export const EditorNoteSearchBar = ({
|
||||
<span
|
||||
className={cn(
|
||||
"w-12 shrink-0 text-center text-xs tabular-nums",
|
||||
query.trim() && matchCount === 0 ? "text-rose-500" : "text-slate-500",
|
||||
query.length > 0 && matchCount === 0 ? "text-rose-500" : "text-slate-500",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
|
||||
@@ -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<MarkdownThemeName, Extension> = {
|
||||
@@ -129,6 +138,8 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
|
||||
onLinkShortcut,
|
||||
onSelectionChange,
|
||||
onReady,
|
||||
searchQuery = "",
|
||||
searchIndex = 0,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -180,6 +191,12 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
|
||||
scrollIntoView: true,
|
||||
});
|
||||
},
|
||||
replaceSearchMatches: (query: string, replacement: string) => {
|
||||
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<MarkdownSourceEditorRef, Markdown
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
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<MarkdownSourceEditorRef, Markdown
|
||||
if (update.selectionSet || update.docChanged) onSelectionChangeRef.current?.();
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
}, [searchQuery, searchIndex]);
|
||||
|
||||
const handlePasteCapture = useCallback((event: ReactClipboardEvent<HTMLDivElement>) => {
|
||||
if (readOnly || !onPasteFiles) return;
|
||||
@@ -378,7 +408,7 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
|
||||
// lights up later list items and looks like extra selection.
|
||||
highlightSelectionMatches: false,
|
||||
closeBracketsKeymap: true,
|
||||
searchKeymap: true,
|
||||
searchKeymap: false,
|
||||
foldKeymap: false,
|
||||
completionKeymap: false,
|
||||
lintKeymap: false,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { history, isolateHistory, undo, redo } from "@codemirror/commands";
|
||||
import { getMarkdownSearchMatches, getMarkdownReplacementChanges } from "./markdown-source-search";
|
||||
|
||||
const replace = (source, query, replacement) => 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");
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
@@ -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<MarkdownSourceEditorRef | null>;
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user