diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index ce5171db3..d196a04b6 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -13,6 +13,8 @@ import { defaultBindingUtils, TLPointerEventInfo, DefaultSharePanel, + type TLDefaultExternalContentHandlerOpts, + type TLUiToast, } from "tldraw"; import "tldraw/tldraw.css"; import { @@ -27,7 +29,7 @@ import { TLDATA_DELIMITER_END, TLDATA_DELIMITER_START, } from "~/constants"; -import { TFile } from "obsidian"; +import { Notice, TFile } from "obsidian"; import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore"; import { createDiscourseNodeUtil, @@ -52,6 +54,7 @@ import { openFileInNewLeaf, resolveDiscourseNodeFile, } from "./utils/openFileUtils"; +import { handleExternalUrlContent } from "./utils/externalContentHandlers"; type TldrawPreviewProps = { store: TLStore; file: TFile; @@ -264,6 +267,28 @@ export const TldrawPreviewComponent = ({ editorRef.current = editor; setIsEditorMounted(true); + editor.registerExternalContentHandler("url", (externalContent) => { + void handleExternalUrlContent({ + editor, + url: externalContent.url, + point: externalContent.point, + plugin, + canvasFile: file, + defaultHandlerOpts: { + toasts: { + addToast: (t: Omit & { id?: string }) => { + new Notice(t.description ?? t.title ?? "Error"); + return ""; + }, + removeToast: () => "", + clearToasts: () => {}, + toasts: { get: () => [], update: () => {} }, + }, + msg: (key?: string) => key ?? "", + } as unknown as TLDefaultExternalContentHandlerOpts, + }); + }); + editor.on("event", (event) => { // Handle pointer events if (event.type !== "pointer") return; diff --git a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx index 8bba05dd2..4a35a3d94 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx @@ -1,7 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import type { TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; -import { DiscourseNodeShape } from "~/components/canvas/shapes/DiscourseNodeShape"; +import { + buildDiscourseNodeShapeRecord, + type DiscourseNodeShape, +} from "~/components/canvas/shapes/DiscourseNodeShape"; import { ensureBlockRefForFile, resolveLinkedFileFromSrc, @@ -229,37 +232,25 @@ export const RelationsPanel = ({ if (existing) return existing; - // Create a new node shape near the selected node const newId = createShapeId(); const src = `asset:obsidian.blockref.${blockRef}`; const x = nodeShape.x + nodeShape.props.w + 80; const y = nodeShape.y; - const nodeTypeId = getFrontmatterForFile(plugin.app, file) ?.nodeTypeId as string; - const created: DiscourseNodeShape = { + const created = buildDiscourseNodeShapeRecord(editor, { id: newId, - typeName: "shape", - type: "discourse-node", x, y, - rotation: 0, - index: editor.getHighestIndexForParent(editor.getCurrentPageId()), - parentId: editor.getCurrentPageId(), - isLocked: false, - opacity: 1, - meta: {}, props: { - w: 200, - h: 100, src, title: file.basename, - nodeTypeId: nodeTypeId, + nodeTypeId, size: "m", fontFamily: "sans", }, - }; + }); editor.createShape(created); return created; diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx index c770fb732..f3c0f984e 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx @@ -1,10 +1,12 @@ import { BaseBoxShapeUtil, + Editor, HTMLContainer, resizeBox, T, TLBaseShape, TLResizeInfo, + TLShapeId, useEditor, useValue, DefaultSizeStyle, @@ -52,6 +54,54 @@ export type DiscourseNodeUtilOptions = { canvasFile: TFile; }; +/** Default props for new discourse node shapes. Used by getDefaultProps and buildDiscourseNodeShapeRecord. */ +export const DEFAULT_DISCOURSE_NODE_PROPS: DiscourseNodeShape["props"] = { + w: 200, + h: 100, + src: null, + title: "", + nodeTypeId: "", + imageSrc: undefined, + size: "s", + fontFamily: "sans", +}; + +export type BuildDiscourseNodeShapeRecordParams = { + id: TLShapeId; + x: number; + y: number; + props: Partial & + Pick; +}; + +/** + * Build a full DiscourseNodeShape record for editor.createShape. + * Merges given props with DEFAULT_DISCOURSE_NODE_PROPS. + */ +export const buildDiscourseNodeShapeRecord = ( + editor: Editor, + { id, x, y, props: propsPartial }: BuildDiscourseNodeShapeRecordParams, +): DiscourseNodeShape => { + const props: DiscourseNodeShape["props"] = { + ...DEFAULT_DISCOURSE_NODE_PROPS, + ...propsPartial, + }; + return { + id, + typeName: "shape", + type: "discourse-node", + x, + y, + rotation: 0, + index: editor.getHighestIndexForParent(editor.getCurrentPageId()), + parentId: editor.getCurrentPageId(), + isLocked: false, + opacity: 1, + meta: {}, + props, + }; +}; + export class DiscourseNodeUtil extends BaseBoxShapeUtil { static type = "discourse-node" as const; declare options: DiscourseNodeUtilOptions; @@ -68,16 +118,7 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil { }; getDefaultProps(): DiscourseNodeShape["props"] { - return { - w: 200, - h: 100, - src: null, - title: "", - nodeTypeId: "", - imageSrc: undefined, - size: "s", - fontFamily: "sans", - }; + return { ...DEFAULT_DISCOURSE_NODE_PROPS }; } override isAspectRatioLocked = () => false; diff --git a/apps/obsidian/src/components/canvas/utils/externalContentHandlers.ts b/apps/obsidian/src/components/canvas/utils/externalContentHandlers.ts new file mode 100644 index 000000000..643fb2de4 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/externalContentHandlers.ts @@ -0,0 +1,222 @@ +import type { Editor, VecLike } from "tldraw"; +import { createShapeId } from "tldraw"; +import { TFile } from "obsidian"; +import { Notice } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import { + defaultHandleExternalUrlContent, + type TLDefaultExternalContentHandlerOpts, +} from "tldraw"; +import { + ensureBlockRefForFile, + extractBlockRefId, +} from "~/components/canvas/stores/assetStore"; +import { getFrontmatterForFile } from "~/components/canvas/shapes/discourseNodeShapeUtils"; +import { + buildDiscourseNodeShapeRecord, + type DiscourseNodeShape, +} from "~/components/canvas/shapes/DiscourseNodeShape"; +import { getNodeTypeById } from "~/utils/typeUtils"; +import { calcDiscourseNodeSize } from "~/utils/calcDiscourseNodeSize"; +import { getFirstImageSrcForFile } from "~/components/canvas/shapes/discourseNodeShapeUtils"; + +const OBSIDIAN_URL_PREFIX = "obsidian://"; + +type ParsedObsidianUrl = { + vault: string; + filePath: string; +}; + +/** + * Parse obsidian://open?vault=...&file=... URLs into vault name and decoded file path. + * Returns null if the URL is not a valid obsidian open link. + */ +export const parseObsidianOpenUrl = (url: string): ParsedObsidianUrl | null => { + if (!url.startsWith(OBSIDIAN_URL_PREFIX)) return null; + + try { + const parsed = new URL(url); + const vault = parsed.searchParams.get("vault") ?? ""; + const file = parsed.searchParams.get("file"); + if (!file) return null; + return { + vault, + filePath: file, + }; + } catch { + return null; + } +}; + +/** + * Resolve an obsidian URL to a TFile in the current vault. + * Returns null if the URL points to another vault or the file is not found. + */ +const resolveObsidianUrlToFile = ( + plugin: DiscourseGraphPlugin, + parsed: ParsedObsidianUrl, +): TFile | null => { + const currentVaultName = plugin.app.vault.getName?.() ?? ""; + if (parsed.vault && currentVaultName && parsed.vault !== currentVaultName) { + return null; + } + + let abstract = plugin.app.vault.getAbstractFileByPath(parsed.filePath); + if (!(abstract instanceof TFile) && !parsed.filePath.endsWith(".md")) { + abstract = plugin.app.vault.getAbstractFileByPath( + `${parsed.filePath}.md`, + ); + } + return abstract instanceof TFile ? abstract : null; +}; + +/** + * Check if the dropped file is a discourse node (has nodeTypeId in frontmatter + * that matches a configured node type). + */ +const isDiscourseNodeFile = ( + plugin: DiscourseGraphPlugin, + file: TFile, +): boolean => { + if (!file.path.endsWith(".md")) return false; + const frontmatter = getFrontmatterForFile(plugin.app, file); + const nodeTypeId = (frontmatter as { nodeTypeId?: string } | null)?.nodeTypeId; + if (!nodeTypeId || typeof nodeTypeId !== "string") return false; + return !!getNodeTypeById(plugin, nodeTypeId); +}; + +type HandleExternalUrlOptions = { + editor: Editor; + url: string; + point?: VecLike; + plugin: DiscourseGraphPlugin; + canvasFile: TFile; + defaultHandlerOpts: TLDefaultExternalContentHandlerOpts; +}; + +/** + * Handle URL drops/pastes: obsidian:// links become discourse node shapes when + * the file is a discourse node; otherwise show a notice. Non-obsidian URLs + * are delegated to the default bookmark handler. + */ +export const handleExternalUrlContent = async ({ + editor, + url, + point, + plugin, + canvasFile, + defaultHandlerOpts, +}: HandleExternalUrlOptions): Promise => { + const position = + point ?? + (editor.inputs.shiftKey + ? editor.inputs.currentPagePoint + : editor.getViewportPageBounds().center); + + if (url.startsWith(OBSIDIAN_URL_PREFIX)) { + const parsed = parseObsidianOpenUrl(url); + if (!parsed) { + new Notice("Invalid Obsidian link. Only discourse nodes can be dropped on the canvas."); + return; + } + + const file = resolveObsidianUrlToFile(plugin, parsed); + if (!file) { + new Notice("File not found in this vault."); + return; + } + + if (!isDiscourseNodeFile(plugin, file)) { + new Notice("Only discourse nodes can be dropped on the canvas."); + return; + } + + await createDiscourseNodeShapeAtPoint({ + editor, + file, + position, + plugin, + canvasFile, + }); + return; + } + + try { + await defaultHandleExternalUrlContent( + editor, + { point, url }, + defaultHandlerOpts, + ); + } catch { + new Notice("This link cannot be added to the canvas."); + } +}; + +type CreateDiscourseNodeShapeAtPointOptions = { + editor: Editor; + file: TFile; + position: VecLike; + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}; + +const createDiscourseNodeShapeAtPoint = async ({ + editor, + file, + position, + plugin, + canvasFile, +}: CreateDiscourseNodeShapeAtPointOptions): Promise => { + const blockRef = await ensureBlockRefForFile({ + app: plugin.app, + canvasFile, + targetFile: file, + }); + + const shapes = editor.getCurrentPageShapes(); + const existing = shapes.find((s) => { + if (s.type !== "discourse-node") return false; + const src = (s as DiscourseNodeShape).props.src ?? ""; + return extractBlockRefId(src) === blockRef; + }) as DiscourseNodeShape | undefined; + + if (existing) { + editor.setSelectedShapes([existing.id]); + editor.zoomToSelection({ animation: { duration: editor.options.animationMediumMs } }); + return; + } + + const frontmatter = getFrontmatterForFile(plugin.app, file); + const nodeTypeId = (frontmatter?.nodeTypeId as string) ?? ""; + const imageSrc = await getFirstImageSrcForFile(plugin.app, file); + const { w, h } = await calcDiscourseNodeSize({ + title: file.basename, + nodeTypeId, + imageSrc: imageSrc ?? undefined, + plugin, + }); + + const src = `asset:obsidian.blockref.${blockRef}`; + const newId = createShapeId(); + const created = buildDiscourseNodeShapeRecord(editor, { + id: newId, + x: position.x, + y: position.y, + props: { + w, + h, + src, + title: file.basename, + nodeTypeId, + imageSrc: imageSrc ?? undefined, + size: "m", + fontFamily: "sans", + }, + }); + + editor.run(() => { + editor.createShape(created); + editor.setSelectedShapes([newId]); + editor.markHistoryStoppingPoint("drop discourse node"); + }); +};