diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index da00bfbf1..7f3064670 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -21,6 +21,7 @@ import { import { createRoot, Root } from "react-dom/client"; import type DiscourseGraphPlugin from "~/index"; import { NodeSearchFooter } from "~/components/NodeSearchFooter"; +import { NodeTypeFilterMenu } from "~/components/NodeTypeFilterMenu"; import { openFileInNewLeaf, openFileInNewTab, @@ -328,6 +329,9 @@ const NodeSearch = ({ const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); + // Single source of truth: ENG-2111's tag chips will read and write this too. + const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState([]); + const [isTypeFilterOpen, setIsTypeFilterOpen] = useState(false); const inputRef = useRef(null); const userNames = useAuthorNames({ app, plugin, candidateState }); @@ -374,6 +378,7 @@ const NodeSearch = ({ return rankDiscourseNodesByTitle({ candidates: candidateState.candidates, query: debouncedQuery, + nodeTypeIds: selectedNodeTypeIds, }) .slice(0, MAX_VISIBLE_RESULTS) .map((result) => ({ @@ -383,7 +388,7 @@ const NodeSearch = ({ badge: getFallbackNodeTypeBadge(result.title), }, })); - }, [candidateState, debouncedQuery, nodeTypesById]); + }, [candidateState, debouncedQuery, nodeTypesById, selectedNodeTypeIds]); // A narrowing query rebuilds `results` before the effect below can reset the // state, so the old index can point past the new list for one render. Clamping @@ -428,6 +433,12 @@ const NodeSearch = ({ }); }; + const handleTypeFilterOpenChange = (nextOpen: boolean): void => { + setIsTypeFilterOpen(nextOpen); + // Returns the keyboard path to the results the moment the panel closes. + if (!nextOpen) inputRef.current?.focus(); + }; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { // Otherwise the caret jumps to the start or end of the query. @@ -458,14 +469,26 @@ const NodeSearch = ({ // Bound here rather than on the input so navigation survives focus moving // elsewhere in the modal, and so result actions have one place to live.
- setQuery(event.target.value)} - className="w-full" - /> + {/* Padded so the filter trigger's count badge, which sits outside the + button box, is not clipped by the modal's overflow-hidden content. */} +
+ setQuery(event.target.value)} + className="min-w-0 flex-1" + /> + +
{candidateState.status === "loading" && ( diff --git a/apps/obsidian/src/components/NodeTypeFilterMenu.tsx b/apps/obsidian/src/components/NodeTypeFilterMenu.tsx new file mode 100644 index 000000000..1c166ed41 --- /dev/null +++ b/apps/obsidian/src/components/NodeTypeFilterMenu.tsx @@ -0,0 +1,291 @@ +import { App, Scope, setIcon } from "obsidian"; +import { useEffect, useMemo, useRef, useState, type ReactElement } from "react"; +import { DiscourseNode } from "~/types"; +import { getAllDiscourseNodeColors } from "~/utils/colorUtils"; +import { + NODE_TYPE_FILTER_SEARCH_THRESHOLD, + filterNodeTypesByQuery, + fromPanelSelectedIds, + hasActiveTypeFilter, + toPanelSelectedIds, +} from "~/utils/discourseNodeTypeFilter"; + +const FilterIcon = ({ name }: { name: string }): ReactElement => ( + // Emptied first because React reuses the node across renders and `setIcon` + // appends rather than replaces. + { + if (!el) return; + el.empty(); + setIcon(el, name); + }} + /> +); + +const NodeTypeFilterRow = ({ + color, + isChecked, + nodeType, + onSelectOnly, + onToggle, +}: { + color: string | undefined; + isChecked: boolean; + nodeType: DiscourseNode; + onSelectOnly: () => void; + onToggle: () => void; +}): ReactElement => ( +
+ + +
+); + +const NodeTypeFilterPanel = ({ + isFilterActive, + nodeTypes, + onSelectedIdsChange, + selectedIds, +}: { + isFilterActive: boolean; + nodeTypes: DiscourseNode[]; + onSelectedIdsChange: (ids: string[]) => void; + selectedIds: string[]; +}): ReactElement => { + const [query, setQuery] = useState(""); + const searchRef = useRef(null); + + const showTypeSearch = nodeTypes.length > NODE_TYPE_FILTER_SEARCH_THRESHOLD; + + const colorsById = useMemo(() => { + const byId = new Map(); + getAllDiscourseNodeColors(nodeTypes).forEach(({ nodeType, colors }) => { + byId.set(nodeType.id, colors.backgroundColor); + }); + return byId; + }, [nodeTypes]); + + const filteredNodeTypes = useMemo( + () => filterNodeTypesByQuery(nodeTypes, query), + [nodeTypes, query], + ); + + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); + + useEffect(() => { + if (showTypeSearch) searchRef.current?.focus(); + }, [showTypeSearch]); + + const toggleType = (id: string): void => { + onSelectedIdsChange( + selectedIdSet.has(id) + ? selectedIds.filter((selectedId) => selectedId !== id) + : [...selectedIds, id], + ); + }; + + return ( +
+ {/* Clearing is the only thing this control ever does, so it says so and + appears only when there is a filter to clear. A "select all" checkbox + would sit checked-and-inert whenever no filter is active, since an empty + selection and a full one are the same state. */} + {isFilterActive && ( +
+ +
+ )} + {showTypeSearch && ( +
+ setQuery(event.target.value)} + className="w-full" + /> +
+ )} +
+ {filteredNodeTypes.length === 0 ? ( +
+ No matching node types +
+ ) : ( + filteredNodeTypes.map((nodeType) => ( + onSelectedIdsChange([nodeType.id])} + onToggle={() => toggleType(nodeType.id)} + /> + )) + )} +
+
+ ); +}; + +export const NodeTypeFilterMenu = ({ + app, + isOpen, + nodeTypes, + onOpenChange, + onSelectedNodeTypeIdsChange, + selectedNodeTypeIds, +}: { + app: App; + isOpen: boolean; + nodeTypes: DiscourseNode[]; + onOpenChange: (isOpen: boolean) => void; + onSelectedNodeTypeIdsChange: (ids: string[]) => void; + selectedNodeTypeIds: string[]; +}): ReactElement => { + const containerRef = useRef(null); + + const allTypeIds = useMemo( + () => nodeTypes.map((nodeType) => nodeType.id), + [nodeTypes], + ); + + const isFilterActive = hasActiveTypeFilter({ + selectedTypeIds: selectedNodeTypeIds, + allTypeIds, + }); + + const panelSelectedIds = useMemo( + () => + toPanelSelectedIds({ selectedTypeIds: selectedNodeTypeIds, allTypeIds }), + [allTypeIds, selectedNodeTypeIds], + ); + + // Escape cannot be intercepted from the DOM. Obsidian registers the Modal's + // close-on-Escape before any plugin React tree exists, so a listener added + // later always runs second — preventDefault plus stopImmediatePropagation in a + // React handler, a capture-phase window listener, and registering on the + // Modal's own scope all fail, the last because Scope resolves in registration + // order. A pushed scope is the only thing that lands above the modal. + useEffect(() => { + if (!isOpen) return; + const scope = new Scope(); + scope.register([], "Escape", () => { + onOpenChange(false); + return false; + }); + app.keymap.pushScope(scope); + return () => app.keymap.popScope(scope); + }, [app, isOpen, onOpenChange]); + + // `activeDocument` rather than `document`, so the listener lands in whichever + // window holds the modal when Obsidian is running a popout. + useEffect(() => { + if (!isOpen) return; + const handlePointerDown = (event: MouseEvent) => { + if (containerRef.current?.contains(event.target as Node)) return; + onOpenChange(false); + }; + activeDocument.addEventListener("mousedown", handlePointerDown, true); + return () => + activeDocument.removeEventListener("mousedown", handlePointerDown, true); + }, [isOpen, onOpenChange]); + + const activeFilterCount = isFilterActive ? selectedNodeTypeIds.length : 0; + + return ( +
{ + if (!isOpen) return; + // Every keystroke stops here while the panel is open. The modal's handler + // is an ancestor and reads Enter as "open the highlighted result" and the + // arrows as "move the selection", so typing in the type search would + // otherwise open a note and close the whole modal. Escape is not handled + // here because it never reaches the DOM — see the pushed scope above. + event.stopPropagation(); + }} + > + + {isOpen && ( + + onSelectedNodeTypeIdsChange( + fromPanelSelectedIds({ panelSelectedIds: panelIds, allTypeIds }), + ) + } + selectedIds={panelSelectedIds} + /> + )} +
+ ); +}; diff --git a/apps/obsidian/src/utils/discourseNodeTypeFilter.ts b/apps/obsidian/src/utils/discourseNodeTypeFilter.ts new file mode 100644 index 000000000..7a54f5006 --- /dev/null +++ b/apps/obsidian/src/utils/discourseNodeTypeFilter.ts @@ -0,0 +1,58 @@ +import { DiscourseNode } from "~/types"; + +/** + * Node type filtering for the search modal. An empty `selectedTypeIds` means no + * filter, matching `filterCandidatesByNodeTypeIds` in QueryEngine — so "nothing + * selected" and "every type selected" are the same state and both show all nodes. + * Ported from Roam's advanced search so both apps filter alike. + */ + +/** Type count above which the panel adds a search box; the modal is desktop-only. */ +export const NODE_TYPE_FILTER_SEARCH_THRESHOLD = 7; + +export const hasActiveTypeFilter = ({ + selectedTypeIds, + allTypeIds, +}: { + selectedTypeIds: string[]; + allTypeIds: string[]; +}): boolean => + selectedTypeIds.length > 0 && selectedTypeIds.length < allTypeIds.length; + +/** Shows no-filter as every row checked, so the panel is never an empty checklist. */ +export const toPanelSelectedIds = ({ + selectedTypeIds, + allTypeIds, +}: { + selectedTypeIds: string[]; + allTypeIds: string[]; +}): string[] => (selectedTypeIds.length === 0 ? allTypeIds : selectedTypeIds); + +/** Collapses both "all checked" and "none checked" back to no filter. */ +export const fromPanelSelectedIds = ({ + panelSelectedIds, + allTypeIds, +}: { + panelSelectedIds: string[]; + allTypeIds: string[]; +}): string[] => { + if ( + panelSelectedIds.length === 0 || + panelSelectedIds.length === allTypeIds.length + ) { + return []; + } + return panelSelectedIds; +}; + +export const filterNodeTypesByQuery = ( + nodeTypes: DiscourseNode[], + query: string, +): DiscourseNode[] => { + const trimmedQuery = query.trim().toLowerCase(); + if (!trimmedQuery) return nodeTypes; + + return nodeTypes.filter((nodeType) => + nodeType.name.toLowerCase().includes(trimmedQuery), + ); +};