diff --git a/apps/obsidian/src/components/TldrawView.tsx b/apps/obsidian/src/components/canvas/TldrawView.tsx similarity index 91% rename from apps/obsidian/src/components/TldrawView.tsx rename to apps/obsidian/src/components/canvas/TldrawView.tsx index 44fd45528..af1c3fb43 100644 --- a/apps/obsidian/src/components/TldrawView.tsx +++ b/apps/obsidian/src/components/canvas/TldrawView.tsx @@ -5,8 +5,8 @@ import { TldrawPreviewComponent } from "./TldrawViewComponent"; import { TLStore } from "tldraw"; import React from "react"; import DiscourseGraphPlugin from "~/index"; -import { processInitialData, TLData } from "~/utils/tldraw"; -import { ObsidianTLAssetStore } from "~/utils/assetStore"; +import { processInitialData, TLData } from "~/components/canvas/tldraw"; +import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; export class TldrawView extends TextFileView { plugin: DiscourseGraphPlugin; @@ -106,7 +106,16 @@ export class TldrawView extends TextFileView { return; } - const { store } = processInitialData(data, assetStore); + if (!this.file) { + console.warn("TldrawView not initialized: missing file"); + return; + } + + const { store } = processInitialData(data, assetStore, { + app: this.app, + canvasFile: this.file, + plugin: this.plugin, + }); return store; } catch (e) { @@ -131,6 +140,11 @@ export class TldrawView extends TextFileView { if (!this.store) throw new Error("TldrawView not initialized: missing store"); + if (!this.assetStore) { + console.warn("Asset store is not set"); + return; + } + root.render( (); const lastSavedDataRef = useRef(""); + const customShapeUtils = [ + ...defaultShapeUtils, + DiscourseNodeUtil.configure({ + app: plugin.app, + canvasFile: file, + plugin, + }), + ]; + useEffect(() => { const timer = setTimeout(() => { setIsReady(true); @@ -92,7 +103,11 @@ export const TldrawPreviewComponent = ({ ); if (match?.[1]) { const data = JSON.parse(match[1]) as TLData; - const { store: newStore } = processInitialData(data, assetStore); + const { store: newStore } = processInitialData(data, assetStore, { + app: plugin.app, + canvasFile: file, + plugin, + }); setCurrentStore(newStore); } } @@ -128,7 +143,12 @@ export const TldrawPreviewComponent = ({
Error in Tldraw component: {JSON.stringify(error)}
)} > - + ) : (
Loading Tldraw...
diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx new file mode 100644 index 000000000..f95839d72 --- /dev/null +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx @@ -0,0 +1,199 @@ +import { + BaseBoxShapeUtil, + HTMLContainer, + T, + TLBaseShape, + useEditor, +} from "tldraw"; +import type { App, TFile } from "obsidian"; +import { memo, createElement, useEffect } from "react"; +import DiscourseGraphPlugin from "~/index"; +import { + getFrontmatterForFile, + getNodeTypeIdFromFrontmatter, + getNodeTypeById, + FrontmatterRecord, +} from "./discourseNodeShapeUtils"; +import { DiscourseNode } from "~/types"; +import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore"; + +export type DiscourseNodeShape = TLBaseShape< + "discourse-node", + { + w: number; + h: number; + // asset-style source: asset:obsidian.blockref. + src: string | null; + // Cached display data + title: string; + nodeTypeId: string; + } +>; + +export type DiscourseNodeUtilOptions = { + app: App; + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}; + +export class DiscourseNodeUtil extends BaseBoxShapeUtil { + static type = "discourse-node" as const; + declare options: DiscourseNodeUtilOptions; + + static props = { + w: T.number, + h: T.number, + src: T.string.nullable(), + title: T.string.optional(), + nodeTypeId: T.string.nullable().optional(), + nodeTypeName: T.string.optional(), + }; + + getDefaultProps(): DiscourseNodeShape["props"] { + return { + w: 200, + h: 100, + src: null, + title: "", + nodeTypeId: "", + }; + } + + component(shape: DiscourseNodeShape) { + return ( + + {createElement(discourseNodeContent, { + shape, + app: this.options.app, + canvasFile: this.options.canvasFile, + plugin: this.options.plugin, + })} + + ); + } + + indicator(shape: DiscourseNodeShape) { + return ; + } + + getFile = async ( + shape: DiscourseNodeShape, + ctx: { app: App; canvasFile: TFile }, + ): Promise => { + const app = ctx?.app ?? this.options.app; + const canvasFile = ctx?.canvasFile ?? this.options.canvasFile; + return resolveLinkedFileFromSrc({ + app, + canvasFile, + src: shape.props.src ?? undefined, + }); + }; + + getFrontmatter = async ( + shape: DiscourseNodeShape, + ctx: { app: App; canvasFile: TFile }, + ): Promise => { + const app = ctx?.app ?? this.options.app; + const file = await this.getFile(shape, ctx); + if (!file) return null; + return getFrontmatterForFile(app, file); + }; + + getRelations = async ( + shape: DiscourseNodeShape, + ctx: { app: App; canvasFile: TFile }, + ): Promise => { + const frontmatter = await this.getFrontmatter(shape, ctx); + if (!frontmatter) return []; + // TODO: derive relations from frontmatter + return []; + }; +} + +const discourseNodeContent = memo( + ({ + shape, + app, + canvasFile, + plugin, + }: { + shape: DiscourseNodeShape; + app: App; + canvasFile: TFile; + plugin: DiscourseGraphPlugin; + }) => { + const editor = useEditor(); + const { src, title, nodeTypeId } = shape.props; + const nodeType = getNodeTypeById(plugin, nodeTypeId); + + useEffect(() => { + const loadNodeData = async () => { + if (!src) { + editor.updateShape({ + id: shape.id, + type: "discourse-node", + props: { + ...shape.props, + title: "(no source)", + }, + }); + return; + } + + try { + const linkedFile = await resolveLinkedFileFromSrc({ + app, + canvasFile, + src, + }); + + if (!linkedFile) { + editor.updateShape({ + id: shape.id, + type: "discourse-node", + props: { + ...shape.props, + title: "(unlinked)", + }, + }); + return; + } + + if (linkedFile.basename !== shape.props.title) { + editor.updateShape({ + id: shape.id, + type: "discourse-node", + props: { + ...shape.props, + title: linkedFile.basename, + }, + }); + } + } catch (error) { + console.error("Error loading node data", error); + return; + } + }; + + void loadNodeData(); + + return () => { + return; + }; + }, [src, shape.id, shape.props, editor, app, canvasFile, plugin]); + + return ( +
+

{title || "..."}

+

{nodeType?.name || ""}

+
+ ); + }, +); + +discourseNodeContent.displayName = "DiscourseNodeContent"; diff --git a/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts new file mode 100644 index 000000000..b5bcf9b40 --- /dev/null +++ b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts @@ -0,0 +1,39 @@ +import type { App, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { DiscourseNode } from "~/types"; +import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore"; + +export type FrontmatterRecord = Record; + +export const getFrontmatterForFile = ( + app: App, + file: TFile, +): FrontmatterRecord | null => { + return (app.metadataCache.getFileCache(file)?.frontmatter ?? + null) as FrontmatterRecord | null; +}; + +export const getNodeTypeIdFromFrontmatter = ( + frontmatter: FrontmatterRecord | null, +): string | null => { + if (!frontmatter) return null; + return (frontmatter as { nodeTypeId?: string })?.nodeTypeId ?? null; +}; + +export const getNodeTypeById = ( + plugin: DiscourseGraphPlugin, + nodeTypeId: string | null, +): DiscourseNode | null => { + if (!nodeTypeId) return null; + return ( + plugin.settings.nodeTypes.find((nodeType) => nodeType.id === nodeTypeId) ?? + null + ); +}; + +export const getRelationsFromFrontmatter = ( + _frontmatter: FrontmatterRecord | null, +): unknown[] => { + // TODO: derive relations from frontmatter when schema is defined + return []; +}; diff --git a/apps/obsidian/src/utils/assetStore.ts b/apps/obsidian/src/components/canvas/stores/assetStore.ts similarity index 69% rename from apps/obsidian/src/utils/assetStore.ts rename to apps/obsidian/src/components/canvas/stores/assetStore.ts index 30f25b0f1..732150f82 100644 --- a/apps/obsidian/src/utils/assetStore.ts +++ b/apps/obsidian/src/components/canvas/stores/assetStore.ts @@ -11,6 +11,77 @@ type AssetStoreOptions = { file: TFile; }; +/** + * Extract the block reference id from either an asset src string (e.g., + * `asset:obsidian.blockref.`) or from the internal asset id with the + * `obsidian.blockref.` prefix. Returns null if the input is not a blockref. + */ +export const extractBlockRefId = (assetIdOrSrc?: string): string | null => { + if (!assetIdOrSrc) return null; + // From app-level src: asset:obsidian.blockref. + if (assetIdOrSrc.startsWith("asset:")) { + const raw = assetIdOrSrc.split(":")[1] ?? ""; + if (!raw.startsWith(ASSET_PREFIX)) return null; + return raw.slice(ASSET_PREFIX.length); + } + // From internal asset id: obsidian.blockref. + if (assetIdOrSrc.startsWith(ASSET_PREFIX)) { + return assetIdOrSrc.slice(ASSET_PREFIX.length); + } + return null; +}; + +/** + * Given a block reference id present in the current canvas markdown file, resolve + * the linked Obsidian file referenced by the block (i.e., the file inside the [[link]]). + */ +export const resolveLinkedTFileByBlockRef = async ( + app: App, + canvasFile: TFile, + blockRefId: string, +): Promise => { + try { + if (!blockRefId) return null; + + const fileCache = app.metadataCache.getFileCache(canvasFile); + if (!fileCache?.blocks?.[blockRefId]) return null; + + const block = fileCache.blocks[blockRefId]; + const fileContent = await app.vault.read(canvasFile); + const blockContent = fileContent.substring( + block.position.start.offset, + block.position.end.offset, + ); + + const match = blockContent.match(/\[\[(.*?)\]\]/); + if (!match?.[1]) return null; + const rawLink = match[1].trim(); + // Drop alias part in [[path|alias]] + const linkPath = rawLink.split("|")[0] ?? rawLink; + return ( + app.metadataCache.getFirstLinkpathDest(linkPath, canvasFile.path) ?? null + ); + } catch (error) { + console.error("Error resolving linked TFile from blockRef:", error); + return null; + } +}; + +export const resolveLinkedFileFromSrc = async ({ + app, + canvasFile, + src, +}: { + app: App; + canvasFile: TFile; + src?: string; +}): Promise => { + if (!src) return null; + const blockRef = extractBlockRefId(src); + if (!blockRef) return null; + return resolveLinkedTFileByBlockRef(app, canvasFile, blockRef); +}; + /** * Proxy class that handles Obsidian-specific file operations for the TLAssetStore */ @@ -73,7 +144,7 @@ class ObsidianMarkdownFileTLAssetStoreProxy { this.setCachedUrl(assetId, assetDataUri); return assetId; - } + }; getCached = async ( blockRefAssetId: BlockRefAssetId, @@ -94,14 +165,14 @@ class ObsidianMarkdownFileTLAssetStoreProxy { console.error("Error getting cached asset:", error); return null; } - } + }; dispose = () => { for (const url of this.resolvedAssetDataCache.values()) { URL.revokeObjectURL(url); } this.resolvedAssetDataCache.clear(); - } + }; private addToTopOfFile = async (content: string) => { await this.app.vault.process(this.file, (data: string) => { @@ -115,34 +186,20 @@ class ObsidianMarkdownFileTLAssetStoreProxy { const rest = data.slice(end.offset); return `${frontmatter}\n${content}\n${rest}`; }); - } + }; private getAssetData = async ( blockRefAssetId: BlockRefAssetId, ): Promise => { try { - const blockRef = blockRefAssetId.slice(ASSET_PREFIX.length); + const blockRef = extractBlockRefId(blockRefAssetId); if (!blockRef) return null; - const fileCache = this.app.metadataCache.getFileCache(this.file); - if (!fileCache?.blocks?.[blockRef]) return null; - - const block = fileCache.blocks[blockRef]; - const fileContent = await this.app.vault.read(this.file); - const blockContent = fileContent.substring( - block.position.start.offset, - block.position.end.offset, - ); - - const match = blockContent.match(/\[\[(.*?)\]\]/); - if (!match?.[1]) return null; - - const linkPath = match[1]; - const linkedFile = this.app.metadataCache.getFirstLinkpathDest( - linkPath, - this.file.path, + const linkedFile = await resolveLinkedTFileByBlockRef( + this.app, + this.file, + blockRef, ); - if (!linkedFile) return null; // TODO: handle other file types too return await this.app.vault.readBinary(linkedFile); @@ -150,7 +207,7 @@ class ObsidianMarkdownFileTLAssetStoreProxy { console.error("Error getting asset data:", error); return null; } - } + }; } /** @@ -174,9 +231,6 @@ export class ObsidianTLAssetStore implements Required { const blockRefAssetId = await this.proxy.storeAsset(asset, file); return { src: `asset:${blockRefAssetId}`, - meta: { - mimeType: file.type, - }, }; } catch (error) { console.error("Error uploading asset:", error); @@ -194,19 +248,7 @@ export class ObsidianTLAssetStore implements Required { const assetId = assetSrc.split(":")[1] as BlockRefAssetId; if (!assetId) return null; - - const mimeType = - (asset.props as unknown as { mimeType?: string })?.mimeType ?? - (asset.meta as unknown as { mimeType?: string })?.mimeType ?? - ""; - - if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) { - return await this.proxy.getCached(assetId); - } - - // Non-media (e.g., text/markdown, application/*): let custom shapes decide. - // Return null so default media shapes won't attempt to render it. - return null; + return await this.proxy.getCached(assetId); } catch (error) { console.error("Error resolving asset:", error); return null; @@ -221,4 +263,4 @@ export class ObsidianTLAssetStore implements Required { dispose = () => { this.proxy.dispose(); }; -} +} \ No newline at end of file diff --git a/apps/obsidian/src/utils/tldraw.ts b/apps/obsidian/src/components/canvas/tldraw.ts similarity index 88% rename from apps/obsidian/src/utils/tldraw.ts rename to apps/obsidian/src/components/canvas/tldraw.ts index a34d140c6..8b551108e 100644 --- a/apps/obsidian/src/utils/tldraw.ts +++ b/apps/obsidian/src/components/canvas/tldraw.ts @@ -1,4 +1,10 @@ -import { createTLStore, defaultShapeUtils, TldrawFile, TLRecord, TLStore } from "tldraw"; +import { + createTLStore, + defaultShapeUtils, + TldrawFile, + TLRecord, + TLStore, +} from "tldraw"; import { FRONTMATTER_KEY, TLDATA_DELIMITER_END, @@ -6,10 +12,14 @@ import { TLDRAW_VERSION, } from "~/constants"; import DiscourseGraphPlugin from "~/index"; -import { checkAndCreateFolder, getNewUniqueFilepath } from "./file"; +import { checkAndCreateFolder, getNewUniqueFilepath } from "../../utils/file"; import { Notice } from "obsidian"; import { format } from "date-fns"; -import { ObsidianTLAssetStore } from "./assetStore"; +import { ObsidianTLAssetStore } from "./stores/assetStore"; +import { + DiscourseNodeUtil, + DiscourseNodeUtilOptions, +} from "~/components/canvas/shapes/DiscourseNodeShape"; export type TldrawPluginMetaData = { "plugin-version": string; @@ -31,7 +41,13 @@ export type TLData = { export const processInitialData = ( data: TLData, assetStore: ObsidianTLAssetStore, + ctx: DiscourseNodeUtilOptions, ): { meta: TldrawPluginMetaData; store: TLStore } => { + const customShapeUtils = [ + ...defaultShapeUtils, + DiscourseNodeUtil.configure(ctx), + ]; + const recordsData = Array.isArray(data.raw.records) ? data.raw.records.reduce( (acc: Record, record: { id: string } & TLRecord) => { @@ -47,13 +63,13 @@ export const processInitialData = ( let store: TLStore; if (recordsData) { store = createTLStore({ - shapeUtils: defaultShapeUtils, + shapeUtils: customShapeUtils, initialData: recordsData, assets: assetStore, }); } else { store = createTLStore({ - shapeUtils: defaultShapeUtils, + shapeUtils: customShapeUtils, assets: assetStore, }); } diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 6be72ae09..28b78e282 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -20,7 +20,7 @@ import { } from "~/utils/createNode"; import { DEFAULT_SETTINGS } from "~/constants"; import { CreateNodeModal } from "~/components/CreateNodeModal"; -import { TldrawView } from "~/components/TldrawView"; +import { TldrawView } from "~/components/canvas/TldrawView"; export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 8a52ec075..136c5bbe9 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -5,7 +5,7 @@ import { CreateNodeModal } from "~/components/CreateNodeModal"; import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal"; import { createDiscourseNode } from "./createNode"; import { VIEW_TYPE_MARKDOWN, VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; -import { createCanvas } from "./tldraw"; +import { createCanvas } from "../components/canvas/tldraw"; export const registerCommands = (plugin: DiscourseGraphPlugin) => { plugin.addCommand({