Skip to content

Commit 268829c

Browse files
Improve note and editor search highlighting
1 parent 52a7439 commit 268829c

4 files changed

Lines changed: 438 additions & 60 deletions

File tree

src/components/editor/note-editor.tsx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ type NoteEditorProps = {
6161
html: string | null;
6262
isNew: boolean;
6363
markdown: string;
64+
onEditorFocusChange?(focused: boolean): void;
65+
onSearchMatchCountChange?(count: number): void;
6466
readOnly: boolean;
67+
searchHighlightAllMatchesYellow?: boolean;
68+
searchActiveMatchIndex?: number | null;
6569
searchQuery: string;
6670
toolbarContainer: HTMLElement | null;
6771
onChange(markdown: string): void;
@@ -80,9 +84,13 @@ function EditorInner({
8084
isNew,
8185
markdown,
8286
readOnly,
87+
searchHighlightAllMatchesYellow,
88+
searchActiveMatchIndex,
8389
searchQuery,
8490
toolbarContainer,
8591
onChange,
92+
onEditorFocusChange,
93+
onSearchMatchCountChange,
8694
onFocusHandled,
8795
editorRef,
8896
}: NoteEditorProps & {
@@ -144,9 +152,42 @@ function EditorInner({
144152
onFocusHandled();
145153
}, [editor, focusMode, onFocusHandled, readOnly]);
146154

155+
useEffect(() => {
156+
const handleFocusIn = () => {
157+
onEditorFocusChange?.(true);
158+
};
159+
160+
const handleFocusOut = (event: FocusEvent) => {
161+
const root = editor.getRootElement();
162+
const nextTarget = event.relatedTarget;
163+
164+
if (root && nextTarget instanceof Node && root.contains(nextTarget)) {
165+
return;
166+
}
167+
168+
onEditorFocusChange?.(false);
169+
};
170+
171+
return editor.registerRootListener((root, prevRoot) => {
172+
if (prevRoot) {
173+
prevRoot.removeEventListener("focusin", handleFocusIn);
174+
prevRoot.removeEventListener("focusout", handleFocusOut);
175+
}
176+
177+
if (!root) {
178+
onEditorFocusChange?.(false);
179+
return;
180+
}
181+
182+
root.addEventListener("focusin", handleFocusIn);
183+
root.addEventListener("focusout", handleFocusOut);
184+
onEditorFocusChange?.(root.contains(document.activeElement));
185+
});
186+
}, [editor, onEditorFocusChange]);
187+
147188
return (
148189
<>
149-
<div className="comet-editor-content-wrap">
190+
<div className="comet-editor-content-wrap relative">
150191
<ContentEditable
151192
className="comet-editor-content"
152193
autoCapitalize="off"
@@ -174,7 +215,12 @@ function EditorInner({
174215
<MarkdownPastePlugin />
175216
<YouTubeEmbedPlugin />
176217
<ImageDropPlugin />
177-
<SearchHighlightPlugin searchWords={searchWords} />
218+
<SearchHighlightPlugin
219+
activeMatchIndex={searchActiveMatchIndex}
220+
highlightAllMatchesYellow={searchHighlightAllMatchesYellow}
221+
onMatchCountChange={onSearchMatchCountChange}
222+
searchWords={searchWords}
223+
/>
178224
<TableActionMenuPlugin />
179225

180226
<TableClickOutsidePlugin />

src/components/editor/plugins/search-highlight-plugin.tsx

Lines changed: 117 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
1-
import { useEffect } from "react";
1+
import { useEffect, useRef } from "react";
22
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
33

4+
const ACTIVE_HIGHLIGHT_NAME = "comet-search-active";
45
const HIGHLIGHT_NAME = "comet-search";
56
const HIGHLIGHT_STYLE_ID = "comet-search-highlight-style";
67
const HIGHLIGHT_STYLES = `
78
::highlight(${HIGHLIGHT_NAME}) {
9+
background-color: var(--editor-selection);
10+
color: var(--editor-text);
11+
}
12+
13+
::highlight(${ACTIVE_HIGHLIGHT_NAME}) {
814
background-color: rgb(253 224 71);
915
color: var(--background);
1016
}
1117
`;
1218

13-
function clearHighlight() {
14-
(CSS as CSSWithHighlights).highlights?.delete(HIGHLIGHT_NAME);
19+
type CSSWithHighlights = typeof CSS & {
20+
highlights?: Map<string, Highlight>;
21+
};
22+
23+
function clearHighlights() {
24+
const highlights = (CSS as CSSWithHighlights).highlights;
25+
highlights?.delete(HIGHLIGHT_NAME);
26+
highlights?.delete(ACTIVE_HIGHLIGHT_NAME);
1527
}
1628

1729
function getOrCreateStyleElement() {
@@ -25,25 +37,30 @@ function getOrCreateStyleElement() {
2537
}
2638

2739
export default function SearchHighlightPlugin({
40+
activeMatchIndex = null,
41+
highlightAllMatchesYellow = false,
42+
onMatchCountChange,
2843
searchWords,
2944
}: {
45+
activeMatchIndex?: number | null;
46+
highlightAllMatchesYellow?: boolean;
47+
onMatchCountChange?(count: number): void;
3048
searchWords: string[];
3149
}) {
3250
const [editor] = useLexicalComposerContext();
51+
const shouldScrollRef = useRef(true);
3352

34-
// Toggle highlight visibility by adding/removing the CSS rule.
35-
// Highlight ranges stay registered — they're just invisible without the rule.
3653
useEffect(() => {
3754
return editor.registerRootListener((root, prevRoot) => {
3855
if (prevRoot) {
3956
prevRoot.removeEventListener("focusin", handleFocusIn);
4057
prevRoot.removeEventListener("focusout", handleFocusOut);
4158
}
59+
4260
if (root) {
4361
root.addEventListener("focusin", handleFocusIn);
4462
root.addEventListener("focusout", handleFocusOut);
4563

46-
// Sync to current state
4764
if (root.contains(document.activeElement)) {
4865
handleFocusIn();
4966
} else {
@@ -61,62 +78,136 @@ export default function SearchHighlightPlugin({
6178
}
6279
}, [editor]);
6380

64-
// Maintain highlight ranges — always applied regardless of focus.
6581
useEffect(() => {
6682
const highlights = (CSS as CSSWithHighlights).highlights;
67-
if (!highlights) return;
83+
if (!highlights) {
84+
onMatchCountChange?.(0);
85+
return;
86+
}
6887

69-
getOrCreateStyleElement().textContent = HIGHLIGHT_STYLES;
88+
shouldScrollRef.current = true;
89+
const root = editor.getRootElement();
90+
getOrCreateStyleElement().textContent =
91+
root?.contains(document.activeElement) ? "" : HIGHLIGHT_STYLES;
7092

7193
if (searchWords.length === 0) {
72-
highlights.delete(HIGHLIGHT_NAME);
94+
clearHighlights();
95+
onMatchCountChange?.(0);
7396
return;
7497
}
7598

7699
const apply = () => {
77-
const root = editor.getRootElement();
78-
if (!root) {
79-
highlights.delete(HIGHLIGHT_NAME);
100+
const currentRoot = editor.getRootElement();
101+
if (!currentRoot) {
102+
clearHighlights();
103+
onMatchCountChange?.(0);
80104
return;
81105
}
82106

83-
const escaped = searchWords.map((w) =>
84-
w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
107+
const escaped = searchWords.map((word) =>
108+
word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
85109
);
86110
const regex = new RegExp(`(${escaped.join("|")})`, "gi");
87111
const ranges: Range[] = [];
88-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
112+
const walker = document.createTreeWalker(
113+
currentRoot,
114+
NodeFilter.SHOW_TEXT,
115+
);
89116

90117
let textNode: Text | null;
91118
while ((textNode = walker.nextNode() as Text | null)) {
92119
const text = textNode.textContent || "";
93120
let match: RegExpExecArray | null;
94121
while ((match = regex.exec(text)) !== null) {
95-
const range = new Range();
122+
const range = document.createRange();
96123
range.setStart(textNode, match.index);
97124
range.setEnd(textNode, match.index + match[0].length);
98125
ranges.push(range);
99126
}
100127
}
101128

102-
if (ranges.length > 0) {
103-
highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges));
104-
} else {
129+
onMatchCountChange?.(ranges.length);
130+
if (ranges.length === 0) {
131+
clearHighlights();
132+
return;
133+
}
134+
135+
if (highlightAllMatchesYellow) {
105136
highlights.delete(HIGHLIGHT_NAME);
137+
highlights.set(ACTIVE_HIGHLIGHT_NAME, new Highlight(...ranges));
138+
} else {
139+
const hasActiveMatch =
140+
activeMatchIndex !== null &&
141+
activeMatchIndex >= 0 &&
142+
activeMatchIndex < ranges.length;
143+
const inactiveRanges = hasActiveMatch
144+
? ranges.filter((_, index) => index !== activeMatchIndex)
145+
: ranges;
146+
147+
if (inactiveRanges.length > 0) {
148+
highlights.set(HIGHLIGHT_NAME, new Highlight(...inactiveRanges));
149+
} else {
150+
highlights.delete(HIGHLIGHT_NAME);
151+
}
152+
153+
if (hasActiveMatch) {
154+
highlights.set(
155+
ACTIVE_HIGHLIGHT_NAME,
156+
new Highlight(ranges[activeMatchIndex]),
157+
);
158+
} else {
159+
highlights.delete(ACTIVE_HIGHLIGHT_NAME);
160+
}
161+
}
162+
163+
if (!shouldScrollRef.current) {
164+
return;
165+
}
166+
167+
shouldScrollRef.current = false;
168+
const hasActiveMatch =
169+
!highlightAllMatchesYellow &&
170+
activeMatchIndex !== null &&
171+
activeMatchIndex >= 0 &&
172+
activeMatchIndex < ranges.length;
173+
const targetRange = hasActiveMatch ? ranges[activeMatchIndex] : ranges[0];
174+
const scrollContainer = currentRoot.closest(
175+
"[data-editor-scroll-container]",
176+
);
177+
if (!scrollContainer) {
178+
return;
106179
}
180+
181+
const scrollRect = scrollContainer.getBoundingClientRect();
182+
const targetRect = targetRange.getBoundingClientRect();
183+
const hasVisibleMatch =
184+
targetRect.bottom > scrollRect.top && targetRect.top < scrollRect.bottom;
185+
186+
if (hasVisibleMatch) {
187+
return;
188+
}
189+
190+
const scrollTop =
191+
scrollContainer.scrollTop +
192+
targetRect.top -
193+
scrollRect.top -
194+
scrollRect.height / 3;
195+
scrollContainer.scrollTo({ top: scrollTop, behavior: "instant" });
107196
};
108197

109198
apply();
110199
return editor.registerUpdateListener(() => {
111200
apply();
112201
});
113-
}, [editor, searchWords]);
202+
}, [
203+
activeMatchIndex,
204+
editor,
205+
highlightAllMatchesYellow,
206+
onMatchCountChange,
207+
searchWords,
208+
]);
114209

115-
useEffect(() => clearHighlight, []);
210+
useEffect(() => clearHighlights, []);
116211

117212
return null;
118213
}
119-
120-
type CSSWithHighlights = typeof CSS & {
121-
highlights?: Map<string, Highlight>;
122-
};

0 commit comments

Comments
 (0)