Skip to content

Commit f9155c4

Browse files
trangdoan982claude
andcommitted
ENG-1681: Add Advanced Search dialog with split-view preview (Roam)
Adds a new command-palette-triggered search dialog ("DG: Open Node Search Menu") with a split-view layout: left panel lists discourse node results, right panel shows a live content preview of the selected node. Key features: - Chip-based type filters: type a 3-letter trigger + space (e.g. "evd ") to add a filter chip; Tab autocompletes ghost text; Backspace focuses then removes the last chip; ←/→ navigates between chips - Filter popover with tri-state select-all, per-row "Only" button, and a type-search field (portaled past modal overflow:hidden) - Sort dropdown (Relevance, Date modified, Date created, Alphabetical, Most connected) with per-sort directional toggles - Help popover showing all keyboard shortcuts and type triggers dynamically from the user's discourse graph config - MiniSearch for fuzzy/prefix client-side search across all signed-in graphs (via getAllDiscourseNodesSince) - Preview pane: debounced pull() for current-graph nodes (80ms, cached); cross-graph nodes show metadata + "open to view" note - Enter/open actions are TODO stubs (covered in ENG-1682) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 82749a3 commit f9155c4

9 files changed

Lines changed: 2351 additions & 0 deletions

File tree

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import React, {
2+
useState,
3+
useRef,
4+
useEffect,
5+
useMemo,
6+
type RefObject,
7+
} from "react";
8+
import { type NodeTypeConfig } from "./types";
9+
10+
type Props = {
11+
chips: string[];
12+
setChips: (chips: string[]) => void;
13+
value: string;
14+
setValue: (v: string) => void;
15+
types: NodeTypeConfig[];
16+
inputRef: RefObject<HTMLInputElement>;
17+
onArrowDown: () => void;
18+
onArrowUp: () => void;
19+
onEnter: () => void;
20+
onShiftEnter: () => void;
21+
onCmdEnter: () => void;
22+
onEscape: () => void;
23+
};
24+
25+
type Ghost = {
26+
typeId: string;
27+
full: string; // full alias string
28+
};
29+
30+
const ChipsSearchInput = ({
31+
chips,
32+
setChips,
33+
value,
34+
setValue,
35+
types,
36+
inputRef,
37+
onArrowDown,
38+
onArrowUp,
39+
onEnter,
40+
onShiftEnter,
41+
onCmdEnter,
42+
onEscape,
43+
}: Props) => {
44+
const [focusedChip, setFocusedChip] = useState(-1);
45+
const chipRefs = useRef<(HTMLSpanElement | null)[]>([]);
46+
47+
// Focus chip element when focusedChip changes
48+
useEffect(() => {
49+
if (focusedChip >= 0 && chipRefs.current[focusedChip]) {
50+
chipRefs.current[focusedChip]?.focus();
51+
}
52+
}, [focusedChip]);
53+
54+
// Clamp focusedChip when chip list shrinks
55+
useEffect(() => {
56+
if (focusedChip >= chips.length) setFocusedChip(-1);
57+
}, [chips.length, focusedChip]);
58+
59+
const focusInput = () => {
60+
setFocusedChip(-1);
61+
setTimeout(() => inputRef.current?.focus(), 0);
62+
};
63+
64+
// Ghost autocomplete: value prefix matches exactly one unselected alias
65+
const ghost = useMemo((): Ghost | null => {
66+
const v = value.trim().toLowerCase();
67+
if (!v) return null;
68+
69+
const candidates: Ghost[] = [];
70+
for (const t of types) {
71+
if (chips.includes(t.id)) continue;
72+
for (const alias of t.aliases) {
73+
if (alias.toLowerCase().startsWith(v) && alias.toLowerCase() !== v) {
74+
candidates.push({ typeId: t.id, full: alias });
75+
break;
76+
}
77+
}
78+
}
79+
return candidates.length === 1 ? candidates[0] : null;
80+
}, [value, types, chips]);
81+
82+
const tryConsumeAsTrigger = (word: string): boolean => {
83+
const lower = word.toLowerCase();
84+
const match = types.find(
85+
(t) =>
86+
!chips.includes(t.id) &&
87+
t.aliases.some((a) => a.toLowerCase() === lower),
88+
);
89+
if (match) {
90+
setChips([...chips, match.id]);
91+
return true;
92+
}
93+
return false;
94+
};
95+
96+
const removeChip = (idx: number) => chips.filter((_, i) => i !== idx);
97+
98+
const onChipKeyDown = (e: React.KeyboardEvent, idx: number) => {
99+
if (e.key === "ArrowLeft") {
100+
e.preventDefault();
101+
if (idx > 0) setFocusedChip(idx - 1);
102+
return;
103+
}
104+
if (e.key === "ArrowRight") {
105+
e.preventDefault();
106+
if (idx < chips.length - 1) setFocusedChip(idx + 1);
107+
else focusInput();
108+
return;
109+
}
110+
if (e.key === "Backspace" || e.key === "Delete") {
111+
e.preventDefault();
112+
const next = removeChip(idx);
113+
setChips(next);
114+
if (next.length === 0) {
115+
focusInput();
116+
return;
117+
}
118+
if (e.key === "Backspace") {
119+
setFocusedChip(idx > 0 ? idx - 1 : 0);
120+
} else {
121+
if (idx >= next.length) focusInput();
122+
else setFocusedChip(idx);
123+
}
124+
return;
125+
}
126+
if (e.key === "Enter" || e.key === " ") {
127+
e.preventDefault();
128+
const next = removeChip(idx);
129+
setChips(next);
130+
if (idx > 0 && next.length > 0)
131+
setFocusedChip(Math.min(idx, next.length - 1));
132+
else focusInput();
133+
return;
134+
}
135+
if (e.key === "Escape") {
136+
focusInput();
137+
return;
138+
}
139+
if (e.key === "ArrowUp") {
140+
e.preventDefault();
141+
onArrowUp();
142+
return;
143+
}
144+
if (e.key === "ArrowDown") {
145+
e.preventDefault();
146+
onArrowDown();
147+
return;
148+
}
149+
// Printable char → return focus to input and let the keystroke land
150+
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
151+
focusInput();
152+
}
153+
};
154+
155+
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
156+
if (e.key === "Tab") {
157+
if (ghost) {
158+
e.preventDefault();
159+
setChips([...chips, ghost.typeId]);
160+
setValue("");
161+
}
162+
return;
163+
}
164+
165+
if (e.key === " ") {
166+
// If input is a single token (no embedded spaces), try to convert to chip
167+
if (!/\s/.test(value) && value.length > 0) {
168+
if (tryConsumeAsTrigger(value)) {
169+
e.preventDefault();
170+
setValue("");
171+
return;
172+
}
173+
}
174+
}
175+
176+
if (e.key === "Backspace") {
177+
const el = inputRef.current;
178+
if (
179+
el &&
180+
el.selectionStart === 0 &&
181+
el.selectionEnd === 0 &&
182+
value.length === 0 &&
183+
chips.length > 0
184+
) {
185+
e.preventDefault();
186+
setFocusedChip(chips.length - 1);
187+
return;
188+
}
189+
}
190+
191+
if (e.key === "ArrowLeft") {
192+
const el = inputRef.current;
193+
if (
194+
el &&
195+
el.selectionStart === 0 &&
196+
el.selectionEnd === 0 &&
197+
chips.length > 0
198+
) {
199+
e.preventDefault();
200+
setFocusedChip(chips.length - 1);
201+
return;
202+
}
203+
}
204+
205+
if (e.key === "ArrowDown") {
206+
e.preventDefault();
207+
onArrowDown();
208+
return;
209+
}
210+
if (e.key === "ArrowUp") {
211+
e.preventDefault();
212+
onArrowUp();
213+
return;
214+
}
215+
216+
if (e.key === "Enter") {
217+
e.preventDefault();
218+
if (e.metaKey || e.ctrlKey) onCmdEnter();
219+
else if (e.shiftKey) onShiftEnter();
220+
else onEnter();
221+
return;
222+
}
223+
224+
if (e.key === "Escape") {
225+
onEscape();
226+
}
227+
};
228+
229+
return (
230+
<div className="dg-as-chips-input">
231+
{chips.map((id, idx) => {
232+
const t = types.find((x) => x.id === id);
233+
if (!t) return null;
234+
return (
235+
<span
236+
key={id}
237+
ref={(el) => {
238+
chipRefs.current[idx] = el;
239+
}}
240+
className={`dg-as-chip${focusedChip === idx ? "focused" : ""}`}
241+
tabIndex={-1}
242+
role="button"
243+
aria-label={`${t.label} filter — press Backspace or Delete to remove`}
244+
onKeyDown={(e) => onChipKeyDown(e, idx)}
245+
onClick={() => setFocusedChip(idx)}
246+
>
247+
{t.kind === "node" ? (
248+
<span
249+
className="dg-as-chip-dot"
250+
style={{ background: t.color }}
251+
/>
252+
) : (
253+
<span
254+
className="dg-as-chip-dot"
255+
style={{
256+
border: `1.5px solid ${t.kind === "page" ? "#1F1F1F" : "#8E8E8E"}`,
257+
background: "transparent",
258+
}}
259+
/>
260+
)}
261+
<span>{t.label}</span>
262+
<span
263+
className="dg-as-chip-x"
264+
role="button"
265+
aria-label={`Remove ${t.label} filter`}
266+
onClick={(e) => {
267+
e.stopPropagation();
268+
setChips(chips.filter((x) => x !== id));
269+
focusInput();
270+
}}
271+
>
272+
<svg
273+
width="10"
274+
height="10"
275+
viewBox="0 0 10 10"
276+
fill="none"
277+
stroke="currentColor"
278+
strokeWidth="1.6"
279+
strokeLinecap="round"
280+
>
281+
<line x1="2" y1="2" x2="8" y2="8" />
282+
<line x1="8" y1="2" x2="2" y2="8" />
283+
</svg>
284+
</span>
285+
</span>
286+
);
287+
})}
288+
289+
<span className="dg-as-input-wrap">
290+
{ghost && (
291+
<span className="dg-as-ghost" aria-hidden="true">
292+
<span className="dg-as-ghost-typed">{value}</span>
293+
<span className="dg-as-ghost-completion">
294+
{ghost.full.slice(value.length)}
295+
</span>
296+
<span className="dg-as-ghost-tabkey">tab</span>
297+
</span>
298+
)}
299+
<input
300+
ref={inputRef}
301+
className="dg-as-search-input"
302+
value={value}
303+
onChange={(e) => setValue(e.target.value)}
304+
onKeyDown={onInputKeyDown}
305+
placeholder={chips.length === 0 ? "Search nodes, pages, blocks…" : ""}
306+
autoFocus
307+
spellCheck={false}
308+
autoComplete="off"
309+
/>
310+
</span>
311+
</div>
312+
);
313+
};
314+
315+
export default ChipsSearchInput;

0 commit comments

Comments
 (0)