From adb507f7065d7ca4108999aa142660329252d21a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 22 May 2026 19:24:53 -0400 Subject: [PATCH 01/17] ENG-1738: Render advanced search results as sidebar block Switch advanced search sidebar behavior to create a single summary block with wikilink children, and wire Option+Enter/footer action to open that block in the right sidebar. This aligns the flow with Roam's native sidebar result rendering while keeping the search dialog focused on result-list interaction. Co-authored-by: Cursor --- .../AdvancedSearchDialog.tsx | 147 ++++++++++-------- .../AdvancedSearchFooter.tsx | 30 +++- .../utils/registerCommandPaletteCommands.ts | 4 +- 3 files changed, 109 insertions(+), 72 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index a5d6bbdd6..797b9ed48 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -9,7 +9,6 @@ import { Button, Dialog, InputGroup, - NonIdealState, Spinner, SpinnerSize, Tag, @@ -21,6 +20,7 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU import renderOverlay, { RoamOverlayProps, } from "roamjs-components/util/renderOverlay"; +import { createBlock } from "roamjs-components/writes"; import { insertPageRefAtRange, snapshotInsertTarget, @@ -37,14 +37,12 @@ import { type SearchResult, type SortConfig, buildSearchIndex, - formatMetadataDate, searchIndexedNodes, sortSearchResults, splitWithHighlights, stripTypePrefix, } from "./utils"; import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; -import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; type Props = Record; @@ -110,43 +108,6 @@ const ResultRow = ({ ); -const PreviewPane = ({ result }: { result: SearchResult | null }) => { - if (!result) { - return ( -
- -
- ); - } - const isPage = !!getPageTitleByPageUid(result.uid); - - return ( -
-
- Created: {formatMetadataDate(result.createdAt)} · Last modified:{" "} - {formatMetadataDate(result.lastModified)} · Author:{" "} - {result.authorName || "Unknown"} -
-
event.preventDefault()} - > -
- {isPage ? ( - - ) : ( - - )} -
-
-
- ); -}; - const AdvancedNodeSearchDialog = ({ isOpen, onClose, @@ -319,6 +280,51 @@ const AdvancedNodeSearchDialog = ({ : !results.length ? "empty" : "results"; + + const onOpenSearchSidebar = useCallback(async () => { + if (contentState !== "results" || !results.length) return; + + try { + const parentUid = + (await window.roamAlphaAPI.ui.mainWindow.getOpenPageOrBlockUid()) || + window.roamAlphaAPI.util.dateToPageUid(new Date()); + + const sidebarBlockTitle = `Advanced search results: "${debouncedSearchTerm || "(empty query)"}"`; + const sidebarChildren = results.map((result) => ({ + text: `[[${result.title}]]`, + })); + + const sidebarBlockUid = await createBlock({ + parentUid, + order: Number.MAX_VALUE, + node: { text: sidebarBlockTitle, children: sidebarChildren }, + }); + + await window.roamAlphaAPI.ui.rightSidebar.addWindow({ + window: { + type: "outline", + // @ts-expect-error - block-uid is valid for outline sidebar windows + // eslint-disable-next-line @typescript-eslint/naming-convention + "block-uid": sidebarBlockUid, + }, + }); + + posthog.capture("Advanced Node Search: Open search sidebar", { + resultCount: results.length, + searchTerm: debouncedSearchTerm, + sortDirection: sort.direction, + sortField: sort.field, + }); + onClose(); + } catch (error) { + console.error("Failed to open search sidebar results block:", error); + renderToast({ + id: "advanced-node-search-sidebar-open-error", + content: "Could not render search results in the right sidebar.", + intent: "danger", + }); + } + }, [contentState, debouncedSearchTerm, onClose, results, sort]); const handleSortChange = useCallback((nextSort: SortConfig): void => { setSort(nextSort); }, []); @@ -357,6 +363,14 @@ const AdvancedNodeSearchDialog = ({ } else if (event.key === "ArrowUp" && results.length) { event.preventDefault(); setActiveIndex((index) => Math.max(index - 1, 0)); + } else if ( + event.key === "Enter" && + event.altKey && + contentState === "results" && + results.length + ) { + event.preventDefault(); + void onOpenSearchSidebar(); } else if ( event.key === "Enter" && !event.metaKey && @@ -386,6 +400,7 @@ const AdvancedNodeSearchDialog = ({ contentState, insertTarget, onClose, + onOpenSearchSidebar, onInsert, onOpen, onOpenInSidebar, @@ -393,8 +408,6 @@ const AdvancedNodeSearchDialog = ({ ], ); - const showSplitView = contentState === "results"; - return (
- {showSplitView ? ( - <> -
- {results.map((result, index) => ( - setActiveIndex(index)} - onMouseEnter={() => setActiveIndex(index)} - result={result} - /> - ))} -
-
- -
- + {contentState === "results" ? ( +
+ {results.map((result, index) => ( + setActiveIndex(index)} + onMouseEnter={() => setActiveIndex(index)} + result={result} + /> + ))} +
) : (
{contentState === "indexing" && ( @@ -489,19 +497,24 @@ const AdvancedNodeSearchDialog = ({ 0} insertTarget={insertTarget} onInsert={() => void onInsert()} onOpen={() => void onOpen()} onOpenInSidebar={() => void onOpenInSidebar()} + onOpenSearchSidebar={() => void onOpenSearchSidebar()} />
); }; -export const renderAdvancedNodeSearchDialog = () => +export const renderAdvancedNodeSearchSidebar = () => renderOverlay({ // eslint-disable-next-line @typescript-eslint/naming-convention Overlay: AdvancedNodeSearchDialog, props: {}, }); + +export const renderAdvancedNodeSearchDialog = () => + renderAdvancedNodeSearchSidebar(); diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx index 26f001084..46cd8c404 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx @@ -12,10 +12,12 @@ export type AdvancedSearchContentState = export type AdvancedSearchFooterProps = { contentState: AdvancedSearchContentState; hasActiveResult: boolean; + hasResults: boolean; insertTarget: InsertTarget | null; onInsert: () => void; onOpen: () => void; onOpenInSidebar: () => void; + onOpenSearchSidebar: () => void; }; const footerKbdClassName = @@ -99,6 +101,21 @@ const InsertFooterAction = ({ /> ); +export const OpenSearchSidebarFooterAction = ({ + disabled, + onOpenSearchSidebar, +}: { + disabled: boolean; + onOpenSearchSidebar: () => void; +}) => ( + void onOpenSearchSidebar()} + /> +); + const CloseFooterHint = () => ( @@ -111,18 +128,25 @@ const CloseFooterHint = () => ( export const AdvancedSearchFooter = ({ contentState, hasActiveResult, + hasResults, insertTarget, onInsert, onOpen, onOpenInSidebar, + onOpenSearchSidebar, }: AdvancedSearchFooterProps) => { - const hasResults = contentState === "results"; - const canOpen = hasActiveResult && hasResults; - const canInsert = !!insertTarget && hasActiveResult && hasResults; + const hasResultsState = contentState === "results"; + const canOpen = hasActiveResult && hasResultsState; + const canInsert = !!insertTarget && hasActiveResult && hasResultsState; + const canOpenSearchSidebar = hasResults && hasResultsState; return (
+ {insertTarget && ( )} diff --git a/apps/roam/src/utils/registerCommandPaletteCommands.ts b/apps/roam/src/utils/registerCommandPaletteCommands.ts index 96d5433f8..6e8893f40 100644 --- a/apps/roam/src/utils/registerCommandPaletteCommands.ts +++ b/apps/roam/src/utils/registerCommandPaletteCommands.ts @@ -46,7 +46,7 @@ import { getUidAndBooleanSetting } from "~/utils/getExportSettings"; import refreshConfigTree from "~/utils/refreshConfigTree"; import { refreshAndNotify } from "~/components/LeftSidebarView"; import { sectionsToBlockProps } from "~/components/settings/LeftSidebarPersonalSettings"; -import { renderAdvancedNodeSearchDialog } from "~/components/AdvancedNodeSearchDialog/AdvancedSearchDialog"; +import { renderAdvancedNodeSearchSidebar } from "~/components/AdvancedNodeSearchDialog/AdvancedSearchDialog"; import { getBlockSelection, insertPageRefAtRange, @@ -367,7 +367,7 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => { if (getFeatureFlag("Advanced node search enabled")) { void addCommand("DG: Open Node Search", () => { posthog.capture("Node Search: Open Command Triggered"); - renderAdvancedNodeSearchDialog(); + renderAdvancedNodeSearchSidebar(); }); } void addCommand("DG: Open - Query drawer", openQueryDrawerWithArgs); From 00063afc04f7f89192caa7e55b97e422ccdc0e74 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 22 May 2026 19:31:15 -0400 Subject: [PATCH 02/17] revert irrelevant changes --- .../AdvancedSearchDialog.tsx | 84 ++++++++++++++----- .../utils/registerCommandPaletteCommands.ts | 4 +- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 797b9ed48..2947bc54c 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -9,6 +9,7 @@ import { Button, Dialog, InputGroup, + NonIdealState, Spinner, SpinnerSize, Tag, @@ -37,6 +38,7 @@ import { type SearchResult, type SortConfig, buildSearchIndex, + formatMetadataDate, searchIndexedNodes, sortSearchResults, splitWithHighlights, @@ -108,6 +110,43 @@ const ResultRow = ({ ); +const PreviewPane = ({ result }: { result: SearchResult | null }) => { + if (!result) { + return ( +
+ +
+ ); + } + const isPage = !!getPageTitleByPageUid(result.uid); + + return ( +
+
+ Created: {formatMetadataDate(result.createdAt)} · Last modified:{" "} + {formatMetadataDate(result.lastModified)} · Author:{" "} + {result.authorName || "Unknown"} +
+
event.preventDefault()} + > +
+ {isPage ? ( + + ) : ( + + )} +
+
+
+ ); +}; + const AdvancedNodeSearchDialog = ({ isOpen, onClose, @@ -408,6 +447,8 @@ const AdvancedNodeSearchDialog = ({ ], ); + const showSplitView = contentState === "results"; + return (
- {contentState === "results" ? ( -
- {results.map((result, index) => ( - setActiveIndex(index)} - onMouseEnter={() => setActiveIndex(index)} - result={result} - /> - ))} -
+ {showSplitView ? ( + <> +
+ {results.map((result, index) => ( + setActiveIndex(index)} + onMouseEnter={() => setActiveIndex(index)} + result={result} + /> + ))} +
+
+ +
+ ) : (
{contentState === "indexing" && ( diff --git a/apps/roam/src/utils/registerCommandPaletteCommands.ts b/apps/roam/src/utils/registerCommandPaletteCommands.ts index 6e8893f40..96d5433f8 100644 --- a/apps/roam/src/utils/registerCommandPaletteCommands.ts +++ b/apps/roam/src/utils/registerCommandPaletteCommands.ts @@ -46,7 +46,7 @@ import { getUidAndBooleanSetting } from "~/utils/getExportSettings"; import refreshConfigTree from "~/utils/refreshConfigTree"; import { refreshAndNotify } from "~/components/LeftSidebarView"; import { sectionsToBlockProps } from "~/components/settings/LeftSidebarPersonalSettings"; -import { renderAdvancedNodeSearchSidebar } from "~/components/AdvancedNodeSearchDialog/AdvancedSearchDialog"; +import { renderAdvancedNodeSearchDialog } from "~/components/AdvancedNodeSearchDialog/AdvancedSearchDialog"; import { getBlockSelection, insertPageRefAtRange, @@ -367,7 +367,7 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => { if (getFeatureFlag("Advanced node search enabled")) { void addCommand("DG: Open Node Search", () => { posthog.capture("Node Search: Open Command Triggered"); - renderAdvancedNodeSearchSidebar(); + renderAdvancedNodeSearchDialog(); }); } void addCommand("DG: Open - Query drawer", openQueryDrawerWithArgs); From 604da57c5d6a406ca1b15a36c5f536c4e4438a74 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 23 May 2026 14:42:21 -0400 Subject: [PATCH 03/17] switch to add page --- .../AdvancedSearchDialog.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 2947bc54c..671496805 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -21,6 +21,7 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU import renderOverlay, { RoamOverlayProps, } from "roamjs-components/util/renderOverlay"; +import createPage from "roamjs-components/writes/createPage"; import { createBlock } from "roamjs-components/writes"; import { insertPageRefAtRange, @@ -324,27 +325,28 @@ const AdvancedNodeSearchDialog = ({ if (contentState !== "results" || !results.length) return; try { - const parentUid = - (await window.roamAlphaAPI.ui.mainWindow.getOpenPageOrBlockUid()) || - window.roamAlphaAPI.util.dateToPageUid(new Date()); - const sidebarBlockTitle = `Advanced search results: "${debouncedSearchTerm || "(empty query)"}"`; const sidebarChildren = results.map((result) => ({ text: `[[${result.title}]]`, })); - const sidebarBlockUid = await createBlock({ - parentUid, - order: Number.MAX_VALUE, - node: { text: sidebarBlockTitle, children: sidebarChildren }, - }); + const sidebarPageUid = await createPage({ title: sidebarBlockTitle }); + await Promise.all( + sidebarChildren.map((node, order) => + createBlock({ + parentUid: sidebarPageUid, + order, + node, + }), + ), + ); await window.roamAlphaAPI.ui.rightSidebar.addWindow({ window: { type: "outline", // @ts-expect-error - block-uid is valid for outline sidebar windows // eslint-disable-next-line @typescript-eslint/naming-convention - "block-uid": sidebarBlockUid, + "block-uid": sidebarPageUid, }, }); From e0b24da6b82e26a6282ea70b7741e6865a760218 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 23 May 2026 23:32:39 -0400 Subject: [PATCH 04/17] cleanup --- .../AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx | 1 - .../AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx | 10 ++++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 671496805..b305fb96a 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -545,7 +545,6 @@ const AdvancedNodeSearchDialog = ({ 0} insertTarget={insertTarget} onInsert={() => void onInsert()} onOpen={() => void onOpen()} diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx index 46cd8c404..aebbdc301 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx @@ -12,7 +12,6 @@ export type AdvancedSearchContentState = export type AdvancedSearchFooterProps = { contentState: AdvancedSearchContentState; hasActiveResult: boolean; - hasResults: boolean; insertTarget: InsertTarget | null; onInsert: () => void; onOpen: () => void; @@ -128,17 +127,16 @@ const CloseFooterHint = () => ( export const AdvancedSearchFooter = ({ contentState, hasActiveResult, - hasResults, insertTarget, onInsert, onOpen, onOpenInSidebar, onOpenSearchSidebar, }: AdvancedSearchFooterProps) => { - const hasResultsState = contentState === "results"; - const canOpen = hasActiveResult && hasResultsState; - const canInsert = !!insertTarget && hasActiveResult && hasResultsState; - const canOpenSearchSidebar = hasResults && hasResultsState; + const hasResults = contentState === "results"; + const canOpen = hasActiveResult && hasResults; + const canInsert = !!insertTarget && hasActiveResult && hasResults; + const canOpenSearchSidebar = hasResults; return (
From 7f3892281d635df9546552a4f1e2dd57ae5b5c58 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 May 2026 15:48:58 -0400 Subject: [PATCH 05/17] current progress, new UI --- .../AdvancedSearchDialog.tsx | 39 ++--- .../AdvancedSearchFooter.tsx | 2 +- .../AdvancedSearchResultsList.tsx | 144 ++++++++++++++++ .../AdvancedSearchSidebarPanel.tsx | 154 ++++++++++++++++++ .../advancedSearchSession.ts | 7 + .../openDgSearchInSidebar.tsx | 83 ++++++++++ .../openSearchResult.ts | 24 +++ 7 files changed, 423 insertions(+), 30 deletions(-) create mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx create mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx create mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/advancedSearchSession.ts create mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/openDgSearchInSidebar.tsx create mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/openSearchResult.ts diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index b305fb96a..e309b0feb 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -21,8 +21,6 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU import renderOverlay, { RoamOverlayProps, } from "roamjs-components/util/renderOverlay"; -import createPage from "roamjs-components/writes/createPage"; -import { createBlock } from "roamjs-components/writes"; import { insertPageRefAtRange, snapshotInsertTarget, @@ -46,7 +44,9 @@ import { stripTypePrefix, } from "./utils"; import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; +import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; +import { openDgSearchInSidebar } from "./openDgSearchInSidebar"; type Props = Record; @@ -102,7 +102,7 @@ const ResultRow = ({ boxShadow: active ? "inset 3px 0 0 #5f57c0" : undefined, }} > - + {nodeConfig ? getNodeBadgeText(nodeConfig) : result.nodeTypeLabel} @@ -325,32 +325,13 @@ const AdvancedNodeSearchDialog = ({ if (contentState !== "results" || !results.length) return; try { - const sidebarBlockTitle = `Advanced search results: "${debouncedSearchTerm || "(empty query)"}"`; - const sidebarChildren = results.map((result) => ({ - text: `[[${result.title}]]`, - })); - - const sidebarPageUid = await createPage({ title: sidebarBlockTitle }); - await Promise.all( - sidebarChildren.map((node, order) => - createBlock({ - parentUid: sidebarPageUid, - order, - node, - }), - ), - ); - - await window.roamAlphaAPI.ui.rightSidebar.addWindow({ - window: { - type: "outline", - // @ts-expect-error - block-uid is valid for outline sidebar windows - // eslint-disable-next-line @typescript-eslint/naming-convention - "block-uid": sidebarPageUid, - }, + await openDgSearchInSidebar({ + query: debouncedSearchTerm, + sort, + results, }); - posthog.capture("Advanced Node Search: Open search sidebar", { + posthog.capture("Advanced Node Search: Dock search sidebar", { resultCount: results.length, searchTerm: debouncedSearchTerm, sortDirection: sort.direction, @@ -358,10 +339,10 @@ const AdvancedNodeSearchDialog = ({ }); onClose(); } catch (error) { - console.error("Failed to open search sidebar results block:", error); + console.error("Failed to dock search results in the sidebar:", error); renderToast({ id: "advanced-node-search-sidebar-open-error", - content: "Could not render search results in the right sidebar.", + content: "Could not dock search results in the right sidebar.", intent: "danger", }); } diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx index aebbdc301..81e1c93e8 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx @@ -110,7 +110,7 @@ export const OpenSearchSidebarFooterAction = ({ void onOpenSearchSidebar()} /> ); diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx new file mode 100644 index 000000000..f08771c22 --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { Button, Icon, Tag } from "@blueprintjs/core"; +import getRoamUrl from "roamjs-components/dom/getRoamUrl"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; +import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; +import { + type SearchResult, + splitWithHighlights, + stripTypePrefix, +} from "./utils"; +import { openSearchResultFromLinkEvent } from "./openSearchResult"; + +const getNodeBadgeText = (node: DiscourseNode): string => + (node.tag?.trim() || node.text).slice(0, 3).toUpperCase(); + +const getTagStyle = (node: DiscourseNode | undefined): React.CSSProperties => { + const color = node?.canvasSettings?.color; + if (!color) return {}; + return getNodeTagStyles(color) ?? {}; +}; + +export const renderHighlightedText = ( + text: string, + keywords: string[], +): React.ReactNode => + splitWithHighlights(text, keywords).map((segment, index) => + segment.isMatch ? ( + {segment.text} + ) : ( + + {segment.text} + + ), + ); + +type AdvancedSearchDialogResultsListProps = { + activeIndex: number; + keywords: string[]; + nodeConfigByType: Record; + onSelect: (index: number) => void; + results: SearchResult[]; +}; + +export const AdvancedSearchDialogResultsList = ({ + activeIndex, + keywords, + nodeConfigByType, + onSelect, + results, +}: AdvancedSearchDialogResultsListProps) => ( + <> + {results.map((result, index) => ( + + ))} + +); + +type AdvancedSearchSidebarResultsListProps = { + keywords: string[]; + results: SearchResult[]; +}; + +export const AdvancedSearchSidebarResultsList = ({ + keywords, + results, +}: AdvancedSearchSidebarResultsListProps) => ( + +); diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx new file mode 100644 index 000000000..1d3f6c0ed --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useRef, useState } from "react"; +import { NonIdealState, Spinner, SpinnerSize } from "@blueprintjs/core"; +import MiniSearch from "minisearch"; +import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import { + DEBOUNCE_MS, + type SearchResult, + type SortConfig, + buildSearchIndex, + searchIndexedNodes, + sortSearchResults, +} from "./utils"; +import type { AdvancedNodeSearchSession } from "./advancedSearchSession"; +import { AdvancedSearchSidebarResultsList } from "./AdvancedSearchResultsList"; + +type AdvancedSearchSidebarPanelProps = { + initialSession: AdvancedNodeSearchSession; +}; + +export const AdvancedSearchSidebarPanel = ({ + initialSession, +}: AdvancedSearchSidebarPanelProps) => { + const [searchTerm, setSearchTerm] = useState(initialSession.query); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState( + initialSession.query, + ); + const [sort] = useState(initialSession.sort); + const [results, setResults] = useState( + initialSession.results, + ); + const [isIndexLoading, setIsIndexLoading] = useState(true); + const [indexError, setIndexError] = useState(false); + + const miniSearchRef = useRef | null>(null); + const allResultsRef = useRef([]); + + const keywords = debouncedSearchTerm.split(/\s+/).filter(Boolean); + + useEffect(() => { + const timeout = setTimeout( + () => setDebouncedSearchTerm(searchTerm.trim()), + DEBOUNCE_MS, + ); + return () => clearTimeout(timeout); + }, [searchTerm]); + + useEffect(() => { + let cancelled = false; + setIsIndexLoading(true); + setIndexError(false); + + const discourseNodes = getDiscourseNodes().filter( + (node) => node.backedBy === "user", + ); + + void buildSearchIndex(discourseNodes) + .then(({ miniSearch, results: indexedResults }) => { + if (cancelled) return; + miniSearchRef.current = miniSearch; + allResultsRef.current = indexedResults; + }) + .catch((error) => { + console.error( + "Error building advanced node search sidebar index:", + error, + ); + if (!cancelled) setIndexError(true); + }) + .finally(() => { + if (!cancelled) setIsIndexLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if ( + isIndexLoading || + indexError || + !debouncedSearchTerm || + !miniSearchRef.current + ) { + if (!debouncedSearchTerm) setResults([]); + return; + } + + const scoredHits = searchIndexedNodes({ + miniSearch: miniSearchRef.current, + allResults: allResultsRef.current, + searchTerm: debouncedSearchTerm, + }); + + setResults(sortSearchResults({ hits: scoredHits, sort })); + }, [debouncedSearchTerm, indexError, isIndexLoading, sort]); + + const resultLabel = + results.length === 1 ? "1 result" : `${results.length} results`; + + return ( +
+
+ setSearchTerm(event.target.value)} + placeholder="Search discourse nodes..." + type="text" + value={searchTerm} + /> +
+
+ {indexError ? ( + + ) : ( + <> + + {isIndexLoading && !results.length + ? "Loading…" + : debouncedSearchTerm + ? resultLabel + : "Type to search"} + + {isIndexLoading && !results.length ? ( +
+ +
+ ) : ( + <> + {debouncedSearchTerm && results.length > 0 && ( + + )} + {debouncedSearchTerm && !results.length && !isIndexLoading && ( +

+ No matches. Try another keyword. +

+ )} + + )} + + )} +
+
+ ); +}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/advancedSearchSession.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/advancedSearchSession.ts new file mode 100644 index 000000000..b0c22f13e --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/advancedSearchSession.ts @@ -0,0 +1,7 @@ +import type { SearchResult, SortConfig } from "./utils"; + +export type AdvancedNodeSearchSession = { + query: string; + sort: SortConfig; + results: SearchResult[]; +}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/openDgSearchInSidebar.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/openDgSearchInSidebar.tsx new file mode 100644 index 000000000..7ffdf167b --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/openDgSearchInSidebar.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import renderWithUnmount from "roamjs-components/util/renderWithUnmount"; +import { AdvancedSearchSidebarPanel } from "./AdvancedSearchSidebarPanel"; +import type { AdvancedNodeSearchSession } from "./advancedSearchSession"; + +const SIDEBAR_ROOT_ID = "dg-node-search-sidebar-root"; + +let unmountSidebarSearch: (() => void) | null = null; + +const waitForLatestSidebarWindow = async (): Promise => { + for (let attempt = 0; attempt < 40; attempt += 1) { + const windows = + document.querySelectorAll(".rm-sidebar-window"); + const latest = windows[windows.length - 1]; + if (latest) return latest; + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } + throw new Error("Sidebar window did not appear"); +}; + +const setSidebarWindowTitle = (windowEl: HTMLElement): void => { + const titleEl = windowEl.querySelector( + ".window-headers span[style*='font-weight']", + ); + if (titleEl) titleEl.textContent = "DG node search"; +}; + +const mountPanelInSidebarWindow = ({ + session, + windowEl, +}: { + session: AdvancedNodeSearchSession; + windowEl: HTMLElement; +}): void => { + unmountSidebarSearch?.(); + unmountSidebarSearch = null; + + const outlineWrapper = windowEl.querySelector(".rm-sidebar-outline-wrapper"); + if (!outlineWrapper) { + throw new Error("Sidebar outline wrapper not found"); + } + + outlineWrapper.innerHTML = ""; + + const root = document.createElement("div"); + root.id = SIDEBAR_ROOT_ID; + root.className = + "rm-sidebar-search dg-node-search-sidebar-root box-border w-full"; + root.onmousedown = (event) => event.stopPropagation(); + outlineWrapper.appendChild(root); + + unmountSidebarSearch = renderWithUnmount( + , + root, + ); +}; + +export const openDgSearchInSidebar = async ( + session: AdvancedNodeSearchSession, +): Promise => { + const anchorPageUid = window.roamAlphaAPI.util.dateToPageUid(new Date()); + + await window.roamAlphaAPI.ui.rightSidebar.addWindow({ + window: { + type: "outline", + // @ts-expect-error - block-uid is valid for outline sidebar windows + // eslint-disable-next-line @typescript-eslint/naming-convention + "block-uid": anchorPageUid, + }, + }); + + const sidebarWindow = await waitForLatestSidebarWindow(); + setSidebarWindowTitle(sidebarWindow); + mountPanelInSidebarWindow({ session, windowEl: sidebarWindow }); +}; + +export const unmountDgSearchSidebar = (): void => { + unmountSidebarSearch?.(); + unmountSidebarSearch = null; + document.getElementById(SIDEBAR_ROOT_ID)?.remove(); +}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/openSearchResult.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/openSearchResult.ts new file mode 100644 index 000000000..8f54564ef --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/openSearchResult.ts @@ -0,0 +1,24 @@ +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; + +export const openSearchResultInMain = async (uid: string): Promise => { + if (getPageTitleByPageUid(uid)) { + await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + return; + } + await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); +}; + +export const openSearchResultFromLinkEvent = async ({ + uid, + shiftKey, +}: { + uid: string; + shiftKey: boolean; +}): Promise => { + if (shiftKey) { + await openBlockInSidebar(uid); + return; + } + await openSearchResultInMain(uid); +}; From 3582b058a2a2ebc7f3137a9278cd6b6d6a1a2649 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 May 2026 15:52:37 -0400 Subject: [PATCH 06/17] cleanup --- .../AdvancedSearchDialog.tsx | 2 +- .../AdvancedSearchResultsList.tsx | 2 +- .../AdvancedSearchSidebarPanel.tsx | 2 +- .../advancedSearchSession.ts | 7 ---- .../openSearchResult.ts | 24 ------------ .../openDgSearchInSidebar.tsx | 37 ++++++++++++++++++- 6 files changed, 38 insertions(+), 36 deletions(-) delete mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/advancedSearchSession.ts delete mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/openSearchResult.ts rename apps/roam/src/{components/AdvancedNodeSearchDialog => utils}/openDgSearchInSidebar.tsx (71%) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index e309b0feb..c66287d04 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -46,7 +46,7 @@ import { import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; -import { openDgSearchInSidebar } from "./openDgSearchInSidebar"; +import { openDgSearchInSidebar } from "../../utils/openDgSearchInSidebar"; type Props = Record; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx index f08771c22..3b17833e2 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx @@ -9,7 +9,7 @@ import { splitWithHighlights, stripTypePrefix, } from "./utils"; -import { openSearchResultFromLinkEvent } from "./openSearchResult"; +import { openSearchResultFromLinkEvent } from "~/utils/openDgSearchInSidebar"; const getNodeBadgeText = (node: DiscourseNode): string => (node.tag?.trim() || node.text).slice(0, 3).toUpperCase(); diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx index 1d3f6c0ed..fb78a3110 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -10,7 +10,7 @@ import { searchIndexedNodes, sortSearchResults, } from "./utils"; -import type { AdvancedNodeSearchSession } from "./advancedSearchSession"; +import type { AdvancedNodeSearchSession } from "~/utils/openDgSearchInSidebar"; import { AdvancedSearchSidebarResultsList } from "./AdvancedSearchResultsList"; type AdvancedSearchSidebarPanelProps = { diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/advancedSearchSession.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/advancedSearchSession.ts deleted file mode 100644 index b0c22f13e..000000000 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/advancedSearchSession.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SearchResult, SortConfig } from "./utils"; - -export type AdvancedNodeSearchSession = { - query: string; - sort: SortConfig; - results: SearchResult[]; -}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/openSearchResult.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/openSearchResult.ts deleted file mode 100644 index 8f54564ef..000000000 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/openSearchResult.ts +++ /dev/null @@ -1,24 +0,0 @@ -import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; -import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; - -export const openSearchResultInMain = async (uid: string): Promise => { - if (getPageTitleByPageUid(uid)) { - await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); - return; - } - await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); -}; - -export const openSearchResultFromLinkEvent = async ({ - uid, - shiftKey, -}: { - uid: string; - shiftKey: boolean; -}): Promise => { - if (shiftKey) { - await openBlockInSidebar(uid); - return; - } - await openSearchResultInMain(uid); -}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/openDgSearchInSidebar.tsx b/apps/roam/src/utils/openDgSearchInSidebar.tsx similarity index 71% rename from apps/roam/src/components/AdvancedNodeSearchDialog/openDgSearchInSidebar.tsx rename to apps/roam/src/utils/openDgSearchInSidebar.tsx index 7ffdf167b..29ac239d7 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/openDgSearchInSidebar.tsx +++ b/apps/roam/src/utils/openDgSearchInSidebar.tsx @@ -1,10 +1,43 @@ import React from "react"; import renderWithUnmount from "roamjs-components/util/renderWithUnmount"; -import { AdvancedSearchSidebarPanel } from "./AdvancedSearchSidebarPanel"; -import type { AdvancedNodeSearchSession } from "./advancedSearchSession"; +import { AdvancedSearchSidebarPanel } from "../components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; +import { + SearchResult, + SortConfig, +} from "~/components/AdvancedNodeSearchDialog/utils"; const SIDEBAR_ROOT_ID = "dg-node-search-sidebar-root"; +export type AdvancedNodeSearchSession = { + query: string; + sort: SortConfig; + results: SearchResult[]; +}; + +export const openSearchResultInMain = async (uid: string): Promise => { + if (getPageTitleByPageUid(uid)) { + await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + return; + } + await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); +}; + +export const openSearchResultFromLinkEvent = async ({ + uid, + shiftKey, +}: { + uid: string; + shiftKey: boolean; +}): Promise => { + if (shiftKey) { + await openBlockInSidebar(uid); + return; + } + await openSearchResultInMain(uid); +}; + let unmountSidebarSearch: (() => void) | null = null; const waitForLatestSidebarWindow = async (): Promise => { From 297009ccab953c7e2e43f1c592014b1b412f3a02 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 May 2026 16:29:59 -0400 Subject: [PATCH 07/17] cleanup and add filter and sort state --- .../AdvancedSearchDialog.tsx | 117 ++------ .../AdvancedSearchResultsList.tsx | 144 --------- .../AdvancedSearchSidebarPanel.tsx | 281 ++++++++++++++++-- .../AdvancedNodeSearchDialog/utils.ts | 3 + .../src/utils/advancedSearchNavigation.ts | 24 ++ apps/roam/src/utils/openDgSearchInSidebar.tsx | 45 +-- 6 files changed, 312 insertions(+), 302 deletions(-) delete mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx create mode 100644 apps/roam/src/utils/advancedSearchNavigation.ts diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index c66287d04..42094fe83 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Button, Dialog, @@ -12,7 +6,6 @@ import { NonIdealState, Spinner, SpinnerSize, - Tag, } from "@blueprintjs/core"; import MiniSearch from "minisearch"; import posthog from "posthog-js"; @@ -30,7 +23,8 @@ import { DiscourseNodeSortControl } from "~/components/DiscourseNodeSortControl" import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; -import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; +import { openSearchResultInMain } from "~/utils/advancedSearchNavigation"; +import { openDgSearchInSidebar } from "~/utils/openDgSearchInSidebar"; import { DEBOUNCE_MS, DEFAULT_SORT_CONFIG, @@ -38,79 +32,18 @@ import { type SortConfig, buildSearchIndex, formatMetadataDate, + getSearchKeywords, searchIndexedNodes, sortSearchResults, - splitWithHighlights, stripTypePrefix, } from "./utils"; import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; -import { openDgSearchInSidebar } from "../../utils/openDgSearchInSidebar"; +import { AdvancedSearchDialogResultsList } from "./AdvancedSearchSidebarPanel"; type Props = Record; -const getNodeBadgeText = (node: DiscourseNode): string => - (node.tag?.trim() || node.text).slice(0, 3).toUpperCase(); - -const getTagStyle = (node: DiscourseNode | undefined): React.CSSProperties => { - const color = node?.canvasSettings?.color; - if (!color) return { flexShrink: 0 }; - return { ...getNodeTagStyles(color), flexShrink: 0 }; -}; - -const renderHighlightedText = ( - text: string, - keywords: string[], -): React.ReactNode => - splitWithHighlights(text, keywords).map((segment, index) => - segment.isMatch ? ( - {segment.text} - ) : ( - - {segment.text} - - ), - ); - -const ResultRow = ({ - active, - keywords, - nodeConfig, - onClick, - onMouseEnter, - result, -}: { - active: boolean; - keywords: string[]; - nodeConfig: DiscourseNode | undefined; - onClick: () => void; - onMouseEnter: () => void; - result: SearchResult; -}) => ( - -); - const PreviewPane = ({ result }: { result: SearchResult | null }) => { if (!result) { return ( @@ -174,7 +107,7 @@ const AdvancedNodeSearchDialog = ({ ); const activeResult = results[activeIndex] ?? null; - const keywords = debouncedSearchTerm.split(/\s+/).filter(Boolean); + const keywords = getSearchKeywords(debouncedSearchTerm); useEffect(() => { if (!isOpen) return; @@ -327,13 +260,15 @@ const AdvancedNodeSearchDialog = ({ try { await openDgSearchInSidebar({ query: debouncedSearchTerm, - sort, results, + selectedNodeTypeIds, + sort, }); posthog.capture("Advanced Node Search: Dock search sidebar", { resultCount: results.length, searchTerm: debouncedSearchTerm, + selectedNodeTypeCount: selectedNodeTypeIds.length, sortDirection: sort.direction, sortField: sort.field, }); @@ -346,7 +281,14 @@ const AdvancedNodeSearchDialog = ({ intent: "danger", }); } - }, [contentState, debouncedSearchTerm, onClose, results, sort]); + }, [ + contentState, + debouncedSearchTerm, + onClose, + results, + selectedNodeTypeIds, + sort, + ]); const handleSortChange = useCallback((nextSort: SortConfig): void => { setSort(nextSort); }, []); @@ -354,12 +296,7 @@ const AdvancedNodeSearchDialog = ({ const onOpen = useCallback(async () => { if (!activeResult || contentState !== "results") return; - const uid = activeResult.uid; - if (getPageTitleByPageUid(uid)) { - await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); - } else { - await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); - } + await openSearchResultInMain(activeResult.uid); onClose(); }, [activeResult, contentState, onClose]); @@ -491,17 +428,13 @@ const AdvancedNodeSearchDialog = ({ ref={resultsPanelRef} role="listbox" > - {results.map((result, index) => ( - setActiveIndex(index)} - onMouseEnter={() => setActiveIndex(index)} - result={result} - /> - ))} +
diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx deleted file mode 100644 index 3b17833e2..000000000 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchResultsList.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React from "react"; -import { Button, Icon, Tag } from "@blueprintjs/core"; -import getRoamUrl from "roamjs-components/dom/getRoamUrl"; -import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; -import type { DiscourseNode } from "~/utils/getDiscourseNodes"; -import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; -import { - type SearchResult, - splitWithHighlights, - stripTypePrefix, -} from "./utils"; -import { openSearchResultFromLinkEvent } from "~/utils/openDgSearchInSidebar"; - -const getNodeBadgeText = (node: DiscourseNode): string => - (node.tag?.trim() || node.text).slice(0, 3).toUpperCase(); - -const getTagStyle = (node: DiscourseNode | undefined): React.CSSProperties => { - const color = node?.canvasSettings?.color; - if (!color) return {}; - return getNodeTagStyles(color) ?? {}; -}; - -export const renderHighlightedText = ( - text: string, - keywords: string[], -): React.ReactNode => - splitWithHighlights(text, keywords).map((segment, index) => - segment.isMatch ? ( - {segment.text} - ) : ( - - {segment.text} - - ), - ); - -type AdvancedSearchDialogResultsListProps = { - activeIndex: number; - keywords: string[]; - nodeConfigByType: Record; - onSelect: (index: number) => void; - results: SearchResult[]; -}; - -export const AdvancedSearchDialogResultsList = ({ - activeIndex, - keywords, - nodeConfigByType, - onSelect, - results, -}: AdvancedSearchDialogResultsListProps) => ( - <> - {results.map((result, index) => ( - - ))} - -); - -type AdvancedSearchSidebarResultsListProps = { - keywords: string[]; - results: SearchResult[]; -}; - -export const AdvancedSearchSidebarResultsList = ({ - keywords, - results, -}: AdvancedSearchSidebarResultsListProps) => ( - -); diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx index fb78a3110..763a851b9 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -1,33 +1,234 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { NonIdealState, Spinner, SpinnerSize } from "@blueprintjs/core"; import MiniSearch from "minisearch"; import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import type { DockedSearchState } from "~/utils/openDgSearchInSidebar"; import { DEBOUNCE_MS, type SearchResult, - type SortConfig, buildSearchIndex, + getSearchKeywords, searchIndexedNodes, sortSearchResults, } from "./utils"; -import type { AdvancedNodeSearchSession } from "~/utils/openDgSearchInSidebar"; -import { AdvancedSearchSidebarResultsList } from "./AdvancedSearchResultsList"; +import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; +import { hasActiveTypeFilter } from "~/utils/discourseNodeTypeFilter"; +import { SORT_FIELD_LABELS, isNonDefaultSort, type SortConfig } from "./utils"; -type AdvancedSearchSidebarPanelProps = { - initialSession: AdvancedNodeSearchSession; -}; +import { Button, Icon, Tag } from "@blueprintjs/core"; +import getRoamUrl from "roamjs-components/dom/getRoamUrl"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; +import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; +import { openSearchResultFromLinkEvent } from "~/utils/advancedSearchNavigation"; +import { splitWithHighlights, stripTypePrefix } from "./utils"; -export const AdvancedSearchSidebarPanel = ({ - initialSession, -}: AdvancedSearchSidebarPanelProps) => { - const [searchTerm, setSearchTerm] = useState(initialSession.query); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState( - initialSession.query, +const renderHighlightedText = ( + text: string, + keywords: string[], +): React.ReactNode => + splitWithHighlights(text, keywords).map((segment, index) => + segment.isMatch ? ( + {segment.text} + ) : ( + + {segment.text} + + ), ); - const [sort] = useState(initialSession.sort); - const [results, setResults] = useState( - initialSession.results, + +const getNodeBadgeText = (node: DiscourseNode): string => + (node.tag?.trim() || node.text).slice(0, 3).toUpperCase(); + +const getTagStyle = (node: DiscourseNode | undefined): React.CSSProperties => { + const color = node?.canvasSettings?.color; + if (!color) return { flexShrink: 0 }; + return { ...getNodeTagStyles(color), flexShrink: 0 }; +}; + +type AdvancedSearchDialogResultsListProps = { + activeIndex: number; + keywords: string[]; + nodeConfigByType: Record; + onSelect: (index: number) => void; + results: SearchResult[]; +}; + +export const AdvancedSearchDialogResultsList = ({ + activeIndex, + keywords, + nodeConfigByType, + onSelect, + results, +}: AdvancedSearchDialogResultsListProps) => ( + <> + {results.map((result, index) => ( + + ))} + +); + +type AdvancedSearchSidebarResultsListProps = { + keywords: string[]; + results: SearchResult[]; +}; + +export const AdvancedSearchSidebarResultsList = ({ + keywords, + results, +}: AdvancedSearchSidebarResultsListProps) => ( + <> + {results.map((result) => { + const displayTitle = stripTypePrefix(result.title); + + return ( + + ); + })} + +); + +type AdvancedSearchDockedFiltersProps = { + discourseNodes: DiscourseNode[]; + selectedNodeTypeIds: string[]; + sort: SortConfig; +}; + +const getNodeIndicatorColor = (node: DiscourseNode): string => + formatHexColor(node.canvasSettings?.color) || "#6b7280"; + +export const AdvancedSearchDockedFilters = ({ + discourseNodes, + selectedNodeTypeIds, + sort, +}: AdvancedSearchDockedFiltersProps): React.ReactElement | null => { + const allTypeIds = discourseNodes.map((node) => node.type); + const isTypeFilterActive = hasActiveTypeFilter({ + selectedTypeIds: selectedNodeTypeIds, + allTypeIds, + }); + const selectedNodes = isTypeFilterActive + ? discourseNodes.filter((node) => selectedNodeTypeIds.includes(node.type)) + : []; + const showSort = isNonDefaultSort(sort); + + if (!isTypeFilterActive && !showSort) return null; + + return ( +
+ {isTypeFilterActive && ( +
+ + Filtered to + + {selectedNodes.map((node) => ( + + + + {node.text} + + + ))} +
+ )} + {showSort && ( +

+ Sorted by {SORT_FIELD_LABELS[sort.field]} ( + {sort.direction === "asc" ? "ascending" : "descending"}) +

+ )} +
); +}; + +export const AdvancedSearchSidebarPanel = (dockedState: DockedSearchState) => { + const { + query, + results: dockedResults, + selectedNodeTypeIds, + sort, + } = dockedState; + + const [searchTerm, setSearchTerm] = useState(query); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(query); + const [results, setResults] = useState(dockedResults); const [isIndexLoading, setIsIndexLoading] = useState(true); const [indexError, setIndexError] = useState(false); @@ -36,7 +237,12 @@ export const AdvancedSearchSidebarPanel = ({ > | null>(null); const allResultsRef = useRef([]); - const keywords = debouncedSearchTerm.split(/\s+/).filter(Boolean); + const discourseNodes = useMemo( + () => getDiscourseNodes().filter((node) => node.backedBy === "user"), + [], + ); + const keywords = getSearchKeywords(debouncedSearchTerm); + const isDockedQuery = debouncedSearchTerm.trim() === query.trim(); useEffect(() => { const timeout = setTimeout( @@ -51,10 +257,6 @@ export const AdvancedSearchSidebarPanel = ({ setIsIndexLoading(true); setIndexError(false); - const discourseNodes = getDiscourseNodes().filter( - (node) => node.backedBy === "user", - ); - void buildSearchIndex(discourseNodes) .then(({ miniSearch, results: indexedResults }) => { if (cancelled) return; @@ -75,16 +277,16 @@ export const AdvancedSearchSidebarPanel = ({ return () => { cancelled = true; }; - }, []); + }, [discourseNodes]); useEffect(() => { - if ( - isIndexLoading || - indexError || - !debouncedSearchTerm || - !miniSearchRef.current - ) { - if (!debouncedSearchTerm) setResults([]); + if (!debouncedSearchTerm) { + setResults([]); + return; + } + + if (isIndexLoading || indexError || !miniSearchRef.current) { + if (!isDockedQuery) setResults([]); return; } @@ -92,16 +294,24 @@ export const AdvancedSearchSidebarPanel = ({ miniSearch: miniSearchRef.current, allResults: allResultsRef.current, searchTerm: debouncedSearchTerm, + typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined, }); setResults(sortSearchResults({ hits: scoredHits, sort })); - }, [debouncedSearchTerm, indexError, isIndexLoading, sort]); + }, [ + debouncedSearchTerm, + indexError, + isDockedQuery, + isIndexLoading, + selectedNodeTypeIds, + sort, + ]); const resultLabel = results.length === 1 ? "1 result" : `${results.length} results`; return ( -
+
+ {debouncedSearchTerm && ( + + )}
{indexError ? ( {isIndexLoading && !results.length ? ( diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts index c75c887c2..daa81e6bf 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts @@ -13,6 +13,9 @@ import { export const DEBOUNCE_MS = 250; export const MAX_RESULTS = 50; +export const getSearchKeywords = (searchTerm: string): string[] => + searchTerm.split(/\s+/).filter(Boolean); + export type SortField = "relevance" | "alphabetical" | "dateCreated" | "author"; export type SortDirection = "asc" | "desc"; diff --git a/apps/roam/src/utils/advancedSearchNavigation.ts b/apps/roam/src/utils/advancedSearchNavigation.ts new file mode 100644 index 000000000..52f96dc56 --- /dev/null +++ b/apps/roam/src/utils/advancedSearchNavigation.ts @@ -0,0 +1,24 @@ +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; + +export const openSearchResultInMain = async (uid: string): Promise => { + if (getPageTitleByPageUid(uid)) { + await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + return; + } + await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); +}; + +export const openSearchResultFromLinkEvent = async ({ + shiftKey, + uid, +}: { + shiftKey: boolean; + uid: string; +}): Promise => { + if (shiftKey) { + await openBlockInSidebar(uid); + return; + } + await openSearchResultInMain(uid); +}; diff --git a/apps/roam/src/utils/openDgSearchInSidebar.tsx b/apps/roam/src/utils/openDgSearchInSidebar.tsx index 29ac239d7..7fe43473b 100644 --- a/apps/roam/src/utils/openDgSearchInSidebar.tsx +++ b/apps/roam/src/utils/openDgSearchInSidebar.tsx @@ -1,41 +1,18 @@ import React from "react"; import renderWithUnmount from "roamjs-components/util/renderWithUnmount"; import { AdvancedSearchSidebarPanel } from "../components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel"; -import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; -import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; import { - SearchResult, - SortConfig, + type SearchResult, + type SortConfig, } from "~/components/AdvancedNodeSearchDialog/utils"; const SIDEBAR_ROOT_ID = "dg-node-search-sidebar-root"; -export type AdvancedNodeSearchSession = { +export type DockedSearchState = { query: string; - sort: SortConfig; results: SearchResult[]; -}; - -export const openSearchResultInMain = async (uid: string): Promise => { - if (getPageTitleByPageUid(uid)) { - await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); - return; - } - await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); -}; - -export const openSearchResultFromLinkEvent = async ({ - uid, - shiftKey, -}: { - uid: string; - shiftKey: boolean; -}): Promise => { - if (shiftKey) { - await openBlockInSidebar(uid); - return; - } - await openSearchResultInMain(uid); + selectedNodeTypeIds: string[]; + sort: SortConfig; }; let unmountSidebarSearch: (() => void) | null = null; @@ -61,10 +38,10 @@ const setSidebarWindowTitle = (windowEl: HTMLElement): void => { }; const mountPanelInSidebarWindow = ({ - session, + dockedState, windowEl, }: { - session: AdvancedNodeSearchSession; + dockedState: DockedSearchState; windowEl: HTMLElement; }): void => { unmountSidebarSearch?.(); @@ -80,18 +57,18 @@ const mountPanelInSidebarWindow = ({ const root = document.createElement("div"); root.id = SIDEBAR_ROOT_ID; root.className = - "rm-sidebar-search dg-node-search-sidebar-root box-border w-full"; + "rm-sidebar-search dg-node-search-sidebar-root box-border w-full min-w-0"; root.onmousedown = (event) => event.stopPropagation(); outlineWrapper.appendChild(root); unmountSidebarSearch = renderWithUnmount( - , + , root, ); }; export const openDgSearchInSidebar = async ( - session: AdvancedNodeSearchSession, + dockedState: DockedSearchState, ): Promise => { const anchorPageUid = window.roamAlphaAPI.util.dateToPageUid(new Date()); @@ -106,7 +83,7 @@ export const openDgSearchInSidebar = async ( const sidebarWindow = await waitForLatestSidebarWindow(); setSidebarWindowTitle(sidebarWindow); - mountPanelInSidebarWindow({ session, windowEl: sidebarWindow }); + mountPanelInSidebarWindow({ dockedState, windowEl: sidebarWindow }); }; export const unmountDgSearchSidebar = (): void => { From e0015ac26b28324358dd8b054425694b3279d990 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 30 May 2026 23:50:46 -0400 Subject: [PATCH 08/17] new layout --- .../AdvancedSearchDialog.tsx | 5 +- .../AdvancedSearchFooter.tsx | 2 +- .../AdvancedSearchSidebarPanel.tsx | 71 ++++++--- .../DiscourseNodeTypeFilter.tsx | 7 +- .../roam/src/utils/discourseNodeTypeFilter.ts | 6 + apps/roam/src/utils/openDgSearchInSidebar.tsx | 135 +++++++++++++----- 6 files changed, 166 insertions(+), 60 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 42094fe83..c649c4c08 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -470,12 +470,9 @@ const AdvancedNodeSearchDialog = ({ ); }; -export const renderAdvancedNodeSearchSidebar = () => +export const renderAdvancedNodeSearchDialog = () => renderOverlay({ // eslint-disable-next-line @typescript-eslint/naming-convention Overlay: AdvancedNodeSearchDialog, props: {}, }); - -export const renderAdvancedNodeSearchDialog = () => - renderAdvancedNodeSearchSidebar(); diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx index 81e1c93e8..336641a1e 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx @@ -100,7 +100,7 @@ const InsertFooterAction = ({ /> ); -export const OpenSearchSidebarFooterAction = ({ +const OpenSearchSidebarFooterAction = ({ disabled, onOpenSearchSidebar, }: { diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx index 763a851b9..300d91958 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -1,5 +1,13 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { NonIdealState, Spinner, SpinnerSize } from "@blueprintjs/core"; +import { + Button, + Icon, + InputGroup, + NonIdealState, + Spinner, + SpinnerSize, + Tag, +} from "@blueprintjs/core"; import MiniSearch from "minisearch"; import getDiscourseNodes from "~/utils/getDiscourseNodes"; import type { DockedSearchState } from "~/utils/openDgSearchInSidebar"; @@ -11,11 +19,9 @@ import { searchIndexedNodes, sortSearchResults, } from "./utils"; -import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; import { hasActiveTypeFilter } from "~/utils/discourseNodeTypeFilter"; +import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; import { SORT_FIELD_LABELS, isNonDefaultSort, type SortConfig } from "./utils"; - -import { Button, Icon, Tag } from "@blueprintjs/core"; import getRoamUrl from "roamjs-components/dom/getRoamUrl"; import type { DiscourseNode } from "~/utils/getDiscourseNodes"; import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; @@ -162,7 +168,14 @@ type AdvancedSearchDockedFiltersProps = { const getNodeIndicatorColor = (node: DiscourseNode): string => formatHexColor(node.canvasSettings?.color) || "#6b7280"; -export const AdvancedSearchDockedFilters = ({ +type SidebarIndexCache = { + miniSearch: MiniSearch; + results: SearchResult[]; +}; + +let cachedSidebarIndex: SidebarIndexCache | null = null; + +const AdvancedSearchDockedFilters = ({ discourseNodes, selectedNodeTypeIds, sort, @@ -218,7 +231,13 @@ export const AdvancedSearchDockedFilters = ({ ); }; -export const AdvancedSearchSidebarPanel = (dockedState: DockedSearchState) => { +type AdvancedSearchSidebarPanelProps = { + dockedState: DockedSearchState; +}; + +export const AdvancedSearchSidebarPanel = ({ + dockedState, +}: AdvancedSearchSidebarPanelProps) => { const { query, results: dockedResults, @@ -257,21 +276,40 @@ export const AdvancedSearchSidebarPanel = (dockedState: DockedSearchState) => { setIsIndexLoading(true); setIndexError(false); + const applyIndex = ({ + miniSearch, + results: indexedResults, + }: SidebarIndexCache): void => { + if (cancelled) return; + miniSearchRef.current = miniSearch; + allResultsRef.current = indexedResults; + setIsIndexLoading(false); + }; + + if (cachedSidebarIndex) { + applyIndex(cachedSidebarIndex); + return () => { + cancelled = true; + }; + } + void buildSearchIndex(discourseNodes) .then(({ miniSearch, results: indexedResults }) => { - if (cancelled) return; - miniSearchRef.current = miniSearch; - allResultsRef.current = indexedResults; + cachedSidebarIndex = { + miniSearch, + results: indexedResults, + }; + applyIndex(cachedSidebarIndex); }) .catch((error) => { console.error( "Error building advanced node search sidebar index:", error, ); - if (!cancelled) setIndexError(true); - }) - .finally(() => { - if (!cancelled) setIsIndexLoading(false); + if (!cancelled) { + setIndexError(true); + setIsIndexLoading(false); + } }); return () => { @@ -313,11 +351,12 @@ export const AdvancedSearchSidebarPanel = (dockedState: DockedSearchState) => { return (
- setSearchTerm(event.target.value)} placeholder="Search discourse nodes..." - type="text" value={searchTerm} />
diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx index 0eef336b9..0ff95698d 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx @@ -12,12 +12,12 @@ import { Popover, Position, } from "@blueprintjs/core"; -import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; import { type DiscourseNode } from "~/utils/getDiscourseNodes"; import { NODE_TYPE_FILTER_SEARCH_THRESHOLD, filterDiscourseNodesByQuery, fromPopoverSelectedIds, + getDiscourseNodeIndicatorColor, getSelectAllCheckState, hasActiveTypeFilter, toPopoverSelectedIds, @@ -30,9 +30,6 @@ export type DiscourseNodeTypeFilterProps = { onPopoverOpenChange?: (isOpen: boolean) => void; }; -const getNodeIndicatorColor = (node: DiscourseNode): string => - formatHexColor(node.canvasSettings?.color) || "#000"; - const NodeTypeFilterRow = ({ isChecked, node, @@ -52,7 +49,7 @@ const NodeTypeFilterRow = ({ {node.text} diff --git a/apps/roam/src/utils/discourseNodeTypeFilter.ts b/apps/roam/src/utils/discourseNodeTypeFilter.ts index f6cf89ce9..d981226f2 100644 --- a/apps/roam/src/utils/discourseNodeTypeFilter.ts +++ b/apps/roam/src/utils/discourseNodeTypeFilter.ts @@ -1,10 +1,16 @@ import { type DiscourseNode } from "~/utils/getDiscourseNodes"; +import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; /* Advanced search: when `selectedTypeIds` has no values, show all node types; otherwise, filter to the selected types. */ export const NODE_TYPE_FILTER_SEARCH_THRESHOLD = 7; export type SelectAllCheckState = "off" | "indeterminate" | "on"; +export const getDiscourseNodeIndicatorColor = ( + node: DiscourseNode, + fallback = "#000", +): string => formatHexColor(node.canvasSettings?.color) || fallback; + export const hasActiveTypeFilter = ({ selectedTypeIds, allTypeIds, diff --git a/apps/roam/src/utils/openDgSearchInSidebar.tsx b/apps/roam/src/utils/openDgSearchInSidebar.tsx index 7fe43473b..3b5bc92ec 100644 --- a/apps/roam/src/utils/openDgSearchInSidebar.tsx +++ b/apps/roam/src/utils/openDgSearchInSidebar.tsx @@ -7,6 +7,10 @@ import { } from "~/components/AdvancedNodeSearchDialog/utils"; const SIDEBAR_ROOT_ID = "dg-node-search-sidebar-root"; +const OUTLINE_WRAPPER_SELECTOR = + "#roam-right-sidebar-content .rm-sidebar-outline-wrapper"; +const SIDEBAR_OPEN_WIDTH_PX = 40; +const MAX_WRAPPER_WAIT_FRAMES = 30; export type DockedSearchState = { query: string; @@ -17,12 +21,33 @@ export type DockedSearchState = { let unmountSidebarSearch: (() => void) | null = null; -const waitForLatestSidebarWindow = async (): Promise => { - for (let attempt = 0; attempt < 40; attempt += 1) { - const windows = - document.querySelectorAll(".rm-sidebar-window"); - const latest = windows[windows.length - 1]; - if (latest) return latest; +const isRightSidebarOpen = (): boolean => { + const sidebar = document.getElementById("right-sidebar"); + return ( + !!sidebar && sidebar.getBoundingClientRect().width > SIDEBAR_OPEN_WIDTH_PX + ); +}; + +const getOutlineWrapperCount = (): number => + document.querySelectorAll(OUTLINE_WRAPPER_SELECTOR).length; + +const getLatestOutlineWrapper = (): HTMLElement | null => { + const wrappers = document.querySelectorAll( + OUTLINE_WRAPPER_SELECTOR, + ); + return wrappers[wrappers.length - 1] ?? null; +}; + +const waitForOutlineWrapper = async ( + minCount: number, +): Promise => { + for (let attempt = 0; attempt < MAX_WRAPPER_WAIT_FRAMES; attempt += 1) { + const wrappers = document.querySelectorAll( + OUTLINE_WRAPPER_SELECTOR, + ); + if (wrappers.length >= minCount && wrappers.length > 0) { + return wrappers[wrappers.length - 1]; + } await new Promise((resolve) => { requestAnimationFrame(() => resolve()); }); @@ -30,28 +55,56 @@ const waitForLatestSidebarWindow = async (): Promise => { throw new Error("Sidebar window did not appear"); }; -const setSidebarWindowTitle = (windowEl: HTMLElement): void => { - const titleEl = windowEl.querySelector( +const openRightSidebar = async ({ + anchorPageUid, + wrapperCountBefore, +}: { + anchorPageUid: string; + wrapperCountBefore: number; +}): Promise => { + const rightSidebar = window.roamAlphaAPI.ui.rightSidebar as { + open?: () => Promise; + }; + if (rightSidebar.open) { + await rightSidebar.open(); + return null; + } + + await addOutlineSidebarWindow(anchorPageUid); + return waitForOutlineWrapper(Math.max(wrapperCountBefore + 1, 1)); +}; + +const addOutlineSidebarWindow = async (blockUid: string): Promise => { + await window.roamAlphaAPI.ui.rightSidebar.addWindow({ + window: { + type: "outline", + // @ts-expect-error - block-uid is valid for outline sidebar windows + // eslint-disable-next-line @typescript-eslint/naming-convention + "block-uid": blockUid, + }, + }); +}; + +const setSidebarWindowTitle = (outlineWrapper: HTMLElement): void => { + const pane = outlineWrapper.closest( + "#roam-right-sidebar-content .sidebar-content > *", + ); + const titleEl = pane?.querySelector( ".window-headers span[style*='font-weight']", ); if (titleEl) titleEl.textContent = "DG node search"; }; -const mountPanelInSidebarWindow = ({ +const mountPanelInOutlineWrapper = ({ dockedState, - windowEl, + outlineWrapper, }: { dockedState: DockedSearchState; - windowEl: HTMLElement; + outlineWrapper: HTMLElement; }): void => { unmountSidebarSearch?.(); unmountSidebarSearch = null; - const outlineWrapper = windowEl.querySelector(".rm-sidebar-outline-wrapper"); - if (!outlineWrapper) { - throw new Error("Sidebar outline wrapper not found"); - } - outlineWrapper.innerHTML = ""; const root = document.createElement("div"); @@ -62,7 +115,7 @@ const mountPanelInSidebarWindow = ({ outlineWrapper.appendChild(root); unmountSidebarSearch = renderWithUnmount( - , + , root, ); }; @@ -70,24 +123,38 @@ const mountPanelInSidebarWindow = ({ export const openDgSearchInSidebar = async ( dockedState: DockedSearchState, ): Promise => { - const anchorPageUid = window.roamAlphaAPI.util.dateToPageUid(new Date()); + const anchorPageUid = + (await window.roamAlphaAPI.ui.mainWindow.getOpenPageOrBlockUid()) || + window.roamAlphaAPI.util.dateToPageUid(new Date()); + const wrapperCountBefore = getOutlineWrapperCount(); - await window.roamAlphaAPI.ui.rightSidebar.addWindow({ - window: { - type: "outline", - // @ts-expect-error - block-uid is valid for outline sidebar windows - // eslint-disable-next-line @typescript-eslint/naming-convention - "block-uid": anchorPageUid, - }, - }); + if (!isRightSidebarOpen()) { + const openedWrapper = await openRightSidebar({ + anchorPageUid, + wrapperCountBefore, + }); + if (openedWrapper) { + setSidebarWindowTitle(openedWrapper); + mountPanelInOutlineWrapper({ + dockedState, + outlineWrapper: openedWrapper, + }); + return; + } + } - const sidebarWindow = await waitForLatestSidebarWindow(); - setSidebarWindowTitle(sidebarWindow); - mountPanelInSidebarWindow({ dockedState, windowEl: sidebarWindow }); -}; + const existingWrapper = getLatestOutlineWrapper(); + if (existingWrapper) { + setSidebarWindowTitle(existingWrapper); + mountPanelInOutlineWrapper({ + dockedState, + outlineWrapper: existingWrapper, + }); + return; + } -export const unmountDgSearchSidebar = (): void => { - unmountSidebarSearch?.(); - unmountSidebarSearch = null; - document.getElementById(SIDEBAR_ROOT_ID)?.remove(); + await addOutlineSidebarWindow(anchorPageUid); + const outlineWrapper = await waitForOutlineWrapper(wrapperCountBefore + 1); + setSidebarWindowTitle(outlineWrapper); + mountPanelInOutlineWrapper({ dockedState, outlineWrapper }); }; From b6eaf44225f501e6b1e9ed1e65c8e8bc205b4864 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 30 May 2026 23:53:46 -0400 Subject: [PATCH 09/17] cleanup --- .../AdvancedSearchDialog.tsx | 2 +- .../AdvancedSearchSidebarPanel.tsx | 2 +- .../src/utils/advancedSearchFooterUtils.ts | 24 +++++++++++++++++++ .../src/utils/advancedSearchNavigation.ts | 24 ------------------- 4 files changed, 26 insertions(+), 26 deletions(-) delete mode 100644 apps/roam/src/utils/advancedSearchNavigation.ts diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index c649c4c08..9398aefba 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -23,7 +23,7 @@ import { DiscourseNodeSortControl } from "~/components/DiscourseNodeSortControl" import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; -import { openSearchResultInMain } from "~/utils/advancedSearchNavigation"; +import { openSearchResultInMain } from "~/utils/advancedSearchFooterUtils"; import { openDgSearchInSidebar } from "~/utils/openDgSearchInSidebar"; import { DEBOUNCE_MS, diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx index 300d91958..2d57c8ae7 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -25,8 +25,8 @@ import { SORT_FIELD_LABELS, isNonDefaultSort, type SortConfig } from "./utils"; import getRoamUrl from "roamjs-components/dom/getRoamUrl"; import type { DiscourseNode } from "~/utils/getDiscourseNodes"; import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; -import { openSearchResultFromLinkEvent } from "~/utils/advancedSearchNavigation"; import { splitWithHighlights, stripTypePrefix } from "./utils"; +import { openSearchResultFromLinkEvent } from "~/utils/advancedSearchFooterUtils"; const renderHighlightedText = ( text: string, diff --git a/apps/roam/src/utils/advancedSearchFooterUtils.ts b/apps/roam/src/utils/advancedSearchFooterUtils.ts index 1981858ad..5c089f32a 100644 --- a/apps/roam/src/utils/advancedSearchFooterUtils.ts +++ b/apps/roam/src/utils/advancedSearchFooterUtils.ts @@ -1,6 +1,8 @@ import getUids from "roamjs-components/dom/getUids"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import updateBlock from "roamjs-components/writes/updateBlock"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; export type BlockSelection = { selectionStart: number; @@ -17,6 +19,28 @@ export type InsertTarget = { const DEFAULT_WINDOW_ID = "main-window"; +export const openSearchResultInMain = async (uid: string): Promise => { + if (getPageTitleByPageUid(uid)) { + await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + return; + } + await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); +}; + +export const openSearchResultFromLinkEvent = async ({ + shiftKey, + uid, +}: { + shiftKey: boolean; + uid: string; +}): Promise => { + if (shiftKey) { + await openBlockInSidebar(uid); + return; + } + await openSearchResultInMain(uid); +}; + export const getBlockSelection = (uid: string): BlockSelection => { const activeElement = document.activeElement; const isFocusedTextarea = diff --git a/apps/roam/src/utils/advancedSearchNavigation.ts b/apps/roam/src/utils/advancedSearchNavigation.ts deleted file mode 100644 index 52f96dc56..000000000 --- a/apps/roam/src/utils/advancedSearchNavigation.ts +++ /dev/null @@ -1,24 +0,0 @@ -import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; -import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; - -export const openSearchResultInMain = async (uid: string): Promise => { - if (getPageTitleByPageUid(uid)) { - await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); - return; - } - await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); -}; - -export const openSearchResultFromLinkEvent = async ({ - shiftKey, - uid, -}: { - shiftKey: boolean; - uid: string; -}): Promise => { - if (shiftKey) { - await openBlockInSidebar(uid); - return; - } - await openSearchResultInMain(uid); -}; From d241625d2fc6310dcd43e9f2447967d1efbe29d6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 10 Jun 2026 18:07:11 -0400 Subject: [PATCH 10/17] address PR comments --- .../AdvancedSearchDialog.tsx | 61 +--- .../AdvancedSearchSidebarPanel.tsx | 114 +++--- .../dockedSearchSidebarStorage.ts | 253 +++++++++++++ .../mountAdvancedSearchInSidebar.tsx | 338 ++++++++++++++++++ .../useAdvancedNodeSearchResults.ts | 68 ++++ .../AdvancedNodeSearchDialog/utils.ts | 9 + apps/roam/src/index.ts | 3 + apps/roam/src/utils/openDgSearchInSidebar.tsx | 160 --------- 8 files changed, 736 insertions(+), 270 deletions(-) create mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts create mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx create mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts delete mode 100644 apps/roam/src/utils/openDgSearchInSidebar.tsx diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 9398aefba..5789b1462 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -7,7 +7,6 @@ import { Spinner, SpinnerSize, } from "@blueprintjs/core"; -import MiniSearch from "minisearch"; import posthog from "posthog-js"; import { render as renderToast } from "roamjs-components/components/Toast"; import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; @@ -24,7 +23,7 @@ import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; import { openSearchResultInMain } from "~/utils/advancedSearchFooterUtils"; -import { openDgSearchInSidebar } from "~/utils/openDgSearchInSidebar"; +import { mountAdvancedSearchInSidebar } from "./mountAdvancedSearchInSidebar"; import { DEBOUNCE_MS, DEFAULT_SORT_CONFIG, @@ -33,14 +32,16 @@ import { buildSearchIndex, formatMetadataDate, getSearchKeywords, - searchIndexedNodes, - sortSearchResults, stripTypePrefix, } from "./utils"; import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; import { AdvancedSearchDialogResultsList } from "./AdvancedSearchSidebarPanel"; +import { + type SearchIndex, + useAdvancedNodeSearchResults, +} from "./useAdvancedNodeSearchResults"; type Props = Record; @@ -90,14 +91,10 @@ const AdvancedNodeSearchDialog = ({ const [isIndexLoading, setIsIndexLoading] = useState(false); const [indexError, setIndexError] = useState(false); const [activeIndex, setActiveIndex] = useState(0); - const [results, setResults] = useState([]); + const [searchIndex, setSearchIndex] = useState(null); const [sort, setSort] = useState(DEFAULT_SORT_CONFIG); const [discourseNodes, setDiscourseNodes] = useState([]); const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState([]); - const miniSearchRef = useRef | null>(null); - const allResultsRef = useRef([]); const resultsPanelRef = useRef(null); const inputRef = useRef(null); const [insertTarget, setInsertTarget] = useState(null); @@ -106,6 +103,15 @@ const AdvancedNodeSearchDialog = ({ discourseNodes.map((node) => [node.type, node]), ); + const results = useAdvancedNodeSearchResults({ + debouncedSearchTerm, + selectedNodeTypeIds, + sort, + isIndexLoading, + indexError, + searchIndex, + }); + const activeResult = results[activeIndex] ?? null; const keywords = getSearchKeywords(debouncedSearchTerm); @@ -133,44 +139,16 @@ const AdvancedNodeSearchDialog = ({ setActiveIndex(0); setSort(DEFAULT_SORT_CONFIG); setSelectedNodeTypeIds([]); - setResults([]); + setSearchIndex(null); setIndexError(false); } }, [isOpen]); - useEffect(() => { - if ( - !isOpen || - isIndexLoading || - indexError || - !debouncedSearchTerm || - !miniSearchRef.current - ) { - setResults([]); - return; - } - - const scoredHits = searchIndexedNodes({ - miniSearch: miniSearchRef.current, - allResults: allResultsRef.current, - searchTerm: debouncedSearchTerm, - typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined, - }); - - setResults(sortSearchResults({ hits: scoredHits, sort })); - }, [ - debouncedSearchTerm, - indexError, - isIndexLoading, - isOpen, - selectedNodeTypeIds, - sort, - ]); - useEffect(() => { let cancelled = false; setIsIndexLoading(true); setIndexError(false); + setSearchIndex(null); const discourseNodes = getDiscourseNodes().filter( (node) => node.backedBy === "user", @@ -180,8 +158,7 @@ const AdvancedNodeSearchDialog = ({ void buildSearchIndex(discourseNodes) .then(({ miniSearch, results: indexedResults }) => { if (cancelled) return; - miniSearchRef.current = miniSearch; - allResultsRef.current = indexedResults; + setSearchIndex({ miniSearch, allResults: indexedResults }); }) .catch((error) => { console.error("Error building advanced node search index:", error); @@ -258,7 +235,7 @@ const AdvancedNodeSearchDialog = ({ if (contentState !== "results" || !results.length) return; try { - await openDgSearchInSidebar({ + await mountAdvancedSearchInSidebar({ query: debouncedSearchTerm, results, selectedNodeTypeIds, diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx index 2d57c8ae7..dc4676c5c 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Button, Icon, @@ -8,16 +8,13 @@ import { SpinnerSize, Tag, } from "@blueprintjs/core"; -import MiniSearch from "minisearch"; import getDiscourseNodes from "~/utils/getDiscourseNodes"; -import type { DockedSearchState } from "~/utils/openDgSearchInSidebar"; import { DEBOUNCE_MS, + type DockedSearchState, type SearchResult, buildSearchIndex, getSearchKeywords, - searchIndexedNodes, - sortSearchResults, } from "./utils"; import { hasActiveTypeFilter } from "~/utils/discourseNodeTypeFilter"; import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; @@ -27,6 +24,10 @@ import type { DiscourseNode } from "~/utils/getDiscourseNodes"; import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; import { splitWithHighlights, stripTypePrefix } from "./utils"; import { openSearchResultFromLinkEvent } from "~/utils/advancedSearchFooterUtils"; +import { + type SearchIndex, + useAdvancedNodeSearchResults, +} from "./useAdvancedNodeSearchResults"; const renderHighlightedText = ( text: string, @@ -168,13 +169,6 @@ type AdvancedSearchDockedFiltersProps = { const getNodeIndicatorColor = (node: DiscourseNode): string => formatHexColor(node.canvasSettings?.color) || "#6b7280"; -type SidebarIndexCache = { - miniSearch: MiniSearch; - results: SearchResult[]; -}; - -let cachedSidebarIndex: SidebarIndexCache | null = null; - const AdvancedSearchDockedFilters = ({ discourseNodes, selectedNodeTypeIds, @@ -193,15 +187,13 @@ const AdvancedSearchDockedFilters = ({ if (!isTypeFilterActive && !showSort) return null; return ( -
+
{isTypeFilterActive && (
- - Filtered to - + Filter: {selectedNodes.map((node) => ( )} {showSort && ( -

+

Sorted by {SORT_FIELD_LABELS[sort.field]} ( {sort.direction === "asc" ? "ascending" : "descending"})

@@ -232,11 +224,17 @@ const AdvancedSearchDockedFilters = ({ }; type AdvancedSearchSidebarPanelProps = { + dgSearchId: string; dockedState: DockedSearchState; + onPersistState: (state: DockedSearchState) => void; + windowId: string; }; export const AdvancedSearchSidebarPanel = ({ + dgSearchId, dockedState, + onPersistState, + windowId, }: AdvancedSearchSidebarPanelProps) => { const { query, @@ -247,21 +245,15 @@ export const AdvancedSearchSidebarPanel = ({ const [searchTerm, setSearchTerm] = useState(query); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(query); - const [results, setResults] = useState(dockedResults); + const [searchIndex, setSearchIndex] = useState(null); const [isIndexLoading, setIsIndexLoading] = useState(true); const [indexError, setIndexError] = useState(false); - const miniSearchRef = useRef | null>(null); - const allResultsRef = useRef([]); - const discourseNodes = useMemo( () => getDiscourseNodes().filter((node) => node.backedBy === "user"), [], ); const keywords = getSearchKeywords(debouncedSearchTerm); - const isDockedQuery = debouncedSearchTerm.trim() === query.trim(); useEffect(() => { const timeout = setTimeout( @@ -275,31 +267,13 @@ export const AdvancedSearchSidebarPanel = ({ let cancelled = false; setIsIndexLoading(true); setIndexError(false); - - const applyIndex = ({ - miniSearch, - results: indexedResults, - }: SidebarIndexCache): void => { - if (cancelled) return; - miniSearchRef.current = miniSearch; - allResultsRef.current = indexedResults; - setIsIndexLoading(false); - }; - - if (cachedSidebarIndex) { - applyIndex(cachedSidebarIndex); - return () => { - cancelled = true; - }; - } + setSearchIndex(null); void buildSearchIndex(discourseNodes) .then(({ miniSearch, results: indexedResults }) => { - cachedSidebarIndex = { - miniSearch, - results: indexedResults, - }; - applyIndex(cachedSidebarIndex); + if (cancelled) return; + setSearchIndex({ miniSearch, allResults: indexedResults }); + setIsIndexLoading(false); }) .catch((error) => { console.error( @@ -317,32 +291,36 @@ export const AdvancedSearchSidebarPanel = ({ }; }, [discourseNodes]); - useEffect(() => { - if (!debouncedSearchTerm) { - setResults([]); - return; - } - - if (isIndexLoading || indexError || !miniSearchRef.current) { - if (!isDockedQuery) setResults([]); - return; - } + const results = useAdvancedNodeSearchResults({ + debouncedSearchTerm, + selectedNodeTypeIds, + sort, + isIndexLoading, + indexError, + searchIndex, + dockedQuery: query, + dockedResults, + }); - const scoredHits = searchIndexedNodes({ - miniSearch: miniSearchRef.current, - allResults: allResultsRef.current, - searchTerm: debouncedSearchTerm, - typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined, + useEffect(() => { + if (!debouncedSearchTerm) return; + + onPersistState({ + query: debouncedSearchTerm, + results, + selectedNodeTypeIds, + sort, + windowId, + dgSearchId, }); - - setResults(sortSearchResults({ hits: scoredHits, sort })); }, [ debouncedSearchTerm, - indexError, - isDockedQuery, - isIndexLoading, + dgSearchId, + onPersistState, + results, selectedNodeTypeIds, sort, + windowId, ]); const resultLabel = @@ -376,7 +354,7 @@ export const AdvancedSearchSidebarPanel = ({ /> ) : ( <> - + {isIndexLoading && !results.length ? "Loading…" : debouncedSearchTerm diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts new file mode 100644 index 000000000..d44aad582 --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts @@ -0,0 +1,253 @@ +import type { DockedSearchState } from "./utils"; + +const STORAGE_KEY = "dg-advanced-search-sidebar-windows"; +const SIDEBAR_OPEN_WIDTH_PX = 40; + +export type RoamSidebarWindow = { + type: string; + "window-id": string; + order?: number; + "pinned?"?: boolean; + "collapsed?"?: boolean; + "search-query-str"?: string; +}; + +export type PersistedDockedSearchState = DockedSearchState & { + dgSearchId: string; + windowId: string; + isDgSearch: true; + updatedAt: number; +}; + +type PersistedDockedSearchRegistry = Record; + +export const isRightSidebarOpen = (): boolean => { + const sidebar = document.getElementById("right-sidebar"); + return ( + !!sidebar && sidebar.getBoundingClientRect().width > SIDEBAR_OPEN_WIDTH_PX + ); +}; + +export const getRoamSidebarWindows = async (): Promise => { + const rightSidebar = window.roamAlphaAPI.ui.rightSidebar as { + getWindows?: () => Promise; + }; + if (!rightSidebar.getWindows) return []; + + try { + return (await rightSidebar.getWindows()) ?? []; + } catch { + return []; + } +}; + +const normalizeRegistryEntry = ( + key: string, + value: PersistedDockedSearchState & { isDgSearch?: boolean }, +): PersistedDockedSearchState | null => { + if (!value.query) return null; + + const dgSearchId = value.dgSearchId ?? key; + const windowId = value.windowId ?? key; + + return { + ...value, + dgSearchId, + windowId, + isDgSearch: true, + updatedAt: value.updatedAt ?? Date.now(), + }; +}; + +const readRegistry = (): PersistedDockedSearchRegistry => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + + const parsed = JSON.parse(raw) as Record< + string, + PersistedDockedSearchState & { isDgSearch?: boolean } + >; + if (!parsed || typeof parsed !== "object") return {}; + + const registry: PersistedDockedSearchRegistry = {}; + for (const [key, value] of Object.entries(parsed)) { + const normalized = normalizeRegistryEntry(key, value); + if (normalized) { + registry[normalized.dgSearchId] = normalized; + } + } + return registry; + } catch { + return {}; + } +}; + +const writeRegistry = (registry: PersistedDockedSearchRegistry): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(registry)); +}; + +export const registerDgSearchWindow = ({ + dgSearchId, + windowId, + state, +}: { + dgSearchId: string; + windowId: string; + state: DockedSearchState; +}): void => { + const registry = readRegistry(); + + for (const [existingId, entry] of Object.entries(registry)) { + if (entry.windowId === windowId && existingId !== dgSearchId) { + delete registry[existingId]; + } + } + + registry[dgSearchId] = { + ...state, + dgSearchId, + windowId, + isDgSearch: true, + updatedAt: Date.now(), + }; + writeRegistry(registry); +}; + +export const loadDgSearchWindowById = ( + dgSearchId: string, +): PersistedDockedSearchState | null => readRegistry()[dgSearchId] ?? null; + +export const loadDgSearchWindowByWindowId = ( + windowId: string, +): PersistedDockedSearchState | null => + listDgSearchWindows().find((state) => state.windowId === windowId) ?? null; + +export const remapDgSearchWindowId = ({ + dgSearchId, + windowId, +}: { + dgSearchId: string; + windowId: string; +}): void => { + const registry = readRegistry(); + const entry = registry[dgSearchId]; + if (!entry) return; + + for (const [existingId, existingEntry] of Object.entries(registry)) { + if (existingId !== dgSearchId && existingEntry.windowId === windowId) { + delete registry[existingId]; + } + } + + registry[dgSearchId] = { + ...entry, + windowId, + updatedAt: Date.now(), + }; + writeRegistry(registry); +}; + +export const removeDgSearchWindow = ({ + dgSearchId, + windowId, +}: { + dgSearchId?: string; + windowId?: string; +}): void => { + const registry = readRegistry(); + let changed = false; + + if (dgSearchId && registry[dgSearchId]) { + delete registry[dgSearchId]; + changed = true; + } else if (windowId) { + const match = Object.entries(registry).find( + ([, entry]) => entry.windowId === windowId, + ); + if (match) { + delete registry[match[0]]; + changed = true; + } + } + + if (changed) writeRegistry(registry); +}; + +export const listDgSearchWindows = (): PersistedDockedSearchState[] => + Object.values(readRegistry()).filter((state) => state.isDgSearch); + +export const syncDgSearchWindowIdsFromRoam = ( + roamWindows: RoamSidebarWindow[], +): void => { + const searchQueryWindows = roamWindows.filter( + (window) => window.type === "search-query", + ); + const dgEntries = listDgSearchWindows(); + const claimedRoamWindowIds = new Set(); + + for (const entry of dgEntries) { + const matchedById = searchQueryWindows.find( + (window) => window["window-id"] === entry.windowId, + ); + if (matchedById) { + claimedRoamWindowIds.add(matchedById["window-id"]); + } + } + + const unmatchedEntries = dgEntries.filter( + (entry) => + !searchQueryWindows.some( + (window) => window["window-id"] === entry.windowId, + ), + ); + const unmatchedRoamWindows = searchQueryWindows.filter( + (window) => !claimedRoamWindowIds.has(window["window-id"]), + ); + + for (const entry of unmatchedEntries) { + const queryMatches = unmatchedRoamWindows.filter( + (window) => window["search-query-str"] === entry.query, + ); + if (queryMatches.length !== 1) continue; + + const [matchedWindow] = queryMatches; + remapDgSearchWindowId({ + dgSearchId: entry.dgSearchId, + windowId: matchedWindow["window-id"], + }); + claimedRoamWindowIds.add(matchedWindow["window-id"]); + } +}; + +export const pruneStaleDockedSearchSidebarStates = async ( + roamWindows?: RoamSidebarWindow[], +): Promise => { + if (!isRightSidebarOpen()) return; + + const resolvedRoamWindows = roamWindows ?? (await getRoamSidebarWindows()); + syncDgSearchWindowIdsFromRoam(resolvedRoamWindows); + + const roamWindowIds = new Set( + resolvedRoamWindows.map((window) => window["window-id"]), + ); + const registry = readRegistry(); + let changed = false; + + for (const dgSearchId of Object.keys(registry)) { + const entry = registry[dgSearchId]; + if (!entry?.isDgSearch) continue; + + if (roamWindowIds.has(entry.windowId)) continue; + + const sidebarWindow = document.querySelector( + `[data-sidebar-window-id="${entry.windowId}"]`, + ); + if (sidebarWindow) continue; + + delete registry[dgSearchId]; + changed = true; + } + + if (changed) writeRegistry(registry); +}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx new file mode 100644 index 000000000..967af164c --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx @@ -0,0 +1,338 @@ +import React from "react"; +import renderWithUnmount from "roamjs-components/util/renderWithUnmount"; +import { AdvancedSearchSidebarPanel } from "./AdvancedSearchSidebarPanel"; +import { + getRoamSidebarWindows, + isRightSidebarOpen, + listDgSearchWindows, + loadDgSearchWindowById, + loadDgSearchWindowByWindowId, + pruneStaleDockedSearchSidebarStates, + registerDgSearchWindow, + removeDgSearchWindow, + syncDgSearchWindowIdsFromRoam, + type PersistedDockedSearchState, +} from "./dockedSearchSidebarStorage"; +import type { DockedSearchState } from "./utils"; + +export const DG_SEARCH_ROOT_CLASS = "dg-node-search-sidebar-root"; +const SEARCH_QUERY_WINDOW_SELECTOR = + '#roam-right-sidebar-content .rm-sidebar-window[data-sidebar-window-id^="sidebar-search-query"]'; +const MAX_WINDOW_WAIT_FRAMES = 30; + +const activeUnmounts = new Map void>(); +const windowGuardObservers = new Map(); +let syncInProgress = false; + +const createDgSearchId = (): string => window.roamAlphaAPI.util.generateUID(); + +const getSidebarWindowId = (sidebarWindow: HTMLElement): string | null => + sidebarWindow.getAttribute("data-sidebar-window-id"); + +const getSearchQuerySidebarWindows = (): HTMLElement[] => [ + ...document.querySelectorAll(SEARCH_QUERY_WINDOW_SELECTOR), +]; + +const hasDgSearchRoot = (sidebarWindow: HTMLElement): boolean => + !!sidebarWindow.querySelector(`.${DG_SEARCH_ROOT_CLASS}`); + +const getSearchMountContainer = ( + sidebarWindow: HTMLElement, +): HTMLElement | null => sidebarWindow.querySelector(".rm-sidebar-search"); + +const findSidebarWindowElement = (windowId: string): HTMLElement | null => + document.querySelector(`[data-sidebar-window-id="${windowId}"]`); + +const waitForNewSearchQueryWindow = async ( + previousWindowIds: Set, +): Promise => { + for (let attempt = 0; attempt < MAX_WINDOW_WAIT_FRAMES; attempt += 1) { + const newWindow = getSearchQuerySidebarWindows().find((sidebarWindow) => { + const windowId = getSidebarWindowId(sidebarWindow); + return !!windowId && !previousWindowIds.has(windowId); + }); + if (newWindow) return newWindow; + + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } + throw new Error("DG search sidebar window did not appear"); +}; + +const addSearchQuerySidebarWindow = async (query: string): Promise => { + await window.roamAlphaAPI.ui.rightSidebar.addWindow({ + window: { + type: "search-query", + // @ts-expect-error - search-query-str replaces block-uid for search-query windows + // eslint-disable-next-line @typescript-eslint/naming-convention + "search-query-str": query, + }, + }); +}; + +const setSidebarWindowTitle = (sidebarWindow: HTMLElement): void => { + const titleEl = sidebarWindow.querySelector( + ".window-headers span[style*='font-weight']", + ); + if (titleEl) titleEl.textContent = "DG node search"; +}; + +const disconnectWindowGuardObserver = (dgSearchId: string): void => { + windowGuardObservers.get(dgSearchId)?.disconnect(); + windowGuardObservers.delete(dgSearchId); +}; + +const attachWindowGuardObserver = ({ + dgSearchId, + sidebarWindow, +}: { + dgSearchId: string; + sidebarWindow: HTMLElement; +}): void => { + disconnectWindowGuardObserver(dgSearchId); + + const searchContainer = getSearchMountContainer(sidebarWindow); + if (!searchContainer) return; + + const observer = new MutationObserver(() => { + if (hasDgSearchRoot(sidebarWindow)) return; + + const savedState = loadDgSearchWindowById(dgSearchId); + if (!savedState) return; + + const windowId = getSidebarWindowId(sidebarWindow); + if (!windowId) return; + + mountPanelInSearchWindow({ + dgSearchId, + dockedState: savedState, + sidebarWindow, + windowId, + }); + }); + + observer.observe(searchContainer, { childList: true, subtree: true }); + windowGuardObservers.set(dgSearchId, observer); +}; + +const mountPanelInSearchWindow = ({ + dgSearchId, + dockedState, + sidebarWindow, + windowId, +}: { + dgSearchId: string; + dockedState: DockedSearchState; + sidebarWindow: HTMLElement; + windowId: string; +}): void => { + disconnectWindowGuardObserver(dgSearchId); + activeUnmounts.get(dgSearchId)?.(); + activeUnmounts.delete(dgSearchId); + + const searchContainer = getSearchMountContainer(sidebarWindow); + if (!searchContainer) { + throw new Error("DG search sidebar window is missing .rm-sidebar-search"); + } + + searchContainer.innerHTML = ""; + + const root = document.createElement("div"); + root.className = `${DG_SEARCH_ROOT_CLASS} box-border w-full min-w-0`; + root.dataset.dgSearchId = dgSearchId; + root.dataset.dgWindowId = windowId; + root.onmousedown = (event) => event.stopPropagation(); + searchContainer.appendChild(root); + + sidebarWindow.dataset.dgNodeSearch = "true"; + + const stateWithIds: DockedSearchState = { + ...dockedState, + dgSearchId, + windowId, + }; + + registerDgSearchWindow({ dgSearchId, windowId, state: stateWithIds }); + + const unmount = renderWithUnmount( + { + registerDgSearchWindow({ + dgSearchId, + windowId, + state: { ...nextState, dgSearchId, windowId }, + }); + }} + windowId={windowId} + />, + root, + ); + + activeUnmounts.set(dgSearchId, unmount); + setSidebarWindowTitle(sidebarWindow); + attachWindowGuardObserver({ dgSearchId, sidebarWindow }); +}; + +const restoreSavedDgSearchWindow = ( + savedState: PersistedDockedSearchState, +): boolean => { + const sidebarWindow = findSidebarWindowElement(savedState.windowId); + if (!sidebarWindow || hasDgSearchRoot(sidebarWindow)) return false; + + mountPanelInSearchWindow({ + dgSearchId: savedState.dgSearchId, + dockedState: savedState, + sidebarWindow, + windowId: savedState.windowId, + }); + return true; +}; + +export const restorePersistedDockedSearchSidebarWindows = (): void => { + for (const savedState of listDgSearchWindows()) { + restoreSavedDgSearchWindow(savedState); + } +}; + +const syncSidebarWindows = async (): Promise => { + if (syncInProgress) return; + syncInProgress = true; + + try { + const roamWindows = await getRoamSidebarWindows(); + syncDgSearchWindowIdsFromRoam(roamWindows); + restorePersistedDockedSearchSidebarWindows(); + await pruneStaleDockedSearchSidebarStates(roamWindows); + } finally { + syncInProgress = false; + } +}; + +const scheduleSyncRetries = (): void => { + void syncSidebarWindows(); + requestAnimationFrame(() => { + void syncSidebarWindows(); + }); + window.setTimeout(() => { + void syncSidebarWindows(); + }, 0); + window.setTimeout(() => { + void syncSidebarWindows(); + }, 100); + window.setTimeout(() => { + void syncSidebarWindows(); + }, 500); +}; + +export const mountAdvancedSearchInSidebar = async ( + dockedState: DockedSearchState, +): Promise => { + const dgSearchId = createDgSearchId(); + const roamWindowsBefore = await getRoamSidebarWindows(); + const previousWindowIds = new Set( + roamWindowsBefore.map((window) => window["window-id"]), + ); + + await addSearchQuerySidebarWindow(dockedState.query); + const sidebarWindow = await waitForNewSearchQueryWindow(previousWindowIds); + const windowId = getSidebarWindowId(sidebarWindow); + + if (!windowId) { + throw new Error( + "DG search sidebar window is missing data-sidebar-window-id", + ); + } + + mountPanelInSearchWindow({ + dgSearchId, + dockedState, + sidebarWindow, + windowId, + }); + + await syncSidebarWindows(); +}; + +let persistenceObserver: MutationObserver | null = null; +let sidebarResizeObserver: ResizeObserver | null = null; +let sidebarCloseClickHandler: ((event: MouseEvent) => void) | null = null; + +export const initDockedSearchSidebarPersistence = (): (() => void) => { + scheduleSyncRetries(); + + persistenceObserver?.disconnect(); + persistenceObserver = new MutationObserver(() => { + void syncSidebarWindows(); + }); + + const sidebarContent = document.getElementById("roam-right-sidebar-content"); + if (sidebarContent) { + persistenceObserver.observe(sidebarContent, { + childList: true, + subtree: true, + }); + + sidebarCloseClickHandler = (event: MouseEvent): void => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + const closeButton = target.closest(".window-headers .bp3-icon-cross"); + if (!closeButton) return; + + const sidebarWindow = + closeButton.closest(".rm-sidebar-window"); + if (!sidebarWindow?.dataset.dgNodeSearch) return; + + const dgSearchId = + sidebarWindow.querySelector(`.${DG_SEARCH_ROOT_CLASS}`) + ?.dataset.dgSearchId ?? + loadDgSearchWindowByWindowId(getSidebarWindowId(sidebarWindow) ?? "") + ?.dgSearchId; + + const windowId = getSidebarWindowId(sidebarWindow) ?? undefined; + removeDgSearchWindow({ dgSearchId, windowId }); + if (dgSearchId) disconnectWindowGuardObserver(dgSearchId); + }; + + sidebarContent.addEventListener("click", sidebarCloseClickHandler, true); + } + + let wasSidebarOpen = isRightSidebarOpen(); + const rightSidebar = document.getElementById("right-sidebar"); + if (rightSidebar) { + sidebarResizeObserver = new ResizeObserver(() => { + const isOpen = isRightSidebarOpen(); + if (!wasSidebarOpen && isOpen) { + scheduleSyncRetries(); + } + wasSidebarOpen = isOpen; + }); + sidebarResizeObserver.observe(rightSidebar); + } + + return () => { + persistenceObserver?.disconnect(); + persistenceObserver = null; + + sidebarResizeObserver?.disconnect(); + sidebarResizeObserver = null; + + if (sidebarContent && sidebarCloseClickHandler) { + sidebarContent.removeEventListener( + "click", + sidebarCloseClickHandler, + true, + ); + sidebarCloseClickHandler = null; + } + + windowGuardObservers.forEach((observer) => observer.disconnect()); + windowGuardObservers.clear(); + + activeUnmounts.forEach((unmount) => unmount()); + activeUnmounts.clear(); + }; +}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts new file mode 100644 index 000000000..d3a682864 --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts @@ -0,0 +1,68 @@ +import { useMemo } from "react"; +import MiniSearch from "minisearch"; +import { + searchIndexedNodes, + sortSearchResults, + type SearchResult, + type SortConfig, +} from "./utils"; + +export type SearchIndex = { + miniSearch: MiniSearch; + allResults: SearchResult[]; +}; + +type UseAdvancedNodeSearchResultsArgs = { + debouncedSearchTerm: string; + selectedNodeTypeIds: string[]; + sort: SortConfig; + isIndexLoading: boolean; + indexError: boolean; + searchIndex: SearchIndex | null; + dockedQuery?: string; + dockedResults?: SearchResult[]; +}; + +export const useAdvancedNodeSearchResults = ({ + debouncedSearchTerm, + selectedNodeTypeIds, + sort, + isIndexLoading, + indexError, + searchIndex, + dockedQuery, + dockedResults, +}: UseAdvancedNodeSearchResultsArgs): SearchResult[] => + useMemo(() => { + if (!debouncedSearchTerm) return []; + + const isDockedQuery = + dockedQuery !== undefined && + debouncedSearchTerm.trim() === dockedQuery.trim(); + + if (isDockedQuery && dockedResults) { + return dockedResults; + } + + if (isIndexLoading || indexError || !searchIndex) { + return []; + } + + const scoredHits = searchIndexedNodes({ + miniSearch: searchIndex.miniSearch, + allResults: searchIndex.allResults, + searchTerm: debouncedSearchTerm, + typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined, + }); + + return sortSearchResults({ hits: scoredHits, sort }); + }, [ + debouncedSearchTerm, + dockedQuery, + dockedResults, + indexError, + isIndexLoading, + searchIndex, + selectedNodeTypeIds, + sort, + ]); diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts index daa81e6bf..6553c0641 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts @@ -47,6 +47,15 @@ export type SearchResult = { authorName: string; }; +export type DockedSearchState = { + query: string; + results: SearchResult[]; + selectedNodeTypeIds: string[]; + sort: SortConfig; + windowId?: string; + dgSearchId?: string; +}; + export type ScoredSearchHit = { result: SearchResult; score: number; diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index 72cef618c..e541a0f79 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -46,6 +46,7 @@ import { settingKeys, } from "./components/settings/utils/settingsEmitter"; import { mountLeftSidebar } from "./components/LeftSidebarView"; +import { initDockedSearchSidebarPersistence } from "~/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar"; export const DEFAULT_CANVAS_PAGE_FORMAT = "Canvas/*"; @@ -194,6 +195,7 @@ export default runExtension(async (onloadArgs) => { const { blockUids } = await initSchema(); const cleanupPullWatchers = setupPullWatchOnSettingsPage(blockUids); + const cleanupDockedSearchSidebar = initDockedSearchSidebarPersistence(); console.log( `[DG Plugin] Total load: ${Math.round(performance.now() - pluginLoadStart)}ms`, @@ -211,6 +213,7 @@ export default runExtension(async (onloadArgs) => { unload: () => { unsubLeftSidebarFlag(); cleanupPullWatchers(); + cleanupDockedSearchSidebar(); cleanups.forEach((fn) => fn()); setSyncActivity(false); unregisterSlashCommands(); diff --git a/apps/roam/src/utils/openDgSearchInSidebar.tsx b/apps/roam/src/utils/openDgSearchInSidebar.tsx deleted file mode 100644 index 3b5bc92ec..000000000 --- a/apps/roam/src/utils/openDgSearchInSidebar.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React from "react"; -import renderWithUnmount from "roamjs-components/util/renderWithUnmount"; -import { AdvancedSearchSidebarPanel } from "../components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel"; -import { - type SearchResult, - type SortConfig, -} from "~/components/AdvancedNodeSearchDialog/utils"; - -const SIDEBAR_ROOT_ID = "dg-node-search-sidebar-root"; -const OUTLINE_WRAPPER_SELECTOR = - "#roam-right-sidebar-content .rm-sidebar-outline-wrapper"; -const SIDEBAR_OPEN_WIDTH_PX = 40; -const MAX_WRAPPER_WAIT_FRAMES = 30; - -export type DockedSearchState = { - query: string; - results: SearchResult[]; - selectedNodeTypeIds: string[]; - sort: SortConfig; -}; - -let unmountSidebarSearch: (() => void) | null = null; - -const isRightSidebarOpen = (): boolean => { - const sidebar = document.getElementById("right-sidebar"); - return ( - !!sidebar && sidebar.getBoundingClientRect().width > SIDEBAR_OPEN_WIDTH_PX - ); -}; - -const getOutlineWrapperCount = (): number => - document.querySelectorAll(OUTLINE_WRAPPER_SELECTOR).length; - -const getLatestOutlineWrapper = (): HTMLElement | null => { - const wrappers = document.querySelectorAll( - OUTLINE_WRAPPER_SELECTOR, - ); - return wrappers[wrappers.length - 1] ?? null; -}; - -const waitForOutlineWrapper = async ( - minCount: number, -): Promise => { - for (let attempt = 0; attempt < MAX_WRAPPER_WAIT_FRAMES; attempt += 1) { - const wrappers = document.querySelectorAll( - OUTLINE_WRAPPER_SELECTOR, - ); - if (wrappers.length >= minCount && wrappers.length > 0) { - return wrappers[wrappers.length - 1]; - } - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); - } - throw new Error("Sidebar window did not appear"); -}; - -const openRightSidebar = async ({ - anchorPageUid, - wrapperCountBefore, -}: { - anchorPageUid: string; - wrapperCountBefore: number; -}): Promise => { - const rightSidebar = window.roamAlphaAPI.ui.rightSidebar as { - open?: () => Promise; - }; - if (rightSidebar.open) { - await rightSidebar.open(); - return null; - } - - await addOutlineSidebarWindow(anchorPageUid); - return waitForOutlineWrapper(Math.max(wrapperCountBefore + 1, 1)); -}; - -const addOutlineSidebarWindow = async (blockUid: string): Promise => { - await window.roamAlphaAPI.ui.rightSidebar.addWindow({ - window: { - type: "outline", - // @ts-expect-error - block-uid is valid for outline sidebar windows - // eslint-disable-next-line @typescript-eslint/naming-convention - "block-uid": blockUid, - }, - }); -}; - -const setSidebarWindowTitle = (outlineWrapper: HTMLElement): void => { - const pane = outlineWrapper.closest( - "#roam-right-sidebar-content .sidebar-content > *", - ); - const titleEl = pane?.querySelector( - ".window-headers span[style*='font-weight']", - ); - if (titleEl) titleEl.textContent = "DG node search"; -}; - -const mountPanelInOutlineWrapper = ({ - dockedState, - outlineWrapper, -}: { - dockedState: DockedSearchState; - outlineWrapper: HTMLElement; -}): void => { - unmountSidebarSearch?.(); - unmountSidebarSearch = null; - - outlineWrapper.innerHTML = ""; - - const root = document.createElement("div"); - root.id = SIDEBAR_ROOT_ID; - root.className = - "rm-sidebar-search dg-node-search-sidebar-root box-border w-full min-w-0"; - root.onmousedown = (event) => event.stopPropagation(); - outlineWrapper.appendChild(root); - - unmountSidebarSearch = renderWithUnmount( - , - root, - ); -}; - -export const openDgSearchInSidebar = async ( - dockedState: DockedSearchState, -): Promise => { - const anchorPageUid = - (await window.roamAlphaAPI.ui.mainWindow.getOpenPageOrBlockUid()) || - window.roamAlphaAPI.util.dateToPageUid(new Date()); - const wrapperCountBefore = getOutlineWrapperCount(); - - if (!isRightSidebarOpen()) { - const openedWrapper = await openRightSidebar({ - anchorPageUid, - wrapperCountBefore, - }); - if (openedWrapper) { - setSidebarWindowTitle(openedWrapper); - mountPanelInOutlineWrapper({ - dockedState, - outlineWrapper: openedWrapper, - }); - return; - } - } - - const existingWrapper = getLatestOutlineWrapper(); - if (existingWrapper) { - setSidebarWindowTitle(existingWrapper); - mountPanelInOutlineWrapper({ - dockedState, - outlineWrapper: existingWrapper, - }); - return; - } - - await addOutlineSidebarWindow(anchorPageUid); - const outlineWrapper = await waitForOutlineWrapper(wrapperCountBefore + 1); - setSidebarWindowTitle(outlineWrapper); - mountPanelInOutlineWrapper({ dockedState, outlineWrapper }); -}; From 5d4068c8f1073568c10ea953f1170cd74fd9acaa Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 10 Jun 2026 18:14:08 -0400 Subject: [PATCH 11/17] fix type --- .../dockedSearchSidebarStorage.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts index d44aad582..6ad985ffa 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts @@ -29,16 +29,7 @@ export const isRightSidebarOpen = (): boolean => { }; export const getRoamSidebarWindows = async (): Promise => { - const rightSidebar = window.roamAlphaAPI.ui.rightSidebar as { - getWindows?: () => Promise; - }; - if (!rightSidebar.getWindows) return []; - - try { - return (await rightSidebar.getWindows()) ?? []; - } catch { - return []; - } + return window.roamAlphaAPI.ui.rightSidebar.getWindows() ?? []; }; const normalizeRegistryEntry = ( From 8effd2aeed03883daaedf015ca11e3fa4f02e9e8 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 10 Jun 2026 18:24:54 -0400 Subject: [PATCH 12/17] simplify --- .../dockedSearchSidebarStorage.ts | 64 +++++--- .../mountAdvancedSearchInSidebar.tsx | 137 ++++++++++-------- 2 files changed, 122 insertions(+), 79 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts index 6ad985ffa..fdc4e0088 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts @@ -29,7 +29,12 @@ export const isRightSidebarOpen = (): boolean => { }; export const getRoamSidebarWindows = async (): Promise => { - return window.roamAlphaAPI.ui.rightSidebar.getWindows() ?? []; + try { + const windows = window.roamAlphaAPI.ui.rightSidebar.getWindows(); + return windows ?? []; + } catch { + return []; + } }; const normalizeRegistryEntry = ( @@ -78,6 +83,18 @@ const writeRegistry = (registry: PersistedDockedSearchRegistry): void => { localStorage.setItem(STORAGE_KEY, JSON.stringify(registry)); }; +const removeOtherEntriesWithWindowId = ( + registry: PersistedDockedSearchRegistry, + windowId: string, + keepDgSearchId: string, +): void => { + for (const [existingId, entry] of Object.entries(registry)) { + if (entry.windowId === windowId && existingId !== keepDgSearchId) { + delete registry[existingId]; + } + } +}; + export const registerDgSearchWindow = ({ dgSearchId, windowId, @@ -88,12 +105,7 @@ export const registerDgSearchWindow = ({ state: DockedSearchState; }): void => { const registry = readRegistry(); - - for (const [existingId, entry] of Object.entries(registry)) { - if (entry.windowId === windowId && existingId !== dgSearchId) { - delete registry[existingId]; - } - } + removeOtherEntriesWithWindowId(registry, windowId, dgSearchId); registry[dgSearchId] = { ...state, @@ -112,7 +124,8 @@ export const loadDgSearchWindowById = ( export const loadDgSearchWindowByWindowId = ( windowId: string, ): PersistedDockedSearchState | null => - listDgSearchWindows().find((state) => state.windowId === windowId) ?? null; + Object.values(readRegistry()).find((state) => state.windowId === windowId) ?? + null; export const remapDgSearchWindowId = ({ dgSearchId, @@ -125,11 +138,7 @@ export const remapDgSearchWindowId = ({ const entry = registry[dgSearchId]; if (!entry) return; - for (const [existingId, existingEntry] of Object.entries(registry)) { - if (existingId !== dgSearchId && existingEntry.windowId === windowId) { - delete registry[existingId]; - } - } + removeOtherEntriesWithWindowId(registry, windowId, dgSearchId); registry[dgSearchId] = { ...entry, @@ -166,16 +175,18 @@ export const removeDgSearchWindow = ({ }; export const listDgSearchWindows = (): PersistedDockedSearchState[] => - Object.values(readRegistry()).filter((state) => state.isDgSearch); + Object.values(readRegistry()); export const syncDgSearchWindowIdsFromRoam = ( roamWindows: RoamSidebarWindow[], ): void => { + const registry = readRegistry(); const searchQueryWindows = roamWindows.filter( (window) => window.type === "search-query", ); - const dgEntries = listDgSearchWindows(); + const dgEntries = Object.values(registry); const claimedRoamWindowIds = new Set(); + let changed = false; for (const entry of dgEntries) { const matchedById = searchQueryWindows.find( @@ -202,12 +213,23 @@ export const syncDgSearchWindowIdsFromRoam = ( ); if (queryMatches.length !== 1) continue; - const [matchedWindow] = queryMatches; - remapDgSearchWindowId({ - dgSearchId: entry.dgSearchId, + const matchedWindow = queryMatches[0]; + removeOtherEntriesWithWindowId( + registry, + matchedWindow["window-id"], + entry.dgSearchId, + ); + registry[entry.dgSearchId] = { + ...entry, windowId: matchedWindow["window-id"], - }); + updatedAt: Date.now(), + }; claimedRoamWindowIds.add(matchedWindow["window-id"]); + changed = true; + } + + if (changed) { + writeRegistry(registry); } }; @@ -217,7 +239,9 @@ export const pruneStaleDockedSearchSidebarStates = async ( if (!isRightSidebarOpen()) return; const resolvedRoamWindows = roamWindows ?? (await getRoamSidebarWindows()); - syncDgSearchWindowIdsFromRoam(resolvedRoamWindows); + if (!roamWindows) { + syncDgSearchWindowIdsFromRoam(resolvedRoamWindows); + } const roamWindowIds = new Set( resolvedRoamWindows.map((window) => window["window-id"]), diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx index 967af164c..076230747 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx @@ -14,6 +14,7 @@ import { type PersistedDockedSearchState, } from "./dockedSearchSidebarStorage"; import type { DockedSearchState } from "./utils"; +import { DEBOUNCE_MS } from "./utils"; export const DG_SEARCH_ROOT_CLASS = "dg-node-search-sidebar-root"; const SEARCH_QUERY_WINDOW_SELECTOR = @@ -22,7 +23,10 @@ const MAX_WINDOW_WAIT_FRAMES = 30; const activeUnmounts = new Map void>(); const windowGuardObservers = new Map(); +const mountingDgSearchIds = new Set(); +let syncTimeout: number | undefined; let syncInProgress = false; +let needsResync = false; const createDgSearchId = (): string => window.roamAlphaAPI.util.generateUID(); @@ -96,7 +100,9 @@ const attachWindowGuardObserver = ({ if (!searchContainer) return; const observer = new MutationObserver(() => { - if (hasDgSearchRoot(sidebarWindow)) return; + if (mountingDgSearchIds.has(dgSearchId) || hasDgSearchRoot(sidebarWindow)) { + return; + } const savedState = loadDgSearchWindowById(dgSearchId); if (!savedState) return; @@ -127,53 +133,60 @@ const mountPanelInSearchWindow = ({ sidebarWindow: HTMLElement; windowId: string; }): void => { - disconnectWindowGuardObserver(dgSearchId); - activeUnmounts.get(dgSearchId)?.(); - activeUnmounts.delete(dgSearchId); + if (mountingDgSearchIds.has(dgSearchId)) return; - const searchContainer = getSearchMountContainer(sidebarWindow); - if (!searchContainer) { - throw new Error("DG search sidebar window is missing .rm-sidebar-search"); - } + mountingDgSearchIds.add(dgSearchId); + try { + disconnectWindowGuardObserver(dgSearchId); + activeUnmounts.get(dgSearchId)?.(); + activeUnmounts.delete(dgSearchId); - searchContainer.innerHTML = ""; + const searchContainer = getSearchMountContainer(sidebarWindow); + if (!searchContainer) { + throw new Error("DG search sidebar window is missing .rm-sidebar-search"); + } - const root = document.createElement("div"); - root.className = `${DG_SEARCH_ROOT_CLASS} box-border w-full min-w-0`; - root.dataset.dgSearchId = dgSearchId; - root.dataset.dgWindowId = windowId; - root.onmousedown = (event) => event.stopPropagation(); - searchContainer.appendChild(root); + searchContainer.innerHTML = ""; - sidebarWindow.dataset.dgNodeSearch = "true"; + const root = document.createElement("div"); + root.className = `${DG_SEARCH_ROOT_CLASS} box-border w-full min-w-0`; + root.dataset.dgSearchId = dgSearchId; + root.dataset.dgWindowId = windowId; + root.onmousedown = (event) => event.stopPropagation(); + searchContainer.appendChild(root); - const stateWithIds: DockedSearchState = { - ...dockedState, - dgSearchId, - windowId, - }; + sidebarWindow.dataset.dgNodeSearch = "true"; - registerDgSearchWindow({ dgSearchId, windowId, state: stateWithIds }); - - const unmount = renderWithUnmount( - { - registerDgSearchWindow({ - dgSearchId, - windowId, - state: { ...nextState, dgSearchId, windowId }, - }); - }} - windowId={windowId} - />, - root, - ); + const stateWithIds: DockedSearchState = { + ...dockedState, + dgSearchId, + windowId, + }; - activeUnmounts.set(dgSearchId, unmount); - setSidebarWindowTitle(sidebarWindow); - attachWindowGuardObserver({ dgSearchId, sidebarWindow }); + registerDgSearchWindow({ dgSearchId, windowId, state: stateWithIds }); + + const unmount = renderWithUnmount( + { + registerDgSearchWindow({ + dgSearchId, + windowId, + state: { ...nextState, dgSearchId, windowId }, + }); + }} + windowId={windowId} + />, + root, + ); + + activeUnmounts.set(dgSearchId, unmount); + setSidebarWindowTitle(sidebarWindow); + attachWindowGuardObserver({ dgSearchId, sidebarWindow }); + } finally { + mountingDgSearchIds.delete(dgSearchId); + } }; const restoreSavedDgSearchWindow = ( @@ -198,7 +211,10 @@ export const restorePersistedDockedSearchSidebarWindows = (): void => { }; const syncSidebarWindows = async (): Promise => { - if (syncInProgress) return; + if (syncInProgress) { + needsResync = true; + return; + } syncInProgress = true; try { @@ -208,23 +224,23 @@ const syncSidebarWindows = async (): Promise => { await pruneStaleDockedSearchSidebarStates(roamWindows); } finally { syncInProgress = false; + if (needsResync) { + needsResync = false; + void syncSidebarWindows(); + } } }; -const scheduleSyncRetries = (): void => { - void syncSidebarWindows(); - requestAnimationFrame(() => { - void syncSidebarWindows(); - }); - window.setTimeout(() => { +const scheduleSyncSidebarWindows = (): void => { + window.clearTimeout(syncTimeout); + syncTimeout = window.setTimeout(() => { void syncSidebarWindows(); - }, 0); - window.setTimeout(() => { - void syncSidebarWindows(); - }, 100); - window.setTimeout(() => { - void syncSidebarWindows(); - }, 500); + }, DEBOUNCE_MS); +}; + +const scheduleInitialSync = (): void => { + scheduleSyncSidebarWindows(); + window.setTimeout(scheduleSyncSidebarWindows, 500); }; export const mountAdvancedSearchInSidebar = async ( @@ -253,7 +269,7 @@ export const mountAdvancedSearchInSidebar = async ( windowId, }); - await syncSidebarWindows(); + scheduleSyncSidebarWindows(); }; let persistenceObserver: MutationObserver | null = null; @@ -261,11 +277,11 @@ let sidebarResizeObserver: ResizeObserver | null = null; let sidebarCloseClickHandler: ((event: MouseEvent) => void) | null = null; export const initDockedSearchSidebarPersistence = (): (() => void) => { - scheduleSyncRetries(); + scheduleInitialSync(); persistenceObserver?.disconnect(); persistenceObserver = new MutationObserver(() => { - void syncSidebarWindows(); + scheduleSyncSidebarWindows(); }); const sidebarContent = document.getElementById("roam-right-sidebar-content"); @@ -306,7 +322,7 @@ export const initDockedSearchSidebarPersistence = (): (() => void) => { sidebarResizeObserver = new ResizeObserver(() => { const isOpen = isRightSidebarOpen(); if (!wasSidebarOpen && isOpen) { - scheduleSyncRetries(); + scheduleInitialSync(); } wasSidebarOpen = isOpen; }); @@ -314,6 +330,9 @@ export const initDockedSearchSidebarPersistence = (): (() => void) => { } return () => { + window.clearTimeout(syncTimeout); + syncTimeout = undefined; + persistenceObserver?.disconnect(); persistenceObserver = null; From 06a83f372ba72fcd34e94b223bb72ab95171c0b6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 10 Jun 2026 18:46:41 -0400 Subject: [PATCH 13/17] revert redundant changes 1 --- .../AdvancedSearchDialog.tsx | 83 +++++++++++++++++-- .../AdvancedSearchSidebarPanel.tsx | 62 -------------- 2 files changed, 75 insertions(+), 70 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 5789b1462..d403eb329 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -6,6 +6,7 @@ import { NonIdealState, Spinner, SpinnerSize, + Tag, } from "@blueprintjs/core"; import posthog from "posthog-js"; import { render as renderToast } from "roamjs-components/components/Toast"; @@ -22,6 +23,7 @@ import { DiscourseNodeSortControl } from "~/components/DiscourseNodeSortControl" import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; +import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; import { openSearchResultInMain } from "~/utils/advancedSearchFooterUtils"; import { mountAdvancedSearchInSidebar } from "./mountAdvancedSearchInSidebar"; import { @@ -32,12 +34,12 @@ import { buildSearchIndex, formatMetadataDate, getSearchKeywords, + splitWithHighlights, stripTypePrefix, } from "./utils"; import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; -import { AdvancedSearchDialogResultsList } from "./AdvancedSearchSidebarPanel"; import { type SearchIndex, useAdvancedNodeSearchResults, @@ -45,6 +47,67 @@ import { type Props = Record; +const getNodeBadgeText = (node: DiscourseNode): string => + (node.tag?.trim() || node.text).slice(0, 3).toUpperCase(); + +const getTagStyle = (node: DiscourseNode | undefined): React.CSSProperties => { + const color = node?.canvasSettings?.color; + if (!color) return { flexShrink: 0 }; + return { ...getNodeTagStyles(color), flexShrink: 0 }; +}; + +const renderHighlightedText = ( + text: string, + keywords: string[], +): React.ReactNode => + splitWithHighlights(text, keywords).map((segment, index) => + segment.isMatch ? ( + {segment.text} + ) : ( + + {segment.text} + + ), + ); + +const ResultRow = ({ + active, + keywords, + nodeConfig, + onClick, + onMouseEnter, + result, +}: { + active: boolean; + keywords: string[]; + nodeConfig: DiscourseNode | undefined; + onClick: () => void; + onMouseEnter: () => void; + result: SearchResult; +}) => ( + +); + const PreviewPane = ({ result }: { result: SearchResult | null }) => { if (!result) { return ( @@ -405,13 +468,17 @@ const AdvancedNodeSearchDialog = ({ ref={resultsPanelRef} role="listbox" > - + {results.map((result, index) => ( + setActiveIndex(index)} + onMouseEnter={() => setActiveIndex(index)} + result={result} + /> + ))}
diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx index dc4676c5c..6760e23d3 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useMemo, useState } from "react"; import { - Button, Icon, InputGroup, NonIdealState, @@ -21,7 +20,6 @@ import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSetting import { SORT_FIELD_LABELS, isNonDefaultSort, type SortConfig } from "./utils"; import getRoamUrl from "roamjs-components/dom/getRoamUrl"; import type { DiscourseNode } from "~/utils/getDiscourseNodes"; -import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; import { splitWithHighlights, stripTypePrefix } from "./utils"; import { openSearchResultFromLinkEvent } from "~/utils/advancedSearchFooterUtils"; import { @@ -43,66 +41,6 @@ const renderHighlightedText = ( ), ); -const getNodeBadgeText = (node: DiscourseNode): string => - (node.tag?.trim() || node.text).slice(0, 3).toUpperCase(); - -const getTagStyle = (node: DiscourseNode | undefined): React.CSSProperties => { - const color = node?.canvasSettings?.color; - if (!color) return { flexShrink: 0 }; - return { ...getNodeTagStyles(color), flexShrink: 0 }; -}; - -type AdvancedSearchDialogResultsListProps = { - activeIndex: number; - keywords: string[]; - nodeConfigByType: Record; - onSelect: (index: number) => void; - results: SearchResult[]; -}; - -export const AdvancedSearchDialogResultsList = ({ - activeIndex, - keywords, - nodeConfigByType, - onSelect, - results, -}: AdvancedSearchDialogResultsListProps) => ( - <> - {results.map((result, index) => ( - - ))} - -); - type AdvancedSearchSidebarResultsListProps = { keywords: string[]; results: SearchResult[]; From d341ad7aef527dad6e78112dd39051a27fc56e3f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 10 Jun 2026 18:47:41 -0400 Subject: [PATCH 14/17] further simplify --- .../AdvancedSearchDialog.tsx | 8 +++++-- .../AdvancedSearchSidebarPanel.tsx | 7 ++---- .../src/utils/advancedSearchFooterUtils.ts | 24 ------------------- 3 files changed, 8 insertions(+), 31 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index d403eb329..fc72189b7 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -24,7 +24,6 @@ import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; -import { openSearchResultInMain } from "~/utils/advancedSearchFooterUtils"; import { mountAdvancedSearchInSidebar } from "./mountAdvancedSearchInSidebar"; import { DEBOUNCE_MS, @@ -336,7 +335,12 @@ const AdvancedNodeSearchDialog = ({ const onOpen = useCallback(async () => { if (!activeResult || contentState !== "results") return; - await openSearchResultInMain(activeResult.uid); + const uid = activeResult.uid; + if (getPageTitleByPageUid(uid)) { + await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + } else { + await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); + } onClose(); }, [activeResult, contentState, onClose]); diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx index 6760e23d3..81cc0df9d 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -19,9 +19,9 @@ import { hasActiveTypeFilter } from "~/utils/discourseNodeTypeFilter"; import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; import { SORT_FIELD_LABELS, isNonDefaultSort, type SortConfig } from "./utils"; import getRoamUrl from "roamjs-components/dom/getRoamUrl"; +import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; import type { DiscourseNode } from "~/utils/getDiscourseNodes"; import { splitWithHighlights, stripTypePrefix } from "./utils"; -import { openSearchResultFromLinkEvent } from "~/utils/advancedSearchFooterUtils"; import { type SearchIndex, useAdvancedNodeSearchResults, @@ -74,10 +74,7 @@ export const AdvancedSearchSidebarResultsList = ({ if (event.shiftKey) { event.preventDefault(); event.stopPropagation(); - void openSearchResultFromLinkEvent({ - shiftKey: true, - uid: result.uid, - }); + void openBlockInSidebar(result.uid); } }} onClick={(event) => { diff --git a/apps/roam/src/utils/advancedSearchFooterUtils.ts b/apps/roam/src/utils/advancedSearchFooterUtils.ts index 5c089f32a..1981858ad 100644 --- a/apps/roam/src/utils/advancedSearchFooterUtils.ts +++ b/apps/roam/src/utils/advancedSearchFooterUtils.ts @@ -1,8 +1,6 @@ import getUids from "roamjs-components/dom/getUids"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import updateBlock from "roamjs-components/writes/updateBlock"; -import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; -import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; export type BlockSelection = { selectionStart: number; @@ -19,28 +17,6 @@ export type InsertTarget = { const DEFAULT_WINDOW_ID = "main-window"; -export const openSearchResultInMain = async (uid: string): Promise => { - if (getPageTitleByPageUid(uid)) { - await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); - return; - } - await window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); -}; - -export const openSearchResultFromLinkEvent = async ({ - shiftKey, - uid, -}: { - shiftKey: boolean; - uid: string; -}): Promise => { - if (shiftKey) { - await openBlockInSidebar(uid); - return; - } - await openSearchResultInMain(uid); -}; - export const getBlockSelection = (uid: string): BlockSelection => { const activeElement = document.activeElement; const isFocusedTextarea = From 2f7761e69d0d0c184abe7261358d6ed842273172 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 10 Jun 2026 18:59:33 -0400 Subject: [PATCH 15/17] further simplify --- .../dockedSearchSidebarStorage.ts | 203 ++++++++---------- .../mountAdvancedSearchInSidebar.tsx | 144 ++++++------- 2 files changed, 148 insertions(+), 199 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts index fdc4e0088..093a1e4c4 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts @@ -2,13 +2,11 @@ import type { DockedSearchState } from "./utils"; const STORAGE_KEY = "dg-advanced-search-sidebar-windows"; const SIDEBAR_OPEN_WIDTH_PX = 40; +const SEARCH_QUERY_WINDOW_ID_PREFIX = "sidebar-search-query"; export type RoamSidebarWindow = { type: string; "window-id": string; - order?: number; - "pinned?"?: boolean; - "collapsed?"?: boolean; "search-query-str"?: string; }; @@ -20,6 +18,9 @@ export type PersistedDockedSearchState = DockedSearchState & { }; type PersistedDockedSearchRegistry = Record; +type StoredRegistryEntry = PersistedDockedSearchState & { + isDgSearch?: boolean; +}; export const isRightSidebarOpen = (): boolean => { const sidebar = document.getElementById("right-sidebar"); @@ -30,7 +31,7 @@ export const isRightSidebarOpen = (): boolean => { export const getRoamSidebarWindows = async (): Promise => { try { - const windows = window.roamAlphaAPI.ui.rightSidebar.getWindows(); + const windows = await window.roamAlphaAPI.ui.rightSidebar.getWindows(); return windows ?? []; } catch { return []; @@ -39,17 +40,14 @@ export const getRoamSidebarWindows = async (): Promise => { const normalizeRegistryEntry = ( key: string, - value: PersistedDockedSearchState & { isDgSearch?: boolean }, + value: StoredRegistryEntry, ): PersistedDockedSearchState | null => { if (!value.query) return null; - const dgSearchId = value.dgSearchId ?? key; - const windowId = value.windowId ?? key; - return { ...value, - dgSearchId, - windowId, + dgSearchId: value.dgSearchId ?? key, + windowId: value.windowId ?? key, isDgSearch: true, updatedAt: value.updatedAt ?? Date.now(), }; @@ -60,18 +58,13 @@ const readRegistry = (): PersistedDockedSearchRegistry => { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return {}; - const parsed = JSON.parse(raw) as Record< - string, - PersistedDockedSearchState & { isDgSearch?: boolean } - >; + const parsed = JSON.parse(raw) as Record; if (!parsed || typeof parsed !== "object") return {}; const registry: PersistedDockedSearchRegistry = {}; for (const [key, value] of Object.entries(parsed)) { - const normalized = normalizeRegistryEntry(key, value); - if (normalized) { - registry[normalized.dgSearchId] = normalized; - } + const entry = normalizeRegistryEntry(key, value); + if (entry) registry[entry.dgSearchId] = entry; } return registry; } catch { @@ -88,13 +81,51 @@ const removeOtherEntriesWithWindowId = ( windowId: string, keepDgSearchId: string, ): void => { - for (const [existingId, entry] of Object.entries(registry)) { - if (entry.windowId === windowId && existingId !== keepDgSearchId) { - delete registry[existingId]; + for (const [id, entry] of Object.entries(registry)) { + if (entry.windowId === windowId && id !== keepDgSearchId) { + delete registry[id]; } } }; +const upsertRegistryEntry = ( + registry: PersistedDockedSearchRegistry, + { + dgSearchId, + windowId, + state, + }: { + dgSearchId: string; + windowId: string; + state: DockedSearchState; + }, +): void => { + removeOtherEntriesWithWindowId(registry, windowId, dgSearchId); + registry[dgSearchId] = { + ...state, + dgSearchId, + windowId, + isDgSearch: true, + updatedAt: Date.now(), + }; +}; + +const getSearchQueryWindows = ( + roamWindows: RoamSidebarWindow[], +): RoamSidebarWindow[] => + roamWindows.filter((window) => window.type === "search-query"); + +const getDomSearchQueryWindowIds = (): Set => + new Set( + [ + ...document.querySelectorAll( + `[data-sidebar-window-id^="${SEARCH_QUERY_WINDOW_ID_PREFIX}"]`, + ), + ] + .map((element) => element.dataset.sidebarWindowId) + .filter((windowId): windowId is string => Boolean(windowId)), + ); + export const registerDgSearchWindow = ({ dgSearchId, windowId, @@ -105,15 +136,7 @@ export const registerDgSearchWindow = ({ state: DockedSearchState; }): void => { const registry = readRegistry(); - removeOtherEntriesWithWindowId(registry, windowId, dgSearchId); - - registry[dgSearchId] = { - ...state, - dgSearchId, - windowId, - isDgSearch: true, - updatedAt: Date.now(), - }; + upsertRegistryEntry(registry, { dgSearchId, windowId, state }); writeRegistry(registry); }; @@ -124,30 +147,9 @@ export const loadDgSearchWindowById = ( export const loadDgSearchWindowByWindowId = ( windowId: string, ): PersistedDockedSearchState | null => - Object.values(readRegistry()).find((state) => state.windowId === windowId) ?? + Object.values(readRegistry()).find((entry) => entry.windowId === windowId) ?? null; -export const remapDgSearchWindowId = ({ - dgSearchId, - windowId, -}: { - dgSearchId: string; - windowId: string; -}): void => { - const registry = readRegistry(); - const entry = registry[dgSearchId]; - if (!entry) return; - - removeOtherEntriesWithWindowId(registry, windowId, dgSearchId); - - registry[dgSearchId] = { - ...entry, - windowId, - updatedAt: Date.now(), - }; - writeRegistry(registry); -}; - export const removeDgSearchWindow = ({ dgSearchId, windowId, @@ -156,22 +158,18 @@ export const removeDgSearchWindow = ({ windowId?: string; }): void => { const registry = readRegistry(); - let changed = false; + const idToRemove = + (dgSearchId && registry[dgSearchId] ? dgSearchId : undefined) ?? + (windowId + ? Object.entries(registry).find( + ([, entry]) => entry.windowId === windowId, + )?.[0] + : undefined); - if (dgSearchId && registry[dgSearchId]) { - delete registry[dgSearchId]; - changed = true; - } else if (windowId) { - const match = Object.entries(registry).find( - ([, entry]) => entry.windowId === windowId, - ); - if (match) { - delete registry[match[0]]; - changed = true; - } - } + if (!idToRemove) return; - if (changed) writeRegistry(registry); + delete registry[idToRemove]; + writeRegistry(registry); }; export const listDgSearchWindows = (): PersistedDockedSearchState[] => @@ -181,84 +179,55 @@ export const syncDgSearchWindowIdsFromRoam = ( roamWindows: RoamSidebarWindow[], ): void => { const registry = readRegistry(); - const searchQueryWindows = roamWindows.filter( - (window) => window.type === "search-query", - ); - const dgEntries = Object.values(registry); - const claimedRoamWindowIds = new Set(); + const unmatchedRoamWindows = [...getSearchQueryWindows(roamWindows)]; let changed = false; - for (const entry of dgEntries) { - const matchedById = searchQueryWindows.find( + for (const entry of Object.values(registry)) { + const exactMatchIndex = unmatchedRoamWindows.findIndex( (window) => window["window-id"] === entry.windowId, ); - if (matchedById) { - claimedRoamWindowIds.add(matchedById["window-id"]); + if (exactMatchIndex >= 0) { + unmatchedRoamWindows.splice(exactMatchIndex, 1); + continue; } - } - const unmatchedEntries = dgEntries.filter( - (entry) => - !searchQueryWindows.some( - (window) => window["window-id"] === entry.windowId, - ), - ); - const unmatchedRoamWindows = searchQueryWindows.filter( - (window) => !claimedRoamWindowIds.has(window["window-id"]), - ); - - for (const entry of unmatchedEntries) { const queryMatches = unmatchedRoamWindows.filter( (window) => window["search-query-str"] === entry.query, ); if (queryMatches.length !== 1) continue; - const matchedWindow = queryMatches[0]; - removeOtherEntriesWithWindowId( - registry, - matchedWindow["window-id"], - entry.dgSearchId, - ); + const [matchedWindow] = queryMatches; + const roamWindowId = matchedWindow["window-id"]; + removeOtherEntriesWithWindowId(registry, roamWindowId, entry.dgSearchId); registry[entry.dgSearchId] = { ...entry, - windowId: matchedWindow["window-id"], + windowId: roamWindowId, updatedAt: Date.now(), }; - claimedRoamWindowIds.add(matchedWindow["window-id"]); + unmatchedRoamWindows.splice(unmatchedRoamWindows.indexOf(matchedWindow), 1); changed = true; } - if (changed) { - writeRegistry(registry); - } + if (changed) writeRegistry(registry); }; -export const pruneStaleDockedSearchSidebarStates = async ( - roamWindows?: RoamSidebarWindow[], -): Promise => { +export const pruneStaleDockedSearchSidebarStates = ( + roamWindows: RoamSidebarWindow[], +): void => { if (!isRightSidebarOpen()) return; - const resolvedRoamWindows = roamWindows ?? (await getRoamSidebarWindows()); - if (!roamWindows) { - syncDgSearchWindowIdsFromRoam(resolvedRoamWindows); - } - - const roamWindowIds = new Set( - resolvedRoamWindows.map((window) => window["window-id"]), + const liveRoamWindowIds = new Set( + roamWindows.map((window) => window["window-id"]), ); + const domWindowIds = getDomSearchQueryWindowIds(); const registry = readRegistry(); let changed = false; for (const dgSearchId of Object.keys(registry)) { - const entry = registry[dgSearchId]; - if (!entry?.isDgSearch) continue; - - if (roamWindowIds.has(entry.windowId)) continue; - - const sidebarWindow = document.querySelector( - `[data-sidebar-window-id="${entry.windowId}"]`, - ); - if (sidebarWindow) continue; + const { windowId } = registry[dgSearchId]; + if (liveRoamWindowIds.has(windowId) || domWindowIds.has(windowId)) { + continue; + } delete registry[dgSearchId]; changed = true; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx index 076230747..8e4721384 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx @@ -11,15 +11,15 @@ import { registerDgSearchWindow, removeDgSearchWindow, syncDgSearchWindowIdsFromRoam, - type PersistedDockedSearchState, } from "./dockedSearchSidebarStorage"; import type { DockedSearchState } from "./utils"; import { DEBOUNCE_MS } from "./utils"; -export const DG_SEARCH_ROOT_CLASS = "dg-node-search-sidebar-root"; +const DG_SEARCH_ROOT_CLASS = "dg-node-search-sidebar-root"; const SEARCH_QUERY_WINDOW_SELECTOR = '#roam-right-sidebar-content .rm-sidebar-window[data-sidebar-window-id^="sidebar-search-query"]'; const MAX_WINDOW_WAIT_FRAMES = 30; +const INITIAL_SYNC_DELAY_MS = 500; const activeUnmounts = new Map void>(); const windowGuardObservers = new Map(); @@ -28,34 +28,22 @@ let syncTimeout: number | undefined; let syncInProgress = false; let needsResync = false; -const createDgSearchId = (): string => window.roamAlphaAPI.util.generateUID(); - -const getSidebarWindowId = (sidebarWindow: HTMLElement): string | null => - sidebarWindow.getAttribute("data-sidebar-window-id"); - -const getSearchQuerySidebarWindows = (): HTMLElement[] => [ - ...document.querySelectorAll(SEARCH_QUERY_WINDOW_SELECTOR), -]; - -const hasDgSearchRoot = (sidebarWindow: HTMLElement): boolean => - !!sidebarWindow.querySelector(`.${DG_SEARCH_ROOT_CLASS}`); - -const getSearchMountContainer = ( - sidebarWindow: HTMLElement, -): HTMLElement | null => sidebarWindow.querySelector(".rm-sidebar-search"); - -const findSidebarWindowElement = (windowId: string): HTMLElement | null => - document.querySelector(`[data-sidebar-window-id="${windowId}"]`); +const getSearchContainer = (sidebarWindow: HTMLElement): HTMLElement | null => + sidebarWindow.querySelector(".rm-sidebar-search"); const waitForNewSearchQueryWindow = async ( previousWindowIds: Set, ): Promise => { for (let attempt = 0; attempt < MAX_WINDOW_WAIT_FRAMES; attempt += 1) { - const newWindow = getSearchQuerySidebarWindows().find((sidebarWindow) => { - const windowId = getSidebarWindowId(sidebarWindow); - return !!windowId && !previousWindowIds.has(windowId); - }); - if (newWindow) return newWindow; + const windows = document.querySelectorAll( + SEARCH_QUERY_WINDOW_SELECTOR, + ); + for (const sidebarWindow of windows) { + const windowId = sidebarWindow.dataset.sidebarWindowId; + if (windowId && !previousWindowIds.has(windowId)) { + return sidebarWindow; + } + } await new Promise((resolve) => { requestAnimationFrame(() => resolve()); @@ -64,24 +52,6 @@ const waitForNewSearchQueryWindow = async ( throw new Error("DG search sidebar window did not appear"); }; -const addSearchQuerySidebarWindow = async (query: string): Promise => { - await window.roamAlphaAPI.ui.rightSidebar.addWindow({ - window: { - type: "search-query", - // @ts-expect-error - search-query-str replaces block-uid for search-query windows - // eslint-disable-next-line @typescript-eslint/naming-convention - "search-query-str": query, - }, - }); -}; - -const setSidebarWindowTitle = (sidebarWindow: HTMLElement): void => { - const titleEl = sidebarWindow.querySelector( - ".window-headers span[style*='font-weight']", - ); - if (titleEl) titleEl.textContent = "DG node search"; -}; - const disconnectWindowGuardObserver = (dgSearchId: string): void => { windowGuardObservers.get(dgSearchId)?.disconnect(); windowGuardObservers.delete(dgSearchId); @@ -96,18 +66,21 @@ const attachWindowGuardObserver = ({ }): void => { disconnectWindowGuardObserver(dgSearchId); - const searchContainer = getSearchMountContainer(sidebarWindow); + const searchContainer = getSearchContainer(sidebarWindow); if (!searchContainer) return; const observer = new MutationObserver(() => { - if (mountingDgSearchIds.has(dgSearchId) || hasDgSearchRoot(sidebarWindow)) { + if ( + mountingDgSearchIds.has(dgSearchId) || + sidebarWindow.querySelector(`.${DG_SEARCH_ROOT_CLASS}`) + ) { return; } const savedState = loadDgSearchWindowById(dgSearchId); if (!savedState) return; - const windowId = getSidebarWindowId(sidebarWindow); + const windowId = sidebarWindow.dataset.sidebarWindowId; if (!windowId) return; mountPanelInSearchWindow({ @@ -141,7 +114,7 @@ const mountPanelInSearchWindow = ({ activeUnmounts.get(dgSearchId)?.(); activeUnmounts.delete(dgSearchId); - const searchContainer = getSearchMountContainer(sidebarWindow); + const searchContainer = getSearchContainer(sidebarWindow); if (!searchContainer) { throw new Error("DG search sidebar window is missing .rm-sidebar-search"); } @@ -155,8 +128,6 @@ const mountPanelInSearchWindow = ({ root.onmousedown = (event) => event.stopPropagation(); searchContainer.appendChild(root); - sidebarWindow.dataset.dgNodeSearch = "true"; - const stateWithIds: DockedSearchState = { ...dockedState, dgSearchId, @@ -170,11 +141,7 @@ const mountPanelInSearchWindow = ({ dgSearchId={dgSearchId} dockedState={stateWithIds} onPersistState={(nextState) => { - registerDgSearchWindow({ - dgSearchId, - windowId, - state: { ...nextState, dgSearchId, windowId }, - }); + registerDgSearchWindow({ dgSearchId, windowId, state: nextState }); }} windowId={windowId} />, @@ -182,31 +149,36 @@ const mountPanelInSearchWindow = ({ ); activeUnmounts.set(dgSearchId, unmount); - setSidebarWindowTitle(sidebarWindow); + + const titleEl = sidebarWindow.querySelector( + ".window-headers span[style*='font-weight']", + ); + if (titleEl) titleEl.textContent = "DG node search"; + attachWindowGuardObserver({ dgSearchId, sidebarWindow }); } finally { mountingDgSearchIds.delete(dgSearchId); } }; -const restoreSavedDgSearchWindow = ( - savedState: PersistedDockedSearchState, -): boolean => { - const sidebarWindow = findSidebarWindowElement(savedState.windowId); - if (!sidebarWindow || hasDgSearchRoot(sidebarWindow)) return false; - - mountPanelInSearchWindow({ - dgSearchId: savedState.dgSearchId, - dockedState: savedState, - sidebarWindow, - windowId: savedState.windowId, - }); - return true; -}; - -export const restorePersistedDockedSearchSidebarWindows = (): void => { +const restorePersistedDockedSearchSidebarWindows = (): void => { for (const savedState of listDgSearchWindows()) { - restoreSavedDgSearchWindow(savedState); + const sidebarWindow = document.querySelector( + `[data-sidebar-window-id="${savedState.windowId}"]`, + ); + if ( + !sidebarWindow || + sidebarWindow.querySelector(`.${DG_SEARCH_ROOT_CLASS}`) + ) { + continue; + } + + mountPanelInSearchWindow({ + dgSearchId: savedState.dgSearchId, + dockedState: savedState, + sidebarWindow, + windowId: savedState.windowId, + }); } }; @@ -221,7 +193,7 @@ const syncSidebarWindows = async (): Promise => { const roamWindows = await getRoamSidebarWindows(); syncDgSearchWindowIdsFromRoam(roamWindows); restorePersistedDockedSearchSidebarWindows(); - await pruneStaleDockedSearchSidebarStates(roamWindows); + pruneStaleDockedSearchSidebarStates(roamWindows); } finally { syncInProgress = false; if (needsResync) { @@ -240,21 +212,29 @@ const scheduleSyncSidebarWindows = (): void => { const scheduleInitialSync = (): void => { scheduleSyncSidebarWindows(); - window.setTimeout(scheduleSyncSidebarWindows, 500); + window.setTimeout(scheduleSyncSidebarWindows, INITIAL_SYNC_DELAY_MS); }; export const mountAdvancedSearchInSidebar = async ( dockedState: DockedSearchState, ): Promise => { - const dgSearchId = createDgSearchId(); + const dgSearchId = window.roamAlphaAPI.util.generateUID(); const roamWindowsBefore = await getRoamSidebarWindows(); const previousWindowIds = new Set( roamWindowsBefore.map((window) => window["window-id"]), ); - await addSearchQuerySidebarWindow(dockedState.query); + await window.roamAlphaAPI.ui.rightSidebar.addWindow({ + window: { + type: "search-query", + // @ts-expect-error - search-query-str replaces block-uid for search-query windows + // eslint-disable-next-line @typescript-eslint/naming-convention + "search-query-str": dockedState.query, + }, + }); + const sidebarWindow = await waitForNewSearchQueryWindow(previousWindowIds); - const windowId = getSidebarWindowId(sidebarWindow); + const windowId = sidebarWindow.dataset.sidebarWindowId; if (!windowId) { throw new Error( @@ -300,17 +280,17 @@ export const initDockedSearchSidebarPersistence = (): (() => void) => { const sidebarWindow = closeButton.closest(".rm-sidebar-window"); - if (!sidebarWindow?.dataset.dgNodeSearch) return; + if (!sidebarWindow) return; + const windowId = sidebarWindow.dataset.sidebarWindowId; const dgSearchId = sidebarWindow.querySelector(`.${DG_SEARCH_ROOT_CLASS}`) ?.dataset.dgSearchId ?? - loadDgSearchWindowByWindowId(getSidebarWindowId(sidebarWindow) ?? "") - ?.dgSearchId; + loadDgSearchWindowByWindowId(windowId ?? "")?.dgSearchId; + if (!dgSearchId) return; - const windowId = getSidebarWindowId(sidebarWindow) ?? undefined; removeDgSearchWindow({ dgSearchId, windowId }); - if (dgSearchId) disconnectWindowGuardObserver(dgSearchId); + disconnectWindowGuardObserver(dgSearchId); }; sidebarContent.addEventListener("click", sidebarCloseClickHandler, true); From cb7ded6f9766eb52976e4d381d3cabcf066142f5 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 16 Jun 2026 15:03:27 -0400 Subject: [PATCH 16/17] Simplify docked search sidebar mount and persistence. Collapse storage into a single mount module, use getWindows() diff to find new sidebar windows, and keep only init + sidebar-reopen sync for restore-on-reload. Co-authored-by: Cursor --- .../AdvancedSearchSidebarPanel.tsx | 2 - .../dockedSearchSidebarStorage.ts | 237 -------------- .../mountAdvancedSearchInSidebar.tsx | 300 ++++++++---------- 3 files changed, 125 insertions(+), 414 deletions(-) delete mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx index 81cc0df9d..463e98e21 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -238,8 +238,6 @@ export const AdvancedSearchSidebarPanel = ({ }); useEffect(() => { - if (!debouncedSearchTerm) return; - onPersistState({ query: debouncedSearchTerm, results, diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts deleted file mode 100644 index 093a1e4c4..000000000 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/dockedSearchSidebarStorage.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { DockedSearchState } from "./utils"; - -const STORAGE_KEY = "dg-advanced-search-sidebar-windows"; -const SIDEBAR_OPEN_WIDTH_PX = 40; -const SEARCH_QUERY_WINDOW_ID_PREFIX = "sidebar-search-query"; - -export type RoamSidebarWindow = { - type: string; - "window-id": string; - "search-query-str"?: string; -}; - -export type PersistedDockedSearchState = DockedSearchState & { - dgSearchId: string; - windowId: string; - isDgSearch: true; - updatedAt: number; -}; - -type PersistedDockedSearchRegistry = Record; -type StoredRegistryEntry = PersistedDockedSearchState & { - isDgSearch?: boolean; -}; - -export const isRightSidebarOpen = (): boolean => { - const sidebar = document.getElementById("right-sidebar"); - return ( - !!sidebar && sidebar.getBoundingClientRect().width > SIDEBAR_OPEN_WIDTH_PX - ); -}; - -export const getRoamSidebarWindows = async (): Promise => { - try { - const windows = await window.roamAlphaAPI.ui.rightSidebar.getWindows(); - return windows ?? []; - } catch { - return []; - } -}; - -const normalizeRegistryEntry = ( - key: string, - value: StoredRegistryEntry, -): PersistedDockedSearchState | null => { - if (!value.query) return null; - - return { - ...value, - dgSearchId: value.dgSearchId ?? key, - windowId: value.windowId ?? key, - isDgSearch: true, - updatedAt: value.updatedAt ?? Date.now(), - }; -}; - -const readRegistry = (): PersistedDockedSearchRegistry => { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return {}; - - const parsed = JSON.parse(raw) as Record; - if (!parsed || typeof parsed !== "object") return {}; - - const registry: PersistedDockedSearchRegistry = {}; - for (const [key, value] of Object.entries(parsed)) { - const entry = normalizeRegistryEntry(key, value); - if (entry) registry[entry.dgSearchId] = entry; - } - return registry; - } catch { - return {}; - } -}; - -const writeRegistry = (registry: PersistedDockedSearchRegistry): void => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(registry)); -}; - -const removeOtherEntriesWithWindowId = ( - registry: PersistedDockedSearchRegistry, - windowId: string, - keepDgSearchId: string, -): void => { - for (const [id, entry] of Object.entries(registry)) { - if (entry.windowId === windowId && id !== keepDgSearchId) { - delete registry[id]; - } - } -}; - -const upsertRegistryEntry = ( - registry: PersistedDockedSearchRegistry, - { - dgSearchId, - windowId, - state, - }: { - dgSearchId: string; - windowId: string; - state: DockedSearchState; - }, -): void => { - removeOtherEntriesWithWindowId(registry, windowId, dgSearchId); - registry[dgSearchId] = { - ...state, - dgSearchId, - windowId, - isDgSearch: true, - updatedAt: Date.now(), - }; -}; - -const getSearchQueryWindows = ( - roamWindows: RoamSidebarWindow[], -): RoamSidebarWindow[] => - roamWindows.filter((window) => window.type === "search-query"); - -const getDomSearchQueryWindowIds = (): Set => - new Set( - [ - ...document.querySelectorAll( - `[data-sidebar-window-id^="${SEARCH_QUERY_WINDOW_ID_PREFIX}"]`, - ), - ] - .map((element) => element.dataset.sidebarWindowId) - .filter((windowId): windowId is string => Boolean(windowId)), - ); - -export const registerDgSearchWindow = ({ - dgSearchId, - windowId, - state, -}: { - dgSearchId: string; - windowId: string; - state: DockedSearchState; -}): void => { - const registry = readRegistry(); - upsertRegistryEntry(registry, { dgSearchId, windowId, state }); - writeRegistry(registry); -}; - -export const loadDgSearchWindowById = ( - dgSearchId: string, -): PersistedDockedSearchState | null => readRegistry()[dgSearchId] ?? null; - -export const loadDgSearchWindowByWindowId = ( - windowId: string, -): PersistedDockedSearchState | null => - Object.values(readRegistry()).find((entry) => entry.windowId === windowId) ?? - null; - -export const removeDgSearchWindow = ({ - dgSearchId, - windowId, -}: { - dgSearchId?: string; - windowId?: string; -}): void => { - const registry = readRegistry(); - const idToRemove = - (dgSearchId && registry[dgSearchId] ? dgSearchId : undefined) ?? - (windowId - ? Object.entries(registry).find( - ([, entry]) => entry.windowId === windowId, - )?.[0] - : undefined); - - if (!idToRemove) return; - - delete registry[idToRemove]; - writeRegistry(registry); -}; - -export const listDgSearchWindows = (): PersistedDockedSearchState[] => - Object.values(readRegistry()); - -export const syncDgSearchWindowIdsFromRoam = ( - roamWindows: RoamSidebarWindow[], -): void => { - const registry = readRegistry(); - const unmatchedRoamWindows = [...getSearchQueryWindows(roamWindows)]; - let changed = false; - - for (const entry of Object.values(registry)) { - const exactMatchIndex = unmatchedRoamWindows.findIndex( - (window) => window["window-id"] === entry.windowId, - ); - if (exactMatchIndex >= 0) { - unmatchedRoamWindows.splice(exactMatchIndex, 1); - continue; - } - - const queryMatches = unmatchedRoamWindows.filter( - (window) => window["search-query-str"] === entry.query, - ); - if (queryMatches.length !== 1) continue; - - const [matchedWindow] = queryMatches; - const roamWindowId = matchedWindow["window-id"]; - removeOtherEntriesWithWindowId(registry, roamWindowId, entry.dgSearchId); - registry[entry.dgSearchId] = { - ...entry, - windowId: roamWindowId, - updatedAt: Date.now(), - }; - unmatchedRoamWindows.splice(unmatchedRoamWindows.indexOf(matchedWindow), 1); - changed = true; - } - - if (changed) writeRegistry(registry); -}; - -export const pruneStaleDockedSearchSidebarStates = ( - roamWindows: RoamSidebarWindow[], -): void => { - if (!isRightSidebarOpen()) return; - - const liveRoamWindowIds = new Set( - roamWindows.map((window) => window["window-id"]), - ); - const domWindowIds = getDomSearchQueryWindowIds(); - const registry = readRegistry(); - let changed = false; - - for (const dgSearchId of Object.keys(registry)) { - const { windowId } = registry[dgSearchId]; - if (liveRoamWindowIds.has(windowId) || domWindowIds.has(windowId)) { - continue; - } - - delete registry[dgSearchId]; - changed = true; - } - - if (changed) writeRegistry(registry); -}; diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx index 8e4721384..b0eb90d1f 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx @@ -1,98 +1,139 @@ import React from "react"; import renderWithUnmount from "roamjs-components/util/renderWithUnmount"; import { AdvancedSearchSidebarPanel } from "./AdvancedSearchSidebarPanel"; -import { - getRoamSidebarWindows, - isRightSidebarOpen, - listDgSearchWindows, - loadDgSearchWindowById, - loadDgSearchWindowByWindowId, - pruneStaleDockedSearchSidebarStates, - registerDgSearchWindow, - removeDgSearchWindow, - syncDgSearchWindowIdsFromRoam, -} from "./dockedSearchSidebarStorage"; import type { DockedSearchState } from "./utils"; -import { DEBOUNCE_MS } from "./utils"; +const STORAGE_KEY = "dg-advanced-search-sidebar-windows"; const DG_SEARCH_ROOT_CLASS = "dg-node-search-sidebar-root"; -const SEARCH_QUERY_WINDOW_SELECTOR = - '#roam-right-sidebar-content .rm-sidebar-window[data-sidebar-window-id^="sidebar-search-query"]'; +const SIDEBAR_OPEN_WIDTH_PX = 40; const MAX_WINDOW_WAIT_FRAMES = 30; -const INITIAL_SYNC_DELAY_MS = 500; + +type RoamSidebarWindow = { + type: string; + "window-id": string; + "search-query-str"?: string; +}; + +type PersistedDockedSearchState = DockedSearchState & { + dgSearchId: string; + windowId: string; + updatedAt: number; +}; + +type PersistedDockedSearchRegistry = Record; const activeUnmounts = new Map void>(); -const windowGuardObservers = new Map(); const mountingDgSearchIds = new Set(); -let syncTimeout: number | undefined; -let syncInProgress = false; -let needsResync = false; -const getSearchContainer = (sidebarWindow: HTMLElement): HTMLElement | null => - sidebarWindow.querySelector(".rm-sidebar-search"); +const isRightSidebarOpen = (): boolean => { + const sidebar = document.getElementById("right-sidebar"); + return ( + !!sidebar && sidebar.getBoundingClientRect().width > SIDEBAR_OPEN_WIDTH_PX + ); +}; -const waitForNewSearchQueryWindow = async ( - previousWindowIds: Set, -): Promise => { - for (let attempt = 0; attempt < MAX_WINDOW_WAIT_FRAMES; attempt += 1) { - const windows = document.querySelectorAll( - SEARCH_QUERY_WINDOW_SELECTOR, - ); - for (const sidebarWindow of windows) { - const windowId = sidebarWindow.dataset.sidebarWindowId; - if (windowId && !previousWindowIds.has(windowId)) { - return sidebarWindow; - } - } +const getRoamSidebarWindows = async (): Promise => { + try { + const windows = await window.roamAlphaAPI.ui.rightSidebar.getWindows(); + return windows ?? []; + } catch { + return []; + } +}; - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); +const readRegistry = (): PersistedDockedSearchRegistry => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + + const parsed = JSON.parse(raw) as PersistedDockedSearchRegistry; + if (!parsed || typeof parsed !== "object") return {}; + + return parsed; + } catch { + return {}; } - throw new Error("DG search sidebar window did not appear"); }; -const disconnectWindowGuardObserver = (dgSearchId: string): void => { - windowGuardObservers.get(dgSearchId)?.disconnect(); - windowGuardObservers.delete(dgSearchId); +const writeRegistry = (registry: PersistedDockedSearchRegistry): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(registry)); }; -const attachWindowGuardObserver = ({ +const upsertRegistryEntry = ({ dgSearchId, - sidebarWindow, + windowId, + state, }: { dgSearchId: string; - sidebarWindow: HTMLElement; + windowId: string; + state: DockedSearchState; }): void => { - disconnectWindowGuardObserver(dgSearchId); + const registry = readRegistry(); - const searchContainer = getSearchContainer(sidebarWindow); - if (!searchContainer) return; - - const observer = new MutationObserver(() => { - if ( - mountingDgSearchIds.has(dgSearchId) || - sidebarWindow.querySelector(`.${DG_SEARCH_ROOT_CLASS}`) - ) { - return; + for (const [id, entry] of Object.entries(registry)) { + if (entry.windowId === windowId && id !== dgSearchId) { + delete registry[id]; } + } - const savedState = loadDgSearchWindowById(dgSearchId); - if (!savedState) return; + registry[dgSearchId] = { + ...state, + dgSearchId, + windowId, + updatedAt: Date.now(), + }; + writeRegistry(registry); +}; - const windowId = sidebarWindow.dataset.sidebarWindowId; - if (!windowId) return; +const pruneRegistry = (liveWindowIds: Set): void => { + const registry = readRegistry(); + let changed = false; - mountPanelInSearchWindow({ - dgSearchId, - dockedState: savedState, - sidebarWindow, - windowId, - }); - }); + for (const dgSearchId of Object.keys(registry)) { + if (liveWindowIds.has(registry[dgSearchId].windowId)) continue; + delete registry[dgSearchId]; + changed = true; + } - observer.observe(searchContainer, { childList: true, subtree: true }); - windowGuardObservers.set(dgSearchId, observer); + if (changed) writeRegistry(registry); +}; + +const listDgSearchWindows = (): PersistedDockedSearchState[] => + Object.values(readRegistry()); + +const getSearchContainer = (sidebarWindow: HTMLElement): HTMLElement | null => + sidebarWindow.querySelector(".rm-sidebar-search"); + +const findNewWindowId = ( + before: RoamSidebarWindow[], + after: RoamSidebarWindow[], +): string | null => { + const beforeIds = new Set(before.map((window) => window["window-id"])); + const newWindows = after.filter( + (window) => !beforeIds.has(window["window-id"]), + ); + const searchQueryWindow = newWindows.find( + (window) => window.type === "search-query", + ); + return ( + searchQueryWindow?.["window-id"] ?? newWindows[0]?.["window-id"] ?? null + ); +}; + +const waitForSidebarWindowElement = async ( + windowId: string, +): Promise => { + for (let attempt = 0; attempt < MAX_WINDOW_WAIT_FRAMES; attempt += 1) { + const sidebarWindow = document.querySelector( + `[data-sidebar-window-id="${windowId}"]`, + ); + if (sidebarWindow) return sidebarWindow; + + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } + throw new Error("DG search sidebar window did not appear"); }; const mountPanelInSearchWindow = ({ @@ -110,7 +151,6 @@ const mountPanelInSearchWindow = ({ mountingDgSearchIds.add(dgSearchId); try { - disconnectWindowGuardObserver(dgSearchId); activeUnmounts.get(dgSearchId)?.(); activeUnmounts.delete(dgSearchId); @@ -134,14 +174,14 @@ const mountPanelInSearchWindow = ({ windowId, }; - registerDgSearchWindow({ dgSearchId, windowId, state: stateWithIds }); + upsertRegistryEntry({ dgSearchId, windowId, state: stateWithIds }); const unmount = renderWithUnmount( { - registerDgSearchWindow({ dgSearchId, windowId, state: nextState }); + upsertRegistryEntry({ dgSearchId, windowId, state: nextState }); }} windowId={windowId} />, @@ -154,8 +194,6 @@ const mountPanelInSearchWindow = ({ ".window-headers span[style*='font-weight']", ); if (titleEl) titleEl.textContent = "DG node search"; - - attachWindowGuardObserver({ dgSearchId, sidebarWindow }); } finally { mountingDgSearchIds.delete(dgSearchId); } @@ -182,37 +220,13 @@ const restorePersistedDockedSearchSidebarWindows = (): void => { } }; -const syncSidebarWindows = async (): Promise => { - if (syncInProgress) { - needsResync = true; - return; - } - syncInProgress = true; - - try { - const roamWindows = await getRoamSidebarWindows(); - syncDgSearchWindowIdsFromRoam(roamWindows); - restorePersistedDockedSearchSidebarWindows(); - pruneStaleDockedSearchSidebarStates(roamWindows); - } finally { - syncInProgress = false; - if (needsResync) { - needsResync = false; - void syncSidebarWindows(); - } - } -}; - -const scheduleSyncSidebarWindows = (): void => { - window.clearTimeout(syncTimeout); - syncTimeout = window.setTimeout(() => { - void syncSidebarWindows(); - }, DEBOUNCE_MS); -}; - -const scheduleInitialSync = (): void => { - scheduleSyncSidebarWindows(); - window.setTimeout(scheduleSyncSidebarWindows, INITIAL_SYNC_DELAY_MS); +const syncDockedSearchWindows = async (): Promise => { + const roamWindows = await getRoamSidebarWindows(); + const liveWindowIds = new Set( + roamWindows.map((window) => window["window-id"]), + ); + pruneRegistry(liveWindowIds); + restorePersistedDockedSearchSidebarWindows(); }; export const mountAdvancedSearchInSidebar = async ( @@ -220,9 +234,6 @@ export const mountAdvancedSearchInSidebar = async ( ): Promise => { const dgSearchId = window.roamAlphaAPI.util.generateUID(); const roamWindowsBefore = await getRoamSidebarWindows(); - const previousWindowIds = new Set( - roamWindowsBefore.map((window) => window["window-id"]), - ); await window.roamAlphaAPI.ui.rightSidebar.addWindow({ window: { @@ -233,76 +244,33 @@ export const mountAdvancedSearchInSidebar = async ( }, }); - const sidebarWindow = await waitForNewSearchQueryWindow(previousWindowIds); - const windowId = sidebarWindow.dataset.sidebarWindowId; - + const roamWindowsAfter = await getRoamSidebarWindows(); + const windowId = findNewWindowId(roamWindowsBefore, roamWindowsAfter); if (!windowId) { - throw new Error( - "DG search sidebar window is missing data-sidebar-window-id", - ); + throw new Error("DG search sidebar window was not created"); } + const sidebarWindow = await waitForSidebarWindowElement(windowId); mountPanelInSearchWindow({ dgSearchId, dockedState, sidebarWindow, windowId, }); - - scheduleSyncSidebarWindows(); }; -let persistenceObserver: MutationObserver | null = null; -let sidebarResizeObserver: ResizeObserver | null = null; -let sidebarCloseClickHandler: ((event: MouseEvent) => void) | null = null; - export const initDockedSearchSidebarPersistence = (): (() => void) => { - scheduleInitialSync(); - - persistenceObserver?.disconnect(); - persistenceObserver = new MutationObserver(() => { - scheduleSyncSidebarWindows(); - }); - - const sidebarContent = document.getElementById("roam-right-sidebar-content"); - if (sidebarContent) { - persistenceObserver.observe(sidebarContent, { - childList: true, - subtree: true, - }); - - sidebarCloseClickHandler = (event: MouseEvent): void => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - - const closeButton = target.closest(".window-headers .bp3-icon-cross"); - if (!closeButton) return; - - const sidebarWindow = - closeButton.closest(".rm-sidebar-window"); - if (!sidebarWindow) return; - - const windowId = sidebarWindow.dataset.sidebarWindowId; - const dgSearchId = - sidebarWindow.querySelector(`.${DG_SEARCH_ROOT_CLASS}`) - ?.dataset.dgSearchId ?? - loadDgSearchWindowByWindowId(windowId ?? "")?.dgSearchId; - if (!dgSearchId) return; - - removeDgSearchWindow({ dgSearchId, windowId }); - disconnectWindowGuardObserver(dgSearchId); - }; - - sidebarContent.addEventListener("click", sidebarCloseClickHandler, true); - } + void syncDockedSearchWindows(); let wasSidebarOpen = isRightSidebarOpen(); const rightSidebar = document.getElementById("right-sidebar"); + let sidebarResizeObserver: ResizeObserver | null = null; + if (rightSidebar) { sidebarResizeObserver = new ResizeObserver(() => { const isOpen = isRightSidebarOpen(); if (!wasSidebarOpen && isOpen) { - scheduleInitialSync(); + void syncDockedSearchWindows(); } wasSidebarOpen = isOpen; }); @@ -310,27 +278,9 @@ export const initDockedSearchSidebarPersistence = (): (() => void) => { } return () => { - window.clearTimeout(syncTimeout); - syncTimeout = undefined; - - persistenceObserver?.disconnect(); - persistenceObserver = null; - sidebarResizeObserver?.disconnect(); sidebarResizeObserver = null; - if (sidebarContent && sidebarCloseClickHandler) { - sidebarContent.removeEventListener( - "click", - sidebarCloseClickHandler, - true, - ); - sidebarCloseClickHandler = null; - } - - windowGuardObservers.forEach((observer) => observer.disconnect()); - windowGuardObservers.clear(); - activeUnmounts.forEach((unmount) => unmount()); activeUnmounts.clear(); }; From 199f2706fcea8026803eae3bb749a2d20302bbfb Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 16 Jun 2026 15:06:26 -0400 Subject: [PATCH 17/17] Fix await-thenable lint in getRoamSidebarWindows. Wrap getWindows() in Promise.resolve so ESLint accepts the await while still handling sync or async API returns. Co-authored-by: Cursor --- .../AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx index b0eb90d1f..653752219 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx @@ -34,7 +34,9 @@ const isRightSidebarOpen = (): boolean => { const getRoamSidebarWindows = async (): Promise => { try { - const windows = await window.roamAlphaAPI.ui.rightSidebar.getWindows(); + const windows = await Promise.resolve( + window.roamAlphaAPI.ui.rightSidebar.getWindows(), + ); return windows ?? []; } catch { return [];