diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx new file mode 100644 index 000000000..2778c840f --- /dev/null +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -0,0 +1,74 @@ +import { type ReactElement } from "react"; +import { getHintKeys, type HintKey } from "~/utils/keyboardHints"; + +type NodeSearchFooterProps = { + canAct: boolean; + onClose: () => void; + onOpenInNewTab: () => void; + onOpenInSplit: () => void; +}; + +type FooterActionProps = { + disabled?: boolean; + keys: HintKey[]; + label: string; + onClick: () => void; +}; + +const KeyHints = ({ keys }: { keys: HintKey[] }): ReactElement => ( + <> + {getHintKeys(keys).map((symbol) => ( + + {symbol} + + ))} + +); + +const FooterAction = ({ + disabled = false, + keys, + label, + onClick, +}: FooterActionProps): ReactElement => ( + +); + +// Sits in Obsidian's `prompt-instructions` container for its type and spacing. +// Obsidian centres that row for the narrow quick switcher; this footer spans a +// full-width result list, so the actions start at its left edge instead. +export const NodeSearchFooter = ({ + canAct, + onClose, + onOpenInNewTab, + onOpenInSplit, +}: NodeSearchFooterProps): ReactElement => ( +
+ + + {/* The Escape key itself is handled by Obsidian's modal scope; this button + is the pointer equivalent, so every footer item responds to a click. */} + +
+); diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx new file mode 100644 index 000000000..f71142cbb --- /dev/null +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -0,0 +1,558 @@ +import { + App, + Component, + MarkdownRenderer, + Modal, + Notice, + renderResults, + TFile, + type SearchResult, +} from "obsidian"; +import { + StrictMode, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, + type ReactElement, +} from "react"; +import { createRoot, Root } from "react-dom/client"; +import type DiscourseGraphPlugin from "~/index"; +import { NodeSearchFooter } from "~/components/NodeSearchFooter"; +import { NodeSortMenu } from "~/components/NodeSortMenu"; +import { NodeTypeChipsSearchInput } from "~/components/NodeTypeChipsSearchInput"; +import { NodeTypeFilterMenu } from "~/components/NodeTypeFilterMenu"; +import type { SearchDropdownId } from "~/components/SearchDropdown"; +import { + openFileInNewLeaf, + openFileInNewTab, +} from "~/components/canvas/utils/openFileUtils"; +import { + QueryEngine, + rankDiscourseNodesByTitle, + type DiscourseNodeCandidate, + type RankedDiscourseNode, +} from "~/services/QueryEngine"; +import { + getNodeTypeBadge, + getFallbackNodeTypeBadge, + type NodeTypeBadge, +} from "~/utils/nodeTypeBadge"; +import { + buildAuthorNameByPath, + resolveAuthorName, + useAuthorNames, +} from "~/utils/discourseNodeAuthor"; +import { + DEFAULT_SORT_DIRECTION, + DEFAULT_SORT_KEY, + sortSearchResults, + type SortDirection, + type SortKey, +} from "~/utils/discourseNodeSort"; + +const MAX_VISIBLE_RESULTS = 50; +const SEARCH_DEBOUNCE_MS = 250; + +type CandidateState = + | { status: "loading" } + | { status: "ready"; candidates: DiscourseNodeCandidate[] } + | { status: "error"; message: string }; + +type NodeTypeDisplay = { + name: string; + /** Null when neither the config nor the title says what type this is. */ + badge: NodeTypeBadge | null; +}; + +type SearchResultRow = RankedDiscourseNode & { + nodeType: NodeTypeDisplay; +}; + +const formatTimestamp = (epochMs: number): string => + new Date(epochMs).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + +const PreviewPane = ({ + app, + result, + authorName, +}: { + app: App; + result: SearchResultRow | undefined; + authorName: string; +}): ReactElement => { + const containerRef = useRef(null); + // Paired with its file so an in-flight read can't put one note's body under + // another note's title. + const [loaded, setLoaded] = useState<{ file: TFile; text: string } | null>( + null, + ); + + const file = result?.file; + + useEffect(() => { + if (!file) { + setLoaded(null); + return; + } + let cancelled = false; + void app.vault.cachedRead(file).then((text) => { + if (!cancelled) setLoaded({ file, text }); + }); + return () => { + cancelled = true; + }; + }, [app, file]); + + useEffect(() => { + const container = containerRef.current; + if (!container || !file || loaded?.file !== file) return; + + container.empty(); + const component = new Component(); + void MarkdownRenderer.render( + app, + loaded.text.trim() || "This note is empty.", + container, + file.path, + component, + ); + + return () => { + component.unload(); + container.empty(); + }; + }, [app, file, loaded]); + + if (!result || !file) { + return ( +
+ Select a result to preview it. +
+ ); + } + + return ( +
+
+
{result.title}
+
+ {`Created ${formatTimestamp(file.stat.ctime)} · Modified ${formatTimestamp( + file.stat.mtime, + )} · ${authorName}`} +
+
+
+
+ ); +}; + +const HighlightedTitle = ({ + title, + match, +}: { + title: string; + match: SearchResult; +}): ReactElement => { + const titleRef = useRef(null); + + useEffect(() => { + const container = titleRef.current; + if (!container) return; + container.empty(); + renderResults(container, title, match); + return () => container.empty(); + }, [title, match]); + + return ( +
+ ); +}; + +const ResultList = ({ + results, + activeIndex, + onActivate, +}: { + results: SearchResultRow[]; + activeIndex: number; + onActivate: (index: number) => void; +}): ReactElement => { + const listRef = useRef(null); + const pointerPositionRef = useRef<{ x: number; y: number } | null>(null); + + useEffect(() => { + const active = listRef.current?.children[activeIndex]; + active?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + // Scrolling drags rows under a stationary cursor, and the mouseenter that + // fires is not a choice. Compare coordinates rather than resetting a flag on + // every activation, so hovering from row to row still counts as a choice. + const hasPointerMoved = (event: MouseEvent): boolean => { + const previous = pointerPositionRef.current; + pointerPositionRef.current = { x: event.clientX, y: event.clientY }; + return ( + previous === null || + previous.x !== event.clientX || + previous.y !== event.clientY + ); + }; + + return ( + // No `aria-label` here: Obsidian renders one as a hover tooltip, which + // covers the results the moment the pointer enters the list. +
{ + hasPointerMoved(event); + }} + className="flex-1 overflow-y-auto" + > + {results.map((result, index) => ( +
hasPointerMoved(event) && onActivate(index)} + onClick={() => onActivate(index)} + // Keeps focus in the search input, so the keyboard path stays live + // after a click. + onMouseDown={(event) => event.preventDefault()} + className={`border-modifier-border flex cursor-pointer items-center gap-2 border-b px-3 py-2 ${ + index === activeIndex ? "bg-modifier-hover" : "" + }`} + > + {result.nodeType.badge && ( + + {result.nodeType.badge.text} + + )} + +
+ ))} +
+ ); +}; + +const NodeSearch = ({ + plugin, + onClose, +}: { + plugin: DiscourseGraphPlugin; + onClose: () => void; +}): ReactElement => { + const { app } = plugin; + const [candidateState, setCandidateState] = useState({ + status: "loading", + }); + 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([]); + // One value per toolbar, so two panels can never be open at once. + const [openDropdown, setOpenDropdown] = useState(null); + const [sortKey, setSortKey] = useState(DEFAULT_SORT_KEY); + const [sortDirection, setSortDirection] = useState( + DEFAULT_SORT_DIRECTION, + ); + // An editable span, so the query shares its line boxes with the filter chips. + const inputRef = useRef(null); + const userNames = useAuthorNames({ + app, + plugin, + candidates: + candidateState.status === "ready" ? candidateState.candidates : null, + }); + + const nodeTypesById = useMemo(() => { + const byId = new Map(); + plugin.settings.nodeTypes.forEach((nodeType, nodeIndex) => { + byId.set(nodeType.id, { + name: nodeType.name, + badge: getNodeTypeBadge({ nodeType, nodeIndex }), + }); + }); + return byId; + }, [plugin.settings.nodeTypes]); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // The fetch is synchronous today, so there is nothing to await or cancel yet. + // Effects run after paint, so the loading state still renders for a frame; if + // this ever becomes a network call, only this body changes. + useEffect(() => { + try { + const candidates = new QueryEngine(app).getDiscourseNodeCandidates(); + setCandidateState({ status: "ready", candidates }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + new Notice(`Could not load discourse nodes: ${message}`); + setCandidateState({ status: "error", message }); + } + }, [app]); + + useEffect(() => { + const timeout = window.setTimeout( + () => setDebouncedQuery(query), + SEARCH_DEBOUNCE_MS, + ); + return () => window.clearTimeout(timeout); + }, [query]); + + // Sort before truncating, so a date or alphabetical sort covers every match. + const results = useMemo(() => { + if (candidateState.status !== "ready") return []; + const ranked = rankDiscourseNodesByTitle({ + candidates: candidateState.candidates, + query: debouncedQuery, + nodeTypeIds: selectedNodeTypeIds, + }); + const authorNameByPath = + sortKey === "author" + ? buildAuthorNameByPath({ + app, + files: ranked.map((result) => result.file), + userNames, + }) + : undefined; + return sortSearchResults({ + results: ranked, + sortKey, + direction: sortDirection, + authorNameByPath, + }) + .slice(0, MAX_VISIBLE_RESULTS) + .map((result) => ({ + ...result, + nodeType: nodeTypesById.get(result.nodeTypeId) ?? { + name: "Unknown type", + badge: getFallbackNodeTypeBadge(result.title), + }, + })); + }, [ + app, + candidateState, + debouncedQuery, + nodeTypesById, + selectedNodeTypeIds, + sortDirection, + sortKey, + userNames, + ]); + + // 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 + // here keeps the preview and the highlighted row from blanking for that frame. + const activeIndexInRange = activeIndex < results.length ? activeIndex : 0; + const activeResult = results[activeIndexInRange]; + + // Only the preview shows an author, so resolve the selection, not all 50 rows. + const authorName = useMemo( + () => + activeResult + ? resolveAuthorName({ app, file: activeResult.file, userNames }) + : "", + [app, activeResult, userNames], + ); + + useEffect(() => { + setActiveIndex(0); + }, [results]); + + const moveActiveIndex = (delta: number) => { + if (!results.length) return; + setActiveIndex((current) => { + const next = current + delta; + if (next < 0) return 0; + if (next > results.length - 1) return results.length - 1; + return next; + }); + }; + + // Closes before opening: `close()` unmounts this React root, so the file and + // app are read first and nothing touches state afterwards. + const openActiveResult = ( + open: (app: App, file: TFile) => Promise, + ): void => { + if (!activeResult) return; + const { file } = activeResult; + onClose(); + void open(app, file).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + new Notice(`Could not open ${file.basename}: ${message}`); + }); + }; + + const handleDropdownOpenChange = ({ + id, + isOpen, + }: { + id: NonNullable; + isOpen: boolean; + }): void => { + setOpenDropdown(isOpen ? id : null); + // Returns the keyboard path to the results the moment the panel closes. + if (!isOpen) 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. + event.preventDefault(); + moveActiveIndex(event.key === "ArrowDown" ? 1 : -1); + return; + } + + if (event.key !== "Enter") return; + // Enter also commits an IME candidate, which must not open a file. + if (event.nativeEvent.isComposing) return; + // Mod+Enter and Alt+Enter are left alone for the insert and dock actions. + if (event.metaKey || event.ctrlKey || event.altKey) return; + // A footer button reached by Tab runs its own action on Enter. Preventing the + // default here would suppress that click and open a new tab instead. + if ( + event.target instanceof HTMLElement && + event.target.closest("button") !== null + ) { + return; + } + + event.preventDefault(); + openActiveResult(event.shiftKey ? openFileInNewLeaf : openFileInNewTab); + }; + + return ( + // 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. +
+ {/* Padded so a trigger's count badge is not clipped by the modal's overflow-hidden content. */} + {/* Top-aligned: the field grows downwards, so the triggers stay on its first line. */} +
+ + + handleDropdownOpenChange({ id: "type-filter", isOpen }) + } + onSelectedNodeTypeIdsChange={setSelectedNodeTypeIds} + selectedNodeTypeIds={selectedNodeTypeIds} + /> + + handleDropdownOpenChange({ id: "sort", isOpen }) + } + onSortChange={({ sortKey: nextKey, direction }) => { + setSortKey(nextKey); + setSortDirection(direction); + }} + sortDirection={sortDirection} + sortKey={sortKey} + /> +
+
+
+ {candidateState.status === "loading" && ( +
Loading discourse nodes…
+ )} + {candidateState.status === "error" && ( +
+ Could not load discourse nodes. {candidateState.message} +
+ )} + {candidateState.status === "ready" && results.length === 0 && ( +
No results
+ )} + {candidateState.status === "ready" && results.length > 0 && ( + + )} +
+ +
+ openActiveResult(openFileInNewTab)} + onOpenInSplit={() => openActiveResult(openFileInNewLeaf)} + /> +
+ ); +}; + +export class NodeSearchModal extends Modal { + private plugin: DiscourseGraphPlugin; + private root: Root | null = null; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + } + + onOpen() { + const { contentEl, modalEl } = this; + // The default modal is too narrow for a result list beside a preview pane. + // Responsive layout is an explicit non-goal, so this is a desktop-only size. + modalEl.addClasses([ + "dg-node-search-modal", + "h-[600px]", + "max-h-[80vh]", + "w-[900px]", + "max-w-[90vw]", + ]); + contentEl.addClasses(["flex", "h-full", "flex-col", "overflow-hidden"]); + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render( + + this.close()} /> + , + ); + } + + onClose() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + this.contentEl.empty(); + } +} diff --git a/apps/obsidian/src/components/NodeSortMenu.tsx b/apps/obsidian/src/components/NodeSortMenu.tsx new file mode 100644 index 000000000..5f29a8cba --- /dev/null +++ b/apps/obsidian/src/components/NodeSortMenu.tsx @@ -0,0 +1,146 @@ +import { App, setIcon } from "obsidian"; +import type { ReactElement } from "react"; +import { SearchDropdown } from "~/components/SearchDropdown"; +import { + SORT_OPTIONS, + getDefaultDirectionForKey, + getSortDirectionLabel, + getSortOptionLabel, + isDefaultSort, + type SortDirection, + type SortKey, +} from "~/utils/discourseNodeSort"; + +const DIRECTIONS: { direction: SortDirection; label: string }[] = [ + { direction: "asc", label: "Asc" }, + { direction: "desc", label: "Desc" }, +]; + +const getDirectionIconName = (direction: SortDirection): string => + direction === "asc" ? "arrow-up-narrow-wide" : "arrow-down-wide-narrow"; + +/** Rows are divs, not buttons: Obsidian's button chrome reads as separate widgets rather than a menu. */ +const SortOptionRow = ({ + isSelected, + label, + onSelect, +}: { + isSelected: boolean; + label: string; + onSelect: () => void; +}): ReactElement => ( +
event.preventDefault()} + className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm ${ + isSelected + ? "bg-accent text-on-accent" + : "text-normal hover:bg-modifier-hover" + }`} + > + {/* Always occupies its slot, so selecting an option does not shift the labels. */} + + {isSelected && ( + (el && setIcon(el, "check")) || undefined} /> + )} + + {label} +
+); + +const DirectionToggle = ({ + onSelect, + sortDirection, + sortKey, +}: { + onSelect: (direction: SortDirection) => void; + sortDirection: SortDirection; + sortKey: SortKey; +}): ReactElement => ( +
+ {DIRECTIONS.map(({ direction, label }) => ( +
onSelect(direction)} + onMouseDown={(event) => event.preventDefault()} + className={`flex flex-1 cursor-pointer items-center justify-center gap-1 rounded px-2 py-1 text-sm ${ + direction === sortDirection + ? "bg-accent text-on-accent" + : "text-normal hover:bg-modifier-hover" + }`} + > + + (el && setIcon(el, getDirectionIconName(direction))) || undefined + } + /> + {label} +
+ ))} +
+); + +export const NodeSortMenu = ({ + app, + isOpen, + onOpenChange, + onSortChange, + sortDirection, + sortKey, +}: { + app: App; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onSortChange: (next: { sortKey: SortKey; direction: SortDirection }) => void; + sortDirection: SortDirection; + sortKey: SortKey; +}): ReactElement => { + const directionLabel = getSortDirectionLabel({ + sortKey, + direction: sortDirection, + }); + + return ( + +
+
Sort by
+ {SORT_OPTIONS.map((option) => ( + + onSortChange({ + sortKey: option.key, + direction: + option.key === sortKey + ? sortDirection + : getDefaultDirectionForKey(option.key), + }) + } + /> + ))} +
+ onSortChange({ sortKey, direction })} + sortDirection={sortDirection} + sortKey={sortKey} + /> +
+ ); +}; diff --git a/apps/obsidian/src/components/NodeTypeChipsSearchInput.tsx b/apps/obsidian/src/components/NodeTypeChipsSearchInput.tsx new file mode 100644 index 000000000..435ea2a17 --- /dev/null +++ b/apps/obsidian/src/components/NodeTypeChipsSearchInput.tsx @@ -0,0 +1,331 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactElement, + type RefObject, +} from "react"; +import { DiscourseNode } from "~/types"; +import { getAllDiscourseNodeColors } from "~/utils/colorUtils"; +import { getHintKeys } from "~/utils/keyboardHints"; +import { + getBestPrefixMatch, + getCompletionSuffix, +} from "~/utils/nodeTypeChipCompletion"; + +/** `-1` means the caret is in the query rather than on a chip. */ +const NO_FOCUSED_CHIP = -1; + +const QUERY_PLACEHOLDER = "Search discourse nodes by title"; + +type NodeTypeChip = { + backgroundColor: string; + id: string; + name: string; + textColor: string; +}; + +const isPlainCharacterKey = (event: KeyboardEvent): boolean => + 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 < 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(""); + }; + + // By id, not by position: `chips` is what the user sees and is the only list an + // index here refers to, so removing positionally from `selectedNodeTypeIds` would + // hit the wrong filter the moment the two ever differed. + 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; + } + + // 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 (!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" + /> + {/* 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/components/NodeTypeFilterMenu.tsx b/apps/obsidian/src/components/NodeTypeFilterMenu.tsx new file mode 100644 index 000000000..e6f9e95a6 --- /dev/null +++ b/apps/obsidian/src/components/NodeTypeFilterMenu.tsx @@ -0,0 +1,220 @@ +import { App } from "obsidian"; +import { useEffect, useMemo, useRef, useState, type ReactElement } from "react"; +import { SearchDropdown } from "~/components/SearchDropdown"; +import { DiscourseNode } from "~/types"; +import { getAllDiscourseNodeColors } from "~/utils/colorUtils"; +import { + NODE_TYPE_FILTER_SEARCH_THRESHOLD, + filterNodeTypesByQuery, + fromPanelSelectedIds, + hasActiveTypeFilter, + toPanelSelectedIds, +} from "~/utils/discourseNodeTypeFilter"; + +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 allTypeIds = useMemo( + () => nodeTypes.map((nodeType) => nodeType.id), + [nodeTypes], + ); + + const isFilterActive = hasActiveTypeFilter({ + selectedTypeIds: selectedNodeTypeIds, + allTypeIds, + }); + + const panelSelectedIds = useMemo( + () => + toPanelSelectedIds({ selectedTypeIds: selectedNodeTypeIds, allTypeIds }), + [allTypeIds, selectedNodeTypeIds], + ); + + const activeFilterCount = isFilterActive ? selectedNodeTypeIds.length : 0; + + return ( + 0 + ? `Filter by type, ${activeFilterCount} selected` + : "Filter by type" + } + badgeCount={activeFilterCount} + iconName="filter" + isActive={isFilterActive} + isDisabled={nodeTypes.length === 0} + isOpen={isOpen} + onOpenChange={onOpenChange} + panelClassName="w-64" + title={ + nodeTypes.length === 0 + ? "No discourse node types configured" + : "Filter by type" + } + > + + onSelectedNodeTypeIdsChange( + fromPanelSelectedIds({ panelSelectedIds: panelIds, allTypeIds }), + ) + } + selectedIds={panelSelectedIds} + /> + + ); +}; diff --git a/apps/obsidian/src/components/SearchDropdown.tsx b/apps/obsidian/src/components/SearchDropdown.tsx new file mode 100644 index 000000000..818b4c7a4 --- /dev/null +++ b/apps/obsidian/src/components/SearchDropdown.tsx @@ -0,0 +1,106 @@ +import { App, Scope, setIcon } from "obsidian"; +import { useEffect, useRef, type ReactElement, type ReactNode } from "react"; + +/** Which toolbar panel is open, so two can never be open at once. */ +export type SearchDropdownId = "type-filter" | "sort" | null; + +export const SearchDropdown = ({ + app, + ariaLabel, + badgeCount = 0, + children, + iconName, + isActive, + isDisabled = false, + isOpen, + onOpenChange, + panelClassName = "w-64", + title, +}: { + app: App; + ariaLabel: string; + badgeCount?: number; + children: ReactNode; + iconName: string; + isActive: boolean; + isDisabled?: boolean; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + panelClassName?: string; + title: string; +}): ReactElement => { + const containerRef = useRef(null); + + // Obsidian's modal Escape is registered before React exists, and wins on its own scope too, so only a pushed scope gets it first. + 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`, so the listener lands in the popout window holding the modal. + 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]); + + return ( +
{ + if (!isOpen) return; + // Panel keystrokes must not reach the modal's Enter and arrow result navigation; Escape never arrives here at all. + event.stopPropagation(); + }} + > + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index 66e243fe6..3cf9c64ce 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -3898,3 +3898,52 @@ kbd.tlui-kbd { background-color: var(--background-secondary); } } + +.dg-node-search-modal .dg-search-result-title span { + background-color: var(--text-highlight-bg); + color: inherit; + border-radius: var(--radius-s); + padding: 0 1px; +} + +/* Only the properties Tailwind utilities cannot win here; the rest of this + footer's layout is utilities on the elements themselves. Obsidian sets + `color`, `background-color`, and `box-shadow` in `button:not(.clickable-icon)` + and `button:hover` — both (0,1,1), which outrank a single utility class — so a + utility would leave the label in `--text-normal` on an interactive-grey pill. + `font-size` has no inherit utility, and without it the button takes + `--font-ui-small` rather than the smaller type of the row it sits in. */ +.dg-node-search-modal .dg-search-footer-action, +.dg-node-search-modal .dg-search-footer-action:hover { + color: inherit; + background-color: transparent; + box-shadow: none; + font-size: inherit; +} + +/* Each key is a bordered cap, so `esc` reads as one of the set rather than as + emphasised text. Kept in CSS for the inherited font and em-based sizing. */ +.dg-node-search-modal .dg-search-footer-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5em; + padding: 0 var(--size-2-1); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: var(--background-primary); + color: var(--text-muted); + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: 1.6; +} + +.dg-node-search-modal .dg-search-footer-action:hover:not(:disabled) { + color: var(--text-normal); +} + +.dg-node-search-modal .dg-search-footer-action:disabled { + cursor: not-allowed; + opacity: 0.5; +} diff --git a/apps/obsidian/src/utils/discourseNodeAuthor.ts b/apps/obsidian/src/utils/discourseNodeAuthor.ts new file mode 100644 index 000000000..57efa0fb9 --- /dev/null +++ b/apps/obsidian/src/utils/discourseNodeAuthor.ts @@ -0,0 +1,93 @@ +import { App, TFile } from "obsidian"; +import { useEffect, useState } from "react"; +import type DiscourseGraphPlugin from "~/index"; +import type { DiscourseNodeCandidate } from "~/services/QueryEngine"; +import { fetchUserNames } from "~/utils/importNodes"; +import { getLoggedInClient } from "~/utils/supabaseContext"; + +/** Single source for a note's author name. Obsidian exposes no Sync user API, so names come from `authorId` frontmatter. */ + +export const LOCAL_AUTHOR_NAME = "You"; +export const UNRESOLVED_AUTHOR_NAME = "Unknown"; + +/** Frontmatter is untyped, so the raw value is narrowed by each caller. */ +const getFrontmatterAuthorId = (app: App, file: TFile): unknown => { + // Annotated rather than asserted: `FrontMatterCache` indexes to `any`, so a cast is a no-op. + const frontmatter: Record | undefined = + app.metadataCache.getFileCache(file)?.frontmatter; + return frontmatter?.authorId; +}; + +/** "You" only when there is no `authorId`; a present but unresolvable id stays "Unknown". */ +export const resolveAuthorName = ({ + app, + file, + userNames, +}: { + app: App; + file: TFile; + userNames: Record; +}): string => { + const authorId = getFrontmatterAuthorId(app, file); + if (authorId === undefined || authorId === null) return LOCAL_AUTHOR_NAME; + if (typeof authorId !== "number") return UNRESOLVED_AUTHOR_NAME; + return userNames[authorId] ?? UNRESOLVED_AUTHOR_NAME; +}; + +export const isUnattributedAuthorName = (authorName: string): boolean => + authorName === UNRESOLVED_AUTHOR_NAME; + +export const buildAuthorNameByPath = ({ + app, + files, + userNames, +}: { + app: App; + files: TFile[]; + userNames: Record; +}): Map => { + const byPath = new Map(); + files.forEach((file) => { + byPath.set(file.path, resolveAuthorName({ app, file, userNames })); + }); + return byPath; +}; + +/** One query returns every person, so this refreshes once per open when a name is missing. */ +export const useAuthorNames = ({ + app, + plugin, + candidates, +}: { + app: App; + plugin: DiscourseGraphPlugin; + candidates: DiscourseNodeCandidate[] | null; +}): Record => { + const [userNames, setUserNames] = useState(plugin.settings.userNames ?? {}); + + useEffect(() => { + if (!candidates) return; + if (!plugin.settings.syncModeEnabled) return; + + const isMissingName = (candidate: DiscourseNodeCandidate): boolean => { + const authorId = getFrontmatterAuthorId(app, candidate.file); + return ( + typeof authorId === "number" && !plugin.settings.userNames?.[authorId] + ); + }; + if (!candidates.some(isMissingName)) return; + + let cancelled = false; + void (async () => { + const client = await getLoggedInClient(plugin); + if (!client || cancelled) return; + await fetchUserNames(plugin, client); + if (!cancelled) setUserNames(plugin.settings.userNames ?? {}); + })(); + return () => { + cancelled = true; + }; + }, [app, plugin, candidates]); + + return userNames; +}; diff --git a/apps/obsidian/src/utils/discourseNodeSort.ts b/apps/obsidian/src/utils/discourseNodeSort.ts new file mode 100644 index 000000000..cd1b9b737 --- /dev/null +++ b/apps/obsidian/src/utils/discourseNodeSort.ts @@ -0,0 +1,140 @@ +import { TFile } from "obsidian"; +import { isUnattributedAuthorName } from "~/utils/discourseNodeAuthor"; + +/** Client-side result ordering, over the full ranked list before display truncation. Mirrors Roam's advanced search. */ + +export type SortKey = + | "relevance" + | "title" + | "dateCreated" + | "dateModified" + | "author"; + +export type SortDirection = "asc" | "desc"; + +export const SORT_OPTIONS: { key: SortKey; label: string }[] = [ + { key: "relevance", label: "Relevance" }, + { key: "title", label: "Alphabetical" }, + { key: "dateCreated", label: "Date created" }, + { key: "dateModified", label: "Date modified" }, + { key: "author", label: "Author" }, +]; + +export const DEFAULT_SORT_KEY: SortKey = "relevance"; +export const DEFAULT_SORT_DIRECTION: SortDirection = "desc"; + +/** Structural, so the sort runs on ranked results or decorated rows alike. */ +export type SortableSearchResult = { + file: TFile; + title: string; + match: { score: number }; +}; + +const DIRECTION_LABELS: Record> = { + relevance: { desc: "Best match first", asc: "Worst match first" }, + title: { asc: "A to Z", desc: "Z to A" }, + dateCreated: { desc: "Newest first", asc: "Oldest first" }, + dateModified: { desc: "Newest first", asc: "Oldest first" }, + author: { asc: "A to Z", desc: "Z to A" }, +}; + +export const getSortDirectionLabel = ({ + sortKey, + direction, +}: { + sortKey: SortKey; + direction: SortDirection; +}): string => DIRECTION_LABELS[sortKey][direction]; + +/** "Descending" means something different per dimension, so switching resets it. */ +export const getDefaultDirectionForKey = (sortKey: SortKey): SortDirection => + sortKey === "title" || sortKey === "author" ? "asc" : "desc"; + +export const isDefaultSort = ({ + sortKey, + direction, +}: { + sortKey: SortKey; + direction: SortDirection; +}): boolean => + sortKey === DEFAULT_SORT_KEY && direction === DEFAULT_SORT_DIRECTION; + +export const getSortOptionLabel = (sortKey: SortKey): string => + SORT_OPTIONS.find((option) => option.key === sortKey)?.label ?? ""; + +const getAuthorName = ({ + result, + authorNameByPath, +}: { + result: SortableSearchResult; + authorNameByPath: Map | undefined; +}): string => authorNameByPath?.get(result.file.path) ?? ""; + +/** Unattributed notes sit after every named author, in both directions. */ +const compareUnattributedLast = ({ + a, + b, + authorNameByPath, +}: { + a: SortableSearchResult; + b: SortableSearchResult; + authorNameByPath: Map | undefined; +}): number => { + const isAUnattributed = isUnattributedAuthorName( + getAuthorName({ result: a, authorNameByPath }), + ); + const isBUnattributed = isUnattributedAuthorName( + getAuthorName({ result: b, authorNameByPath }), + ); + if (isAUnattributed === isBUnattributed) return 0; + return isAUnattributed ? 1 : -1; +}; + +const compareAscending = ({ + a, + b, + sortKey, + authorNameByPath, +}: { + a: SortableSearchResult; + b: SortableSearchResult; + sortKey: SortKey; + authorNameByPath: Map | undefined; +}): number => { + if (sortKey === "relevance") return a.match.score - b.match.score; + if (sortKey === "title") return a.title.localeCompare(b.title); + if (sortKey === "dateCreated") return a.file.stat.ctime - b.file.stat.ctime; + if (sortKey === "dateModified") return a.file.stat.mtime - b.file.stat.mtime; + + const authorDelta = getAuthorName({ + result: a, + authorNameByPath, + }).localeCompare(getAuthorName({ result: b, authorNameByPath })); + return authorDelta !== 0 ? authorDelta : a.title.localeCompare(b.title); +}; + +/** `authorNameByPath` is only needed for the author sort. */ +export const sortSearchResults = ({ + results, + sortKey, + direction, + authorNameByPath, +}: { + results: T[]; + sortKey: SortKey; + direction: SortDirection; + authorNameByPath?: Map; +}): T[] => + [...results].sort((a, b) => { + if (sortKey === "author") { + // Outside the direction flip on purpose: this partition is not reversible. + const unattributedDelta = compareUnattributedLast({ + a, + b, + authorNameByPath, + }); + if (unattributedDelta !== 0) return unattributedDelta; + } + const delta = compareAscending({ a, b, sortKey, authorNameByPath }); + return direction === "asc" ? delta : -delta; + }); diff --git a/apps/obsidian/src/utils/discourseNodeTypeFilter.ts b/apps/obsidian/src/utils/discourseNodeTypeFilter.ts new file mode 100644 index 000000000..07c9a7be9 --- /dev/null +++ b/apps/obsidian/src/utils/discourseNodeTypeFilter.ts @@ -0,0 +1,64 @@ +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 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), + ); +}; diff --git a/apps/obsidian/src/utils/keyboardHints.ts b/apps/obsidian/src/utils/keyboardHints.ts new file mode 100644 index 000000000..ec38800a8 --- /dev/null +++ b/apps/obsidian/src/utils/keyboardHints.ts @@ -0,0 +1,35 @@ +import { Platform } from "obsidian"; + +export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape" | "Tab"; + +// Obsidian shows glyphs on macOS and spelled-out words everywhere else. +const MAC_SYMBOLS: Record = { + Mod: "⌘", + Alt: "⌥", + Shift: "⇧", + Enter: "↵", + Escape: "esc", + Tab: "⇥", +}; + +const NON_MAC_SYMBOLS: Record = { + Mod: "Ctrl", + Alt: "Alt", + Shift: "Shift", + Enter: "Enter", + Escape: "Esc", + Tab: "Tab", +}; + +/** Takes `isMacOS` so the non-mac branch can be checked without that platform. */ +export const formatHintKeys = ({ + keys, + isMacOS, +}: { + keys: HintKey[]; + isMacOS: boolean; +}): string[] => + keys.map((key) => (isMacOS ? MAC_SYMBOLS : NON_MAC_SYMBOLS)[key]); + +export const getHintKeys = (keys: HintKey[]): string[] => + formatHintKeys({ keys, isMacOS: Platform.isMacOS }); diff --git a/apps/obsidian/src/utils/nodeTypeBadge.ts b/apps/obsidian/src/utils/nodeTypeBadge.ts new file mode 100644 index 000000000..a4ad3f258 --- /dev/null +++ b/apps/obsidian/src/utils/nodeTypeBadge.ts @@ -0,0 +1,40 @@ +import { DiscourseNode } from "~/types"; +import { getNodeTagColors } from "./colorUtils"; + +const BADGE_TEXT_LENGTH = 3; + +export type NodeTypeBadge = { + text: string; + backgroundColor: string; + textColor: string; +}; + +export const formatNodeTypeBadgeText = (source: string): string => + source.replace(/^#+/, "").trim().slice(0, BADGE_TEXT_LENGTH).toUpperCase(); + +export const getNodeTypeBadge = ({ + nodeType, + nodeIndex, +}: { + nodeType: DiscourseNode; + nodeIndex: number; +}): NodeTypeBadge => ({ + text: formatNodeTypeBadgeText(nodeType.tag?.trim() || nodeType.name), + ...getNodeTagColors(nodeType, nodeIndex), +}); + +export const getFallbackNodeTypeBadge = ( + title: string, +): NodeTypeBadge | null => { + const [prefix, ...rest] = title.split(" - "); + if (!rest.length || !prefix) return null; + + const text = formatNodeTypeBadgeText(prefix); + if (!text) return null; + + return { + text, + backgroundColor: "var(--background-modifier-hover)", + textColor: "var(--text-muted)", + }; +}; 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); +}; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index f72544360..de3e2eae6 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -3,6 +3,7 @@ import type DiscourseGraphPlugin from "~/index"; import { NodeTypeModal } from "~/components/NodeTypeModal"; import ModifyNodeModal from "~/components/ModifyNodeModal"; import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal"; +import { NodeSearchModal } from "~/components/NodeSearchModal"; import { ImportNodesModal } from "~/components/ImportNodesModal"; import { FeedbackModal } from "~/components/FeedbackModal"; import { convertPageToDiscourseNode, createDiscourseNode } from "./createNode"; @@ -137,6 +138,15 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + plugin.addCommand({ + id: "open-node-search", + name: "Open node search", + hotkeys: [], + callback: () => { + new NodeSearchModal(plugin.app, plugin).open(); + }, + }); + plugin.addCommand({ id: "import-nodes-from-another-space", name: "Import nodes from another space",