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 && (
+
+
+
+ )}
+
+ );
+};
+
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`;
+ });
+};