diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 7f3064670..8ac390391 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,7 @@ 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); + const inputRef = useRef(null); const userNames = useAuthorNames({ app, plugin, candidateState }); const nodeTypesById = useMemo(() => { @@ -471,14 +472,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 it shares the query's line box; out of the tab order because Tab commits. + + {chip.name} + {/* `clickable-icon`, because Obsidian's `button:not(.clickable-icon)` rule outranks a utility class and would paint its own box behind the ×. */} + + +); + +/** `NodeSearch` owns the filter 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 }); + + // Uncontrolled, so re-rendering cannot move the caret — hence writing the text by hand. + 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 < chips.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(); + }, [chips, focusedChipIndex, inputRef]); + + 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 removeChip = (chipId: string): void => { + onSelectedNodeTypeIdsChange( + selectedNodeTypeIds.filter((id) => id !== chipId), + ); + }; + + const handleChipKeyDown = ( + event: KeyboardEvent, + chipIndex: number, + chipId: string, + ): void => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + setFocusedChipIndex(Math.max(0, chipIndex - 1)); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + if (chipIndex >= chips.length - 1) { + focusQuery(); + return; + } + setFocusedChipIndex(chipIndex + 1); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + removeChip(chipId); + const remaining = chips.length - 1; + if (!remaining) { + 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, remaining - 1); + if (nextIndex < 0) { + focusQuery(); + return; + } + setFocusedChipIndex(nextIndex); + return; + } + + 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 (!chips.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(chips.length - 1); + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + setFocusedChipIndex(chips.length - 1); + } + }; + + return ( +
{ + if (event.target === event.currentTarget) focusQuery(); + }} + > + {chips.map((chip, index) => ( + setFocusedChipIndex(index)} + onKeyDown={(event) => handleChipKeyDown(event, index, chip.id)} + onRemove={() => { + removeChip(chip.id); + 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" + /> + {!!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..97ab61ade --- /dev/null +++ b/apps/obsidian/src/utils/nodeTypeChipCompletion.ts @@ -0,0 +1,44 @@ +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; +}; + +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); +};