Skip to content

Commit e4a4339

Browse files
trangdoan982claudedevin-ai-integration[bot]
authored
[ENG-1527] Handle image embeds in editor right-click convert to node (#885)
* handle image embeds in editor right-click "Turn into discourse node" When the selected text is an image embed (wikilink, markdown, or HTML img), open ModifyNodeModal to let the user pick a title/type, create the discourse node, embed the image in it, and replace the selection with a link. Closes ENG-1527 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix * working * lint * pr comment + lint * address PR comments * Update apps/obsidian/src/utils/editorMenuUtils.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Update apps/obsidian/src/utils/editorMenuUtils.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Update apps/obsidian/src/utils/editorMenuUtils.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * address PR comments * fix ci --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 59383dc commit e4a4339

4 files changed

Lines changed: 174 additions & 52 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ local/*
4040
.cursor/debug.log
4141
apps/tldraw-sync-worker/tsconfig.tsbuildinfo
4242
apps/tldraw-sync-worker/.wrangler/*
43+
.claude/*

apps/obsidian/src/index.ts

Lines changed: 82 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import {
1111
import { EditorView } from "@codemirror/view";
1212
import { SettingsTab } from "~/components/Settings";
1313
import { Settings, VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types";
14+
import {
15+
addConvertSubmenu,
16+
isImageFile,
17+
replaceImageEmbedInEditor,
18+
} from "~/utils/editorMenuUtils";
1419
import { registerCommands } from "~/utils/registerCommands";
1520
import { DiscourseContextView } from "~/components/DiscourseContextView";
1621
import { VIEW_TYPE_TLDRAW_DG_PREVIEW, FRONTMATTER_KEY } from "~/constants";
@@ -146,8 +151,53 @@ export default class DiscourseGraphPlugin extends Plugin {
146151
);
147152

148153
this.registerEvent(
149-
// @ts-ignore - file-menu event exists but is not in the type definitions
150154
this.app.workspace.on("file-menu", (menu: Menu, file: TFile) => {
155+
if (isImageFile(file)) {
156+
addConvertSubmenu({
157+
menu,
158+
label: "Convert into",
159+
nodeTypes: this.settings.nodeTypes,
160+
onClick: (nodeType) => {
161+
new ModifyNodeModal(this.app, {
162+
nodeTypes: this.settings.nodeTypes,
163+
plugin: this,
164+
initialTitle: "",
165+
initialNodeType: nodeType,
166+
onSubmit: async ({
167+
nodeType: selectedType,
168+
title,
169+
selectedExistingNode,
170+
}) => {
171+
const targetFile =
172+
selectedExistingNode ??
173+
(await createDiscourseNode({
174+
plugin: this,
175+
nodeType: selectedType,
176+
text: title,
177+
}));
178+
179+
if (!targetFile) return;
180+
181+
const imageLink = this.app.metadataCache.fileToLinktext(
182+
file,
183+
targetFile.path,
184+
);
185+
await this.app.vault.append(
186+
targetFile,
187+
`\n![[${imageLink}]]\n`,
188+
);
189+
replaceImageEmbedInEditor({
190+
app: this.app,
191+
imageFile: file,
192+
targetFile,
193+
});
194+
},
195+
}).open();
196+
},
197+
});
198+
return;
199+
}
200+
151201
const fileCache = this.app.metadataCache.getFileCache(file);
152202
const fileNodeType = fileCache?.frontmatter?.nodeTypeId;
153203

@@ -157,36 +207,26 @@ export default class DiscourseGraphPlugin extends Plugin {
157207
(nodeType) => nodeType.id === fileNodeType,
158208
)
159209
) {
160-
menu.addItem((menuItem) => {
161-
menuItem.setTitle("Convert into");
162-
menuItem.setIcon("file-type");
163-
164-
// @ts-ignore - setSubmenu is not officially in the API but works
165-
const submenu = menuItem.setSubmenu();
166-
167-
this.settings.nodeTypes.forEach((nodeType) => {
168-
submenu.addItem((item: any) => {
169-
item
170-
.setTitle(nodeType.name)
171-
.setIcon("file-type")
172-
.onClick(() => {
173-
new ModifyNodeModal(this.app, {
174-
nodeTypes: this.settings.nodeTypes,
175-
plugin: this,
176-
initialTitle: file.basename,
177-
initialNodeType: nodeType,
178-
onSubmit: async ({ nodeType, title }) => {
179-
await convertPageToDiscourseNode({
180-
plugin: this,
181-
file,
182-
nodeType,
183-
title,
184-
});
185-
},
186-
}).open();
210+
addConvertSubmenu({
211+
menu,
212+
label: "Convert into",
213+
nodeTypes: this.settings.nodeTypes,
214+
onClick: (nodeType) => {
215+
new ModifyNodeModal(this.app, {
216+
nodeTypes: this.settings.nodeTypes,
217+
plugin: this,
218+
initialTitle: file.basename,
219+
initialNodeType: nodeType,
220+
onSubmit: async ({ nodeType, title }) => {
221+
await convertPageToDiscourseNode({
222+
plugin: this,
223+
file,
224+
nodeType,
225+
title,
187226
});
188-
});
189-
});
227+
},
228+
}).open();
229+
},
190230
});
191231
}
192232
}),
@@ -196,29 +236,19 @@ export default class DiscourseGraphPlugin extends Plugin {
196236
this.app.workspace.on("editor-menu", (menu: Menu, editor: Editor) => {
197237
if (!editor.getSelection()) return;
198238

199-
menu.addItem((menuItem) => {
200-
menuItem.setTitle("Turn into discourse node");
201-
menuItem.setIcon("file-type");
202-
203-
// Create submenu using the unofficial API pattern
204-
// @ts-ignore - setSubmenu is not officially in the API but works
205-
const submenu = menuItem.setSubmenu();
206-
207-
this.settings.nodeTypes.forEach((nodeType) => {
208-
submenu.addItem((item: any) => {
209-
item
210-
.setTitle(nodeType.name)
211-
.setIcon("file-type")
212-
.onClick(async () => {
213-
await createDiscourseNode({
214-
plugin: this,
215-
editor,
216-
nodeType,
217-
text: editor.getSelection().trim() || "",
218-
});
219-
});
239+
const selection = editor.getSelection().trim();
240+
addConvertSubmenu({
241+
menu,
242+
label: "Turn into discourse node",
243+
nodeTypes: this.settings.nodeTypes,
244+
onClick: async (nodeType) => {
245+
await createDiscourseNode({
246+
plugin: this,
247+
editor,
248+
nodeType,
249+
text: selection,
220250
});
221-
});
251+
},
222252
});
223253
}),
224254
);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/* eslint-disable @typescript-eslint/consistent-type-definitions */
2+
// Unofficial Obsidian APIs — may break on Obsidian updates.
3+
// Only declare what we actually use.
4+
import "obsidian";
5+
6+
declare module "obsidian" {
7+
// module has to be declared using interface instead of type to merge with the official types
8+
interface Workspace {
9+
on(
10+
name: "file-menu",
11+
callback: (menu: Menu, file: TFile) => void,
12+
): EventRef;
13+
}
14+
15+
interface MenuItem {
16+
setSubmenu(): Menu;
17+
}
18+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { App, MarkdownView, Menu, TFile } from "obsidian";
2+
import { DiscourseNode } from "~/types";
3+
4+
/**
5+
* Add a "Convert into" / "Turn into discourse node" submenu to a context menu,
6+
* with one item per node type.
7+
*/
8+
export const addConvertSubmenu = ({
9+
menu,
10+
label,
11+
nodeTypes,
12+
onClick,
13+
}: {
14+
menu: Menu;
15+
label: string;
16+
nodeTypes: DiscourseNode[];
17+
onClick: (nodeType: DiscourseNode) => void | Promise<void>;
18+
}): void => {
19+
menu.addItem((menuItem) => {
20+
menuItem.setTitle(label);
21+
menuItem.setIcon("file-type");
22+
23+
const submenu = menuItem.setSubmenu();
24+
25+
nodeTypes.forEach((nodeType) => {
26+
submenu.addItem((item) => {
27+
item
28+
.setTitle(nodeType.name)
29+
.setIcon("file-type")
30+
.onClick(() => void onClick(nodeType));
31+
});
32+
});
33+
});
34+
};
35+
36+
/**
37+
* Replace the first embed of `imageFile` in the active editor with a link to `targetFile`.
38+
*/
39+
export const replaceImageEmbedInEditor = ({
40+
app,
41+
imageFile,
42+
targetFile,
43+
}: {
44+
app: App;
45+
imageFile: TFile;
46+
targetFile: TFile;
47+
}): void => {
48+
const activeView = app.workspace.getActiveViewOfType(MarkdownView);
49+
if (!activeView?.file) return;
50+
51+
const cache = app.metadataCache.getFileCache(activeView.file);
52+
const embed = cache?.embeds?.find((e) => {
53+
const resolved = app.metadataCache.getFirstLinkpathDest(
54+
e.link,
55+
activeView.file!.path,
56+
);
57+
return resolved?.path === imageFile.path;
58+
});
59+
if (!embed) return;
60+
61+
const from = activeView.editor.offsetToPos(embed.position.start.offset);
62+
const to = activeView.editor.offsetToPos(embed.position.end.offset);
63+
const linkText = app.metadataCache.fileToLinktext(
64+
targetFile,
65+
activeView.file.path,
66+
);
67+
activeView.editor.replaceRange(`[[${linkText}]]`, from, to);
68+
};
69+
70+
const IMAGE_EXTENSIONS = /^(png|jpe?g|gif|svg|bmp|webp|avif|tiff?)$/i;
71+
72+
export const isImageFile = (file: TFile): boolean =>
73+
IMAGE_EXTENSIONS.test(file.extension);

0 commit comments

Comments
 (0)