From 5fb0fcaebe3d558cc92a09b360c1d05b97663671 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 Aug 2025 15:40:40 -0400 Subject: [PATCH 1/3] eng-658-add-existing-nodes-flow --- .../src/components/RelationshipSection.tsx | 6 +- apps/obsidian/src/components/SearchBar.tsx | 46 ++++++---- .../components/canvas/DiscourseNodePanel.tsx | 54 ++++++----- .../components/canvas/ExistingNodeSearch.tsx | 90 +++++++++++++++++++ .../src/components/canvas/TldrawView.tsx | 15 ++-- apps/obsidian/src/services/QueryEngine.ts | 61 +++++++++++-- 6 files changed, 216 insertions(+), 56 deletions(-) create mode 100644 apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx diff --git a/apps/obsidian/src/components/RelationshipSection.tsx b/apps/obsidian/src/components/RelationshipSection.tsx index 9414902b5..7c29408e1 100644 --- a/apps/obsidian/src/components/RelationshipSection.tsx +++ b/apps/obsidian/src/components/RelationshipSection.tsx @@ -148,12 +148,12 @@ const AddRelationship = ({ activeFile }: RelationshipSectionProps) => { const nodeTypeIdsToSearch = compatibleNodeTypes.map((type) => type.id); const results = - await queryEngineRef.current?.searchCompatibleNodeByTitle( + await queryEngineRef.current.searchCompatibleNodeByTitle({ query, - nodeTypeIdsToSearch, + compatibleNodeTypeIds: nodeTypeIdsToSearch, activeFile, selectedRelationType, - ); + }); if (results.length === 0 && query.length >= 2) { setSearchError( diff --git a/apps/obsidian/src/components/SearchBar.tsx b/apps/obsidian/src/components/SearchBar.tsx index 32532fd10..096b87e94 100644 --- a/apps/obsidian/src/components/SearchBar.tsx +++ b/apps/obsidian/src/components/SearchBar.tsx @@ -74,6 +74,7 @@ const SearchBar = ({ renderItem, asyncSearch, disabled = false, + className, }: { onSelect: (item: T | null) => void; placeholder?: string; @@ -81,30 +82,41 @@ const SearchBar = ({ renderItem?: (item: T, el: HTMLElement) => void; asyncSearch: (query: string) => Promise; disabled?: boolean; + className?: string; }) => { const inputRef = useRef(null); const [selected, setSelected] = useState(null); const plugin = usePlugin(); const app = plugin.app; + const asyncSearchRef = useRef(asyncSearch); useEffect(() => { - if (inputRef.current && app) { - const suggest = new GenericSuggest( - app, - inputRef.current, - (item) => { - setSelected(item); - onSelect(item); - }, - { - getItemText, - renderItem, - asyncSearch, + asyncSearchRef.current = asyncSearch; + }, [asyncSearch]); + + useEffect(() => { + if (!inputRef.current || !app) return; + const suggest = new GenericSuggest( + app, + inputRef.current, + (item) => { + setSelected(item); + onSelect(item); + }, + { + getItemText: (item: T) => getItemText(item), + renderItem: (item: T, el: HTMLElement) => { + if (renderItem) { + renderItem(item, el); + return; + } + el.setText(getItemText(item)); }, - ); - return () => suggest.close(); - } - }, [onSelect, app, getItemText, renderItem, asyncSearch]); + asyncSearch: (query: string) => asyncSearchRef.current(query), + }, + ); + return () => suggest.close(); + }, [app, getItemText, renderItem, onSelect, asyncSearch]); const clearSelection = useCallback(() => { if (inputRef.current) { @@ -124,7 +136,7 @@ const SearchBar = ({ selected ? "pr-9" : "" } border-modifier-border rounded border bg-${ selected || disabled ? "secondary" : "primary" - } ${disabled ? "cursor-not-allowed opacity-70" : "cursor-text"}`} + } ${disabled ? "cursor-not-allowed opacity-70" : "cursor-text"} ${className}`} readOnly={!!selected || disabled} disabled={disabled} /> diff --git a/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx b/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx index 147426aa4..25d9f37c1 100644 --- a/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx +++ b/apps/obsidian/src/components/canvas/DiscourseNodePanel.tsx @@ -13,6 +13,7 @@ import { openCreateDiscourseNodeAt } from "./utils/nodeCreationFlow"; import { getNodeTypeById } from "~/utils/utils"; import { useEffect } from "react"; import { setDiscourseNodeToolContext } from "./DiscourseNodeTool"; +import { ExistingNodeSearch } from "./ExistingNodeSearch"; export const DiscourseNodePanel = ({ plugin, @@ -26,8 +27,8 @@ export const DiscourseNodePanel = ({ const rDraggingImage = React.useRef(null); const didDragRef = React.useRef(false); const [focusedNodeTypeId, setFocusedNodeTypeId] = React.useState< - string | null - >(null); + string | undefined + >(undefined); type DragState = | { name: "idle" } @@ -188,7 +189,7 @@ export const DiscourseNodePanel = ({ useEffect(() => { if (!focusedNodeTypeId) return; const exists = !!getNodeTypeById(plugin, focusedNodeTypeId); - if (!exists) setFocusedNodeTypeId(null); + if (!exists) setFocusedNodeTypeId(undefined); }, [focusedNodeTypeId, plugin]); const focusedNodeType = focusedNodeTypeId @@ -208,7 +209,7 @@ export const DiscourseNodePanel = ({ const handleItemClick = (id: string) => { if (didDragRef.current) return; if (focusedNodeTypeId) { - setFocusedNodeTypeId(null); + setFocusedNodeTypeId(undefined); return; } setFocusedNodeTypeId(id); @@ -217,26 +218,31 @@ export const DiscourseNodePanel = ({ }; return ( -
-
-
- {displayNodeTypes.map((nodeType) => ( - handleItemClick(nodeType.id)} - /> - ))} -
-
- {state.name === "dragging" - ? (getNodeTypeById(plugin, state.nodeTypeId)?.name ?? "") - : null} +
+ editor} + nodeTypeId={focusedNodeTypeId} + /> +
+
+
+ {displayNodeTypes.map((nodeType) => ( + handleItemClick(nodeType.id)} + /> + ))} +
+
+ {state.name === "dragging" + ? (getNodeTypeById(plugin, state.nodeTypeId)?.name ?? "") + : null} +
diff --git a/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx b/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx new file mode 100644 index 000000000..77bd49f25 --- /dev/null +++ b/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx @@ -0,0 +1,90 @@ +import { useCallback, useState } from "react"; +import { TFile } from "obsidian"; +import { createShapeId, Editor } from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { QueryEngine } from "~/services/QueryEngine"; +import SearchBar from "~/components/SearchBar"; +import { addWikilinkBlockrefForFile } from "./stores/assetStore"; +import { getFrontmatterForFile } from "./shapes/discourseNodeShapeUtils"; +import { DiscourseNode } from "~/types"; + +export const ExistingNodeSearch = ({ + plugin, + canvasFile, + getEditor, + nodeTypeId, +}: { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + getEditor: () => Editor | null; + nodeTypeId?: string; +}) => { + const [engine] = useState(() => new QueryEngine(plugin.app)); + + const search = useCallback( + async (query: string) => { + return engine.searchDiscourseNodesByTitle(query, nodeTypeId); + }, + [engine, nodeTypeId], + ); + + const getItemText = useCallback((file: TFile) => file.basename, []); + + const renderItem = useCallback((file: TFile, el: HTMLElement) => { + const wrapper = el.createEl("div", { + cls: "file-suggestion", + attr: { style: "display:flex; align-items:center; gap:8px;" }, + }); + wrapper.createEl("div", { text: "📄" }); + wrapper.createEl("div", { text: file.basename }); + }, []); + + const handleSelect = useCallback( + (file: TFile | null) => { + const editor = getEditor(); + if (!file || !editor) return; + void (async () => { + const pagePoint = editor.getViewportScreenCenter(); + try { + const src = await addWikilinkBlockrefForFile({ + app: plugin.app, + canvasFile, + linkedFile: file, + }); + const id = createShapeId(); + editor.createShape({ + id, + type: "discourse-node", + x: pagePoint.x - Math.random() * 100, + y: pagePoint.y - Math.random() * 100, + props: { + w: 200, + h: 100, + src, + title: file.basename, + nodeTypeId: getFrontmatterForFile(plugin.app, file)?.nodeTypeId, + }, + }); + editor.markHistoryStoppingPoint("add existing discourse node"); + editor.setSelectedShapes([id]); + } catch (error) { + console.error("Error in handleSelect:", error); + } + })(); + }, + [canvasFile, getEditor, plugin.app], + ); + + return ( +
+ + onSelect={handleSelect} + placeholder="Node search" + getItemText={getItemText} + renderItem={renderItem} + asyncSearch={search} + className="!bg-[var(--color-panel)] !text-[var(--color-text)]" + /> +
+ ); +}; diff --git a/apps/obsidian/src/components/canvas/TldrawView.tsx b/apps/obsidian/src/components/canvas/TldrawView.tsx index 02d1e9ef1..e224dbb52 100644 --- a/apps/obsidian/src/components/canvas/TldrawView.tsx +++ b/apps/obsidian/src/components/canvas/TldrawView.tsx @@ -7,6 +7,7 @@ import React from "react"; import DiscourseGraphPlugin from "~/index"; import { processInitialData, TLData } from "~/components/canvas/utils/tldraw"; import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; +import { PluginProvider } from "../PluginContext"; export class TldrawView extends TextFileView { plugin: DiscourseGraphPlugin; @@ -147,12 +148,14 @@ export class TldrawView extends TextFileView { root.render( - + + + , ); return root; diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index fba2c83ae..23a071b78 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -26,12 +26,61 @@ export class QueryEngine { this.app = app; } - async searchCompatibleNodeByTitle( + /** + * Search across all Discourse Nodes (files that have frontmatter nodeTypeId) + */ + searchDiscourseNodesByTitle = async ( query: string, - compatibleNodeTypeIds: string[], - activeFile: TFile, - selectedRelationType: string, - ): Promise { + nodeTypeId?: string, + ): Promise => { + if (!query || query.length < this.MIN_QUERY_LENGTH) { + return []; + } + if (!this.dc) { + console.warn( + "Datacore API not available. Search functionality is not available.", + ); + return []; + } + + try { + const dcQuery = nodeTypeId + ? `@page and exists(nodeTypeId) and nodeTypeId = "${nodeTypeId}"` + : "@page and exists(nodeTypeId)"; + const potentialNodes = await this.dc.query(dcQuery); + + const searchResults = potentialNodes.filter((p: any) => + this.fuzzySearch(p.$name, query), + ); + + const files = searchResults + .map((dcFile: any) => { + if (dcFile && dcFile.$path) { + const realFile = this.app.vault.getAbstractFileByPath(dcFile.$path); + if (realFile && realFile instanceof TFile) return realFile; + } + return dcFile as TFile; + }) + .filter((f: TFile | null): f is TFile => Boolean(f)); + + return files; + } catch (error) { + console.error("Error in searchDiscourseNodesByTitle:", error); + return []; + } + }; + + searchCompatibleNodeByTitle = async ({ + query, + compatibleNodeTypeIds, + activeFile, + selectedRelationType, + }: { + query: string; + compatibleNodeTypeIds: string[]; + activeFile: TFile; + selectedRelationType: string; + }): Promise => { if (!query || query.length < this.MIN_QUERY_LENGTH) { return []; } @@ -96,7 +145,7 @@ export class QueryEngine { console.error("Error in searchNodeByTitle:", error); return []; } - } + }; /** * Enhanced fuzzy search implementation From fa7ad358599a9ea8d78417879ee66143d9d1397c Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 30 Aug 2025 11:58:40 -0400 Subject: [PATCH 2/3] address PR comments --- .../components/canvas/ExistingNodeSearch.tsx | 3 +- .../src/components/canvas/TldrawView.tsx | 1 - .../components/canvas/TldrawViewComponent.tsx | 5 +-- apps/obsidian/src/services/QueryEngine.ts | 39 ++++++++++++------- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx b/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx index 77bd49f25..98e0a97c4 100644 --- a/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx +++ b/apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx @@ -6,7 +6,6 @@ import { QueryEngine } from "~/services/QueryEngine"; import SearchBar from "~/components/SearchBar"; import { addWikilinkBlockrefForFile } from "./stores/assetStore"; import { getFrontmatterForFile } from "./shapes/discourseNodeShapeUtils"; -import { DiscourseNode } from "~/types"; export const ExistingNodeSearch = ({ plugin, @@ -23,7 +22,7 @@ export const ExistingNodeSearch = ({ const search = useCallback( async (query: string) => { - return engine.searchDiscourseNodesByTitle(query, nodeTypeId); + return await engine.searchDiscourseNodesByTitle(query, nodeTypeId); }, [engine, nodeTypeId], ); diff --git a/apps/obsidian/src/components/canvas/TldrawView.tsx b/apps/obsidian/src/components/canvas/TldrawView.tsx index e224dbb52..496f8a857 100644 --- a/apps/obsidian/src/components/canvas/TldrawView.tsx +++ b/apps/obsidian/src/components/canvas/TldrawView.tsx @@ -151,7 +151,6 @@ export class TldrawView extends TextFileView { diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index b69f77ab6..61c73dc60 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -20,7 +20,6 @@ import { TLData, processInitialData, } from "~/components/canvas/utils/tldraw"; -import DiscourseGraphPlugin from "~/index"; import { DEFAULT_SAVE_DELAY, TLDATA_DELIMITER_END, @@ -32,17 +31,16 @@ import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; import { DiscourseNodeUtil } from "~/components/canvas/shapes/DiscourseNodeShape"; import { DiscourseNodeTool } from "./DiscourseNodeTool"; import { DiscourseNodePanel } from "./DiscourseNodePanel"; +import { usePlugin } from "~/components/PluginContext"; interface TldrawPreviewProps { store: TLStore; - plugin: DiscourseGraphPlugin; file: TFile; assetStore: ObsidianTLAssetStore; } export const TldrawPreviewComponent = ({ store, - plugin, file, assetStore, }: TldrawPreviewProps) => { @@ -52,6 +50,7 @@ export const TldrawPreviewComponent = ({ const saveTimeoutRef = useRef(); const lastSavedDataRef = useRef(""); const editorRef = useRef(); + const plugin = usePlugin(); const customShapeUtils = [ ...defaultShapeUtils, diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index 23a071b78..019169b30 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -9,20 +9,31 @@ type AppWithPlugins = App & { plugins: { plugins: { [key: string]: { - api: any; + api: unknown; }; }; }; }; +type DatacorePage = { + $name: string; + $path?: string; +}; + export class QueryEngine { private app: App; - private dc: any; + private dc: + | { + query: (query: string) => DatacorePage[]; + } + | undefined; private readonly MIN_QUERY_LENGTH = 2; constructor(app: App) { const appWithPlugins = app as AppWithPlugins; - this.dc = appWithPlugins.plugins?.plugins?.["datacore"]?.api; + this.dc = appWithPlugins.plugins?.plugins?.["datacore"]?.api as + | { query: (query: string) => DatacorePage[] } + | undefined; this.app = app; } @@ -49,19 +60,19 @@ export class QueryEngine { : "@page and exists(nodeTypeId)"; const potentialNodes = await this.dc.query(dcQuery); - const searchResults = potentialNodes.filter((p: any) => + const searchResults = potentialNodes.filter((p: DatacorePage) => this.fuzzySearch(p.$name, query), ); const files = searchResults - .map((dcFile: any) => { + .map((dcFile: DatacorePage) => { if (dcFile && dcFile.$path) { const realFile = this.app.vault.getAbstractFileByPath(dcFile.$path); if (realFile && realFile instanceof TFile) return realFile; } - return dcFile as TFile; + return null; }) - .filter((f: TFile | null): f is TFile => Boolean(f)); + .filter((f): f is TFile => f instanceof TFile); return files; } catch (error) { @@ -97,31 +108,32 @@ export class QueryEngine { .join(" or ")}`; const potentialNodes = this.dc.query(dcQuery); - const searchResults = potentialNodes.filter((p: any) => { + const searchResults = potentialNodes.filter((p: DatacorePage) => { return this.fuzzySearch(p.$name, query); }); let existingRelatedFiles: string[] = []; if (selectedRelationType) { const fileCache = this.app.metadataCache.getFileCache(activeFile); - const existingRelations = - fileCache?.frontmatter?.[selectedRelationType] || []; + const existingRelations: string[] = + (fileCache?.frontmatter?.[selectedRelationType] as string[]) || []; existingRelatedFiles = existingRelations.map((relation: string) => { const match = relation.match(/\[\[(.*?)(?:\|.*?)?\]\]/); - return match ? match[1] : relation.replace(/^\[\[|\]\]$/g, ""); + return match?.[1] ?? relation.replace(/^\[\[|\]\]$/g, ""); }); } const finalResults = searchResults - .map((dcFile: any) => { + .map((dcFile: DatacorePage) => { if (dcFile && dcFile.$path) { const realFile = this.app.vault.getAbstractFileByPath(dcFile.$path); if (realFile && realFile instanceof TFile) { return realFile; } } - return dcFile as TFile; + return null; }) + .filter((f): f is TFile => f instanceof TFile) .filter((file: TFile) => { if (file.path === activeFile.path) return false; @@ -236,6 +248,7 @@ export class QueryEngine { ); if (regex.test(fileName)) { + if (!page.$path) continue; const file = this.app.vault.getAbstractFileByPath(page.$path); if (file && file instanceof TFile) { const extractedContent = extractContentFromTitle( From 16fcb65b9e79fc6f628a8e3d77e276f53cbab810 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 30 Aug 2025 12:02:41 -0400 Subject: [PATCH 3/3] small changes --- apps/obsidian/src/components/SearchBar.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/obsidian/src/components/SearchBar.tsx b/apps/obsidian/src/components/SearchBar.tsx index 096b87e94..df1899043 100644 --- a/apps/obsidian/src/components/SearchBar.tsx +++ b/apps/obsidian/src/components/SearchBar.tsx @@ -102,6 +102,7 @@ const SearchBar = ({ (item) => { setSelected(item); onSelect(item); + inputRef.current?.blur(); }, { getItemText: (item: T) => getItemText(item), @@ -127,14 +128,12 @@ const SearchBar = ({ }, [onSelect]); return ( -
+
({ {selected && !disabled && (