diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index a5d6bbdd6..fc72189b7 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, @@ -14,7 +8,6 @@ import { SpinnerSize, Tag, } 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"; @@ -31,6 +24,7 @@ import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; +import { mountAdvancedSearchInSidebar } from "./mountAdvancedSearchInSidebar"; import { DEBOUNCE_MS, DEFAULT_SORT_CONFIG, @@ -38,14 +32,17 @@ import { type SortConfig, buildSearchIndex, formatMetadataDate, - searchIndexedNodes, - sortSearchResults, + getSearchKeywords, splitWithHighlights, stripTypePrefix, } from "./utils"; import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; +import { + type SearchIndex, + useAdvancedNodeSearchResults, +} from "./useAdvancedNodeSearchResults"; type Props = Record; @@ -156,14 +153,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); @@ -172,8 +165,17 @@ const AdvancedNodeSearchDialog = ({ discourseNodes.map((node) => [node.type, node]), ); + const results = useAdvancedNodeSearchResults({ + debouncedSearchTerm, + selectedNodeTypeIds, + sort, + isIndexLoading, + indexError, + searchIndex, + }); + const activeResult = results[activeIndex] ?? null; - const keywords = debouncedSearchTerm.split(/\s+/).filter(Boolean); + const keywords = getSearchKeywords(debouncedSearchTerm); useEffect(() => { if (!isOpen) return; @@ -199,44 +201,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", @@ -246,8 +220,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); @@ -319,6 +292,42 @@ const AdvancedNodeSearchDialog = ({ : !results.length ? "empty" : "results"; + + const onOpenSearchSidebar = useCallback(async () => { + if (contentState !== "results" || !results.length) return; + + try { + await mountAdvancedSearchInSidebar({ + query: debouncedSearchTerm, + 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, + }); + onClose(); + } catch (error) { + console.error("Failed to dock search results in the sidebar:", error); + renderToast({ + id: "advanced-node-search-sidebar-open-error", + content: "Could not dock search results in the right sidebar.", + intent: "danger", + }); + } + }, [ + contentState, + debouncedSearchTerm, + onClose, + results, + selectedNodeTypeIds, + sort, + ]); const handleSortChange = useCallback((nextSort: SortConfig): void => { setSort(nextSort); }, []); @@ -357,6 +366,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 +403,7 @@ const AdvancedNodeSearchDialog = ({ contentState, insertTarget, onClose, + onOpenSearchSidebar, onInsert, onOpen, onOpenInSidebar, @@ -493,6 +511,7 @@ const AdvancedNodeSearchDialog = ({ onInsert={() => void onInsert()} onOpen={() => void onOpen()} onOpenInSidebar={() => void onOpenInSidebar()} + onOpenSearchSidebar={() => void onOpenSearchSidebar()} /> diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx index 26f001084..336641a1e 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchFooter.tsx @@ -16,6 +16,7 @@ export type AdvancedSearchFooterProps = { onInsert: () => void; onOpen: () => void; onOpenInSidebar: () => void; + onOpenSearchSidebar: () => void; }; const footerKbdClassName = @@ -99,6 +100,21 @@ const InsertFooterAction = ({ /> ); +const OpenSearchSidebarFooterAction = ({ + disabled, + onOpenSearchSidebar, +}: { + disabled: boolean; + onOpenSearchSidebar: () => void; +}) => ( + void onOpenSearchSidebar()} + /> +); + const CloseFooterHint = () => ( @@ -115,14 +131,20 @@ export const AdvancedSearchFooter = ({ onInsert, onOpen, onOpenInSidebar, + onOpenSearchSidebar, }: AdvancedSearchFooterProps) => { const hasResults = contentState === "results"; const canOpen = hasActiveResult && hasResults; const canInsert = !!insertTarget && hasActiveResult && hasResults; + const canOpenSearchSidebar = hasResults; return (
+ {insertTarget && ( )} diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx new file mode 100644 index 000000000..463e98e21 --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx @@ -0,0 +1,321 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Icon, + InputGroup, + NonIdealState, + Spinner, + SpinnerSize, + Tag, +} from "@blueprintjs/core"; +import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import { + DEBOUNCE_MS, + type DockedSearchState, + type SearchResult, + buildSearchIndex, + getSearchKeywords, +} from "./utils"; +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 { + type SearchIndex, + useAdvancedNodeSearchResults, +} from "./useAdvancedNodeSearchResults"; + +const renderHighlightedText = ( + text: string, + keywords: string[], +): React.ReactNode => + splitWithHighlights(text, keywords).map((segment, index) => + segment.isMatch ? ( + {segment.text} + ) : ( + + {segment.text} + + ), + ); + +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"; + +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 && ( +
+ Filter: + {selectedNodes.map((node) => ( + + + + {node.text} + + + ))} +
+ )} + {showSort && ( +

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

+ )} +
+ ); +}; + +type AdvancedSearchSidebarPanelProps = { + dgSearchId: string; + dockedState: DockedSearchState; + onPersistState: (state: DockedSearchState) => void; + windowId: string; +}; + +export const AdvancedSearchSidebarPanel = ({ + dgSearchId, + dockedState, + onPersistState, + windowId, +}: AdvancedSearchSidebarPanelProps) => { + const { + query, + results: dockedResults, + selectedNodeTypeIds, + sort, + } = dockedState; + + const [searchTerm, setSearchTerm] = useState(query); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(query); + const [searchIndex, setSearchIndex] = useState(null); + const [isIndexLoading, setIsIndexLoading] = useState(true); + const [indexError, setIndexError] = useState(false); + + const discourseNodes = useMemo( + () => getDiscourseNodes().filter((node) => node.backedBy === "user"), + [], + ); + const keywords = getSearchKeywords(debouncedSearchTerm); + + useEffect(() => { + const timeout = setTimeout( + () => setDebouncedSearchTerm(searchTerm.trim()), + DEBOUNCE_MS, + ); + return () => clearTimeout(timeout); + }, [searchTerm]); + + useEffect(() => { + let cancelled = false; + setIsIndexLoading(true); + setIndexError(false); + setSearchIndex(null); + + void buildSearchIndex(discourseNodes) + .then(({ miniSearch, results: indexedResults }) => { + if (cancelled) return; + setSearchIndex({ miniSearch, allResults: indexedResults }); + setIsIndexLoading(false); + }) + .catch((error) => { + console.error( + "Error building advanced node search sidebar index:", + error, + ); + if (!cancelled) { + setIndexError(true); + setIsIndexLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [discourseNodes]); + + const results = useAdvancedNodeSearchResults({ + debouncedSearchTerm, + selectedNodeTypeIds, + sort, + isIndexLoading, + indexError, + searchIndex, + dockedQuery: query, + dockedResults, + }); + + useEffect(() => { + onPersistState({ + query: debouncedSearchTerm, + results, + selectedNodeTypeIds, + sort, + windowId, + dgSearchId, + }); + }, [ + debouncedSearchTerm, + dgSearchId, + onPersistState, + results, + selectedNodeTypeIds, + sort, + windowId, + ]); + + const resultLabel = + results.length === 1 ? "1 result" : `${results.length} results`; + + return ( +
+
+ setSearchTerm(event.target.value)} + placeholder="Search discourse nodes..." + value={searchTerm} + /> +
+ {debouncedSearchTerm && ( + + )} +
+ {indexError ? ( + + ) : ( + <> + + {isIndexLoading && !results.length + ? "Loading…" + : debouncedSearchTerm + ? `${resultLabel} for “${debouncedSearchTerm}”` + : "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/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/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx new file mode 100644 index 000000000..653752219 --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/mountAdvancedSearchInSidebar.tsx @@ -0,0 +1,289 @@ +import React from "react"; +import renderWithUnmount from "roamjs-components/util/renderWithUnmount"; +import { AdvancedSearchSidebarPanel } from "./AdvancedSearchSidebarPanel"; +import type { DockedSearchState } from "./utils"; + +const STORAGE_KEY = "dg-advanced-search-sidebar-windows"; +const DG_SEARCH_ROOT_CLASS = "dg-node-search-sidebar-root"; +const SIDEBAR_OPEN_WIDTH_PX = 40; +const MAX_WINDOW_WAIT_FRAMES = 30; + +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 mountingDgSearchIds = new Set(); + +const isRightSidebarOpen = (): boolean => { + const sidebar = document.getElementById("right-sidebar"); + return ( + !!sidebar && sidebar.getBoundingClientRect().width > SIDEBAR_OPEN_WIDTH_PX + ); +}; + +const getRoamSidebarWindows = async (): Promise => { + try { + const windows = await Promise.resolve( + window.roamAlphaAPI.ui.rightSidebar.getWindows(), + ); + return windows ?? []; + } catch { + return []; + } +}; + +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 {}; + } +}; + +const writeRegistry = (registry: PersistedDockedSearchRegistry): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(registry)); +}; + +const upsertRegistryEntry = ({ + dgSearchId, + windowId, + state, +}: { + dgSearchId: string; + windowId: string; + state: DockedSearchState; +}): void => { + const registry = readRegistry(); + + for (const [id, entry] of Object.entries(registry)) { + if (entry.windowId === windowId && id !== dgSearchId) { + delete registry[id]; + } + } + + registry[dgSearchId] = { + ...state, + dgSearchId, + windowId, + updatedAt: Date.now(), + }; + writeRegistry(registry); +}; + +const pruneRegistry = (liveWindowIds: Set): void => { + const registry = readRegistry(); + let changed = false; + + for (const dgSearchId of Object.keys(registry)) { + if (liveWindowIds.has(registry[dgSearchId].windowId)) continue; + delete registry[dgSearchId]; + changed = true; + } + + 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 = ({ + dgSearchId, + dockedState, + sidebarWindow, + windowId, +}: { + dgSearchId: string; + dockedState: DockedSearchState; + sidebarWindow: HTMLElement; + windowId: string; +}): void => { + if (mountingDgSearchIds.has(dgSearchId)) return; + + mountingDgSearchIds.add(dgSearchId); + try { + activeUnmounts.get(dgSearchId)?.(); + activeUnmounts.delete(dgSearchId); + + const searchContainer = getSearchContainer(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); + + const stateWithIds: DockedSearchState = { + ...dockedState, + dgSearchId, + windowId, + }; + + upsertRegistryEntry({ dgSearchId, windowId, state: stateWithIds }); + + const unmount = renderWithUnmount( + { + upsertRegistryEntry({ dgSearchId, windowId, state: nextState }); + }} + windowId={windowId} + />, + root, + ); + + activeUnmounts.set(dgSearchId, unmount); + + const titleEl = sidebarWindow.querySelector( + ".window-headers span[style*='font-weight']", + ); + if (titleEl) titleEl.textContent = "DG node search"; + } finally { + mountingDgSearchIds.delete(dgSearchId); + } +}; + +const restorePersistedDockedSearchSidebarWindows = (): void => { + for (const savedState of listDgSearchWindows()) { + 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, + }); + } +}; + +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 ( + dockedState: DockedSearchState, +): Promise => { + const dgSearchId = window.roamAlphaAPI.util.generateUID(); + const roamWindowsBefore = await getRoamSidebarWindows(); + + 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 roamWindowsAfter = await getRoamSidebarWindows(); + const windowId = findNewWindowId(roamWindowsBefore, roamWindowsAfter); + if (!windowId) { + throw new Error("DG search sidebar window was not created"); + } + + const sidebarWindow = await waitForSidebarWindowElement(windowId); + mountPanelInSearchWindow({ + dgSearchId, + dockedState, + sidebarWindow, + windowId, + }); +}; + +export const initDockedSearchSidebarPersistence = (): (() => void) => { + 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) { + void syncDockedSearchWindows(); + } + wasSidebarOpen = isOpen; + }); + sidebarResizeObserver.observe(rightSidebar); + } + + return () => { + sidebarResizeObserver?.disconnect(); + sidebarResizeObserver = null; + + 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 c75c887c2..6553c0641 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"; @@ -44,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/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,