diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx new file mode 100644 index 000000000..176aa1edd --- /dev/null +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -0,0 +1,87 @@ +import { type ReactElement } from "react"; +import { getHintKeys, type HintKey } from "~/utils/keyboardHints"; + +type NodeSearchFooterProps = { + canAct: boolean; + canInsertLink: boolean; + onClose: () => void; + onInsertLink: () => 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, + canInsertLink, + onClose, + onInsertLink, + onOpenInNewTab, + onOpenInSplit, +}: NodeSearchFooterProps): ReactElement => ( +
+ {/* Absent, not disabled: with no cursor there is nothing to insert into. */} + {canInsertLink && ( + + )} + + + {/* 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..1058ea95e --- /dev/null +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -0,0 +1,574 @@ +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 { + openFileInNewLeaf, + openFileInNewTab, +} from "~/components/canvas/utils/openFileUtils"; +import { + insertLinkAtInsertTarget, + snapshotInsertTarget, + type EditorInsertTarget, +} from "~/utils/editorInsertTarget"; +import { + QueryEngine, + rankDiscourseNodesByTitle, + type DiscourseNodeCandidate, + type RankedDiscourseNode, +} from "~/services/QueryEngine"; +import { + getNodeTypeBadge, + getFallbackNodeTypeBadge, + type NodeTypeBadge, +} from "~/utils/nodeTypeBadge"; +import { fetchUserNames } from "~/utils/importNodes"; +import { getLoggedInClient } from "~/utils/supabaseContext"; + +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 LOCAL_AUTHOR_NAME = "You"; +const UNRESOLVED_AUTHOR_NAME = "Unknown"; + +/** Frontmatter is untyped, so the raw value is narrowed by each caller. */ +const getFrontmatterAuthorId = (app: App, file: TFile): unknown => { + const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter as + | Record + | undefined; + return frontmatter?.authorId; +}; + +/** + * "You" belongs only to a note with no `authorId` at all — every note in an + * unsynced vault. An id that is present but unresolvable stays "Unknown" rather + * than claiming local authorship. `useAuthorNames` has already cached the + * names, so this stays synchronous. + */ +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; +}; + +/** + * `fetchUserNames` returns every person in the vault's spaces in one query, so + * this refreshes once per open when a name is missing rather than querying per + * author. + */ +const useAuthorNames = ({ + app, + plugin, + candidateState, +}: { + app: App; + plugin: DiscourseGraphPlugin; + candidateState: CandidateState; +}): Record => { + const [userNames, setUserNames] = useState(plugin.settings.userNames ?? {}); + + useEffect(() => { + if (candidateState.status !== "ready") 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 (!candidateState.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, candidateState]); + + return userNames; +}; + +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, + insertTarget, + onClose, +}: { + plugin: DiscourseGraphPlugin; + insertTarget: EditorInsertTarget | null; + 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); + const inputRef = useRef(null); + const userNames = useAuthorNames({ app, plugin, candidateState }); + + 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]); + + const results = useMemo(() => { + if (candidateState.status !== "ready") return []; + return rankDiscourseNodesByTitle({ + candidates: candidateState.candidates, + query: debouncedQuery, + }) + .slice(0, MAX_VISIBLE_RESULTS) + .map((result) => ({ + ...result, + nodeType: nodeTypesById.get(result.nodeTypeId) ?? { + name: "Unknown type", + badge: getFallbackNodeTypeBadge(result.title), + }, + })); + }, [candidateState, debouncedQuery, nodeTypesById]); + + // 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}`); + }); + }; + + // Closes before inserting, like `openActiveResult`. + const insertLinkToActiveResult = (): void => { + if (!activeResult || !insertTarget) return; + const { file } = activeResult; + onClose(); + try { + insertLinkAtInsertTarget({ app, file, target: insertTarget }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + new Notice(`Could not insert a link to ${file.basename}: ${message}`); + } + }; + + 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; + if ((event.metaKey || event.ctrlKey) && !event.altKey && insertTarget) { + event.preventDefault(); + insertLinkToActiveResult(); + return; + } + // Alt+Enter is left alone for the dock action. + 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. +
+ setQuery(event.target.value)} + className="w-full" + /> +
+
+ {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; + /** Snapshotted in the constructor: `open()` has not taken focus yet. */ + private insertTarget: EditorInsertTarget | null; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + this.insertTarget = snapshotInsertTarget(app); + } + + 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/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index 0edee84e6..5b67cae09 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -1,4 +1,4 @@ -import { TFile, App } from "obsidian"; +import { TFile, App, prepareFuzzySearch, type SearchResult } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { BulkImportPattern, BulkImportCandidate, DiscourseNode } from "~/types"; import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression"; @@ -21,6 +21,17 @@ type DatacorePage = { $path?: string; }; +export type DiscourseNodeCandidate = { + file: TFile; + /** Scored and rendered as-is: `renderResults` re-slices whatever was scored. */ + title: string; + nodeTypeId: string; +}; + +export type RankedDiscourseNode = DiscourseNodeCandidate & { + match: SearchResult; +}; + export class QueryEngine { private app: App; private dc: @@ -40,6 +51,26 @@ export class QueryEngine { functional = () => !!this.dc; + /** + * Datacore when installed, vault iteration otherwise — `getFilesWithNodeTypeId` + * owns that fallback. Call once per open, not per keystroke: the scan is the + * pipeline's most expensive step, and staying unfiltered keeps filter changes free. + */ + getDiscourseNodeCandidates = (): DiscourseNodeCandidate[] => { + const candidates: DiscourseNodeCandidate[] = []; + + for (const file of this.getFilesWithNodeTypeId()) { + const frontmatter = this.app.metadataCache.getFileCache(file) + ?.frontmatter as Record | undefined; + const nodeTypeId = frontmatter?.nodeTypeId; + if (typeof nodeTypeId !== "string" || !nodeTypeId) continue; + + candidates.push({ file, title: file.basename, nodeTypeId }); + } + + return candidates; + }; + /** * Search across all discourse nodes (files that have frontmatter nodeTypeId) */ @@ -602,6 +633,54 @@ export class QueryEngine { } } +/** Exported so callers can memoise the filtered array against their selected ids. */ +export const filterCandidatesByNodeTypeIds = ( + candidates: DiscourseNodeCandidate[], + nodeTypeIds?: string[], +): DiscourseNodeCandidate[] => { + if (!nodeTypeIds?.length) return candidates; + const selected = new Set(nodeTypeIds); + return candidates.filter((candidate) => selected.has(candidate.nodeTypeId)); +}; + +/** + * Best match first, uncapped — capping is the caller's, so a later re-sort orders the + * whole set rather than a top slice. Filters before scoring: same results, less work. + */ +export const rankDiscourseNodesByTitle = ({ + candidates, + query, + nodeTypeIds, +}: { + candidates: DiscourseNodeCandidate[]; + query: string; + nodeTypeIds?: string[]; +}): RankedDiscourseNode[] => { + const filtered = filterCandidatesByNodeTypeIds(candidates, nodeTypeIds); + const trimmedQuery = query.trim(); + + // Filter-only searches still need a list, so an empty query is not an empty result. + if (!trimmedQuery) { + return [...filtered] + .sort((a, b) => a.title.localeCompare(b.title)) + .map((candidate) => ({ + ...candidate, + match: { score: 0, matches: [] }, + })); + } + + const score = prepareFuzzySearch(trimmedQuery); + const ranked: RankedDiscourseNode[] = []; + + for (const candidate of filtered) { + const match = score(candidate.title); + if (match) ranked.push({ ...candidate, match }); + } + + // Sort is stable, so equal scores keep candidate order. + return ranked.sort((a, b) => b.match.score - a.match.score); +}; + /** * Returns raw imported node entries from import/ folder (no DB). * Uses DataCore when available; otherwise iterates vault. Only includes files 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/editorInsertTarget.ts b/apps/obsidian/src/utils/editorInsertTarget.ts new file mode 100644 index 000000000..bc386dcd3 --- /dev/null +++ b/apps/obsidian/src/utils/editorInsertTarget.ts @@ -0,0 +1,50 @@ +import { App, MarkdownView, TFile, type EditorPosition } from "obsidian"; + +/** Held rather than re-looked-up, so the link lands in the pre-open note. */ +export type EditorInsertTarget = { + view: MarkdownView; + from: EditorPosition; + to: EditorPosition; +}; + +/** + * Call before the modal mounts, while the editor still owns the cursor. + * `hasFocus()` is not part of the gate: opening the search from the command + * palette means that palette already took focus. + */ +export const snapshotInsertTarget = (app: App): EditorInsertTarget | null => { + const view = app.workspace.getActiveViewOfType(MarkdownView); + // Reading mode has no cursor. + if (!view || !view.file || view.getMode() !== "source") return null; + + const { editor } = view; + return { + view, + // A selection is replaced rather than left beside the link. + from: editor.getCursor("from"), + to: editor.getCursor("to"), + }; +}; + +/** `generateMarkdownLink` is what honours the vault's link-format settings. */ +export const insertLinkAtInsertTarget = ({ + app, + file, + target, +}: { + app: App; + file: TFile; + target: EditorInsertTarget; +}): void => { + const { view, from, to } = target; + const sourceFile = view.file; + if (!sourceFile) return; + + const link = app.fileManager.generateMarkdownLink(file, sourceFile.path); + const { editor } = view; + editor.replaceRange(link, from, to); + + app.workspace.setActiveLeaf(view.leaf, { focus: true }); + editor.setCursor({ line: from.line, ch: from.ch + link.length }); + editor.focus(); +}; diff --git a/apps/obsidian/src/utils/keyboardHints.ts b/apps/obsidian/src/utils/keyboardHints.ts new file mode 100644 index 000000000..a14586ab3 --- /dev/null +++ b/apps/obsidian/src/utils/keyboardHints.ts @@ -0,0 +1,33 @@ +import { Platform } from "obsidian"; + +export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape"; + +// Obsidian shows glyphs on macOS and spelled-out words everywhere else. +const MAC_SYMBOLS: Record = { + Mod: "⌘", + Alt: "⌥", + Shift: "⇧", + Enter: "↵", + Escape: "esc", +}; + +const NON_MAC_SYMBOLS: Record = { + Mod: "Ctrl", + Alt: "Alt", + Shift: "Shift", + Enter: "Enter", + Escape: "Esc", +}; + +/** 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/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",