diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index d19bfea5e..fce4abd2b 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -93,7 +93,6 @@ import ToastListener, { dispatchToastEvent } from "./ToastListener"; import { CanvasDrawerPanel } from "./CanvasDrawer"; import { ClipboardPanel, ClipboardProvider } from "./Clipboard"; import internalError from "~/utils/internalError"; -import { syncCanvasNodeTitlesOnLoad } from "~/utils/syncCanvasNodeTitlesOnLoad"; import { AUTO_CANVAS_RELATIONS_KEY } from "~/data/userSettings"; import { getSetting } from "~/utils/extensionSettings"; import { isPluginTimerReady, waitForPluginTimer } from "~/utils/pluginTimer"; @@ -117,6 +116,7 @@ import { } from "./useCanvasStoreAdapterArgs"; import posthog from "posthog-js"; import { json, normalizeProps } from "~/utils/getBlockProps"; +import { syncCanvasNodesOnLoad } from "~/utils/syncCanvasNodesOnLoad"; declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -1003,14 +1003,15 @@ const TldrawCanvasShared = ({ appRef.current = app; - void syncCanvasNodeTitlesOnLoad( - app, - allNodes.map((n) => n.type), - allRelationIds, - ).catch((error) => { + void syncCanvasNodesOnLoad({ + editor: app, + nodeTypeIds: allNodes.map((n) => n.type), + relationShapeTypeIds: allRelationIds, + extensionAPI, + }).catch((error) => { internalError({ error, - type: "Canvas: Sync node titles on load", + type: "Canvas: Sync nodes on load", }); }); diff --git a/apps/roam/src/utils/calcCanvasNodeSizeAndImg.ts b/apps/roam/src/utils/calcCanvasNodeSizeAndImg.ts index fe37643e3..8f9aa24cb 100644 --- a/apps/roam/src/utils/calcCanvasNodeSizeAndImg.ts +++ b/apps/roam/src/utils/calcCanvasNodeSizeAndImg.ts @@ -76,7 +76,15 @@ const getFirstImageByUid = (uid: string): string | null => { return findFirstImage(tree); }; -const calcCanvasNodeSizeAndImg = async ({ +const getNodeCanvasSettings = (nodeType: string): Record => { + const allNodes = getDiscourseNodes(); + const canvasSettings = Object.fromEntries( + allNodes.map((n) => [n.type, { ...n.canvasSettings }]), + ); + return canvasSettings[nodeType] || {}; +}; + +export const getCanvasNodeKeyImageUrl = async ({ nodeText, uid, nodeType, @@ -86,26 +94,16 @@ const calcCanvasNodeSizeAndImg = async ({ uid: string; nodeType: string; extensionAPI: OnloadArgs["extensionAPI"]; -}) => { - const allNodes = getDiscourseNodes(); - const canvasSettings = Object.fromEntries( - allNodes.map((n) => [n.type, { ...n.canvasSettings }]), - ); +}): Promise => { const { "query-builder-alias": qbAlias = "", "key-image": isKeyImage = "", "key-image-option": keyImageOption = "", - } = canvasSettings[nodeType] || {}; - - const { w, h } = measureCanvasNodeText({ - ...DEFAULT_STYLE_PROPS, - maxWidth: MAX_WIDTH, - text: nodeText, - }); + } = getNodeCanvasSettings(nodeType); - if (!isKeyImage) return { w, h, imageUrl: "" }; + if (!isKeyImage) return ""; - let imageUrl; + let imageUrl: string | null; if (keyImageOption === "query-builder") { const parentUid = resolveQueryBuilderRef({ queryRef: qbAlias, @@ -122,14 +120,46 @@ const calcCanvasNodeSizeAndImg = async ({ } else { imageUrl = getFirstImageByUid(uid); } + return imageUrl ?? ""; +}; + +const calcCanvasNodeSizeAndImg = async ({ + nodeText, + uid, + nodeType, + extensionAPI, +}: { + nodeText: string; + uid: string; + nodeType: string; + extensionAPI: OnloadArgs["extensionAPI"]; +}) => { + const { w, h } = measureCanvasNodeText({ + ...DEFAULT_STYLE_PROPS, + maxWidth: MAX_WIDTH, + text: nodeText, + }); + + const imageUrl = await getCanvasNodeKeyImageUrl({ + nodeText, + uid, + nodeType, + extensionAPI, + }); + if (!imageUrl) return { w, h, imageUrl: "" }; try { const { width, height } = await loadImage(imageUrl); - if (!width || !height || !Number.isFinite(width) || !Number.isFinite(height)) { + if ( + !width || + !height || + !Number.isFinite(width) || + !Number.isFinite(height) + ) { return { w, h, imageUrl: "" }; } - + const aspectRatio = width / height; const nodeImageHeight = w / aspectRatio; const newHeight = h + nodeImageHeight; diff --git a/apps/roam/src/utils/syncCanvasNodeTitlesOnLoad.ts b/apps/roam/src/utils/syncCanvasNodesOnLoad.ts similarity index 58% rename from apps/roam/src/utils/syncCanvasNodeTitlesOnLoad.ts rename to apps/roam/src/utils/syncCanvasNodesOnLoad.ts index acd74df5d..05bfe099c 100644 --- a/apps/roam/src/utils/syncCanvasNodeTitlesOnLoad.ts +++ b/apps/roam/src/utils/syncCanvasNodesOnLoad.ts @@ -1,5 +1,9 @@ import type { Editor } from "tldraw"; +import type { OnloadArgs } from "roamjs-components/types"; import type { DiscourseNodeShape } from "~/components/canvas/DiscourseNodeUtil"; +import calcCanvasNodeSizeAndImg, { + getCanvasNodeKeyImageUrl, +} from "./calcCanvasNodeSizeAndImg"; /** * Query Roam for current :node/title or :block/string for each uid. @@ -56,11 +60,37 @@ const deleteNodeShapeAndRelations = ( * - Updates shapes whose title changed * - Removes shapes whose uid no longer exists in the graph */ +export const syncCanvasNodesOnLoad = async ({ + editor, + nodeTypeIds, + relationShapeTypeIds, + extensionAPI, +}: { + editor: Editor; + nodeTypeIds: string[]; + relationShapeTypeIds: string[]; + extensionAPI: OnloadArgs["extensionAPI"]; +}): Promise => { + const { discourseNodeShapes, uidToTitle } = await syncCanvasNodeTitlesOnLoad( + editor, + nodeTypeIds, + relationShapeTypeIds, + ); + await syncCanvasKeyImagesOnLoad({ + editor, + discourseNodeShapes, + uidToTitle, + extensionAPI, + }); +}; export const syncCanvasNodeTitlesOnLoad = async ( editor: Editor, nodeTypeIds: string[], relationShapeTypeIds: string[], -): Promise => { +): Promise<{ + discourseNodeShapes: DiscourseNodeShape[]; + uidToTitle: Map; +}> => { const nodeTypeSet = new Set(nodeTypeIds); const relationIds = new Set(relationShapeTypeIds); const allRecords = editor.store.allRecords(); @@ -72,7 +102,8 @@ export const syncCanvasNodeTitlesOnLoad = async ( ) as DiscourseNodeShape[]; const uids = [...new Set(discourseNodeShapes.map((s) => s.props.uid))]; - if (uids.length === 0) return; + if (uids.length === 0) + return { discourseNodeShapes: [], uidToTitle: new Map() }; const uidToTitle = await queryTitlesByUids(uids); @@ -104,4 +135,66 @@ export const syncCanvasNodeTitlesOnLoad = async ( })), ); } + + return { discourseNodeShapes, uidToTitle }; +}; + +const syncCanvasKeyImagesOnLoad = async ({ + editor, + discourseNodeShapes, + uidToTitle, + extensionAPI, +}: { + editor: Editor; + discourseNodeShapes: DiscourseNodeShape[]; + uidToTitle: Map; + extensionAPI: OnloadArgs["extensionAPI"]; +}): Promise => { + const survivingShapes = discourseNodeShapes.filter((s) => + uidToTitle.has(s.props.uid), + ); + const imageUpdates: { + id: DiscourseNodeShape["id"]; + type: string; + props: { imageUrl: string; w: number; h: number }; + }[] = []; + + // First pass: cheaply fetch imageUrls (no image loading) to find which shapes changed. + const urlResults = await Promise.all( + survivingShapes.map(async (shape) => { + const title = uidToTitle.get(shape.props.uid) ?? shape.props.title ?? ""; + const imageUrl = await getCanvasNodeKeyImageUrl({ + nodeText: title, + uid: shape.props.uid, + nodeType: shape.type, + extensionAPI, + }); + return { shape, title, imageUrl }; + }), + ); + + const changedShapes = urlResults.filter( + ({ shape, imageUrl }) => (shape.props.imageUrl ?? "") !== imageUrl, + ); + + // Second pass: load images only for shapes whose URL changed, to compute new dimensions. + await Promise.all( + changedShapes.map(async ({ shape, title }) => { + const { w, h, imageUrl } = await calcCanvasNodeSizeAndImg({ + nodeText: title, + uid: shape.props.uid, + nodeType: shape.type, + extensionAPI, + }); + imageUpdates.push({ + id: shape.id, + type: shape.type, + props: { imageUrl, w, h }, + }); + }), + ); + + if (imageUpdates.length > 0) { + editor.updateShapes(imageUpdates); + } };