Skip to content

Commit 0dd9e26

Browse files
ENG-1739: Insert active result as page link at cursor (#1058)
* ENG-1729: Add initial Roam advanced node search dialog. Introduce a command-palette search dialog with debounced DG node queries, result excerpts, colored type badges, title highlighting, and a preview panel with metadata. Co-authored-by: Cursor <cursoragent@cursor.com> * Address review feedback for advanced node search dialog. Index discourse nodes once per open, search locally with MiniSearch, reuse getNodeTagStyles, pull content only for visible results, and switch layout styling to Tailwind. Co-authored-by: Cursor <cursoragent@cursor.com> * more fix. but need to figure out tailwindcss bug * current state. still need more styling fix * fix styling * fix lint * address some reviews * use native Preview * make the preview not interactable * clean * add metadata date * Address PR review feedback for advanced node search. Derive search results during render, dedupe indexed UIDs, surface total index failures, and align command palette registration with existing patterns. Co-authored-by: Cursor <cursoragent@cursor.com> * remove uncessary reset state * Insert active search result as page link at cursor (ENG-1739). Wire Advanced Node Search footer insert with block snapshot, shared cursor utilities, and Cmd+Enter so users can drop [[Page Title]] into the block they were editing. Co-authored-by: Cursor <cursoragent@cursor.com> * simplify * refactor * refactor util files * make sure it adds wikilink to the right window * fix lint * clean up functions * Address PR review feedback for insert footer. Use Blueprint Button for footer shortcuts, default missing window-id to main-window, and drop unnecessary keywords memo. Co-authored-by: Cursor <cursoragent@cursor.com> * clean up --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0ebdfbf commit 0dd9e26

4 files changed

Lines changed: 317 additions & 56 deletions

File tree

apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@ import {
1515
Tag,
1616
} from "@blueprintjs/core";
1717
import MiniSearch from "minisearch";
18+
import posthog from "posthog-js";
1819
import { render as renderToast } from "roamjs-components/components/Toast";
1920
import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid";
2021
import renderOverlay, {
2122
RoamOverlayProps,
2223
} from "roamjs-components/util/renderOverlay";
24+
import {
25+
insertPageRefAtRange,
26+
snapshotInsertTarget,
27+
type InsertTarget,
28+
} from "~/utils/advancedSearchFooterUtils";
2329
import { DiscourseNodeSortControl } from "~/components/DiscourseNodeSortControl";
2430
import getDiscourseNodes, {
2531
type DiscourseNode,
@@ -38,6 +44,7 @@ import {
3844
stripTypePrefix,
3945
} from "./utils";
4046
import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents";
47+
import { AdvancedSearchFooter } from "./AdvancedSearchFooter";
4148

4249
type Props = Record<string, unknown>;
4350

@@ -156,6 +163,7 @@ const AdvancedNodeSearchDialog = ({
156163
const allResultsRef = useRef<SearchResult[]>([]);
157164
const resultsPanelRef = useRef<HTMLDivElement | null>(null);
158165
const inputRef = useRef<HTMLInputElement | null>(null);
166+
const [insertTarget, setInsertTarget] = useState<InsertTarget | null>(null);
159167

160168
const nodeConfigByType = useMemo(() => {
161169
const discourseNodes = getDiscourseNodes().filter(
@@ -172,6 +180,8 @@ const AdvancedNodeSearchDialog = ({
172180
useEffect(() => {
173181
if (!isOpen) return;
174182

183+
setInsertTarget(snapshotInsertTarget());
184+
175185
const focusInput = () => inputRef.current?.focus();
176186

177187
focusInput();
@@ -270,6 +280,37 @@ const AdvancedNodeSearchDialog = ({
270280
activeRow?.scrollIntoView({ block: "nearest" });
271281
}, [activeIndex, activeResult?.uid, debouncedSearchTerm]);
272282

283+
const onInsert = useCallback(async () => {
284+
if (!activeResult || !insertTarget) return;
285+
286+
const pageTitle =
287+
getPageTitleByPageUid(activeResult.uid) ??
288+
stripTypePrefix(activeResult.title);
289+
290+
await insertPageRefAtRange({
291+
blockUid: insertTarget.blockUid,
292+
pageTitle,
293+
selectionEnd: insertTarget.selectionEnd,
294+
selectionStart: insertTarget.selectionStart,
295+
windowId: insertTarget.windowId,
296+
});
297+
298+
posthog.capture("Advanced Node Search: Insert", {
299+
uid: activeResult.uid,
300+
pageTitle,
301+
});
302+
onClose();
303+
}, [activeResult, insertTarget, onClose]);
304+
305+
const contentState = indexError
306+
? "error"
307+
: isIndexLoading
308+
? "indexing"
309+
: !debouncedSearchTerm
310+
? "initial"
311+
: !results.length
312+
? "empty"
313+
: "results";
273314
const handleSortChange = useCallback((nextSort: SortConfig): void => {
274315
setSort(nextSort);
275316
}, []);
@@ -282,24 +323,30 @@ const AdvancedNodeSearchDialog = ({
282323
} else if (event.key === "ArrowUp" && results.length) {
283324
event.preventDefault();
284325
setActiveIndex((index) => Math.max(index - 1, 0));
326+
} else if (
327+
event.key === "Enter" &&
328+
(event.metaKey || event.ctrlKey) &&
329+
contentState === "results" &&
330+
activeResult &&
331+
insertTarget
332+
) {
333+
event.preventDefault();
334+
void onInsert();
285335
} else if (event.key === "Escape") {
286336
event.preventDefault();
287337
onClose();
288338
}
289339
},
290-
[onClose, results.length],
340+
[
341+
activeResult,
342+
contentState,
343+
insertTarget,
344+
onClose,
345+
onInsert,
346+
results.length,
347+
],
291348
);
292349

293-
const contentState = indexError
294-
? "error"
295-
: isIndexLoading
296-
? "indexing"
297-
: !debouncedSearchTerm
298-
? "initial"
299-
: !results.length
300-
? "empty"
301-
: "results";
302-
303350
const showSplitView = contentState === "results";
304351

305352
return (
@@ -389,6 +436,12 @@ const AdvancedNodeSearchDialog = ({
389436
</div>
390437
)}
391438
</div>
439+
<AdvancedSearchFooter
440+
contentState={contentState}
441+
hasActiveResult={!!activeResult}
442+
insertTarget={insertTarget}
443+
onInsert={() => void onInsert()}
444+
/>
392445
</div>
393446
</Dialog>
394447
);
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import React from "react";
2+
import { Button } from "@blueprintjs/core";
3+
import type { InsertTarget } from "~/utils/advancedSearchFooterUtils";
4+
5+
export type AdvancedSearchContentState =
6+
| "error"
7+
| "indexing"
8+
| "initial"
9+
| "empty"
10+
| "results";
11+
12+
export type AdvancedSearchFooterProps = {
13+
contentState: AdvancedSearchContentState;
14+
hasActiveResult: boolean;
15+
insertTarget: InsertTarget | null;
16+
onInsert: () => void;
17+
};
18+
19+
const footerKbdClassName =
20+
"rounded border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-xs text-gray-600";
21+
22+
const footerLabelClassName =
23+
"inline-flex shrink-0 items-center gap-1 text-xs lowercase text-gray-500";
24+
25+
type FooterShortcutHintProps = {
26+
disabled?: boolean;
27+
keys: string[];
28+
label: string;
29+
onClick?: () => void;
30+
};
31+
32+
export const FooterShortcutHint = ({
33+
disabled = false,
34+
keys,
35+
label,
36+
onClick,
37+
}: FooterShortcutHintProps) => (
38+
<Button
39+
className="inline-flex !min-h-0 items-center gap-2 p-0"
40+
disabled={disabled}
41+
minimal
42+
onClick={onClick}
43+
small
44+
>
45+
<span className={footerLabelClassName}>
46+
{keys.map((key) => (
47+
<kbd className={footerKbdClassName} key={key}>
48+
{key}
49+
</kbd>
50+
))}
51+
{label}
52+
</span>
53+
</Button>
54+
);
55+
56+
export const InsertFooterAction = ({
57+
disabled,
58+
onInsert,
59+
}: {
60+
disabled: boolean;
61+
onInsert: () => void;
62+
}) => (
63+
<FooterShortcutHint
64+
disabled={disabled}
65+
keys={["⌘", "↵"]}
66+
label="insert"
67+
onClick={() => void onInsert()}
68+
/>
69+
);
70+
71+
const CloseFooterHint = () => (
72+
<span className={footerLabelClassName}>
73+
<kbd className={footerKbdClassName}>esc</kbd>
74+
close
75+
</span>
76+
);
77+
78+
export const AdvancedSearchFooter = ({
79+
contentState,
80+
hasActiveResult,
81+
insertTarget,
82+
onInsert,
83+
}: AdvancedSearchFooterProps) => {
84+
const hasResults = contentState === "results";
85+
const canInsert = !!insertTarget && hasActiveResult && hasResults;
86+
87+
return (
88+
<div className="flex w-full flex-none items-center justify-between border-t border-gray-200 bg-gray-50 px-3 py-2">
89+
<div className="inline-flex shrink-0 items-center gap-3">
90+
{insertTarget && (
91+
<InsertFooterAction disabled={!canInsert} onInsert={onInsert} />
92+
)}
93+
</div>
94+
<CloseFooterHint />
95+
</div>
96+
);
97+
};
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import getUids from "roamjs-components/dom/getUids";
2+
import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
3+
import updateBlock from "roamjs-components/writes/updateBlock";
4+
5+
export type BlockSelection = {
6+
selectionStart: number;
7+
selectionEnd: number;
8+
selectedText: string;
9+
};
10+
11+
export type InsertTarget = {
12+
blockUid: string;
13+
windowId: string;
14+
selectionStart: number;
15+
selectionEnd: number;
16+
};
17+
18+
const DEFAULT_WINDOW_ID = "main-window";
19+
20+
export const getBlockSelection = (uid: string): BlockSelection => {
21+
const activeElement = document.activeElement;
22+
const isFocusedTextarea =
23+
activeElement instanceof HTMLTextAreaElement &&
24+
activeElement.classList.contains("rm-block-input") &&
25+
getUids(activeElement).blockUid === uid;
26+
if (isFocusedTextarea) {
27+
return {
28+
selectionStart: activeElement.selectionStart,
29+
selectionEnd: activeElement.selectionEnd,
30+
selectedText: activeElement.value.substring(
31+
activeElement.selectionStart,
32+
activeElement.selectionEnd,
33+
),
34+
};
35+
}
36+
const textareas = document.querySelectorAll("textarea.rm-block-input");
37+
for (const el of textareas) {
38+
const textarea = el as HTMLTextAreaElement;
39+
if (getUids(textarea).blockUid === uid) {
40+
return {
41+
selectionStart: textarea.selectionStart,
42+
selectionEnd: textarea.selectionEnd,
43+
selectedText: textarea.value.substring(
44+
textarea.selectionStart,
45+
textarea.selectionEnd,
46+
),
47+
};
48+
}
49+
}
50+
const textLength = (getTextByBlockUid(uid) || "").length;
51+
return {
52+
selectionStart: textLength,
53+
selectionEnd: textLength,
54+
selectedText: "",
55+
};
56+
};
57+
58+
const insertTargetFromFocusedBlock = (): InsertTarget | null => {
59+
const focusedBlock = window.roamAlphaAPI.ui.getFocusedBlock();
60+
if (!focusedBlock?.["block-uid"]) return null;
61+
62+
const blockUid = focusedBlock["block-uid"];
63+
const selection = getBlockSelection(blockUid);
64+
65+
return {
66+
blockUid,
67+
windowId: focusedBlock["window-id"] || DEFAULT_WINDOW_ID,
68+
selectionStart: selection.selectionStart,
69+
selectionEnd: selection.selectionEnd,
70+
};
71+
};
72+
73+
export const snapshotInsertTarget = (): InsertTarget | null => {
74+
const fromApi = insertTargetFromFocusedBlock();
75+
if (fromApi) return fromApi;
76+
77+
const activeElement = document.activeElement;
78+
if (
79+
activeElement instanceof HTMLTextAreaElement &&
80+
activeElement.classList.contains("rm-block-input")
81+
) {
82+
const { blockUid, windowId } = getUids(activeElement);
83+
if (!blockUid) return null;
84+
85+
return {
86+
blockUid,
87+
windowId: windowId || DEFAULT_WINDOW_ID,
88+
selectionStart: activeElement.selectionStart,
89+
selectionEnd: activeElement.selectionEnd,
90+
};
91+
}
92+
93+
return null;
94+
};
95+
96+
const findBlockTextarea = (blockUid: string): HTMLTextAreaElement | null => {
97+
const textareas = document.querySelectorAll("textarea.rm-block-input");
98+
for (const el of textareas) {
99+
const textarea = el as HTMLTextAreaElement;
100+
if (getUids(textarea).blockUid === blockUid) return textarea;
101+
}
102+
return null;
103+
};
104+
105+
export const restoreBlockFocus = ({
106+
blockUid,
107+
newCursorPosition,
108+
windowId,
109+
}: {
110+
blockUid: string;
111+
newCursorPosition: number;
112+
windowId: string;
113+
}): void => {
114+
if (window.roamAlphaAPI.ui.setBlockFocusAndSelection) {
115+
void window.roamAlphaAPI.ui.setBlockFocusAndSelection({
116+
location: {
117+
// eslint-disable-next-line @typescript-eslint/naming-convention
118+
"block-uid": blockUid,
119+
// eslint-disable-next-line @typescript-eslint/naming-convention
120+
"window-id": windowId,
121+
},
122+
selection: { start: newCursorPosition },
123+
});
124+
return;
125+
}
126+
127+
setTimeout(() => {
128+
const textarea = findBlockTextarea(blockUid);
129+
if (!textarea) return;
130+
textarea.focus();
131+
textarea.setSelectionRange(newCursorPosition, newCursorPosition);
132+
}, 50);
133+
};
134+
135+
export const insertPageRefAtRange = async ({
136+
blockUid,
137+
pageTitle,
138+
selectionEnd,
139+
selectionStart,
140+
windowId,
141+
}: {
142+
blockUid: string;
143+
pageTitle: string;
144+
selectionEnd: number;
145+
selectionStart: number;
146+
windowId: string;
147+
}): Promise<void> => {
148+
const pageRef = `[[${pageTitle}]]`;
149+
const originalText = getTextByBlockUid(blockUid) || "";
150+
const newText = `${originalText.substring(0, selectionStart)}${pageRef}${originalText.substring(selectionEnd)}`;
151+
const newCursorPosition = selectionStart + pageRef.length;
152+
153+
await updateBlock({ uid: blockUid, text: newText });
154+
restoreBlockFocus({ blockUid, newCursorPosition, windowId });
155+
};

0 commit comments

Comments
 (0)