Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/obsidian/src/components/canvas/CustomContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DefaultContextMenu {...props}>
<DefaultContextMenuContent />
{shouldShowConvertTo && (
<TldrawUiMenuGroup id="convert-to">
<TldrawUiMenuSubmenu id="convert-to-submenu" label="Convert To">
{plugin.settings.nodeTypes.map((nodeType) => (
<TldrawUiMenuItem
key={nodeType.id}
id={`convert-to-${nodeType.id}`}
label={"Convert to " + nodeType.name}
icon="file-type"
onSelect={() => {
void convertToDiscourseNode({
editor,
shape: selectedShape,
nodeType,
plugin,
canvasFile,
});
}}
/>
))}
</TldrawUiMenuSubmenu>
</TldrawUiMenuGroup>
)}
</DefaultContextMenu>
);
};

10 changes: 6 additions & 4 deletions apps/obsidian/src/components/canvas/TldrawViewComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -337,7 +338,11 @@ export const TldrawPreviewComponent = ({
},
}}
components={{
/* eslint-disable-next-line @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/naming-convention */
ContextMenu: (props) => (
<CustomContextMenu canvasFile={file} props={props} />
),

StylePanel: () => {
const tools = useTools();
const isDiscourseNodeSelected = useIsToolSelected(
Expand All @@ -353,9 +358,7 @@ export const TldrawPreviewComponent = ({

return <DiscourseToolPanel plugin={plugin} canvasFile={file} />;
},
/* eslint-disable-next-line @typescript-eslint/naming-convention */
OnTheCanvas: () => <ToastListener canvasId={file.path} />,
/* eslint-disable-next-line @typescript-eslint/naming-convention */
Toolbar: (props) => {
const tools = useTools();
const isDiscourseNodeSelected = useIsToolSelected(
Expand All @@ -378,7 +381,6 @@ export const TldrawPreviewComponent = ({
</DefaultToolbar>
);
},
/* eslint-disable-next-line @typescript-eslint/naming-convention */
InFrontOfTheCanvas: () => (
<RelationsOverlay plugin={plugin} file={file} />
),
Expand Down
279 changes: 279 additions & 0 deletions apps/obsidian/src/components/canvas/utils/convertToDiscourseNode.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> => {
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<TLShapeId | undefined> => {
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<TLShapeId | undefined> => {
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<TLShapeId> => {
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<TFile | null> => {
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<void> => {
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`;
});
};