From 3bbe5f8ffbb930b337a6a11e59a56b348614e78c Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 10 Sep 2026 19:46:12 -0400 Subject: [PATCH 1/2] ENG-1892 Offer Convert To on tldraw shapes that carry text Extend the canvas right-click "Convert To" submenu beyond text and image shapes to geo (labelled rectangles, ellipses, etc.) and note (sticky) shapes, gated on the shape actually having text. Menu gating and converter dispatch now share canConvertShapeToNode so they cannot drift. Co-Authored-By: Claude Opus 5 --- .../components/canvas/CustomContextMenu.tsx | 15 ++++--- .../canvas/utils/convertToDiscourseNode.ts | 45 ++++++++++++------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/apps/obsidian/src/components/canvas/CustomContextMenu.tsx b/apps/obsidian/src/components/canvas/CustomContextMenu.tsx index eeaf3a03c..9e5f2a91d 100644 --- a/apps/obsidian/src/components/canvas/CustomContextMenu.tsx +++ b/apps/obsidian/src/components/canvas/CustomContextMenu.tsx @@ -10,7 +10,10 @@ import { } from "tldraw"; import type { TFile } from "obsidian"; import { usePlugin } from "~/components/PluginContext"; -import { convertToDiscourseNode } from "./utils/convertToDiscourseNode"; +import { + canConvertShapeToNode, + convertToDiscourseNode, +} from "./utils/convertToDiscourseNode"; import { convertArrowToDiscourseRelation, getValidRelationTypesForArrow, @@ -34,9 +37,11 @@ export const CustomContextMenu = ({ [editor], ); - const shouldShowConvertTo = - selectedShape && - (selectedShape.type === "text" || selectedShape.type === "image"); + const shouldShowConvertTo = useValue( + "shouldShowConvertTo", + () => canConvertShapeToNode(editor, editor.getOnlySelectedShape()), + [editor], + ); const isReadonly = useValue( "isReadonly", @@ -86,7 +91,7 @@ export const CustomContextMenu = ({ )} - {shouldShowConvertTo && ( + {shouldShowConvertTo && selectedShape && ( {plugin.settings.nodeTypes.map((nodeType) => ( diff --git a/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts b/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts index 16d6fd738..882161ffd 100644 --- a/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts +++ b/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts @@ -3,7 +3,7 @@ import { TLShape, createShapeId, TLAssetId, - TLTextShape, + TLRichText, TLShapeId, renderPlaintextFromRichText, } from "tldraw"; @@ -20,6 +20,24 @@ import { showToast } from "./toastUtils"; import ModifyNodeModal from "~/components/ModifyNodeModal"; import { calcDiscourseNodeSize } from "~/utils/calcDiscourseNodeSize"; +// Arrow is excluded on purpose: it owns the "Relation" submenu instead. +const TEXT_BEARING_SHAPE_TYPES: readonly string[] = ["text", "geo", "note"]; + +const getShapeText = (editor: Editor, shape: TLShape): string => { + if (!TEXT_BEARING_SHAPE_TYPES.includes(shape.type)) return ""; + const { richText } = shape.props as { richText?: TLRichText }; + if (!richText) return ""; + return renderPlaintextFromRichText(editor, richText).trim(); +}; + +export const canConvertShapeToNode = ( + editor: Editor, + shape: TLShape | null, +): boolean => { + if (!shape) return false; + return shape.type === "image" || getShapeText(editor, shape) !== ""; +}; + type ConvertToDiscourseNodeArgs = { editor: Editor; shape: TLShape; @@ -34,15 +52,15 @@ export const convertToDiscourseNode = async ( try { const { shape } = args; - if (shape.type === "text") { - return await convertTextShapeToNode(args); - } else if (shape.type === "image") { + if (shape.type === "image") { return await convertImageShapeToNode(args); + } else if (TEXT_BEARING_SHAPE_TYPES.includes(shape.type)) { + return convertTextBearingShapeToNode(args); } else { showToast({ severity: "warning", title: "Cannot Convert", - description: "Only text and image shapes can be converted", + description: "Only shapes with text and images can be converted", targetCanvasId: args.canvasFile.path, }); } @@ -57,23 +75,20 @@ export const convertToDiscourseNode = async ( } }; -const convertTextShapeToNode = ({ +const convertTextBearingShapeToNode = ({ editor, shape, nodeType, plugin, canvasFile, }: ConvertToDiscourseNodeArgs): TLShapeId | undefined => { - const text = renderPlaintextFromRichText( - editor, - (shape as TLTextShape).props.richText, - ); + const text = getShapeText(editor, shape); - if (!text.trim()) { + if (!text) { showToast({ severity: "warning", title: "Cannot Convert", - description: "Text shape has no content to convert", + description: "Shape has no text to convert", targetCanvasId: canvasFile.path, }); return undefined; @@ -85,7 +100,7 @@ const convertTextShapeToNode = ({ nodeTypes: plugin.settings.nodeTypes, plugin, initialNodeType: nodeType, - initialTitle: text.trim(), + initialTitle: text, onSubmit: async ({ nodeType: selectedNodeType, title, @@ -116,11 +131,11 @@ const convertTextShapeToNode = ({ showToast({ severity: "success", title: "Shape Converted", - description: `Converted text to ${selectedNodeType.name}`, + description: `Converted shape to ${selectedNodeType.name}`, targetCanvasId: canvasFile.path, }); } catch (error) { - console.error("Error creating node from text:", error); + console.error("Error creating node from shape text:", error); throw error; } }, From 8acf5b0aa8706c5be83ad5a1c180551241cd6276 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 10 Sep 2026 19:53:40 -0400 Subject: [PATCH 2/2] Address review: clarify rich-text allowlist, disable Convert To in readonly Rename the allowlist to RICH_TEXT_SHAPE_TYPES so it is obvious that getShapeText only reads props.richText -- adding a shape that stores its label elsewhere (arrow, frame) would otherwise silently do nothing. Also disable the Convert To items in readonly canvases, matching the Relation items, and note that images are gated at conversion time. Co-Authored-By: Claude Opus 5 --- .../src/components/canvas/CustomContextMenu.tsx | 1 + .../canvas/utils/convertToDiscourseNode.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/obsidian/src/components/canvas/CustomContextMenu.tsx b/apps/obsidian/src/components/canvas/CustomContextMenu.tsx index 9e5f2a91d..de4b58460 100644 --- a/apps/obsidian/src/components/canvas/CustomContextMenu.tsx +++ b/apps/obsidian/src/components/canvas/CustomContextMenu.tsx @@ -100,6 +100,7 @@ export const CustomContextMenu = ({ id={`convert-to-${nodeType.id}`} label={"Convert to " + nodeType.name} icon="file-type" + disabled={isReadonly} onSelect={() => { void convertToDiscourseNode({ editor, diff --git a/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts b/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts index 882161ffd..afd05d6bf 100644 --- a/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts +++ b/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts @@ -20,11 +20,12 @@ import { showToast } from "./toastUtils"; import ModifyNodeModal from "~/components/ModifyNodeModal"; import { calcDiscourseNodeSize } from "~/utils/calcDiscourseNodeSize"; -// Arrow is excluded on purpose: it owns the "Relation" submenu instead. -const TEXT_BEARING_SHAPE_TYPES: readonly string[] = ["text", "geo", "note"]; +// Only shapes storing a richText prop. Arrow is not one (it uses props.text) and +// owns the "Relation" submenu instead. +const RICH_TEXT_SHAPE_TYPES: readonly string[] = ["text", "geo", "note"]; const getShapeText = (editor: Editor, shape: TLShape): string => { - if (!TEXT_BEARING_SHAPE_TYPES.includes(shape.type)) return ""; + if (!RICH_TEXT_SHAPE_TYPES.includes(shape.type)) return ""; const { richText } = shape.props as { richText?: TLRichText }; if (!richText) return ""; return renderPlaintextFromRichText(editor, richText).trim(); @@ -35,6 +36,7 @@ export const canConvertShapeToNode = ( shape: TLShape | null, ): boolean => { if (!shape) return false; + // Images are gated at conversion time, not here: the asset may not resolve to a vault file. return shape.type === "image" || getShapeText(editor, shape) !== ""; }; @@ -54,13 +56,13 @@ export const convertToDiscourseNode = async ( if (shape.type === "image") { return await convertImageShapeToNode(args); - } else if (TEXT_BEARING_SHAPE_TYPES.includes(shape.type)) { + } else if (RICH_TEXT_SHAPE_TYPES.includes(shape.type)) { return convertTextBearingShapeToNode(args); } else { showToast({ severity: "warning", title: "Cannot Convert", - description: "Only shapes with text and images can be converted", + description: "Only shapes with text or images can be converted", targetCanvasId: args.canvasFile.path, }); }