Skip to content

Commit 667b243

Browse files
trangdoan982claudedevin-ai-integration[bot]
authored
ENG-1565 Convert image to node via hover icon (#936)
* ENG-1565 Convert image to node via hover icon Add a "Convert to node" icon that appears on hover over embedded images in the Obsidian editor. Clicking it opens ModifyNodeModal to create a discourse node from the image. Also extracts shared image-to-node conversion logic into openConvertImageToNodeModal so both the file-menu and hover icon reuse the same handler. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * revert prettier reformatting of style.css, keep only new CSS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * some changes * some changes * address PR comments * Update apps/obsidian/src/utils/imageEmbedHoverIcon.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix * address PR comments --------- 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 813072d commit 667b243

3 files changed

Lines changed: 192 additions & 35 deletions

File tree

apps/obsidian/src/index.ts

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import { Settings, VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types";
1414
import {
1515
addConvertSubmenu,
1616
isImageFile,
17-
replaceImageEmbedInEditor,
17+
openConvertImageToNodeModal,
1818
} from "~/utils/editorMenuUtils";
19+
import { createImageEmbedHoverExtension } from "~/utils/imageEmbedHoverIcon";
1920
import { registerCommands } from "~/utils/registerCommands";
2021
import { DiscourseContextView } from "~/components/DiscourseContextView";
2122
import { VIEW_TYPE_TLDRAW_DG_PREVIEW, FRONTMATTER_KEY } from "~/constants";
@@ -159,41 +160,11 @@ export default class DiscourseGraphPlugin extends Plugin {
159160
label: "Convert into",
160161
nodeTypes: this.settings.nodeTypes,
161162
onClick: (nodeType) => {
162-
new ModifyNodeModal(this.app, {
163-
nodeTypes: this.settings.nodeTypes,
163+
openConvertImageToNodeModal({
164164
plugin: this,
165-
initialTitle: "",
165+
imageFile: file,
166166
initialNodeType: nodeType,
167-
onSubmit: async ({
168-
nodeType: selectedType,
169-
title,
170-
selectedExistingNode,
171-
}) => {
172-
const targetFile =
173-
selectedExistingNode ??
174-
(await createDiscourseNode({
175-
plugin: this,
176-
nodeType: selectedType,
177-
text: title,
178-
}));
179-
180-
if (!targetFile) return;
181-
182-
const imageLink = this.app.metadataCache.fileToLinktext(
183-
file,
184-
targetFile.path,
185-
);
186-
await this.app.vault.append(
187-
targetFile,
188-
`\n![[${imageLink}]]\n`,
189-
);
190-
replaceImageEmbedInEditor({
191-
app: this.app,
192-
imageFile: file,
193-
targetFile,
194-
});
195-
},
196-
}).open();
167+
});
197168
},
198169
});
199170
return;
@@ -300,6 +271,9 @@ export default class DiscourseGraphPlugin extends Plugin {
300271

301272
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
302273
this.registerEditorExtension(nodeTagHotkeyExtension);
274+
275+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
276+
this.registerEditorExtension(createImageEmbedHoverExtension(this));
303277
}
304278

305279
private createStyleElement() {

apps/obsidian/src/utils/editorMenuUtils.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { App, MarkdownView, Menu, TFile } from "obsidian";
22
import { DiscourseNode } from "~/types";
3+
import type DiscourseGraphPlugin from "~/index";
4+
import { createDiscourseNode } from "~/utils/createNode";
5+
import ModifyNodeModal from "~/components/ModifyNodeModal";
36

47
/**
58
* Add a "Convert into" / "Turn into discourse node" submenu to a context menu,
@@ -36,7 +39,7 @@ export const addConvertSubmenu = ({
3639
/**
3740
* Replace the first embed of `imageFile` in the active editor with a link to `targetFile`.
3841
*/
39-
export const replaceImageEmbedInEditor = ({
42+
const replaceImageEmbedInEditor = ({
4043
app,
4144
imageFile,
4245
targetFile,
@@ -71,3 +74,50 @@ const IMAGE_EXTENSIONS = /^(png|jpe?g|gif|svg|bmp|webp|avif|tiff?)$/i;
7174

7275
export const isImageFile = (file: TFile): boolean =>
7376
IMAGE_EXTENSIONS.test(file.extension);
77+
78+
/**
79+
* Open ModifyNodeModal to convert an image file into a discourse node.
80+
* Shared by file-menu "Convert into" and the hover icon on embedded images.
81+
*/
82+
export const openConvertImageToNodeModal = ({
83+
plugin,
84+
imageFile,
85+
initialNodeType,
86+
}: {
87+
plugin: DiscourseGraphPlugin;
88+
imageFile: TFile;
89+
initialNodeType?: DiscourseNode;
90+
}): void => {
91+
new ModifyNodeModal(plugin.app, {
92+
nodeTypes: plugin.settings.nodeTypes,
93+
plugin,
94+
initialTitle: "",
95+
initialNodeType,
96+
onSubmit: async ({
97+
nodeType: selectedType,
98+
title,
99+
selectedExistingNode,
100+
}) => {
101+
const targetFile =
102+
selectedExistingNode ??
103+
(await createDiscourseNode({
104+
plugin,
105+
nodeType: selectedType,
106+
text: title,
107+
}));
108+
109+
if (!targetFile) return;
110+
111+
const imageLink = plugin.app.metadataCache.fileToLinktext(
112+
imageFile,
113+
targetFile.path,
114+
);
115+
await plugin.app.vault.append(targetFile, `\n![[${imageLink}]]\n`);
116+
replaceImageEmbedInEditor({
117+
app: plugin.app,
118+
imageFile,
119+
targetFile,
120+
});
121+
},
122+
}).open();
123+
};
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {
2+
type PluginValue,
3+
EditorView,
4+
ViewPlugin,
5+
ViewUpdate,
6+
} from "@codemirror/view";
7+
import { setIcon, TFile } from "obsidian";
8+
import type DiscourseGraphPlugin from "~/index";
9+
import {
10+
isImageFile,
11+
openConvertImageToNodeModal,
12+
} from "~/utils/editorMenuUtils";
13+
14+
const ICON_CLASS = "dg-image-convert-icon";
15+
16+
const resolveImageFile = (
17+
embedEl: HTMLElement,
18+
plugin: DiscourseGraphPlugin,
19+
): TFile | null => {
20+
const src = embedEl.getAttribute("src");
21+
if (!src) return null;
22+
23+
const activeFile = plugin.app.workspace.getActiveFile();
24+
if (!activeFile) return null;
25+
26+
const resolved = plugin.app.metadataCache.getFirstLinkpathDest(
27+
src,
28+
activeFile.path,
29+
);
30+
if (!resolved || !isImageFile(resolved)) return null;
31+
32+
return resolved;
33+
};
34+
35+
const createConvertIcon = (
36+
embedEl: HTMLElement,
37+
plugin: DiscourseGraphPlugin,
38+
): HTMLButtonElement => {
39+
const btn = document.createElement("button");
40+
btn.className = `${ICON_CLASS} absolute z-[2] right-[42px] w-[26px] h-[26px] flex cursor-[var(--cursor)] border-none opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto`;
41+
btn.style.cssText = `
42+
top: var(--size-2-2);
43+
padding: var(--size-2-2) var(--size-2-3);
44+
color: var(--text-muted);
45+
background-color: var(--background-primary);
46+
`;
47+
btn.title = "Convert to node";
48+
setIcon(btn, "file-input");
49+
50+
btn.addEventListener("click", (e) => {
51+
e.stopPropagation();
52+
e.preventDefault();
53+
54+
const imageFile = resolveImageFile(embedEl, plugin);
55+
if (!imageFile) return;
56+
57+
openConvertImageToNodeModal({ plugin, imageFile });
58+
});
59+
60+
return btn;
61+
};
62+
63+
const processContainer = (
64+
container: HTMLElement,
65+
plugin: DiscourseGraphPlugin,
66+
): void => {
67+
const embeds = container.querySelectorAll<HTMLElement>(
68+
".internal-embed.image-embed",
69+
);
70+
71+
for (const embedEl of embeds) {
72+
if (embedEl.querySelector(`.${ICON_CLASS}`)) continue;
73+
74+
const imageFile = resolveImageFile(embedEl, plugin);
75+
if (!imageFile) continue;
76+
77+
embedEl.classList.add("group", "relative");
78+
embedEl.appendChild(createConvertIcon(embedEl, plugin));
79+
}
80+
};
81+
82+
/**
83+
* CodeMirror ViewPlugin that adds a "Convert to node" hover icon
84+
* on embedded images in the live-preview editor.
85+
*/
86+
export const createImageEmbedHoverExtension = (
87+
plugin: DiscourseGraphPlugin,
88+
): ViewPlugin<PluginValue> => {
89+
return ViewPlugin.fromClass(
90+
class {
91+
private dom: HTMLElement;
92+
private observer: MutationObserver;
93+
94+
constructor(view: EditorView) {
95+
this.dom = view.dom;
96+
processContainer(view.dom, plugin);
97+
98+
// Obsidian renders embeds asynchronously after doc changes,
99+
// so we need a MutationObserver to catch newly added image embeds.
100+
this.observer = new MutationObserver((mutations) => {
101+
const hasRelevantMutation = mutations.some((m) =>
102+
Array.from(m.addedNodes).some(
103+
(n) =>
104+
n instanceof HTMLElement &&
105+
!n.classList.contains(ICON_CLASS) &&
106+
(n.matches(".internal-embed.image-embed") ||
107+
n.querySelector(".internal-embed.image-embed")),
108+
),
109+
);
110+
if (hasRelevantMutation) {
111+
processContainer(this.dom, plugin);
112+
}
113+
});
114+
this.observer.observe(this.dom, {
115+
childList: true,
116+
subtree: true,
117+
});
118+
}
119+
120+
update(update: ViewUpdate): void {
121+
if (update.docChanged || update.viewportChanged) {
122+
processContainer(update.view.dom, plugin);
123+
}
124+
}
125+
126+
destroy(): void {
127+
this.observer.disconnect();
128+
const icons = this.dom.querySelectorAll(`.${ICON_CLASS}`);
129+
icons.forEach((icon) => icon.remove());
130+
}
131+
},
132+
);
133+
};

0 commit comments

Comments
 (0)