diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index a120ebfaf..04be58716 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -14,8 +14,9 @@ import { Settings, VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types"; import { addConvertSubmenu, isImageFile, - replaceImageEmbedInEditor, + openConvertImageToNodeModal, } from "~/utils/editorMenuUtils"; +import { createImageEmbedHoverExtension } from "~/utils/imageEmbedHoverIcon"; import { registerCommands } from "~/utils/registerCommands"; import { DiscourseContextView } from "~/components/DiscourseContextView"; import { VIEW_TYPE_TLDRAW_DG_PREVIEW, FRONTMATTER_KEY } from "~/constants"; @@ -159,41 +160,11 @@ export default class DiscourseGraphPlugin extends Plugin { label: "Convert into", nodeTypes: this.settings.nodeTypes, onClick: (nodeType) => { - new ModifyNodeModal(this.app, { - nodeTypes: this.settings.nodeTypes, + openConvertImageToNodeModal({ plugin: this, - initialTitle: "", + imageFile: file, initialNodeType: nodeType, - onSubmit: async ({ - nodeType: selectedType, - title, - selectedExistingNode, - }) => { - const targetFile = - selectedExistingNode ?? - (await createDiscourseNode({ - plugin: this, - nodeType: selectedType, - text: title, - })); - - if (!targetFile) return; - - const imageLink = this.app.metadataCache.fileToLinktext( - file, - targetFile.path, - ); - await this.app.vault.append( - targetFile, - `\n![[${imageLink}]]\n`, - ); - replaceImageEmbedInEditor({ - app: this.app, - imageFile: file, - targetFile, - }); - }, - }).open(); + }); }, }); return; @@ -300,6 +271,9 @@ export default class DiscourseGraphPlugin extends Plugin { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument this.registerEditorExtension(nodeTagHotkeyExtension); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.registerEditorExtension(createImageEmbedHoverExtension(this)); } private createStyleElement() { diff --git a/apps/obsidian/src/utils/editorMenuUtils.ts b/apps/obsidian/src/utils/editorMenuUtils.ts index 2c822956d..81553e4d0 100644 --- a/apps/obsidian/src/utils/editorMenuUtils.ts +++ b/apps/obsidian/src/utils/editorMenuUtils.ts @@ -1,5 +1,8 @@ import { App, MarkdownView, Menu, TFile } from "obsidian"; import { DiscourseNode } from "~/types"; +import type DiscourseGraphPlugin from "~/index"; +import { createDiscourseNode } from "~/utils/createNode"; +import ModifyNodeModal from "~/components/ModifyNodeModal"; /** * Add a "Convert into" / "Turn into discourse node" submenu to a context menu, @@ -36,7 +39,7 @@ export const addConvertSubmenu = ({ /** * Replace the first embed of `imageFile` in the active editor with a link to `targetFile`. */ -export const replaceImageEmbedInEditor = ({ +const replaceImageEmbedInEditor = ({ app, imageFile, targetFile, @@ -71,3 +74,50 @@ const IMAGE_EXTENSIONS = /^(png|jpe?g|gif|svg|bmp|webp|avif|tiff?)$/i; export const isImageFile = (file: TFile): boolean => IMAGE_EXTENSIONS.test(file.extension); + +/** + * Open ModifyNodeModal to convert an image file into a discourse node. + * Shared by file-menu "Convert into" and the hover icon on embedded images. + */ +export const openConvertImageToNodeModal = ({ + plugin, + imageFile, + initialNodeType, +}: { + plugin: DiscourseGraphPlugin; + imageFile: TFile; + initialNodeType?: DiscourseNode; +}): void => { + new ModifyNodeModal(plugin.app, { + nodeTypes: plugin.settings.nodeTypes, + plugin, + initialTitle: "", + initialNodeType, + onSubmit: async ({ + nodeType: selectedType, + title, + selectedExistingNode, + }) => { + const targetFile = + selectedExistingNode ?? + (await createDiscourseNode({ + plugin, + nodeType: selectedType, + text: title, + })); + + if (!targetFile) return; + + const imageLink = plugin.app.metadataCache.fileToLinktext( + imageFile, + targetFile.path, + ); + await plugin.app.vault.append(targetFile, `\n![[${imageLink}]]\n`); + replaceImageEmbedInEditor({ + app: plugin.app, + imageFile, + targetFile, + }); + }, + }).open(); +}; diff --git a/apps/obsidian/src/utils/imageEmbedHoverIcon.ts b/apps/obsidian/src/utils/imageEmbedHoverIcon.ts new file mode 100644 index 000000000..e29c58007 --- /dev/null +++ b/apps/obsidian/src/utils/imageEmbedHoverIcon.ts @@ -0,0 +1,133 @@ +import { + type PluginValue, + EditorView, + ViewPlugin, + ViewUpdate, +} from "@codemirror/view"; +import { setIcon, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import { + isImageFile, + openConvertImageToNodeModal, +} from "~/utils/editorMenuUtils"; + +const ICON_CLASS = "dg-image-convert-icon"; + +const resolveImageFile = ( + embedEl: HTMLElement, + plugin: DiscourseGraphPlugin, +): TFile | null => { + const src = embedEl.getAttribute("src"); + if (!src) return null; + + const activeFile = plugin.app.workspace.getActiveFile(); + if (!activeFile) return null; + + const resolved = plugin.app.metadataCache.getFirstLinkpathDest( + src, + activeFile.path, + ); + if (!resolved || !isImageFile(resolved)) return null; + + return resolved; +}; + +const createConvertIcon = ( + embedEl: HTMLElement, + plugin: DiscourseGraphPlugin, +): HTMLButtonElement => { + const btn = document.createElement("button"); + 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`; + btn.style.cssText = ` + top: var(--size-2-2); + padding: var(--size-2-2) var(--size-2-3); + color: var(--text-muted); + background-color: var(--background-primary); + `; + btn.title = "Convert to node"; + setIcon(btn, "file-input"); + + btn.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + + const imageFile = resolveImageFile(embedEl, plugin); + if (!imageFile) return; + + openConvertImageToNodeModal({ plugin, imageFile }); + }); + + return btn; +}; + +const processContainer = ( + container: HTMLElement, + plugin: DiscourseGraphPlugin, +): void => { + const embeds = container.querySelectorAll( + ".internal-embed.image-embed", + ); + + for (const embedEl of embeds) { + if (embedEl.querySelector(`.${ICON_CLASS}`)) continue; + + const imageFile = resolveImageFile(embedEl, plugin); + if (!imageFile) continue; + + embedEl.classList.add("group", "relative"); + embedEl.appendChild(createConvertIcon(embedEl, plugin)); + } +}; + +/** + * CodeMirror ViewPlugin that adds a "Convert to node" hover icon + * on embedded images in the live-preview editor. + */ +export const createImageEmbedHoverExtension = ( + plugin: DiscourseGraphPlugin, +): ViewPlugin => { + return ViewPlugin.fromClass( + class { + private dom: HTMLElement; + private observer: MutationObserver; + + constructor(view: EditorView) { + this.dom = view.dom; + processContainer(view.dom, plugin); + + // Obsidian renders embeds asynchronously after doc changes, + // so we need a MutationObserver to catch newly added image embeds. + this.observer = new MutationObserver((mutations) => { + const hasRelevantMutation = mutations.some((m) => + Array.from(m.addedNodes).some( + (n) => + n instanceof HTMLElement && + !n.classList.contains(ICON_CLASS) && + (n.matches(".internal-embed.image-embed") || + n.querySelector(".internal-embed.image-embed")), + ), + ); + if (hasRelevantMutation) { + processContainer(this.dom, plugin); + } + }); + this.observer.observe(this.dom, { + childList: true, + subtree: true, + }); + } + + update(update: ViewUpdate): void { + if (update.docChanged || update.viewportChanged) { + processContainer(update.view.dom, plugin); + } + } + + destroy(): void { + this.observer.disconnect(); + const icons = this.dom.querySelectorAll(`.${ICON_CLASS}`); + icons.forEach((icon) => icon.remove()); + } + }, + ); +};