From 654a2e3d5ef4a85b084c90227068a24fff375820 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 19 Aug 2026 13:29:32 -0400 Subject: [PATCH 1/5] ENG-2110 Add node type filter dropdown menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a type filter beside the node search input. Roam's advanced search is the design and behaviour reference; its pure filter semantics are ported, its BlueprintJS structure is not. The search seam already supported this — rankDiscourseNodesByTitle takes nodeTypeIds and filters before scoring — so this is UI and state only. - discourseNodeTypeFilter.ts ports Roam's semantics, including the canonicalisation that makes "none selected" and "all selected" both mean no filter, matching filterCandidatesByNodeTypeIds. - NodeTypeFilterMenu renders an Obsidian-native trigger (clickable-icon + setIcon) with a count badge, over a panel with checkbox rows, colour dots, per-row Only, Select all with indeterminate state, and a type search past 7 types. - selectedNodeTypeIds lives in NodeSearch as the single source of truth so ENG-2111's chips can share it. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchModal.tsx | 58 +++- .../src/components/NodeTypeFilterMenu.tsx | 292 ++++++++++++++++++ .../src/utils/discourseNodeTypeFilter.ts | 78 +++++ 3 files changed, 419 insertions(+), 9 deletions(-) create mode 100644 apps/obsidian/src/components/NodeTypeFilterMenu.tsx create mode 100644 apps/obsidian/src/utils/discourseNodeTypeFilter.ts diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index da00bfbf1..d6130291a 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,10 @@ const NodeSearch = ({ const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); + // The single source of truth for active type filters: F6's chips will read and + // write this same state, so either surface can manage them. + const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState([]); + const [isTypeFilterOpen, setIsTypeFilterOpen] = useState(false); const inputRef = useRef(null); const userNames = useAuthorNames({ app, plugin, candidateState }); @@ -374,6 +379,7 @@ const NodeSearch = ({ return rankDiscourseNodesByTitle({ candidates: candidateState.candidates, query: debouncedQuery, + nodeTypeIds: selectedNodeTypeIds, }) .slice(0, MAX_VISIBLE_RESULTS) .map((result) => ({ @@ -383,7 +389,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,7 +434,30 @@ const NodeSearch = ({ }); }; + // Focus stays in the search input while the panel is open, so Escape arrives + // here rather than at the panel. Obsidian's Modal closes on Escape from its own + // keymap scope, so the native event has to stop or the modal goes too. + const closeTypeFilterOnEscape = ( + event: KeyboardEvent, + ): void => { + event.preventDefault(); + event.nativeEvent.stopImmediatePropagation(); + setIsTypeFilterOpen(false); + inputRef.current?.focus(); + }; + + 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 === "Escape" && isTypeFilterOpen) { + closeTypeFilterOnEscape(event); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { // Otherwise the caret jumps to the start or end of the query. event.preventDefault(); @@ -458,14 +487,25 @@ 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..a0171537e --- /dev/null +++ b/apps/obsidian/src/components/NodeTypeFilterMenu.tsx @@ -0,0 +1,292 @@ +import { 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, + getSelectAllCheckState, + 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 = ({ + nodeTypes, + onSelectedIdsChange, + selectedIds, +}: { + 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]); + + const selectAllState = getSelectAllCheckState({ + selectedIds, + totalCount: nodeTypes.length, + }); + + useEffect(() => { + if (showTypeSearch) searchRef.current?.focus(); + }, [showTypeSearch]); + + const handleSelectAll = (): void => { + if (selectAllState === "off") { + onSelectedIdsChange(nodeTypes.map((nodeType) => nodeType.id)); + return; + } + onSelectedIdsChange([]); + }; + + const toggleType = (id: string): void => { + onSelectedIdsChange( + selectedIdSet.has(id) + ? selectedIds.filter((selectedId) => selectedId !== id) + : [...selectedIds, id], + ); + }; + + const hasTypeSearchQuery = query.trim().length > 0; + + return ( +
+ {showTypeSearch && ( +
+ setQuery(event.target.value)} + className="w-full" + /> +
+ )} +
+ {filteredNodeTypes.length === 0 ? ( +
+ No matching node types +
+ ) : ( + <> + {/* A partial list has no "all" to speak of, so the row is dropped + while searching rather than acting on the hidden types too. */} + {!hasTypeSearchQuery && ( + + )} + {filteredNodeTypes.map((nodeType) => ( + onSelectedIdsChange([nodeType.id])} + onToggle={() => toggleType(nodeType.id)} + /> + ))} + + )} +
+
+ ); +}; + +export const NodeTypeFilterMenu = ({ + isOpen, + nodeTypes, + onOpenChange, + onSelectedNodeTypeIdsChange, + selectedNodeTypeIds, +}: { + 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], + ); + + // `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 (event.key !== "Escape" || !isOpen) return; + // Obsidian's Modal closes on Escape from its own keymap scope, so the + // native event has to stop here or the whole modal goes with the panel. + event.preventDefault(); + event.nativeEvent.stopImmediatePropagation(); + // The modal's own Escape handler is an ancestor of this one; stopping the + // synthetic event too keeps it from closing the panel a second time. + event.stopPropagation(); + onOpenChange(false); + }} + > + + {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..c9a4df53e --- /dev/null +++ b/apps/obsidian/src/utils/discourseNodeTypeFilter.ts @@ -0,0 +1,78 @@ +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. + */ + +/** Below this many types the list is short enough to scan without a search box. */ +export const NODE_TYPE_FILTER_SEARCH_THRESHOLD = 7; + +export type SelectAllCheckState = "off" | "indeterminate" | "on"; + +export const hasActiveTypeFilter = ({ + selectedTypeIds, + allTypeIds, +}: { + selectedTypeIds: string[]; + allTypeIds: string[]; +}): boolean => + selectedTypeIds.length > 0 && selectedTypeIds.length < allTypeIds.length; + +/** + * The stored empty set means "no filter", which the panel shows as every row + * checked — otherwise an unfiltered search would render as 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 the empty set, so the + * count badge and the search agree that neither is a 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), + ); +}; + +export const getSelectAllCheckState = ({ + selectedIds, + totalCount, +}: { + selectedIds: string[]; + totalCount: number; +}): SelectAllCheckState => { + if (selectedIds.length === 0) return "off"; + if (selectedIds.length === totalCount) return "on"; + return "indeterminate"; +}; From a5f9b71d87b6d242149727498bec216628603514 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 19 Aug 2026 17:15:35 -0400 Subject: [PATCH 2/5] ENG-2110 Address review: contain panel keystrokes, swap Select all for Clear filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel keystrokes no longer reach the modal's key handler. Only Escape was stopped before, so Enter typed in the type search ran the modal's "open the highlighted result" branch — closing the modal and opening an unrelated note — and the arrows moved the result selection. Replaces the "Select all" checkbox with a "Clear filter (n)" button shown only while a filter is active. Because an empty selection and a full one are the same state, the checkbox sat checked and inert whenever nothing was filtered, so clicking it appeared to do nothing. Clearing is the control's only real function, so it now says that. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeTypeFilterMenu.tsx | 92 ++++++++----------- 1 file changed, 39 insertions(+), 53 deletions(-) diff --git a/apps/obsidian/src/components/NodeTypeFilterMenu.tsx b/apps/obsidian/src/components/NodeTypeFilterMenu.tsx index a0171537e..d53ba9274 100644 --- a/apps/obsidian/src/components/NodeTypeFilterMenu.tsx +++ b/apps/obsidian/src/components/NodeTypeFilterMenu.tsx @@ -6,7 +6,6 @@ import { NODE_TYPE_FILTER_SEARCH_THRESHOLD, filterNodeTypesByQuery, fromPanelSelectedIds, - getSelectAllCheckState, hasActiveTypeFilter, toPanelSelectedIds, } from "~/utils/discourseNodeTypeFilter"; @@ -70,10 +69,12 @@ const NodeTypeFilterRow = ({ ); const NodeTypeFilterPanel = ({ + isFilterActive, nodeTypes, onSelectedIdsChange, selectedIds, }: { + isFilterActive: boolean; nodeTypes: DiscourseNode[]; onSelectedIdsChange: (ids: string[]) => void; selectedIds: string[]; @@ -98,23 +99,10 @@ const NodeTypeFilterPanel = ({ const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); - const selectAllState = getSelectAllCheckState({ - selectedIds, - totalCount: nodeTypes.length, - }); - useEffect(() => { if (showTypeSearch) searchRef.current?.focus(); }, [showTypeSearch]); - const handleSelectAll = (): void => { - if (selectAllState === "off") { - onSelectedIdsChange(nodeTypes.map((nodeType) => nodeType.id)); - return; - } - onSelectedIdsChange([]); - }; - const toggleType = (id: string): void => { onSelectedIdsChange( selectedIdSet.has(id) @@ -123,10 +111,24 @@ const NodeTypeFilterPanel = ({ ); }; - const hasTypeSearchQuery = query.trim().length > 0; - 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 && (
) : ( - <> - {/* A partial list has no "all" to speak of, so the row is dropped - while searching rather than acting on the hidden types too. */} - {!hasTypeSearchQuery && ( - - )} - {filteredNodeTypes.map((nodeType) => ( - onSelectedIdsChange([nodeType.id])} - onToggle={() => toggleType(nodeType.id)} - /> - ))} - + filteredNodeTypes.map((nodeType) => ( + onSelectedIdsChange([nodeType.id])} + onToggle={() => toggleType(nodeType.id)} + /> + )) )}
@@ -233,14 +214,18 @@ export const NodeTypeFilterMenu = ({ ref={containerRef} className="relative shrink-0" onKeyDown={(event) => { - if (event.key !== "Escape" || !isOpen) return; - // Obsidian's Modal closes on Escape from its own keymap scope, so the - // native event has to stop here or the whole modal goes with the panel. + 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. + event.stopPropagation(); + if (event.key !== "Escape") return; + // Obsidian's Modal closes on Escape from its own keymap scope, which sits + // outside React, so the native event has to stop too or the modal goes + // with the panel. event.preventDefault(); event.nativeEvent.stopImmediatePropagation(); - // The modal's own Escape handler is an ancestor of this one; stopping the - // synthetic event too keeps it from closing the panel a second time. - event.stopPropagation(); onOpenChange(false); }} > @@ -278,6 +263,7 @@ export const NodeTypeFilterMenu = ({ {isOpen && ( onSelectedNodeTypeIdsChange( From 66d9a3a5222c5f28588a773714486a9230d756a9 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 19 Aug 2026 22:34:16 -0400 Subject: [PATCH 3/5] ENG-2110 Drop the unused select-all check state helper getSelectAllCheckState and SelectAllCheckState lost their only consumer when the Select all checkbox became the Clear filter button. Roam keeps its own copy, which its tri-state checkbox still uses. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/discourseNodeTypeFilter.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/apps/obsidian/src/utils/discourseNodeTypeFilter.ts b/apps/obsidian/src/utils/discourseNodeTypeFilter.ts index c9a4df53e..07c9a7be9 100644 --- a/apps/obsidian/src/utils/discourseNodeTypeFilter.ts +++ b/apps/obsidian/src/utils/discourseNodeTypeFilter.ts @@ -10,8 +10,6 @@ import { DiscourseNode } from "~/types"; /** Below this many types the list is short enough to scan without a search box. */ export const NODE_TYPE_FILTER_SEARCH_THRESHOLD = 7; -export type SelectAllCheckState = "off" | "indeterminate" | "on"; - export const hasActiveTypeFilter = ({ selectedTypeIds, allTypeIds, @@ -64,15 +62,3 @@ export const filterNodeTypesByQuery = ( nodeType.name.toLowerCase().includes(trimmedQuery), ); }; - -export const getSelectAllCheckState = ({ - selectedIds, - totalCount, -}: { - selectedIds: string[]; - totalCount: number; -}): SelectAllCheckState => { - if (selectedIds.length === 0) return "off"; - if (selectedIds.length === totalCount) return "on"; - return "indeterminate"; -}; From 5360a8d7d4b8fd544369a9bcb458a46e8b14ab26 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 19 Aug 2026 22:43:07 -0400 Subject: [PATCH 4/5] ENG-2110 Close the filter panel on Escape via a pushed keymap scope 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 does not work, nor does a capture-phase window listener, nor registering on the Modal's own scope, since Scope resolves in registration order. The previous DOM attempt here closed the whole modal instead of the panel. Pushing a Scope while the panel is open lands above the modal in the stack, so the panel gets Escape first. The dead DOM handlers in both the panel and the modal are gone with it. Diagnosis credit to the SearchDropdown work on ENG-2112 (#1320), which fixes the same bug for the sort dropdown. Migrating this component onto that shared shell would drop ~120 duplicated lines and is best done once both land, since SearchDropdown does not exist on this branch. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchModal.tsx | 18 +--------- .../src/components/NodeTypeFilterMenu.tsx | 33 +++++++++++++------ 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index d6130291a..5047fcbc3 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -434,18 +434,6 @@ const NodeSearch = ({ }); }; - // Focus stays in the search input while the panel is open, so Escape arrives - // here rather than at the panel. Obsidian's Modal closes on Escape from its own - // keymap scope, so the native event has to stop or the modal goes too. - const closeTypeFilterOnEscape = ( - event: KeyboardEvent, - ): void => { - event.preventDefault(); - event.nativeEvent.stopImmediatePropagation(); - setIsTypeFilterOpen(false); - inputRef.current?.focus(); - }; - const handleTypeFilterOpenChange = (nextOpen: boolean): void => { setIsTypeFilterOpen(nextOpen); // Returns the keyboard path to the results the moment the panel closes. @@ -453,11 +441,6 @@ const NodeSearch = ({ }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && isTypeFilterOpen) { - closeTypeFilterOnEscape(event); - return; - } - if (event.key === "ArrowDown" || event.key === "ArrowUp") { // Otherwise the caret jumps to the start or end of the query. event.preventDefault(); @@ -499,6 +482,7 @@ const NodeSearch = ({ className="min-w-0 flex-1" /> void; @@ -194,6 +196,23 @@ export const NodeTypeFilterMenu = ({ [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(() => { @@ -218,15 +237,9 @@ export const NodeTypeFilterMenu = ({ // 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. + // 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(); - if (event.key !== "Escape") return; - // Obsidian's Modal closes on Escape from its own keymap scope, which sits - // outside React, so the native event has to stop too or the modal goes - // with the panel. - event.preventDefault(); - event.nativeEvent.stopImmediatePropagation(); - onOpenChange(false); }} >