(el && setIcon(el, "info")) || undefined} />
+
+);
diff --git a/apps/obsidian/src/components/RelationshipSection.tsx b/apps/obsidian/src/components/RelationshipSection.tsx
index 04e9f69c4..6b7ace8bf 100644
--- a/apps/obsidian/src/components/RelationshipSection.tsx
+++ b/apps/obsidian/src/components/RelationshipSection.tsx
@@ -26,7 +26,7 @@ import {
removeRelationBySourceDestinationType,
updateRelation,
} from "~/utils/relationsStore";
-import { InfoTooltip } from "./DiscourseContextView";
+import { InfoTooltip } from "./InfoTooltip";
type RelationTypeOption = {
id: string;
diff --git a/apps/obsidian/src/components/discourseContextBadge.ts b/apps/obsidian/src/components/discourseContextBadge.ts
new file mode 100644
index 000000000..9e31609fe
--- /dev/null
+++ b/apps/obsidian/src/components/discourseContextBadge.ts
@@ -0,0 +1,91 @@
+import { setIcon, setTooltip, TFile } from "obsidian";
+import type { DiscourseNode } from "~/types";
+
+/** Marks a badge so a re-run can find and update it. */
+export const DISCOURSE_CONTEXT_BADGE_CLASS = "dg-discourse-context-badge";
+const BADGE_COUNT_CLASS = "dg-discourse-context-badge-count";
+const BADGE_PATH_ATTR = "data-dg-path";
+
+export type DiscourseContextBadgeProps = {
+ file: TFile;
+ nodeType: DiscourseNode;
+ relationCount: number;
+ onActivate: (args: { file: TFile; anchor: HTMLElement }) => void;
+};
+
+const badgeTooltip = ({
+ nodeType,
+ relationCount,
+}: Pick
): string => {
+ const relations = relationCount === 1 ? "relation" : "relations";
+ return `${nodeType.name}: ${relationCount} ${relations} — open discourse context`;
+};
+
+/**
+ * Plain DOM, not React, so both render paths share it without mounting a React
+ * root per link.
+ */
+export const createDiscourseContextBadge = ({
+ file,
+ nodeType,
+ relationCount,
+ onActivate,
+}: DiscourseContextBadgeProps): HTMLElement => {
+ const badge = createSpan();
+ badge.className = `${DISCOURSE_CONTEXT_BADGE_CLASS} inline-flex items-center gap-0.5 align-middle ml-1 px-1 rounded cursor-pointer select-none text-[10px] leading-none text-[var(--text-muted)] hover:text-[var(--text-normal)] hover:bg-[var(--background-modifier-hover)] transition-colors duration-150`;
+
+ const icon = badge.createSpan({
+ cls: "inline-flex items-center [&>svg]:h-3 [&>svg]:w-3",
+ });
+ setIcon(icon, "network");
+
+ badge.createSpan({ cls: BADGE_COUNT_CLASS, text: String(relationCount) });
+
+ const label = badgeTooltip({ nodeType, relationCount });
+ setTooltip(badge, label);
+ badge.setAttribute("aria-label", label);
+ badge.setAttribute("role", "button");
+ badge.setAttribute("tabindex", "0");
+ badge.setAttribute(BADGE_PATH_ATTR, file.path);
+
+ const activate = (event: Event): void => {
+ // Do not follow the link the badge sits next to.
+ event.preventDefault();
+ event.stopPropagation();
+ onActivate({ file, anchor: badge });
+ };
+
+ // Otherwise the caret moves, expanding the raw [[...]] under the popover.
+ badge.addEventListener("mousedown", (event: MouseEvent) => {
+ event.preventDefault();
+ });
+ badge.addEventListener("click", activate);
+ badge.addEventListener("keydown", (event: KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ activate(event);
+ });
+
+ return badge;
+};
+
+/** The badge's target, so a refresh can tell an update from a replacement. */
+export const badgeTargetPath = (badge: Element): string | null =>
+ badge.getAttribute(BADGE_PATH_ATTR);
+
+/** In place, so an open popover anchored to it keeps a connected anchor. */
+export const updateDiscourseContextBadge = ({
+ badge,
+ nodeType,
+ relationCount,
+}: {
+ badge: HTMLElement;
+ nodeType: DiscourseNode;
+ relationCount: number;
+}): void => {
+ const count = badge.querySelector(`.${BADGE_COUNT_CLASS}`);
+ if (count) count.textContent = String(relationCount);
+
+ const label = badgeTooltip({ nodeType, relationCount });
+ setTooltip(badge, label);
+ badge.setAttribute("aria-label", label);
+};
diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts
index 84fc5436a..b65dccac5 100644
--- a/apps/obsidian/src/constants.ts
+++ b/apps/obsidian/src/constants.ts
@@ -119,6 +119,7 @@ export const DEFAULT_SETTINGS: Settings = {
canvasAttachmentsFolderPath: "attachments",
nodeTagHotkey: "\\",
showHelpMenuStatusBarIcon: false,
+ showDiscourseContextOverlay: true,
spacePassword: undefined,
accountLocalId: undefined,
syncModeEnabled: false,
diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts
index 12f66ad2a..d121f7f27 100644
--- a/apps/obsidian/src/index.ts
+++ b/apps/obsidian/src/index.ts
@@ -20,6 +20,13 @@ import {
} from "~/utils/editorMenuUtils";
import { createImageEmbedHoverExtension } from "~/utils/imageEmbedHoverIcon";
import { createWikilinkDragExtension } from "~/utils/wikilinkDragHandler";
+import { createDiscourseContextOverlayExtension } from "~/utils/discourseContextOverlayExtension";
+import {
+ registerDiscourseContextOverlayRefresh,
+ refreshDiscourseContextOverlaySurfaces,
+} from "~/utils/discourseContextOverlayRefresh";
+import { refreshMarkdownEditors } from "~/utils/markdownViewRefresh";
+import { closeDiscourseContextPopover } from "~/components/DiscourseContextPopover";
import {
registerCommands,
createModifyNodeModalSubmitHandler,
@@ -105,6 +112,7 @@ export default class DiscourseGraphPlugin extends Plugin {
}
this.relationsIndex.initialize();
+ registerDiscourseContextOverlayRefresh(this);
registerCommands(this);
this.addSettingTab(new SettingsTab(this.app, this));
@@ -268,36 +276,22 @@ export default class DiscourseGraphPlugin extends Plugin {
}),
);
- type EditorWithCm = { cm: EditorView };
- const hasCodeMirrorView = (editor: unknown): editor is EditorWithCm => {
- if (!editor || typeof editor !== "object") return false;
- return "cm" in editor;
- };
-
- // Dispatch a no-op CM6 transaction to every markdown editor so their
- // ViewPlugin re-evaluates hasVisibleCanvasLeaf and shows/hides widgets.
- // layout-change covers splits/moves, active-leaf-change covers tab switches.
- const refreshMarkdownEditors = (): void => {
- this.app.workspace.iterateAllLeaves((leaf) => {
- if (
- leaf.view instanceof MarkdownView &&
- hasCodeMirrorView(leaf.view.editor)
- ) {
- leaf.view.editor.cm.dispatch({});
- }
- });
- };
+ // Re-evaluate ViewPlugins on splits/moves (layout-change) and tab switches.
+ const refreshEditors = (): void => refreshMarkdownEditors(this.app);
+ this.registerEvent(this.app.workspace.on("layout-change", refreshEditors));
this.registerEvent(
- this.app.workspace.on("layout-change", refreshMarkdownEditors),
- );
- this.registerEvent(
- this.app.workspace.on("active-leaf-change", refreshMarkdownEditors),
+ this.app.workspace.on("active-leaf-change", refreshEditors),
);
// Register editor keydown listener for node tag hotkey
this.setupNodeTagHotkey();
}
+ /** Applies the overlay setting immediately, without a reload. */
+ refreshDiscourseContextOverlay(): void {
+ refreshDiscourseContextOverlaySurfaces(this);
+ }
+
setHelpMenuStatusBarItemVisibility(): void {
if (!this.settings.showHelpMenuStatusBarIcon) {
this.helpMenuStatusBarItem?.remove();
@@ -369,6 +363,7 @@ export default class DiscourseGraphPlugin extends Plugin {
this.registerEditorExtension(createImageEmbedHoverExtension(this));
this.registerEditorExtension(createWikilinkDragExtension(this));
+ this.registerEditorExtension(createDiscourseContextOverlayExtension(this));
this.registerEditorExtension(createDiscourseTagExtension(this));
@@ -501,6 +496,8 @@ export default class DiscourseGraphPlugin extends Plugin {
this.fileChangeListener = null;
}
+ // Lives on document.body with its own listeners; would outlive the plugin.
+ closeDiscourseContextPopover();
this.relationsIndex.unload();
}
}
diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts
index 050c476a3..fe998bafe 100644
--- a/apps/obsidian/src/types.ts
+++ b/apps/obsidian/src/types.ts
@@ -68,6 +68,7 @@ export type Settings = {
canvasAttachmentsFolderPath: string;
nodeTagHotkey: string;
showHelpMenuStatusBarIcon: boolean;
+ showDiscourseContextOverlay: boolean;
spacePassword?: string;
accountLocalId?: string;
syncModeEnabled?: boolean;
diff --git a/apps/obsidian/src/utils/discourseContextOverlayExtension.ts b/apps/obsidian/src/utils/discourseContextOverlayExtension.ts
new file mode 100644
index 000000000..122a7ce2d
--- /dev/null
+++ b/apps/obsidian/src/utils/discourseContextOverlayExtension.ts
@@ -0,0 +1,156 @@
+import {
+ type PluginValue,
+ ViewPlugin,
+ type ViewUpdate,
+ WidgetType,
+ Decoration,
+ type DecorationSet,
+ EditorView,
+} from "@codemirror/view";
+import { editorInfoField, editorLivePreviewField } from "obsidian";
+import type DiscourseGraphPlugin from "~/index";
+import {
+ createDiscourseContextBadge,
+ updateDiscourseContextBadge,
+} from "~/components/discourseContextBadge";
+import { openDiscourseContextPopover } from "~/components/DiscourseContextPopover";
+import {
+ resolveDiscourseLinkTarget,
+ type DiscourseLinkTarget,
+} from "./discourseLinkUtils";
+import { extractLinktext, INTERNAL_LINK_RE } from "./internalLinkParsing";
+
+class DiscourseContextBadgeWidget extends WidgetType {
+ constructor(
+ private target: DiscourseLinkTarget,
+ private plugin: DiscourseGraphPlugin,
+ ) {
+ super();
+ }
+
+ /** Keyed on what the badge displays, so keystrokes elsewhere do not rebuild it. */
+ eq(other: DiscourseContextBadgeWidget): boolean {
+ return (
+ this.target.file.path === other.target.file.path &&
+ this.target.relationCount === other.target.relationCount &&
+ this.target.nodeType.id === other.target.nodeType.id &&
+ this.target.nodeType.name === other.target.nodeType.name
+ );
+ }
+
+ toDOM(): HTMLElement {
+ return createDiscourseContextBadge({
+ file: this.target.file,
+ nodeType: this.target.nodeType,
+ relationCount: this.target.relationCount,
+ onActivate: ({ file, anchor }) =>
+ openDiscourseContextPopover({
+ plugin: this.plugin,
+ file,
+ anchor,
+ relationCount: this.target.relationCount,
+ }),
+ });
+ }
+
+ /**
+ * Updates in place so a popover anchored to this badge keeps a connected
+ * anchor; without this CM6 replaces the element on every count change.
+ */
+ updateDOM(dom: HTMLElement): boolean {
+ updateDiscourseContextBadge({
+ badge: dom,
+ nodeType: this.target.nodeType,
+ relationCount: this.target.relationCount,
+ });
+ return true;
+ }
+
+ /** True (the CM6 default) means the editor ignores the event, so our click handler runs. */
+ ignoreEvent(): boolean {
+ return true;
+ }
+}
+
+const buildBadgeDecorations = (
+ view: EditorView,
+ plugin: DiscourseGraphPlugin,
+): DecorationSet => {
+ if (!plugin.settings.showDiscourseContextOverlay) return Decoration.none;
+ // Source mode shows raw markdown; a badge there is noise.
+ if (!view.state.field(editorLivePreviewField, false)) return Decoration.none;
+
+ const sourcePath = view.state.field(editorInfoField, false)?.file?.path;
+ if (!sourcePath) return Decoration.none;
+
+ const widgets = [];
+
+ for (const { from, to } of view.visibleRanges) {
+ const text = view.state.doc.sliceString(from, to);
+ let match: RegExpExecArray | null;
+ INTERNAL_LINK_RE.lastIndex = 0;
+
+ while ((match = INTERNAL_LINK_RE.exec(text)) !== null) {
+ const checkPos = from + match.index - 1;
+ const isEmbed =
+ checkPos >= 0 &&
+ view.state.doc.sliceString(checkPos, checkPos + 1) === "!";
+ if (isEmbed) continue;
+
+ const target = resolveDiscourseLinkTarget({
+ plugin,
+ linktext: extractLinktext(match[0]),
+ sourcePath,
+ });
+ if (!target) continue;
+
+ const matchEnd = from + match.index + match[0].length;
+ widgets.push(
+ Decoration.widget({
+ widget: new DiscourseContextBadgeWidget(target, plugin),
+ side: 1,
+ }).range(matchEnd),
+ );
+ }
+ }
+
+ return Decoration.set(widgets, true);
+};
+
+export const createDiscourseContextOverlayExtension = (
+ plugin: DiscourseGraphPlugin,
+): ViewPlugin =>
+ ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+ private enabled: boolean;
+ private indexVersion: number;
+
+ constructor(view: EditorView) {
+ this.enabled = plugin.settings.showDiscourseContextOverlay;
+ this.indexVersion = plugin.relationsIndex.getVersion();
+ this.decorations = buildBadgeDecorations(view, plugin);
+ }
+
+ update(update: ViewUpdate): void {
+ // Setting and relation changes arrive as an empty transaction, which
+ // changes neither doc nor viewport, so both need comparing explicitly.
+ const enabled = plugin.settings.showDiscourseContextOverlay;
+ const indexVersion = plugin.relationsIndex.getVersion();
+ if (
+ !update.docChanged &&
+ !update.viewportChanged &&
+ enabled === this.enabled &&
+ indexVersion === this.indexVersion
+ ) {
+ return;
+ }
+ this.enabled = enabled;
+ this.indexVersion = indexVersion;
+ this.decorations = buildBadgeDecorations(update.view, plugin);
+ }
+ },
+ {
+ decorations: (v) => v.decorations,
+ },
+ );
diff --git a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts
new file mode 100644
index 000000000..0309b47b5
--- /dev/null
+++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts
@@ -0,0 +1,48 @@
+import { debounce, type TFile } from "obsidian";
+import type DiscourseGraphPlugin from "~/index";
+import { getNodeTypeIdFromFrontmatter } from "./discourseLinkFrontmatter";
+import { refreshMarkdownEditors } from "./markdownViewRefresh";
+
+const REFRESH_DEBOUNCE_MS = 300;
+
+/** Only a discourse node's own frontmatter can change what a badge shows. */
+const isDiscourseNodeFile = (
+ plugin: DiscourseGraphPlugin,
+ file: TFile,
+): boolean =>
+ !!getNodeTypeIdFromFrontmatter(
+ plugin.app.metadataCache.getFileCache(file)?.frontmatter,
+ );
+
+/** Redraws the overlay when relations or a node's frontmatter change. */
+export const refreshDiscourseContextOverlaySurfaces = (
+ plugin: DiscourseGraphPlugin,
+): void => {
+ refreshMarkdownEditors(plugin.app);
+};
+
+export const registerDiscourseContextOverlayRefresh = (
+ plugin: DiscourseGraphPlugin,
+): void => {
+ const refresh = debounce(
+ () => refreshDiscourseContextOverlaySurfaces(plugin),
+ REFRESH_DEBOUNCE_MS,
+ true,
+ );
+
+ plugin.register(plugin.relationsIndex.onChange(refresh));
+ // Files that were nodes must still trigger a refresh once they stop being
+ // one, or their existing badges never get removed.
+ const knownNodePaths = new Set();
+ // "changed", not "resolved": resolved also fires while a preview renders.
+ plugin.registerEvent(
+ plugin.app.metadataCache.on("changed", (file) => {
+ if (isDiscourseNodeFile(plugin, file)) {
+ knownNodePaths.add(file.path);
+ } else if (!knownNodePaths.delete(file.path)) {
+ return;
+ }
+ refresh();
+ }),
+ );
+};
diff --git a/apps/obsidian/src/utils/markdownViewRefresh.ts b/apps/obsidian/src/utils/markdownViewRefresh.ts
new file mode 100644
index 000000000..cc54f96bb
--- /dev/null
+++ b/apps/obsidian/src/utils/markdownViewRefresh.ts
@@ -0,0 +1,24 @@
+import { MarkdownView, type App } from "obsidian";
+import type { EditorView } from "@codemirror/view";
+
+type EditorWithCm = { cm: EditorView };
+
+export const hasCodeMirrorView = (editor: unknown): editor is EditorWithCm => {
+ if (!editor || typeof editor !== "object") return false;
+ return "cm" in editor;
+};
+
+/**
+ * Empty CM6 transaction to every open editor, forcing ViewPlugin.update() to
+ * run when something it reads changes outside the editor.
+ */
+export const refreshMarkdownEditors = (app: App): void => {
+ app.workspace.iterateAllLeaves((leaf) => {
+ if (
+ leaf.view instanceof MarkdownView &&
+ hasCodeMirrorView(leaf.view.editor)
+ ) {
+ leaf.view.editor.cm.dispatch({});
+ }
+ });
+};
diff --git a/apps/website/content/obsidian/configuration/general-settings.md b/apps/website/content/obsidian/configuration/general-settings.md
index a7f2b13e7..b7800133d 100644
--- a/apps/website/content/obsidian/configuration/general-settings.md
+++ b/apps/website/content/obsidian/configuration/general-settings.md
@@ -15,6 +15,16 @@ This setting controls the visibility of identifiers in your note's frontmatter s
- When disabled, these IDs will be hidden from view
- This can be useful if you prefer a cleaner frontmatter appearance while still maintaining the underlying structure
+## Show discourse context overlay
+
+This setting controls whether links to discourse nodes carry an inline badge showing how many relations the linked node has.
+
+- When enabled, a badge appears after each link to a discourse node in Live Preview
+- Selecting a badge opens that node's discourse context in a popover, where you can review its relationships and add a new one
+- A node with no relations shows a badge reading `0`, and its popover says "No discourse relation found"
+- Links to notes that are not discourse nodes never show a badge
+- When disabled, the badges are removed immediately; the [discourse context view](/docs/obsidian/core-features/discourse-context) remains available from the sidebar
+
## Discourse nodes folder path
This setting determines where new discourse nodes will be created in your vault.
diff --git a/apps/website/content/obsidian/core-features/discourse-context.md b/apps/website/content/obsidian/core-features/discourse-context.md
index 6251122c2..ae4604eb9 100644
--- a/apps/website/content/obsidian/core-features/discourse-context.md
+++ b/apps/website/content/obsidian/core-features/discourse-context.md
@@ -26,6 +26,12 @@ You can configure a custom hotkey in the Obsidian settings to quickly toggle the
3. Configure a custom hotkey in settings
+### Method 4: Using the discourse context overlay
+
+Links to a discourse node show a small badge with the number of relations that node has. Select the badge to open its discourse context in place, without leaving the note you are reading.
+
+The badge appears in Live Preview, on every link to a discourse node. A node with no relations yet shows a badge reading `0`, and opening it says "No discourse relation found" alongside the option to add one. You can turn the badge off in [General settings](/docs/obsidian/configuration/general-settings).
+
## Using the discourse context
The discourse context view shows you:
@@ -41,3 +47,5 @@ You can use this view to:
- Understand how nodes connect to each other
- Add new relationships
- Get a quick overview of your graph structure
+
+The overlay badge opens the same relationships in a popover, so it shows exactly what the sidebar view would show for that node. Relations that are still waiting to be accepted after an import are not counted in the badge; open the discourse context view to review those.