Skip to content

Commit cbf7e08

Browse files
authored
[ENG-871] Convert text and image into a discourse node (Obsidian) (#523)
* curr progress * feature complete * address PR comments * revert change
1 parent 84813e8 commit cbf7e08

3 files changed

Lines changed: 351 additions & 4 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
DefaultContextMenu,
3+
TldrawUiMenuGroup,
4+
TldrawUiMenuSubmenu,
5+
TldrawUiMenuItem,
6+
useEditor,
7+
TLUiContextMenuProps,
8+
DefaultContextMenuContent,
9+
useValue,
10+
} from "tldraw";
11+
import type { TFile } from "obsidian";
12+
import { usePlugin } from "~/components/PluginContext";
13+
import { convertToDiscourseNode } from "./utils/convertToDiscourseNode";
14+
15+
type CustomContextMenuProps = {
16+
canvasFile: TFile;
17+
props: TLUiContextMenuProps;
18+
};
19+
20+
export const CustomContextMenu = ({
21+
canvasFile,
22+
props,
23+
}: CustomContextMenuProps) => {
24+
const editor = useEditor();
25+
const plugin = usePlugin();
26+
27+
const selectedShape = useValue(
28+
"selectedShape",
29+
() => editor.getOnlySelectedShape(),
30+
[editor],
31+
);
32+
33+
const shouldShowConvertTo =
34+
selectedShape &&
35+
(selectedShape.type === "text" || selectedShape.type === "image");
36+
37+
return (
38+
<DefaultContextMenu {...props}>
39+
<DefaultContextMenuContent />
40+
{shouldShowConvertTo && (
41+
<TldrawUiMenuGroup id="convert-to">
42+
<TldrawUiMenuSubmenu id="convert-to-submenu" label="Convert To">
43+
{plugin.settings.nodeTypes.map((nodeType) => (
44+
<TldrawUiMenuItem
45+
key={nodeType.id}
46+
id={`convert-to-${nodeType.id}`}
47+
label={"Convert to " + nodeType.name}
48+
icon="file-type"
49+
onSelect={() => {
50+
void convertToDiscourseNode({
51+
editor,
52+
shape: selectedShape,
53+
nodeType,
54+
plugin,
55+
canvasFile,
56+
});
57+
}}
58+
/>
59+
))}
60+
</TldrawUiMenuSubmenu>
61+
</TldrawUiMenuGroup>
62+
)}
63+
</DefaultContextMenu>
64+
);
65+
};
66+

apps/obsidian/src/components/canvas/TldrawViewComponent.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import ToastListener from "./ToastListener";
5050
import { RelationsOverlay } from "./overlays/RelationOverlay";
5151
import { showToast } from "./utils/toastUtils";
5252
import { WHITE_LOGO_SVG } from "~/icons";
53+
import { CustomContextMenu } from "./CustomContextMenu";
5354

5455
type TldrawPreviewProps = {
5556
store: TLStore;
@@ -372,7 +373,11 @@ export const TldrawPreviewComponent = ({
372373
},
373374
}}
374375
components={{
375-
/* eslint-disable-next-line @typescript-eslint/naming-convention */
376+
/* eslint-disable @typescript-eslint/naming-convention */
377+
ContextMenu: (props) => (
378+
<CustomContextMenu canvasFile={file} props={props} />
379+
),
380+
376381
StylePanel: () => {
377382
const tools = useTools();
378383
const isDiscourseNodeSelected = useIsToolSelected(
@@ -388,9 +393,7 @@ export const TldrawPreviewComponent = ({
388393

389394
return <DiscourseToolPanel plugin={plugin} canvasFile={file} />;
390395
},
391-
/* eslint-disable-next-line @typescript-eslint/naming-convention */
392396
OnTheCanvas: () => <ToastListener canvasId={file.path} />,
393-
/* eslint-disable-next-line @typescript-eslint/naming-convention */
394397
Toolbar: (props) => {
395398
const tools = useTools();
396399
const isDiscourseNodeSelected = useIsToolSelected(
@@ -413,7 +416,6 @@ export const TldrawPreviewComponent = ({
413416
</DefaultToolbar>
414417
);
415418
},
416-
/* eslint-disable-next-line @typescript-eslint/naming-convention */
417419
InFrontOfTheCanvas: () => (
418420
<RelationsOverlay plugin={plugin} file={file} />
419421
),
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import {
2+
Editor,
3+
TLShape,
4+
createShapeId,
5+
TLAssetId,
6+
TLTextShape,
7+
TLShapeId,
8+
renderPlaintextFromRichText,
9+
} from "tldraw";
10+
import type { TFile } from "obsidian";
11+
import { DiscourseNode } from "~/types";
12+
import DiscourseGraphPlugin from "~/index";
13+
import { createDiscourseNode as createDiscourseNodeFile } from "~/utils/createNode";
14+
import {
15+
addWikilinkBlockrefForFile,
16+
extractBlockRefId,
17+
resolveLinkedTFileByBlockRef,
18+
} from "~/components/canvas/stores/assetStore";
19+
import { showToast } from "./toastUtils";
20+
import { CreateNodeModal } from "~/components/CreateNodeModal";
21+
22+
type ConvertToDiscourseNodeArgs = {
23+
editor: Editor;
24+
shape: TLShape;
25+
nodeType: DiscourseNode;
26+
plugin: DiscourseGraphPlugin;
27+
canvasFile: TFile;
28+
};
29+
30+
export const convertToDiscourseNode = async (
31+
args: ConvertToDiscourseNodeArgs,
32+
): Promise<string | undefined> => {
33+
try {
34+
const { shape } = args;
35+
36+
if (shape.type === "text") {
37+
return await convertTextShapeToNode(args);
38+
} else if (shape.type === "image") {
39+
return await convertImageShapeToNode(args);
40+
} else {
41+
showToast({
42+
severity: "warning",
43+
title: "Cannot Convert",
44+
description: "Only text and image shapes can be converted",
45+
targetCanvasId: args.canvasFile.path,
46+
});
47+
}
48+
} catch (error) {
49+
console.error("Error converting shape to discourse node:", error);
50+
showToast({
51+
severity: "error",
52+
title: "Conversion Failed",
53+
description: `Could not convert shape: ${error instanceof Error ? error.message : "Unknown error"}`,
54+
targetCanvasId: args.canvasFile.path,
55+
});
56+
}
57+
};
58+
59+
const convertTextShapeToNode = async ({
60+
editor,
61+
shape,
62+
nodeType,
63+
plugin,
64+
canvasFile,
65+
}: ConvertToDiscourseNodeArgs): Promise<TLShapeId | undefined> => {
66+
const text = renderPlaintextFromRichText(
67+
editor,
68+
(shape as TLTextShape).props.richText,
69+
);
70+
71+
if (!text.trim()) {
72+
showToast({
73+
severity: "warning",
74+
title: "Cannot Convert",
75+
description: "Text shape has no content to convert",
76+
targetCanvasId: canvasFile.path,
77+
});
78+
return undefined;
79+
}
80+
81+
const createdFile = await createDiscourseNodeFile({
82+
plugin,
83+
nodeType,
84+
text: text.trim(),
85+
});
86+
87+
if (!createdFile) {
88+
throw new Error("Failed to create discourse node file");
89+
}
90+
91+
const shapeId = await createDiscourseNodeShape({
92+
editor,
93+
shape,
94+
createdFile,
95+
nodeType,
96+
plugin,
97+
canvasFile,
98+
});
99+
100+
showToast({
101+
severity: "success",
102+
title: "Shape Converted",
103+
description: `Converted text to ${nodeType.name}`,
104+
targetCanvasId: canvasFile.path,
105+
});
106+
107+
return shapeId;
108+
};
109+
110+
const convertImageShapeToNode = async ({
111+
editor,
112+
shape,
113+
nodeType,
114+
plugin,
115+
canvasFile,
116+
}: ConvertToDiscourseNodeArgs): Promise<TLShapeId | undefined> => {
117+
const imageFile = await getImageFileFromShape({
118+
shape,
119+
editor,
120+
plugin,
121+
canvasFile,
122+
});
123+
124+
let shapeId: TLShapeId | undefined;
125+
126+
const modal = new CreateNodeModal(plugin.app, {
127+
nodeTypes: plugin.settings.nodeTypes,
128+
plugin,
129+
initialNodeType: nodeType,
130+
initialTitle: "",
131+
onNodeCreate: async (selectedNodeType: DiscourseNode, title: string) => {
132+
try {
133+
const createdFile = await createDiscourseNodeFile({
134+
plugin,
135+
nodeType: selectedNodeType,
136+
text: title,
137+
});
138+
139+
if (!createdFile) {
140+
throw new Error("Failed to create discourse node file");
141+
}
142+
143+
if (imageFile) {
144+
await embedImageInNode(createdFile, imageFile, plugin);
145+
}
146+
147+
shapeId = await createDiscourseNodeShape({
148+
editor,
149+
shape,
150+
createdFile,
151+
nodeType: selectedNodeType,
152+
plugin,
153+
canvasFile,
154+
});
155+
156+
showToast({
157+
severity: "success",
158+
title: "Shape Converted",
159+
description: `Converted image to ${selectedNodeType.name}`,
160+
targetCanvasId: canvasFile.path,
161+
});
162+
} catch (error) {
163+
console.error("Error creating node from image:", error);
164+
throw error;
165+
}
166+
},
167+
});
168+
169+
modal.open();
170+
171+
return shapeId;
172+
};
173+
174+
const createDiscourseNodeShape = async ({
175+
editor,
176+
shape,
177+
createdFile,
178+
nodeType,
179+
plugin,
180+
canvasFile,
181+
}: {
182+
editor: Editor;
183+
shape: TLShape;
184+
createdFile: TFile;
185+
nodeType: DiscourseNode;
186+
plugin: DiscourseGraphPlugin;
187+
canvasFile: TFile;
188+
}): Promise<TLShapeId> => {
189+
const src = await addWikilinkBlockrefForFile({
190+
app: plugin.app,
191+
canvasFile,
192+
linkedFile: createdFile,
193+
});
194+
195+
// Get the position and size of the original shape
196+
const { x, y } = shape;
197+
const width = "w" in shape.props ? Number(shape.props.w) : 200;
198+
const height = "h" in shape.props ? Number(shape.props.h) : 100;
199+
200+
const shapeId = createShapeId();
201+
// TODO: Update the imageSrc, width and height of the shape after the key figure is merged
202+
editor.createShape({
203+
id: shapeId,
204+
type: "discourse-node",
205+
x,
206+
y,
207+
props: {
208+
w: Math.max(width, 200),
209+
h: Math.max(height, 100),
210+
src: src ?? "",
211+
title: createdFile.basename,
212+
nodeTypeId: nodeType.id,
213+
},
214+
});
215+
216+
editor.deleteShape(shape.id);
217+
editor.setSelectedShapes([shapeId]);
218+
219+
editor.markHistoryStoppingPoint(`convert ${shape.type} to discourse node`);
220+
221+
return shapeId;
222+
};
223+
224+
const getImageFileFromShape = async ({
225+
shape,
226+
editor,
227+
plugin,
228+
canvasFile,
229+
}: {
230+
shape: TLShape;
231+
editor: Editor;
232+
plugin: DiscourseGraphPlugin;
233+
canvasFile: TFile;
234+
}): Promise<TFile | null> => {
235+
if (shape.type !== "image") return null;
236+
237+
try {
238+
const assetId =
239+
"assetId" in shape.props ? (shape.props.assetId as TLAssetId) : null;
240+
if (!assetId) return null;
241+
242+
const asset = editor.getAsset(assetId);
243+
if (!asset) return null;
244+
245+
const src = asset.props.src;
246+
if (!src) return null;
247+
248+
const blockRefId = extractBlockRefId(src);
249+
if (!blockRefId) return null;
250+
251+
const canvasFileCache = plugin.app.metadataCache.getFileCache(canvasFile);
252+
if (!canvasFileCache) return null;
253+
254+
return await resolveLinkedTFileByBlockRef({
255+
app: plugin.app,
256+
canvasFile,
257+
blockRefId,
258+
canvasFileCache,
259+
});
260+
} catch (error) {
261+
console.error("Error getting image file from shape:", error);
262+
return null;
263+
}
264+
};
265+
const embedImageInNode = async (
266+
nodeFile: TFile,
267+
imageFile: TFile,
268+
plugin: DiscourseGraphPlugin,
269+
): Promise<void> => {
270+
const imageLink = plugin.app.metadataCache.fileToLinktext(
271+
imageFile,
272+
nodeFile.path,
273+
);
274+
const imageEmbed = `![[${imageLink}]]`;
275+
276+
await plugin.app.vault.process(nodeFile, (data: string) => {
277+
return `${data}\n${imageEmbed}\n`;
278+
});
279+
};

0 commit comments

Comments
 (0)