From 1bcef45b5027d7432c77986da6b0e61d594f5bb0 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 19 Aug 2026 22:35:24 -0400 Subject: [PATCH 1/4] ENG-2111 Add keyboard-only node type filtering with tag chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chips and the query caret now share one field, so a keyboard-first user can narrow by type without reaching for the mouse. Typing offers the best prefix match as ghost text and Tab commits it to a chip; text left unconfirmed stays a plain keyword query, so filtering never happens by accident. Roam's advanced search is the design and behaviour reference. Obsidian has no tag-input component at all, so this is a build rather than a port: the filter semantics carry over, the BlueprintJS structure does not. - nodeTypeChipCompletion.ts holds the matching rules as pure functions, prefix only — the suggestion is drawn as a completion of what was typed, and a substring match has no suffix to render. - NodeTypeChipsSearchInput writes the raw selectedNodeTypeIds ENG-2110 owns, so chips and the filter dropdown are two views of one state. It skips that module's canonicalisation on purpose: collapsing a full selection to "no filter" would make a chip the user just added vanish. - Chips and the query are inline siblings in a block box, so they flow together left to right and top to bottom and a long query wraps beside the last chip rather than into a column of its own. That rules out an input or a textarea, both of which can only wrap inside their own box, so the query is an editable span. Its text is uncontrolled, since re-rendering it on every keystroke would move the caret. - The Tab hint is inline after the caret rather than an overlay, so it follows the text and wraps with it. It shows whenever Tab would commit, including once the query spells a type name in full and no suffix is left to ghost — Roam hides it there, precisely when the user is about to press it. - Arrow, Enter and Escape are left to bubble to NodeSearch, which already navigates and opens results. Roam forwards them through a prop instead. Enter's default is suppressed here so the editable gains no line break: the modal deliberately leaves modified Enter unhandled for the insert and dock actions. - Backspace on an empty field highlights the last chip and only removes it on a second press, so a stray keystroke cannot silently drop a filter. - Styling is Tailwind throughout and adds no CSS. Two rules this build makes non-obvious: `border-solid` is required because `@tailwind base` is omitted, so nothing sets a default border style and `border` alone renders nothing; and `ring-*` is inert for the same reason, so the focused chip takes an outline. The chip's remove button carries `clickable-icon` to dodge Obsidian's `button:not(.clickable-icon)` rule, which outranks a utility class and would otherwise paint a box behind the ×. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchModal.tsx | 21 +- .../components/NodeTypeChipsSearchInput.tsx | 328 ++++++++++++++++++ apps/obsidian/src/utils/keyboardHints.ts | 4 +- .../src/utils/nodeTypeChipCompletion.ts | 45 +++ 4 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 apps/obsidian/src/components/NodeTypeChipsSearchInput.tsx create mode 100644 apps/obsidian/src/utils/nodeTypeChipCompletion.ts diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 7f3064670..4342f632c 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 { NodeTypeChipsSearchInput } from "~/components/NodeTypeChipsSearchInput"; import { NodeTypeFilterMenu } from "~/components/NodeTypeFilterMenu"; import { openFileInNewLeaf, @@ -332,7 +333,8 @@ const NodeSearch = ({ // 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); + // An editable span, so the query shares its line boxes with the filter chips. + const inputRef = useRef(null); const userNames = useAuthorNames({ app, plugin, candidateState }); const nodeTypesById = useMemo(() => { @@ -471,14 +473,15 @@ const NodeSearch = ({
{/* 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" + {/* Top-aligned: the field grows downwards, so the trigger stays on its first line. */} +
+ + event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey; + +// An editable span has no selectionStart, so the caret comes from the selection. +const isCaretAtStart = (field: HTMLElement | null): boolean => { + const selection = field?.ownerDocument.getSelection(); + if (!field || !selection?.isCollapsed || !selection.anchorNode) return false; + if (!field.contains(selection.anchorNode)) return false; + return selection.anchorOffset === 0; +}; + +const setCaretToEnd = (field: HTMLElement): void => { + const selection = field.ownerDocument.getSelection(); + if (!selection) return; + const range = field.ownerDocument.createRange(); + range.selectNodeContents(field); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +}; + +const NodeTypeChipTag = ({ + chip, + isFocused, + onFocusChip, + onKeyDown, + onRemove, + registerRef, +}: { + chip: NodeTypeChip; + isFocused: boolean; + onFocusChip: () => void; + onKeyDown: (event: KeyboardEvent) => void; + onRemove: () => void; + registerRef: (element: HTMLSpanElement | null) => void; +}): ReactElement => ( + // Inline-flex, so a chip shares its line box with the query and wraps alongside it. + // Out of the tab order: Tab is the commit key, so arrows and Backspace reach chips. + + + {chip.name} + + {/* `clickable-icon`, because Obsidian's `button:not(.clickable-icon)` rule outranks a utility class and would paint its own box behind the ×. */} + + +); + +/** Chips and the query caret in one field; `NodeSearch` owns the state and, as an ancestor, already handles the arrows, Enter and Escape that bubble out of here. */ +export const NodeTypeChipsSearchInput = ({ + inputRef, + nodeTypes, + onQueryChange, + onSelectedNodeTypeIdsChange, + query, + selectedNodeTypeIds, +}: { + inputRef: RefObject; + nodeTypes: DiscourseNode[]; + onQueryChange: (query: string) => void; + onSelectedNodeTypeIdsChange: (ids: string[]) => void; + query: string; + selectedNodeTypeIds: string[]; +}): ReactElement => { + const [focusedChipIndex, setFocusedChipIndex] = useState(NO_FOCUSED_CHIP); + const chipRefs = useRef<(HTMLSpanElement | null)[]>([]); + + const chipsById = useMemo(() => { + const byId = new Map(); + getAllDiscourseNodeColors(nodeTypes).forEach(({ nodeType, colors }) => { + byId.set(nodeType.id, { + backgroundColor: colors.backgroundColor, + id: nodeType.id, + name: nodeType.name, + textColor: colors.textColor, + }); + }); + return byId; + }, [nodeTypes]); + + const chips = useMemo( + () => selectedNodeTypeIds.flatMap((id) => chipsById.get(id) ?? []), + [chipsById, selectedNodeTypeIds], + ); + + const bestPrefixMatch = getBestPrefixMatch({ + nodeTypes, + query, + selectedTypeIds: selectedNodeTypeIds, + }); + + const completionSuffix = getCompletionSuffix({ bestPrefixMatch, query }); + + /** + * The query text is uncontrolled: React owns the chips but never the editable's + * content, so re-rendering on each keystroke cannot move the caret. Programmatic + * changes therefore have to write the text themselves. + */ + const writeQuery = (value: string): void => { + const field = inputRef.current; + if (field) field.textContent = value; + onQueryChange(value); + }; + + const focusQuery = (): void => { + setFocusedChipIndex(NO_FOCUSED_CHIP); + const field = inputRef.current; + if (!field) return; + field.focus(); + setCaretToEnd(field); + }; + + useEffect(() => { + if (focusedChipIndex === NO_FOCUSED_CHIP) return; + if (focusedChipIndex < selectedNodeTypeIds.length) { + chipRefs.current[focusedChipIndex]?.focus(); + return; + } + // The dropdown can clear the filter mid-focus, stranding focus on a removed node. + setFocusedChipIndex(NO_FOCUSED_CHIP); + inputRef.current?.focus(); + }, [focusedChipIndex, inputRef, selectedNodeTypeIds]); + + const commitNodeType = (nodeType: DiscourseNode): void => { + if (selectedNodeTypeIds.includes(nodeType.id)) return; + // Raw, not canonicalised: collapsing a full selection would vanish the new chip. + onSelectedNodeTypeIdsChange([...selectedNodeTypeIds, nodeType.id]); + writeQuery(""); + }; + + const removeChipAt = (chipIndex: number): string[] => { + const nextIds = selectedNodeTypeIds.filter( + (_, index) => index !== chipIndex, + ); + onSelectedNodeTypeIdsChange(nextIds); + return nextIds; + }; + + const handleChipKeyDown = ( + event: KeyboardEvent, + chipIndex: number, + ): void => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + setFocusedChipIndex(Math.max(0, chipIndex - 1)); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + if (chipIndex >= selectedNodeTypeIds.length - 1) { + focusQuery(); + return; + } + setFocusedChipIndex(chipIndex + 1); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + const nextIds = removeChipAt(chipIndex); + if (!nextIds.length) { + focusQuery(); + return; + } + // Backspace walks left, Delete takes the chip that closed the gap: no focus jump. + const nextIndex = + event.key === "Backspace" + ? chipIndex - 1 + : Math.min(chipIndex, nextIds.length - 1); + if (nextIndex < 0) { + focusQuery(); + return; + } + setFocusedChipIndex(nextIndex); + return; + } + + // Typing with a chip focused is a return to the query, not a lost keystroke. + if (isPlainCharacterKey(event)) { + event.preventDefault(); + writeQuery(event.key); + focusQuery(); + } + }; + + const handleQueryKeyDown = (event: KeyboardEvent): void => { + // Suppress the line break only; Enter still bubbles, and modified Enter reaches nothing else. + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault(); + } + + if (event.key === "Tab") { + // With nothing pending, Tab is left alone so it still reaches the footer actions. + if (!bestPrefixMatch) return; + event.preventDefault(); + commitNodeType(bestPrefixMatch); + return; + } + + if (!selectedNodeTypeIds.length) return; + if (!isCaretAtStart(inputRef.current)) return; + + // Highlight first, so an over-eager Backspace cannot silently drop a filter. + if (event.key === "Backspace" && !query.length) { + event.preventDefault(); + setFocusedChipIndex(selectedNodeTypeIds.length - 1); + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + setFocusedChipIndex(selectedNodeTypeIds.length - 1); + } + }; + + return ( +
{ + if (focusedChipIndex === NO_FOCUSED_CHIP) focusQuery(); + }} + > + {chips.map((chip, index) => ( + setFocusedChipIndex(index)} + onKeyDown={(event) => handleChipKeyDown(event, index)} + onRemove={() => { + removeChipAt(index); + focusQuery(); + }} + registerRef={(element) => { + chipRefs.current[index] = element; + }} + /> + ))} + {/* `plaintext-only` so a pasted selection cannot bring markup in with it. */} + + onQueryChange(event.currentTarget.textContent ?? "") + } + onKeyDown={handleQueryKeyDown} + className="dg-search-chip-input whitespace-pre-wrap break-words align-middle outline-none" + /> + {/* Inline rather than an overlay, so it follows the caret and wraps with the text. */} + {!!bestPrefixMatch && ( + + {completionSuffix} + + {getHintKeys(["Tab"])[0]} + + + )} + {!query && !chips.length && ( + + {QUERY_PLACEHOLDER} + + )} +
+ ); +}; diff --git a/apps/obsidian/src/utils/keyboardHints.ts b/apps/obsidian/src/utils/keyboardHints.ts index a14586ab3..ec38800a8 100644 --- a/apps/obsidian/src/utils/keyboardHints.ts +++ b/apps/obsidian/src/utils/keyboardHints.ts @@ -1,6 +1,6 @@ import { Platform } from "obsidian"; -export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape"; +export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape" | "Tab"; // Obsidian shows glyphs on macOS and spelled-out words everywhere else. const MAC_SYMBOLS: Record = { @@ -9,6 +9,7 @@ const MAC_SYMBOLS: Record = { Shift: "⇧", Enter: "↵", Escape: "esc", + Tab: "⇥", }; const NON_MAC_SYMBOLS: Record = { @@ -17,6 +18,7 @@ const NON_MAC_SYMBOLS: Record = { Shift: "Shift", Enter: "Enter", Escape: "Esc", + Tab: "Tab", }; /** Takes `isMacOS` so the non-mac branch can be checked without that platform. */ diff --git a/apps/obsidian/src/utils/nodeTypeChipCompletion.ts b/apps/obsidian/src/utils/nodeTypeChipCompletion.ts new file mode 100644 index 000000000..1d0151a00 --- /dev/null +++ b/apps/obsidian/src/utils/nodeTypeChipCompletion.ts @@ -0,0 +1,45 @@ +import { DiscourseNode } from "~/types"; + +/** Ghost completion for the search modal's chips; prefix-only, as a substring match has no suffix to draw. */ + +/** Exact match beats the first partial, so a name that prefixes another stays reachable. */ +export const getBestPrefixMatch = ({ + nodeTypes, + query, + selectedTypeIds, +}: { + nodeTypes: DiscourseNode[]; + query: string; + selectedTypeIds: string[]; +}): DiscourseNode | null => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return null; + + const selectedTypeIdSet = new Set(selectedTypeIds); + const matches = nodeTypes.filter( + (nodeType) => + !selectedTypeIdSet.has(nodeType.id) && + nodeType.name.toLowerCase().startsWith(normalizedQuery), + ); + if (!matches.length) return null; + + const exactMatch = matches.find( + (nodeType) => nodeType.name.toLowerCase() === normalizedQuery, + ); + return exactMatch ?? matches[0] ?? null; +}; + +/** What is left to type; empty once the name is fully spelled, though still committable. */ +export const getCompletionSuffix = ({ + bestPrefixMatch, + query, +}: { + bestPrefixMatch: DiscourseNode | null; + query: string; +}): string => { + if (!bestPrefixMatch) return ""; + const trimmedQuery = query.trim(); + const { name } = bestPrefixMatch; + if (name.toLowerCase() === trimmedQuery.toLowerCase()) return ""; + return name.slice(trimmedQuery.length); +}; From 3eff78c6027ca1ef327671631cd63f9db64412b6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 19 Aug 2026 23:02:04 -0400 Subject: [PATCH 2/4] ENG-2111 Address review: remove chips by id, drop the inline max-width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chip removal and keyboard bounds used positions in `selectedNodeTypeIds` while the index came from `chips`, the rendered list. The two cannot diverge today — the modal reads settings once with no subscription — but the assumption was load-bearing for nothing, so removal is by id and navigation is bounded by what is actually on screen. - `max-w-40` replaces the inline max-width, per apps/obsidian/AGENTS.md:100. The chip's colours stay inline: they are computed per type. Co-Authored-By: Claude Opus 5 --- .../components/NodeTypeChipsSearchInput.tsx | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/obsidian/src/components/NodeTypeChipsSearchInput.tsx b/apps/obsidian/src/components/NodeTypeChipsSearchInput.tsx index 454cfe1ca..6abe80494 100644 --- a/apps/obsidian/src/components/NodeTypeChipsSearchInput.tsx +++ b/apps/obsidian/src/components/NodeTypeChipsSearchInput.tsx @@ -18,7 +18,6 @@ import { /** `-1` means the caret is in the query rather than on a chip. */ const NO_FOCUSED_CHIP = -1; -const CHIP_LABEL_MAX_WIDTH = "10rem"; const QUERY_PLACEHOLDER = "Search discourse nodes by title"; type NodeTypeChip = { @@ -80,9 +79,7 @@ const NodeTypeChipTag = ({ isFocused ? "outline-accent outline outline-2 outline-offset-1" : "" }`} > - - {chip.name} - + {chip.name} {/* `clickable-icon`, because Obsidian's `button:not(.clickable-icon)` rule outranks a utility class and would paint its own box behind the ×. */}