Skip to content

Commit 4868212

Browse files
authored
Merge pull request #17 from Albert-PZY/codex/search-navigation-highlights
fix(search): center and highlight matched content
2 parents 22ba184 + c8a71df commit 4868212

3 files changed

Lines changed: 286 additions & 5 deletions

File tree

tests/editor-shell.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,64 @@ describe("editor shell controls", () => {
666666

667667
test("finds, replaces, and replaces all matches", () => {
668668
const { root, editor, cleanup } = mountShell("Body body");
669+
const previousScrollIntoView = HTMLElement.prototype.scrollIntoView;
670+
const previousGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
671+
let hideSecondMatch = false;
672+
const scrolls: Array<{
673+
index: string | null;
674+
block?: ScrollLogicalPosition;
675+
behavior?: ScrollBehavior;
676+
}> = [];
677+
HTMLElement.prototype.scrollIntoView = function (options?: boolean | ScrollIntoViewOptions) {
678+
const normalized = typeof options === "object" ? options : undefined;
679+
scrolls.push({
680+
index: this.dataset.searchMatchIndex ?? null,
681+
block: normalized?.block,
682+
behavior: normalized?.behavior,
683+
});
684+
};
685+
HTMLElement.prototype.getBoundingClientRect = function () {
686+
if (this.classList.contains("editor-search-match")) {
687+
if (hideSecondMatch && this.dataset.searchMatchIndex === "1") {
688+
return {
689+
x: 0,
690+
y: 0,
691+
top: 0,
692+
left: 0,
693+
right: 0,
694+
bottom: 0,
695+
width: 0,
696+
height: 0,
697+
toJSON: () => ({}),
698+
} as DOMRect;
699+
}
700+
return {
701+
x: 0,
702+
y: 0,
703+
top: 0,
704+
left: 0,
705+
right: 48,
706+
bottom: 20,
707+
width: 48,
708+
height: 20,
709+
toJSON: () => ({}),
710+
} as DOMRect;
711+
}
712+
if (this.tagName === "P") {
713+
return {
714+
x: 0,
715+
y: 0,
716+
top: 0,
717+
left: 0,
718+
right: 320,
719+
bottom: 24,
720+
width: 320,
721+
height: 24,
722+
toJSON: () => ({}),
723+
} as DOMRect;
724+
}
725+
return previousGetBoundingClientRect.call(this);
726+
};
669727

670728
try {
671729
clickAction(root, "find");
@@ -683,15 +741,42 @@ describe("editor shell controls", () => {
683741
editor.view.state.selection.to,
684742
)).toBe("Body");
685743
expect(panel.querySelector(".editor-search-count")?.textContent).toBe("1 / 2");
744+
expect(root.querySelectorAll(".editor-search-match")).toHaveLength(2);
745+
expect(root.querySelector(".editor-search-match-current")?.getAttribute(
746+
"data-search-match-index",
747+
)).toBe("0");
748+
expect(scrolls.at(-1)).toEqual({ index: "0", block: "center", behavior: "auto" });
749+
750+
hideSecondMatch = true;
751+
panel.querySelector<HTMLButtonElement>('[data-search-action="next"]')?.click();
752+
expect(root.querySelector(".editor-search-match-current")?.getAttribute(
753+
"data-search-match-index",
754+
)).toBe("1");
755+
expect(scrolls.at(-1)).toEqual({ index: null, block: "center", behavior: "auto" });
756+
hideSecondMatch = false;
757+
panel.querySelector<HTMLButtonElement>('[data-search-action="previous"]')?.click();
686758

687759
replacement.value = "Copy";
688760
panel.querySelector<HTMLButtonElement>('[data-search-action="replace"]')?.click();
689761
expect(editor.getMarkdown()).toBe("Copy body");
762+
expect(root.querySelectorAll(".editor-search-match")).toHaveLength(1);
763+
expect(root.querySelector(".editor-search-match-current")?.textContent).toBe("body");
690764

691765
panel.querySelector<HTMLButtonElement>('[data-search-action="replace-all"]')?.click();
692766
expect(editor.getMarkdown()).toBe("Copy Copy");
693767
expect(panel.querySelector(".editor-search-count")?.textContent).toBe("Replaced 1");
768+
expect(root.querySelectorAll(".editor-search-replacement")).toHaveLength(1);
769+
expect(root.querySelector(".editor-search-replacement")?.textContent).toBe("Copy");
770+
771+
query.value = "Copy";
772+
query.dispatchEvent(new InputEvent("input", { bubbles: true }));
773+
expect(root.querySelectorAll(".editor-search-match")).toHaveLength(2);
774+
panel.querySelector<HTMLButtonElement>('[data-search-action="close"]')?.click();
775+
expect(root.querySelectorAll(".editor-search-match")).toHaveLength(0);
694776
} finally {
777+
if (previousScrollIntoView) HTMLElement.prototype.scrollIntoView = previousScrollIntoView;
778+
else delete (HTMLElement.prototype as unknown as Record<string, unknown>).scrollIntoView;
779+
HTMLElement.prototype.getBoundingClientRect = previousGetBoundingClientRect;
695780
cleanup();
696781
}
697782
});

website/editor-search.ts

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,49 @@
1-
import { TextSelection } from "prosemirror-state";
1+
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
2+
import { Decoration, DecorationSet } from "prosemirror-view";
23

34
import type { Editor } from "../src/lib.ts";
45
import { onLocaleChange, t, translateTree } from "./i18n.ts";
56

67
type SearchMatch = { from: number; to: number };
78

9+
type SearchHighlightMeta = {
10+
matches: SearchMatch[];
11+
activeIndex: number;
12+
kind?: "match" | "replacement";
13+
};
14+
15+
const searchHighlightKey = new PluginKey<DecorationSet>("website-search-highlights");
16+
const searchHighlightPlugin = new Plugin<DecorationSet>({
17+
key: searchHighlightKey,
18+
state: {
19+
init: () => DecorationSet.empty,
20+
apply(transaction, current, _oldState, newState) {
21+
const meta = transaction.getMeta(searchHighlightKey) as SearchHighlightMeta | undefined;
22+
if (meta) {
23+
const decorations = meta.matches
24+
.filter((match) => match.from < match.to && match.to <= newState.doc.content.size)
25+
.map((match, index) => Decoration.inline(match.from, match.to, {
26+
class: [
27+
"editor-search-match",
28+
meta.kind === "replacement" ? "editor-search-replacement" : "",
29+
index === meta.activeIndex ? "editor-search-match-current" : "",
30+
].filter(Boolean).join(" "),
31+
"data-search-match-index": String(index),
32+
}));
33+
return DecorationSet.create(newState.doc, decorations);
34+
}
35+
return transaction.docChanged
36+
? current.map(transaction.mapping, transaction.doc)
37+
: current;
38+
},
39+
},
40+
props: {
41+
decorations(state) {
42+
return searchHighlightKey.getState(state) ?? DecorationSet.empty;
43+
},
44+
},
45+
});
46+
847
export type EditorSearch = {
948
open(): void;
1049
close(): void;
@@ -58,6 +97,86 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
5897
let matches: SearchMatch[] = [];
5998
let activeIndex = -1;
6099

100+
const clearNodeViewHighlight = (): void => {
101+
for (const element of editor.view.dom.querySelectorAll(".editor-search-node-current")) {
102+
element.classList.remove("editor-search-node-current");
103+
}
104+
};
105+
106+
const ensureHighlightPlugin = (): void => {
107+
const view = editor.view;
108+
if (searchHighlightKey.getState(view.state) !== undefined) return;
109+
view.updateState(view.state.reconfigure({
110+
plugins: [...view.state.plugins, searchHighlightPlugin],
111+
}));
112+
};
113+
114+
const updateHighlights = (kind: SearchHighlightMeta["kind"] = "match"): void => {
115+
clearNodeViewHighlight();
116+
ensureHighlightPlugin();
117+
editor.view.dispatch(editor.view.state.tr.setMeta(searchHighlightKey, {
118+
matches,
119+
activeIndex,
120+
kind,
121+
} satisfies SearchHighlightMeta));
122+
};
123+
124+
const scrollElementToCenter = (element: Element | null): boolean => {
125+
if (!(element instanceof HTMLElement) || typeof element.scrollIntoView !== "function") {
126+
return false;
127+
}
128+
const bounds = element.getBoundingClientRect();
129+
if (bounds.width <= 0 && bounds.height <= 0) return false;
130+
element.scrollIntoView({
131+
block: "center",
132+
inline: "nearest",
133+
behavior: "auto",
134+
});
135+
return true;
136+
};
137+
138+
const scrollRenderedAncestorToCenter = (start: Element | null): boolean => {
139+
let element = start;
140+
while (element) {
141+
if (scrollElementToCenter(element)) {
142+
return true;
143+
}
144+
element = element.parentElement;
145+
}
146+
return false;
147+
};
148+
149+
const scrollPositionToCenter = (position: number, highlightNodeView = false): void => {
150+
try {
151+
const clamped = Math.max(0, Math.min(position, editor.view.state.doc.content.size));
152+
const target = editor.view.domAtPos(clamped).node;
153+
const start = target instanceof Element ? target : target.parentElement;
154+
const nodeView = highlightNodeView
155+
? start?.closest<HTMLElement>('[contenteditable="false"]') ?? null
156+
: null;
157+
if (nodeView) {
158+
nodeView.classList.add("editor-search-node-current");
159+
if (!scrollElementToCenter(nodeView)) scrollRenderedAncestorToCenter(nodeView.parentElement);
160+
return;
161+
}
162+
scrollRenderedAncestorToCenter(start);
163+
} catch {}
164+
};
165+
166+
const scrollActiveMatchToCenter = (match: SearchMatch): void => {
167+
clearNodeViewHighlight();
168+
const highlighted = editor.view.dom.querySelector(".editor-search-match-current");
169+
if (scrollElementToCenter(highlighted)) return;
170+
const nodeView = highlighted?.closest<HTMLElement>('[contenteditable="false"]') ?? null;
171+
if (nodeView) {
172+
nodeView.classList.add("editor-search-node-current");
173+
if (!scrollElementToCenter(nodeView)) scrollRenderedAncestorToCenter(nodeView.parentElement);
174+
return;
175+
}
176+
if (scrollRenderedAncestorToCenter(highlighted?.parentElement ?? null)) return;
177+
scrollPositionToCenter(match.from, true);
178+
};
179+
61180
const renderCount = (replacementCount?: number): void => {
62181
if (replacementCount !== undefined) {
63182
count.textContent = t("home.search.replaced", { count: replacementCount });
@@ -77,29 +196,37 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
77196
matches = findMatches(editor, queryInput.value);
78197
if (matches.length === 0) {
79198
activeIndex = -1;
199+
updateHighlights();
80200
renderCount();
81201
return;
82202
}
83203
activeIndex = ((index % matches.length) + matches.length) % matches.length;
84204
const match = matches[activeIndex]!;
205+
ensureHighlightPlugin();
85206
const transaction = editor.view.state.tr
86207
.setSelection(TextSelection.create(editor.view.state.doc, match.from, match.to))
208+
.setMeta(searchHighlightKey, { matches, activeIndex } satisfies SearchHighlightMeta)
87209
.scrollIntoView();
88210
editor.view.dispatch(transaction);
211+
scrollActiveMatchToCenter(match);
89212
renderCount();
90213
};
91214

92215
const refresh = (): void => {
93216
matches = findMatches(editor, queryInput.value);
94217
activeIndex = matches.length > 0 ? 0 : -1;
95218
if (activeIndex >= 0) selectMatch(activeIndex);
96-
else renderCount();
219+
else {
220+
updateHighlights();
221+
renderCount();
222+
}
97223
};
98224

99225
const replaceCurrent = (): void => {
100226
matches = findMatches(editor, queryInput.value);
101227
if (matches.length === 0) {
102228
activeIndex = -1;
229+
updateHighlights();
103230
renderCount();
104231
return;
105232
}
@@ -111,7 +238,13 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
111238
matches = findMatches(editor, queryInput.value);
112239
if (matches.length > 0) selectMatch(Math.min(index, matches.length - 1));
113240
else {
114-
activeIndex = -1;
241+
matches = replacementInput.value
242+
? [{ from: match.from, to: match.from + replacementInput.value.length }]
243+
: [];
244+
activeIndex = matches.length > 0 ? 0 : -1;
245+
updateHighlights("replacement");
246+
if (matches[0]) scrollActiveMatchToCenter(matches[0]);
247+
else scrollPositionToCenter(match.from);
115248
renderCount(1);
116249
}
117250
};
@@ -120,23 +253,39 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
120253
matches = findMatches(editor, queryInput.value);
121254
if (matches.length === 0) {
122255
activeIndex = -1;
256+
updateHighlights();
123257
renderCount();
124258
return;
125259
}
126260
const replacementCount = matches.length;
261+
const firstMatchPosition = matches[0]!.from;
262+
let offset = 0;
263+
const replacementMatches = replacementInput.value
264+
? matches.map((match) => {
265+
const from = match.from + offset;
266+
offset += replacementInput.value.length - (match.to - match.from);
267+
return { from, to: from + replacementInput.value.length };
268+
})
269+
: [];
127270
let transaction = editor.view.state.tr;
128271
for (const match of [...matches].reverse()) {
129272
transaction = transaction.insertText(replacementInput.value, match.from, match.to);
130273
}
131274
editor.view.dispatch(transaction.scrollIntoView());
132-
matches = findMatches(editor, queryInput.value);
133-
activeIndex = -1;
275+
matches = replacementMatches;
276+
activeIndex = matches.length > 0 ? 0 : -1;
277+
updateHighlights("replacement");
278+
if (matches[0]) scrollActiveMatchToCenter(matches[0]);
279+
else scrollPositionToCenter(firstMatchPosition);
134280
renderCount(replacementCount);
135281
};
136282

137283
const close = (): void => {
138284
if (panel.hidden) return;
139285
panel.hidden = true;
286+
matches = [];
287+
activeIndex = -1;
288+
updateHighlights();
140289
editor.focus();
141290
};
142291

@@ -191,6 +340,9 @@ export function mountEditorSearch(main: HTMLElement, editor: Editor): EditorSear
191340
},
192341
close,
193342
destroy(): void {
343+
matches = [];
344+
activeIndex = -1;
345+
updateHighlights();
194346
cleanupLocale();
195347
panel.removeEventListener("click", onClick);
196348
panel.removeEventListener("keydown", onKeyDown);

0 commit comments

Comments
 (0)