Skip to content
Open
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
42 changes: 8 additions & 34 deletions apps/obsidian/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
52 changes: 51 additions & 1 deletion apps/obsidian/src/utils/editorMenuUtils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
};
133 changes: 133 additions & 0 deletions apps/obsidian/src/utils/imageEmbedHoverIcon.ts
Original file line number Diff line number Diff line change
@@ -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);
`;
Comment on lines +41 to +46
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Hardcoded inline styles via style.cssText violates AGENTS.md rule

The button created in createConvertIcon sets multiple CSS properties via btn.style.cssText at lines 41–46. The mandatory rule in apps/obsidian/AGENTS.md:100 states: "Do not hardcode styles inline — use CSS classes and Obsidian's CSS variables." While the values reference CSS variables, the delivery mechanism is inline style.cssText, which directly violates this rule. These properties should be moved to a CSS class in the plugin's stylesheet (e.g., styles.css or src/styles/).

Prompt for agents
The inline styles assigned via btn.style.cssText in createConvertIcon (imageEmbedHoverIcon.ts:41-46) should be extracted to a CSS class. 

1. Create a new CSS class (e.g., .dg-image-convert-icon) in apps/obsidian/styles.css or a file in apps/obsidian/src/styles/.
2. Move the properties (top, padding, color, background-color) into that class, keeping the CSS variable references.
3. In createConvertIcon, add the class name to btn.className instead of setting btn.style.cssText.

The existing Tailwind utility classes on the same button (line 40) are fine since the project compiles Tailwind; it's only the raw inline styles that violate the rule.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these styles are here to make sure we matches Obsidian styling choices. All these vars are interpretable by Obsidian

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<HTMLElement>(
".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<PluginValue> => {
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());
}
},
);
};
Loading