diff --git a/apps/obsidian/src/components/canvas/CustomContextMenu.tsx b/apps/obsidian/src/components/canvas/CustomContextMenu.tsx new file mode 100644 index 000000000..bc557cbe1 --- /dev/null +++ b/apps/obsidian/src/components/canvas/CustomContextMenu.tsx @@ -0,0 +1,66 @@ +import { + DefaultContextMenu, + TldrawUiMenuGroup, + TldrawUiMenuSubmenu, + TldrawUiMenuItem, + useEditor, + TLUiContextMenuProps, + DefaultContextMenuContent, + useValue, +} from "tldraw"; +import type { TFile } from "obsidian"; +import { usePlugin } from "~/components/PluginContext"; +import { convertToDiscourseNode } from "./utils/convertToDiscourseNode"; + +type CustomContextMenuProps = { + canvasFile: TFile; + props: TLUiContextMenuProps; +}; + +export const CustomContextMenu = ({ + canvasFile, + props, +}: CustomContextMenuProps) => { + const editor = useEditor(); + const plugin = usePlugin(); + + const selectedShape = useValue( + "selectedShape", + () => editor.getOnlySelectedShape(), + [editor], + ); + + const shouldShowConvertTo = + selectedShape && + (selectedShape.type === "text" || selectedShape.type === "image"); + + return ( + + + {shouldShowConvertTo && ( + + + {plugin.settings.nodeTypes.map((nodeType) => ( + { + void convertToDiscourseNode({ + editor, + shape: selectedShape, + nodeType, + plugin, + canvasFile, + }); + }} + /> + ))} + + + )} + + ); +}; + diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index 9149baf44..46c153f0c 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -50,6 +50,7 @@ import ToastListener from "./ToastListener"; import { RelationsOverlay } from "./overlays/RelationOverlay"; import { showToast } from "./utils/toastUtils"; import { WHITE_LOGO_SVG } from "~/icons"; +import { CustomContextMenu } from "./CustomContextMenu"; type TldrawPreviewProps = { store: TLStore; @@ -337,7 +338,11 @@ export const TldrawPreviewComponent = ({ }, }} components={{ - /* eslint-disable-next-line @typescript-eslint/naming-convention */ + /* eslint-disable @typescript-eslint/naming-convention */ + ContextMenu: (props) => ( + + ), + StylePanel: () => { const tools = useTools(); const isDiscourseNodeSelected = useIsToolSelected( @@ -353,9 +358,7 @@ export const TldrawPreviewComponent = ({ return ; }, - /* eslint-disable-next-line @typescript-eslint/naming-convention */ OnTheCanvas: () => , - /* eslint-disable-next-line @typescript-eslint/naming-convention */ Toolbar: (props) => { const tools = useTools(); const isDiscourseNodeSelected = useIsToolSelected( @@ -378,7 +381,6 @@ export const TldrawPreviewComponent = ({ ); }, - /* eslint-disable-next-line @typescript-eslint/naming-convention */ InFrontOfTheCanvas: () => ( ), diff --git a/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts b/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts new file mode 100644 index 000000000..686b2d3e0 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts @@ -0,0 +1,279 @@ +import { + Editor, + TLShape, + createShapeId, + TLAssetId, + TLTextShape, + TLShapeId, + renderPlaintextFromRichText, +} from "tldraw"; +import type { TFile } from "obsidian"; +import { DiscourseNode } from "~/types"; +import DiscourseGraphPlugin from "~/index"; +import { createDiscourseNode as createDiscourseNodeFile } from "~/utils/createNode"; +import { + addWikilinkBlockrefForFile, + extractBlockRefId, + resolveLinkedTFileByBlockRef, +} from "~/components/canvas/stores/assetStore"; +import { showToast } from "./toastUtils"; +import { CreateNodeModal } from "~/components/CreateNodeModal"; + +type ConvertToDiscourseNodeArgs = { + editor: Editor; + shape: TLShape; + nodeType: DiscourseNode; + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}; + +export const convertToDiscourseNode = async ( + args: ConvertToDiscourseNodeArgs, +): Promise => { + try { + const { shape } = args; + + if (shape.type === "text") { + return await convertTextShapeToNode(args); + } else if (shape.type === "image") { + return await convertImageShapeToNode(args); + } else { + showToast({ + severity: "warning", + title: "Cannot Convert", + description: "Only text and image shapes can be converted", + targetCanvasId: args.canvasFile.path, + }); + } + } catch (error) { + console.error("Error converting shape to discourse node:", error); + showToast({ + severity: "error", + title: "Conversion Failed", + description: `Could not convert shape: ${error instanceof Error ? error.message : "Unknown error"}`, + targetCanvasId: args.canvasFile.path, + }); + } +}; + +const convertTextShapeToNode = async ({ + editor, + shape, + nodeType, + plugin, + canvasFile, +}: ConvertToDiscourseNodeArgs): Promise => { + const text = renderPlaintextFromRichText( + editor, + (shape as TLTextShape).props.richText, + ); + + if (!text.trim()) { + showToast({ + severity: "warning", + title: "Cannot Convert", + description: "Text shape has no content to convert", + targetCanvasId: canvasFile.path, + }); + return undefined; + } + + const createdFile = await createDiscourseNodeFile({ + plugin, + nodeType, + text: text.trim(), + }); + + if (!createdFile) { + throw new Error("Failed to create discourse node file"); + } + + const shapeId = await createDiscourseNodeShape({ + editor, + shape, + createdFile, + nodeType, + plugin, + canvasFile, + }); + + showToast({ + severity: "success", + title: "Shape Converted", + description: `Converted text to ${nodeType.name}`, + targetCanvasId: canvasFile.path, + }); + + return shapeId; +}; + +const convertImageShapeToNode = async ({ + editor, + shape, + nodeType, + plugin, + canvasFile, +}: ConvertToDiscourseNodeArgs): Promise => { + const imageFile = await getImageFileFromShape({ + shape, + editor, + plugin, + canvasFile, + }); + + let shapeId: TLShapeId | undefined; + + const modal = new CreateNodeModal(plugin.app, { + nodeTypes: plugin.settings.nodeTypes, + plugin, + initialNodeType: nodeType, + initialTitle: "", + onNodeCreate: async (selectedNodeType: DiscourseNode, title: string) => { + try { + const createdFile = await createDiscourseNodeFile({ + plugin, + nodeType: selectedNodeType, + text: title, + }); + + if (!createdFile) { + throw new Error("Failed to create discourse node file"); + } + + if (imageFile) { + await embedImageInNode(createdFile, imageFile, plugin); + } + + shapeId = await createDiscourseNodeShape({ + editor, + shape, + createdFile, + nodeType: selectedNodeType, + plugin, + canvasFile, + }); + + showToast({ + severity: "success", + title: "Shape Converted", + description: `Converted image to ${selectedNodeType.name}`, + targetCanvasId: canvasFile.path, + }); + } catch (error) { + console.error("Error creating node from image:", error); + throw error; + } + }, + }); + + modal.open(); + + return shapeId; +}; + +const createDiscourseNodeShape = async ({ + editor, + shape, + createdFile, + nodeType, + plugin, + canvasFile, +}: { + editor: Editor; + shape: TLShape; + createdFile: TFile; + nodeType: DiscourseNode; + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}): Promise => { + const src = await addWikilinkBlockrefForFile({ + app: plugin.app, + canvasFile, + linkedFile: createdFile, + }); + + // Get the position and size of the original shape + const { x, y } = shape; + const width = "w" in shape.props ? Number(shape.props.w) : 200; + const height = "h" in shape.props ? Number(shape.props.h) : 100; + + const shapeId = createShapeId(); + // TODO: Update the imageSrc, width and height of the shape after the key figure is merged + editor.createShape({ + id: shapeId, + type: "discourse-node", + x, + y, + props: { + w: Math.max(width, 200), + h: Math.max(height, 100), + src: src ?? "", + title: createdFile.basename, + nodeTypeId: nodeType.id, + }, + }); + + editor.deleteShape(shape.id); + editor.setSelectedShapes([shapeId]); + + editor.markHistoryStoppingPoint(`convert ${shape.type} to discourse node`); + + return shapeId; +}; + +const getImageFileFromShape = async ({ + shape, + editor, + plugin, + canvasFile, +}: { + shape: TLShape; + editor: Editor; + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}): Promise => { + if (shape.type !== "image") return null; + + try { + const assetId = + "assetId" in shape.props ? (shape.props.assetId as TLAssetId) : null; + if (!assetId) return null; + + const asset = editor.getAsset(assetId); + if (!asset) return null; + + const src = asset.props.src; + if (!src) return null; + + const blockRefId = extractBlockRefId(src); + if (!blockRefId) return null; + + const canvasFileCache = plugin.app.metadataCache.getFileCache(canvasFile); + if (!canvasFileCache) return null; + + return await resolveLinkedTFileByBlockRef({ + app: plugin.app, + canvasFile, + blockRefId, + canvasFileCache, + }); + } catch (error) { + console.error("Error getting image file from shape:", error); + return null; + } +}; +const embedImageInNode = async ( + nodeFile: TFile, + imageFile: TFile, + plugin: DiscourseGraphPlugin, +): Promise => { + const imageLink = plugin.app.metadataCache.fileToLinktext( + imageFile, + nodeFile.path, + ); + const imageEmbed = `![[${imageLink}]]`; + + await plugin.app.vault.process(nodeFile, (data: string) => { + return `${data}\n${imageEmbed}\n`; + }); +};