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..ee0914204 100644 --- a/apps/roam/src/utils/calcCanvasNodeSizeAndImg.ts +++ b/apps/roam/src/utils/calcCanvasNodeSizeAndImg.ts @@ -10,7 +10,7 @@ import { render as renderToast } from "roamjs-components/components/Toast"; import { loadImage } from "./loadImage"; import { DEFAULT_STYLE_PROPS } from "~/components/canvas/DiscourseNodeUtil"; -const extractFirstImageUrl = (text: string): string | null => { +export const extractFirstImageUrl = (text: string): string | null => { const regex = /!\[.*?\]\((https:\/\/[^)]+)\)/; const result = text.match(regex) || resolveRefs(text).match(regex); return result ? result[1] : null; @@ -19,7 +19,7 @@ const extractFirstImageUrl = (text: string): string | null => { // Matches embed, embed-path, and embed-children syntax: // {{[[embed]]: ((block-uid)) }}, {{[[embed-path]]: ((block-uid)) }}, {{[[embed-children]]: ((block-uid)) }} // Also handles multiple parentheses: {{[[embed]]: ((((block-uid)))) }} -const EMBED_REGEX = +export const EMBED_REGEX = /{{\[\[(?:embed|embed-path|embed-children)\]\]:\s*\(\(+([^)]+?)\)+\)\s*}}/i; const getBlockReferences = ( @@ -37,7 +37,7 @@ const getBlockReferences = ( return result[":block/refs"] || []; }; -const findFirstImage = ( +export const findFirstImage = ( node: TreeNode, visited = new Set(), ): string | null => { @@ -71,12 +71,20 @@ const findFirstImage = ( return null; }; -const getFirstImageByUid = (uid: string): string | null => { +export const getFirstImageByUid = (uid: string): string | null => { const tree = getFullTreeByParentUid(uid); 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,19 +120,54 @@ const calcCanvasNodeSizeAndImg = async ({ } else { imageUrl = getFirstImageByUid(uid); } + return imageUrl ?? ""; +}; + +const calcCanvasNodeSizeAndImg = async ({ + nodeText, + uid, + nodeType, + extensionAPI, + imageUrl: preloadedImageUrl, +}: { + nodeText: string; + uid: string; + nodeType: string; + extensionAPI: OnloadArgs["extensionAPI"]; + /** Pre-fetched image URL. When provided, skips calling getCanvasNodeKeyImageUrl. */ + imageUrl?: string; +}) => { + const { w, h } = measureCanvasNodeText({ + ...DEFAULT_STYLE_PROPS, + maxWidth: MAX_WIDTH, + text: nodeText, + }); + + const imageUrl = + preloadedImageUrl !== undefined + ? preloadedImageUrl + : 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; - return { w, h: newHeight, imageUrl }; + const aspectRatio = width / height; + return { w, h: h + w / aspectRatio, imageUrl }; } catch { renderToast({ id: "tldraw-image-load-fail", diff --git a/apps/roam/src/utils/syncCanvasNodeTitlesOnLoad.ts b/apps/roam/src/utils/syncCanvasNodeTitlesOnLoad.ts deleted file mode 100644 index acd74df5d..000000000 --- a/apps/roam/src/utils/syncCanvasNodeTitlesOnLoad.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Editor } from "tldraw"; -import type { DiscourseNodeShape } from "~/components/canvas/DiscourseNodeUtil"; - -/** - * Query Roam for current :node/title or :block/string for each uid. - * Returns a map of uid -> title for uids that exist; uids not in the map no longer exist. - */ -const queryTitlesByUids = async ( - uids: string[], -): Promise> => { - if (uids.length === 0) return new Map(); - - const results = (await window.roamAlphaAPI.data.async.fast.q( - `[:find ?uid (pull ?e [:node/title :block/string]) - :in $ [?uid ...] - :where [?e :block/uid ?uid]]`, - uids, - /* eslint-disable-next-line @typescript-eslint/naming-convention */ - )) as [string, { ":node/title"?: string; ":block/string"?: string }][]; - - const map = new Map(); - for (const [uid, pull] of results) { - const title = pull?.[":node/title"] ?? pull?.[":block/string"] ?? ""; - map.set(uid, title); - } - return map; -}; - -/** Delete relation arrows and their bindings that reference this node shape, then the node shape. */ -const deleteNodeShapeAndRelations = ( - editor: Editor, - shape: DiscourseNodeShape, - relationIds: Set, -): void => { - const bindingsToThisShape = Array.from(relationIds).flatMap((typeId) => - editor.getBindingsToShape(shape.id, typeId), - ); - const relationShapeIdsAndType = bindingsToThisShape.map((b) => ({ - id: b.fromId, - type: b.type, - })); - const bindingsToDelete = relationShapeIdsAndType.flatMap(({ id, type }) => - editor.getBindingsFromShape(id, type), - ); - const relationShapeIdsToDelete = relationShapeIdsAndType.map((r) => r.id); - const bindingIdsToDelete = bindingsToDelete.map((b) => b.id); - editor - .deleteShapes(relationShapeIdsToDelete) - .deleteBindings(bindingIdsToDelete); - editor.deleteShapes([shape.id]); -}; - -/** - * On canvas load: sync discourse node shape titles with Roam and remove shapes whose nodes no longer exist. - * - Queries Roam for current title per uid via async.fast.q - * - Updates shapes whose title changed - * - Removes shapes whose uid no longer exists in the graph - */ -export const syncCanvasNodeTitlesOnLoad = async ( - editor: Editor, - nodeTypeIds: string[], - relationShapeTypeIds: string[], -): Promise => { - const nodeTypeSet = new Set(nodeTypeIds); - const relationIds = new Set(relationShapeTypeIds); - const allRecords = editor.store.allRecords(); - const discourseNodeShapes = allRecords.filter( - (r) => - r.typeName === "shape" && - nodeTypeSet.has((r as DiscourseNodeShape).type) && - typeof (r as DiscourseNodeShape).props?.uid === "string", - ) as DiscourseNodeShape[]; - - const uids = [...new Set(discourseNodeShapes.map((s) => s.props.uid))]; - if (uids.length === 0) return; - - const uidToTitle = await queryTitlesByUids(uids); - - const shapesToUpdate: { shape: DiscourseNodeShape; newTitle: string }[] = []; - const shapesToRemove: DiscourseNodeShape[] = []; - - for (const shape of discourseNodeShapes) { - const uid = shape.props.uid; - const currentInRoam = uidToTitle.get(uid); - if (currentInRoam === undefined) { - shapesToRemove.push(shape); - } else if ((shape.props.title ?? "") !== (currentInRoam ?? "")) { - shapesToUpdate.push({ shape, newTitle: currentInRoam }); - } - } - - if (shapesToRemove.length > 0) { - for (const shape of shapesToRemove) { - deleteNodeShapeAndRelations(editor, shape, relationIds); - } - } - - if (shapesToUpdate.length > 0) { - editor.updateShapes( - shapesToUpdate.map(({ shape, newTitle }) => ({ - id: shape.id, - type: shape.type, - props: { title: newTitle }, - })), - ); - } -}; diff --git a/apps/roam/src/utils/syncCanvasNodesOnLoad.ts b/apps/roam/src/utils/syncCanvasNodesOnLoad.ts new file mode 100644 index 000000000..d2dc9740e --- /dev/null +++ b/apps/roam/src/utils/syncCanvasNodesOnLoad.ts @@ -0,0 +1,405 @@ +import type { Editor } from "tldraw"; +import type { OnloadArgs } from "roamjs-components/types"; +import type { DiscourseNodeShape } from "~/components/canvas/DiscourseNodeUtil"; +import { DEFAULT_STYLE_PROPS } from "~/components/canvas/DiscourseNodeUtil"; +import { MAX_WIDTH } from "~/components/canvas/Tldraw"; +import type { TreeNode } from "roamjs-components/types"; +import calcCanvasNodeSizeAndImg, { + findFirstImage, + getFirstImageByUid, +} from "./calcCanvasNodeSizeAndImg"; +import getDiscourseNodes from "./getDiscourseNodes"; +import resolveQueryBuilderRef from "./resolveQueryBuilderRef"; +import runQuery from "./runQuery"; +import { measureCanvasNodeText } from "./measureCanvasNodeText"; + +/** + * Query Roam for current :node/title or :block/string for each uid. + * Returns a map of uid -> title for uids that exist; uids not in the map no longer exist. + */ +const queryTitlesByUids = async ( + uids: string[], +): Promise> => { + if (uids.length === 0) return new Map(); + + const results = (await window.roamAlphaAPI.data.async.fast.q( + `[:find ?uid (pull ?e [:node/title :block/string]) + :in $ [?uid ...] + :where [?e :block/uid ?uid]]`, + uids, + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + )) as [string, { ":node/title"?: string; ":block/string"?: string }][]; + + const map = new Map(); + for (const [uid, pull] of results) { + const title = pull?.[":node/title"] ?? pull?.[":block/string"] ?? ""; + map.set(uid, title); + } + return map; +}; + +/** Delete relation arrows and their bindings that reference this node shape, then the node shape. */ +const deleteNodeShapeAndRelations = ( + editor: Editor, + shape: DiscourseNodeShape, + relationIds: Set, +): void => { + const bindingsToThisShape = Array.from(relationIds).flatMap((typeId) => + editor.getBindingsToShape(shape.id, typeId), + ); + const relationShapeIdsAndType = bindingsToThisShape.map((b) => ({ + id: b.fromId, + type: b.type, + })); + const bindingsToDelete = relationShapeIdsAndType.flatMap(({ id, type }) => + editor.getBindingsFromShape(id, type), + ); + const relationShapeIdsToDelete = relationShapeIdsAndType.map((r) => r.id); + const bindingIdsToDelete = bindingsToDelete.map((b) => b.id); + editor + .deleteShapes(relationShapeIdsToDelete) + .deleteBindings(bindingIdsToDelete); + editor.deleteShapes([shape.id]); +}; + +/** + * On canvas load: sync discourse node shape titles and key images with Roam, + * and remove shapes whose nodes no longer exist. + * - Queries Roam for current title per uid via async.fast.q + * - Updates shapes whose title changed + * - Removes shapes whose uid no longer exists in the graph + * - Updates key images for surviving shapes, recomputing dimensions only when + * a shape gains or loses an image + */ +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, + nodeTypeIds, + relationShapeTypeIds, +}: { + editor: Editor; + nodeTypeIds: string[]; + relationShapeTypeIds: string[]; +}): Promise<{ + discourseNodeShapes: DiscourseNodeShape[]; + uidToTitle: Map; +}> => { + const nodeTypeSet = new Set(nodeTypeIds); + const relationIds = new Set(relationShapeTypeIds); + const allRecords = editor.store.allRecords(); + const discourseNodeShapes = allRecords.filter( + (r) => + r.typeName === "shape" && + nodeTypeSet.has((r as DiscourseNodeShape).type) && + typeof (r as DiscourseNodeShape).props?.uid === "string", + ) as DiscourseNodeShape[]; + + const uids = [...new Set(discourseNodeShapes.map((s) => s.props.uid))]; + if (uids.length === 0) + return { discourseNodeShapes: [], uidToTitle: new Map() }; + + const uidToTitle = await queryTitlesByUids(uids); + + const shapesToUpdate: { shape: DiscourseNodeShape; newTitle: string }[] = []; + const shapesToRemove: DiscourseNodeShape[] = []; + + for (const shape of discourseNodeShapes) { + const uid = shape.props.uid; + const currentInRoam = uidToTitle.get(uid); + if (currentInRoam === undefined) { + shapesToRemove.push(shape); + } else if ((shape.props.title ?? "") !== (currentInRoam ?? "")) { + shapesToUpdate.push({ shape, newTitle: currentInRoam }); + } + } + + if (shapesToRemove.length > 0) { + for (const shape of shapesToRemove) { + deleteNodeShapeAndRelations(editor, shape, relationIds); + } + } + + if (shapesToUpdate.length > 0) { + editor.updateShapes( + shapesToUpdate.map(({ shape, newTitle }) => ({ + id: shape.id, + type: shape.type, + props: { title: newTitle }, + })), + ); + } + + return { discourseNodeShapes, uidToTitle }; +}; + +type BlockNode = { + uid: string; + text: string; + order: number; + parentUid: string; + children: BlockNode[]; +}; + +/** + * Batch-fetch the first markdown image URL for multiple UIDs. + * + * The naive alternative — calling getFirstImageByUid() per shape — uses + * window.roamAlphaAPI.pull, which is synchronous and blocks the main thread. + * For a canvas with many shapes this causes a noticeable UI freeze on load. + * + * Instead, we use a single async Datalog query (data.async.fast.q) that fetches + * all block strings for all page UIDs off the main thread, reconstruct the tree + * in JS, then DFS with findFirstImage (handling embeds and block refs). + * + * Trade-off: unlike getFirstImageByUid, this fetches all blocks on the page with + * no early termination. For very large pages this may transfer more data, but the + * async execution avoids blocking the UI entirely. + * + * Block UIDs (discourse nodes that are blocks, not pages) are partitioned out + * and handled with getFirstImageByUid individually since the page-scoped Datalog + * query does not apply to them. + */ +const batchGetFirstImageUrlsByUids = async ( + uids: string[], +): Promise> => { + if (uids.length === 0) return new Map(); + + // Identify which UIDs are page UIDs — the batch tree query only works for pages. + const pageUidRows = (await window.roamAlphaAPI.data.async.fast.q( + `[:find ?uid + :in $ [?uid ...] + :where [?e :block/uid ?uid] + [?e :node/title]]`, + uids, + )) as [string][]; + + const pageUidSet = new Set(pageUidRows.map(([uid]) => uid)); + const pageUids = [...pageUidSet]; + const blockUids = uids.filter((uid) => !pageUidSet.has(uid)); + + const uidToImageUrl = new Map(); + + // Batch tree query for page UIDs. + if (pageUids.length > 0) { + // Each row: [pageUid, blockUid, string, order, parentUid] + // parentUid === pageUid for top-level blocks, a block uid for nested ones. + const results = (await window.roamAlphaAPI.data.async.fast.q( + `[:find ?pageUid ?blockUid ?string ?order ?parentUid + :in $ [?pageUid ...] + :where [?page :block/uid ?pageUid] + [?block :block/page ?page] + [?block :block/uid ?blockUid] + [?block :block/string ?string] + [?block :block/order ?order] + [?parent :block/children ?block] + [?parent :block/uid ?parentUid]]`, + pageUids, + )) as [string, string, string, number, string][]; + + const pageToBlockMap = new Map>(); + for (const [pageUid, blockUid, text, order, parentUid] of results) { + if (!pageToBlockMap.has(pageUid)) pageToBlockMap.set(pageUid, new Map()); + pageToBlockMap.get(pageUid)!.set(blockUid, { + uid: blockUid, + text, + order, + parentUid, + children: [], + }); + } + + for (const pageUid of pageUids) { + const blockMap = pageToBlockMap.get(pageUid); + if (!blockMap) { + uidToImageUrl.set(pageUid, ""); + continue; + } + const rootBlocks: BlockNode[] = []; + for (const block of blockMap.values()) { + if (block.parentUid === pageUid) { + rootBlocks.push(block); + } else { + blockMap.get(block.parentUid)?.children.push(block); + } + } + for (const block of blockMap.values()) { + block.children.sort((a, b) => a.order - b.order); + } + rootBlocks.sort((a, b) => a.order - b.order); + // Use a synthetic root so findFirstImage can traverse all top-level blocks. + const syntheticRoot = { + uid: pageUid, + text: "", + children: rootBlocks as unknown as TreeNode[], + } as TreeNode; + uidToImageUrl.set(pageUid, findFirstImage(syntheticRoot) ?? ""); + } + } + + // Block UIDs: getFirstImageByUid handles both pages and blocks correctly. + for (const uid of blockUids) { + uidToImageUrl.set(uid, getFirstImageByUid(uid) ?? ""); + } + + return uidToImageUrl; +}; + +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), + ); + if (survivingShapes.length === 0) return; + + // Compute canvas settings once to avoid calling getDiscourseNodes() per shape. + const allNodes = getDiscourseNodes(); + const nodeTypeToCanvasSettings = Object.fromEntries( + allNodes.map((n) => [n.type, n.canvasSettings as Record]), + ); + + // Separate shapes by key-image fetch strategy. + const firstImageShapes: DiscourseNodeShape[] = []; + const queryBuilderShapes: DiscourseNodeShape[] = []; + for (const shape of survivingShapes) { + const settings = nodeTypeToCanvasSettings[shape.type] ?? {}; + if (!settings["key-image"]) continue; + if (settings["key-image-option"] === "query-builder") { + queryBuilderShapes.push(shape); + } else { + firstImageShapes.push(shape); + } + } + + // Batch query for "First image on page" shapes — one Roam query for all. + const uidToFirstImage = await batchGetFirstImageUrlsByUids( + firstImageShapes.map((s) => s.props.uid), + ); + + // Per-shape queries for "Query builder" shapes (inherently per-node). + const qbResults = await Promise.all( + queryBuilderShapes.map(async (shape) => { + const title = uidToTitle.get(shape.props.uid) ?? shape.props.title ?? ""; + const settings = nodeTypeToCanvasSettings[shape.type] ?? {}; + const qbAlias = settings["query-builder-alias"] ?? ""; + const parentUid = resolveQueryBuilderRef({ + queryRef: qbAlias, + extensionAPI, + }); + const results = await runQuery({ + extensionAPI, + parentUid, + // eslint-disable-next-line @typescript-eslint/naming-convention + inputs: { NODETEXT: title, NODEUID: shape.props.uid }, + }); + const resultUid = results.allProcessedResults[0]?.uid ?? ""; + const imageUrl = getFirstImageByUid(resultUid) ?? ""; + return { shape, imageUrl }; + }), + ); + + const urlResults: { shape: DiscourseNodeShape; imageUrl: string }[] = [ + // Shapes with no key-image configured always get an empty imageUrl. + ...survivingShapes + .filter((s) => !nodeTypeToCanvasSettings[s.type]?.["key-image"]) + .map((shape) => ({ shape, imageUrl: "" })), + ...firstImageShapes.map((shape) => ({ + shape, + imageUrl: uidToFirstImage.get(shape.props.uid) ?? "", + })), + ...qbResults, + ]; + + const changedShapes = urlResults.filter( + ({ shape, imageUrl }) => (shape.props.imageUrl ?? "") !== imageUrl, + ); + + // Only load images when imageUrl transitions between empty and non-empty, + // since those are the only cases that require recomputing dimensions. + const imageUpdates: { + id: DiscourseNodeShape["id"]; + type: string; + props: { imageUrl: string; w?: number; h?: number }; + }[] = []; + + await Promise.all( + changedShapes.map(async ({ shape, imageUrl }) => { + const prevImageUrl = shape.props.imageUrl ?? ""; + const title = uidToTitle.get(shape.props.uid) ?? shape.props.title ?? ""; + + if (prevImageUrl === "" && imageUrl !== "") { + // Image newly added: compute dimensions including image height. + // Pass the pre-fetched imageUrl to skip re-fetching it. + const { + w, + h, + imageUrl: resolvedImageUrl, + } = await calcCanvasNodeSizeAndImg({ + nodeText: title, + uid: shape.props.uid, + nodeType: shape.type, + extensionAPI, + imageUrl, + }); + imageUpdates.push({ + id: shape.id, + type: shape.type, + props: { imageUrl: resolvedImageUrl, w, h }, + }); + } else if (prevImageUrl !== "" && imageUrl === "") { + // Image removed: recompute as text-only dimensions. + const { w, h } = measureCanvasNodeText({ + ...DEFAULT_STYLE_PROPS, + maxWidth: MAX_WIDTH, + text: title, + }); + imageUpdates.push({ + id: shape.id, + type: shape.type, + props: { imageUrl: "", w, h }, + }); + } else { + // URL changed but both are non-empty: update imageUrl only, leave w/h. + imageUpdates.push({ + id: shape.id, + type: shape.type, + props: { imageUrl }, + }); + } + }), + ); + + if (imageUpdates.length > 0) { + editor.updateShapes(imageUpdates); + } +};