Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion apps/obsidian/src/components/canvas/TldrawViewComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
defaultBindingUtils,
TLPointerEventInfo,
DefaultSharePanel,
type TLDefaultExternalContentHandlerOpts,
type TLUiToast,
} from "tldraw";
import "tldraw/tldraw.css";
import {
Expand All @@ -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,
Expand All @@ -52,6 +54,7 @@ import {
openFileInNewLeaf,
resolveDiscourseNodeFile,
} from "./utils/openFileUtils";
import { handleExternalUrlContent } from "./utils/externalContentHandlers";
type TldrawPreviewProps = {
store: TLStore;
file: TFile;
Expand Down Expand Up @@ -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<TLUiToast, "id"> & { id?: string }) => {
new Notice(t.description ?? t.title ?? "Error");
return "";
},
removeToast: () => "",
clearToasts: () => {},
toasts: { get: () => [], update: () => {} },
},
msg: (key?: string) => key ?? "",
} as unknown as TLDefaultExternalContentHandlerOpts,
});
});
Comment on lines +270 to +290
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The void keyword is used with an async function inside a handler, which means unhandled promise rejections could occur if handleExternalUrlContent throws an error outside of its try-catch blocks. While the function has internal error handling, any unexpected errors in the async chain (like in ensureBlockRefForFile, getFirstImageSrcForFile, or calcDiscourseNodeSize) that aren't caught will result in unhandled promise rejections.

editor.registerExternalContentHandler("url", async (externalContent) => {
  try {
    await handleExternalUrlContent({...});
  } catch (error) {
    console.error('Error handling external URL content:', error);
    new Notice('Failed to add content to canvas');
  }
});

Alternatively, if the handler must be synchronous, ensure handleExternalUrlContent has comprehensive error handling for all async operations.

Suggested change
editor.registerExternalContentHandler("url", (externalContent) => {
void handleExternalUrlContent({
editor,
url: externalContent.url,
point: externalContent.point,
plugin,
canvasFile: file,
defaultHandlerOpts: {
toasts: {
addToast: (t: Omit<TLUiToast, "id"> & { id?: string }) => {
new Notice(t.description ?? t.title ?? "Error");
return "";
},
removeToast: () => "",
clearToasts: () => {},
toasts: { get: () => [], update: () => {} },
},
msg: (key?: string) => key ?? "",
} as unknown as TLDefaultExternalContentHandlerOpts,
});
});
editor.registerExternalContentHandler("url", async (externalContent) => {
try {
await handleExternalUrlContent({
editor,
url: externalContent.url,
point: externalContent.point,
plugin,
canvasFile: file,
defaultHandlerOpts: {
toasts: {
addToast: (t: Omit<TLUiToast, "id"> & { id?: string }) => {
new Notice(t.description ?? t.title ?? "Error");
return "";
},
removeToast: () => "",
clearToasts: () => {},
toasts: { get: () => [], update: () => {} },
},
msg: (key?: string) => key ?? "",
} as unknown as TLDefaultExternalContentHandlerOpts,
});
} catch (error) {
console.error('Error handling external URL content:', error);
new Notice('Failed to add content to canvas');
}
});

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +270 to +290
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 External content handler discards promise with void, breaking tldraw's async handler contract

The registerExternalContentHandler callback uses void handleExternalUrlContent(...) which discards the returned promise, causing the handler to return undefined synchronously instead of a Promise<void>.

Root Cause and Impact

Tldraw's registerExternalContentHandler expects the handler to return a Promise<void> so it can await the handler's completion. By using void (TldrawViewComponent.tsx:271), the promise from handleExternalUrlContent is discarded and the callback returns undefined synchronously.

When tldraw does await handler(content), it gets undefined, which resolves immediately. This means tldraw will consider the content handling "done" before the async work actually completes. Concretely:

  1. For obsidian:// URLs: ensureBlockRefForFile at externalContentHandlers.ts:170 is async and must complete before the shape can be created. Since tldraw isn't waiting, drag indicators and other UI may dismiss before the node appears on canvas.
  2. For non-obsidian URLs: The fallback call to defaultHandleExternalUrlContent at externalContentHandlers.ts:145 also runs fire-and-forget, so bookmark/embed creation may not complete properly.
  3. Error handling: Errors thrown after the first await inside handleExternalUrlContent become unhandled promise rejections instead of being caught by tldraw's error handling.

The fix is to return the promise instead of discarding it, e.g. return handleExternalUrlContent(...) instead of void handleExternalUrlContent(...).

Suggested change
editor.registerExternalContentHandler("url", (externalContent) => {
void handleExternalUrlContent({
editor,
url: externalContent.url,
point: externalContent.point,
plugin,
canvasFile: file,
defaultHandlerOpts: {
toasts: {
addToast: (t: Omit<TLUiToast, "id"> & { id?: string }) => {
new Notice(t.description ?? t.title ?? "Error");
return "";
},
removeToast: () => "",
clearToasts: () => {},
toasts: { get: () => [], update: () => {} },
},
msg: (key?: string) => key ?? "",
} as unknown as TLDefaultExternalContentHandlerOpts,
});
});
editor.registerExternalContentHandler("url", (externalContent) => {
return handleExternalUrlContent({
editor,
url: externalContent.url,
point: externalContent.point,
plugin,
canvasFile: file,
defaultHandlerOpts: {
toasts: {
addToast: (t: Omit<TLUiToast, "id"> & { id?: string }) => {
new Notice(t.description ?? t.title ?? "Error");
return "";
},
removeToast: () => "",
clearToasts: () => {},
toasts: { get: () => [], update: () => {} },
},
msg: (key?: string) => key ?? "",
} as unknown as TLDefaultExternalContentHandlerOpts,
});
});
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


editor.on("event", (event) => {
// Handle pointer events
if (event.type !== "pointer") return;
Expand Down
23 changes: 7 additions & 16 deletions apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 51 additions & 10 deletions apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
BaseBoxShapeUtil,
Editor,
HTMLContainer,
resizeBox,
T,
TLBaseShape,
TLResizeInfo,
TLShapeId,
useEditor,
useValue,
DefaultSizeStyle,
Expand Down Expand Up @@ -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<DiscourseNodeShape["props"]> &
Pick<DiscourseNodeShape["props"], "src" | "title" | "nodeTypeId">;
};

/**
* 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<DiscourseNodeShape> {
static type = "discourse-node" as const;
declare options: DiscourseNodeUtilOptions;
Expand All @@ -68,16 +118,7 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
};

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;
Expand Down
Loading