From acd8024d3fdc251fd033bdfd1531c29e5eba1a9a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 17:53:10 -0400 Subject: [PATCH 1/6] ENG-1249 Add the discourse context overlay in Reading view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading view needs a markdown post processor, which this plugin had no precedent for. Because Obsidian reuses rendered sections and re-runs post processors over them — as it also does for hover previews and exports — the pass is idempotent per link rather than one-shot. It is refreshed by re-applying badges over the already-rendered content rather than by calling previewMode.rerender(), which tears the preview down and does not rebuild it in a pane that is not currently painting. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/index.ts | 9 ++- .../discourseContextOverlayPostProcessor.ts | 79 +++++++++++++++++++ .../utils/discourseContextOverlayRefresh.ts | 23 +++++- .../configuration/general-settings.md | 2 +- .../core-features/discourse-context.md | 2 +- 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index d121f7f27..535f80165 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -21,6 +21,7 @@ import { import { createImageEmbedHoverExtension } from "~/utils/imageEmbedHoverIcon"; import { createWikilinkDragExtension } from "~/utils/wikilinkDragHandler"; import { createDiscourseContextOverlayExtension } from "~/utils/discourseContextOverlayExtension"; +import { createDiscourseContextOverlayPostProcessor } from "~/utils/discourseContextOverlayPostProcessor"; import { registerDiscourseContextOverlayRefresh, refreshDiscourseContextOverlaySurfaces, @@ -112,6 +113,9 @@ export default class DiscourseGraphPlugin extends Plugin { } this.relationsIndex.initialize(); + this.registerMarkdownPostProcessor( + createDiscourseContextOverlayPostProcessor(this), + ); registerDiscourseContextOverlayRefresh(this); registerCommands(this); @@ -287,7 +291,10 @@ export default class DiscourseGraphPlugin extends Plugin { this.setupNodeTagHotkey(); } - /** Applies the overlay setting immediately, without a reload. */ + /** + * Re-renders both markdown surfaces so the discourse context overlay appears + * or disappears immediately when its setting is toggled, without a reload. + */ refreshDiscourseContextOverlay(): void { refreshDiscourseContextOverlaySurfaces(this); } diff --git a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts new file mode 100644 index 000000000..da3969616 --- /dev/null +++ b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts @@ -0,0 +1,79 @@ +import type { MarkdownPostProcessorContext } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import { + createDiscourseContextBadge, + DISCOURSE_CONTEXT_BADGE_CLASS, +} from "~/components/discourseContextBadge"; +import { openDiscourseContextPopover } from "~/components/DiscourseContextPopover"; +import { resolveDiscourseLinkTarget } from "./discourseLinkUtils"; + +/** + * Adds, updates or removes the badge on every discourse-node link in `el`. + * Idempotent: Obsidian reuses rendered sections and re-runs post processors. + */ +export const applyDiscourseContextBadges = ({ + plugin, + el, + sourcePath, +}: { + plugin: DiscourseGraphPlugin; + el: HTMLElement; + sourcePath: string; +}): void => { + const links = el.querySelectorAll("a.internal-link"); + + for (const link of Array.from(links)) { + const existing = link.nextElementSibling?.hasClass( + DISCOURSE_CONTEXT_BADGE_CLASS, + ) + ? link.nextElementSibling + : null; + + // data-href holds the link as written; href is resolved and URL-encoded. + const linktext = + link.getAttribute("data-href") ?? link.getAttribute("href"); + if (!linktext) continue; + + const target = resolveDiscourseLinkTarget({ + plugin, + linktext, + sourcePath, + }); + if (!target) { + existing?.remove(); + continue; + } + + const badge = createDiscourseContextBadge({ + file: target.file, + nodeType: target.nodeType, + relationCount: target.relationCount, + onActivate: ({ file, anchor }) => + openDiscourseContextPopover({ + plugin, + file, + anchor, + relationCount: target.relationCount, + }), + }); + + // Replaced, not skipped, or it keeps a count from before the last change. + existing?.remove(); + link.insertAdjacentElement("afterend", badge); + } +}; + +/** Strips every badge under `el`, for when the setting is switched off. */ +export const removeDiscourseContextBadges = (el: HTMLElement): void => { + el.querySelectorAll(`.${DISCOURSE_CONTEXT_BADGE_CLASS}`).forEach((badge) => + badge.remove(), + ); +}; + +export const createDiscourseContextOverlayPostProcessor = + (plugin: DiscourseGraphPlugin) => + (el: HTMLElement, ctx: MarkdownPostProcessorContext): void => { + if (!plugin.settings.showDiscourseContextOverlay) return; + if (!ctx.sourcePath) return; + applyDiscourseContextBadges({ plugin, el, sourcePath: ctx.sourcePath }); + }; diff --git a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts index 6ccc24e44..a354e8c29 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts @@ -1,8 +1,12 @@ -import { debounce, type TFile } from "obsidian"; +import { debounce, MarkdownView, type TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { QueryEngine } from "~/services/QueryEngine"; import { getNodeTypeIdFromFrontmatter } from "./discourseLinkFrontmatter"; import { refreshMarkdownEditors } from "./markdownViewRefresh"; +import { + applyDiscourseContextBadges, + removeDiscourseContextBadges, +} from "./discourseContextOverlayPostProcessor"; const REFRESH_DEBOUNCE_MS = 300; @@ -15,11 +19,26 @@ const isDiscourseNodeFile = ( plugin.app.metadataCache.getFileCache(file)?.frontmatter, ); -/** Redraws the overlay when relations or a node's frontmatter change. */ +/** + * Redraws both surfaces when relations or frontmatter change. Reading view is + * refreshed in place: rerender() blanks a pane that is not currently painting. + */ export const refreshDiscourseContextOverlaySurfaces = ( plugin: DiscourseGraphPlugin, ): void => { refreshMarkdownEditors(plugin.app); + plugin.app.workspace.iterateAllLeaves((leaf) => { + if (!(leaf.view instanceof MarkdownView)) return; + const el = leaf.view.previewMode?.containerEl; + if (!el) return; + if (!plugin.settings.showDiscourseContextOverlay) { + removeDiscourseContextBadges(el); + return; + } + const sourcePath = leaf.view.file?.path; + if (!sourcePath) return; + applyDiscourseContextBadges({ plugin, el, sourcePath }); + }); }; export const registerDiscourseContextOverlayRefresh = ( diff --git a/apps/website/content/obsidian/configuration/general-settings.md b/apps/website/content/obsidian/configuration/general-settings.md index b7800133d..1fcb6e2e2 100644 --- a/apps/website/content/obsidian/configuration/general-settings.md +++ b/apps/website/content/obsidian/configuration/general-settings.md @@ -19,7 +19,7 @@ This setting controls the visibility of identifiers in your note's frontmatter s 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 +- When enabled, a badge appears after each link to a discourse node, in both Live Preview and Reading view - 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 diff --git a/apps/website/content/obsidian/core-features/discourse-context.md b/apps/website/content/obsidian/core-features/discourse-context.md index ae4604eb9..665d1ba4c 100644 --- a/apps/website/content/obsidian/core-features/discourse-context.md +++ b/apps/website/content/obsidian/core-features/discourse-context.md @@ -30,7 +30,7 @@ You can configure a custom hotkey in the Obsidian settings to quickly toggle the 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). +The badge appears in both Live Preview and Reading view, 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 From 786949d4269e1d14d485a0e15e6cf133e1c131ca Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 18:10:45 -0400 Subject: [PATCH 2/6] ENG-1249 Leave embedded links to the post processor on refresh Review finding: the Reading view refresh scans the whole preview container and passes the view's own file path, but links inside a transclusion resolve against the embedded file. Refreshing them that way could drop a badge or show another node's count. The refresh now skips links inside an embed. The post processor still badges them correctly on render, where Obsidian supplies the embedded file's source path; the cost is that a count inside an embed updates on the next render rather than immediately. Also updates an existing badge in place rather than replacing it when the target is unchanged, so an open popover keeps a connected anchor. Co-Authored-By: Claude Opus 5 --- .../discourseContextOverlayPostProcessor.ts | 20 +++++++++++++++++-- .../utils/discourseContextOverlayRefresh.ts | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts index da3969616..8d2132d54 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts @@ -1,7 +1,9 @@ import type { MarkdownPostProcessorContext } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { + badgeTargetPath, createDiscourseContextBadge, + updateDiscourseContextBadge, DISCOURSE_CONTEXT_BADGE_CLASS, } from "~/components/discourseContextBadge"; import { openDiscourseContextPopover } from "~/components/DiscourseContextPopover"; @@ -15,18 +17,22 @@ export const applyDiscourseContextBadges = ({ plugin, el, sourcePath, + skipEmbedded = false, }: { plugin: DiscourseGraphPlugin; el: HTMLElement; sourcePath: string; + /** Links inside a transclusion resolve against the embedded file, not `sourcePath`. */ + skipEmbedded?: boolean; }): void => { const links = el.querySelectorAll("a.internal-link"); for (const link of Array.from(links)) { + if (skipEmbedded && link.closest(".internal-embed")) continue; const existing = link.nextElementSibling?.hasClass( DISCOURSE_CONTEXT_BADGE_CLASS, ) - ? link.nextElementSibling + ? (link.nextElementSibling as HTMLElement) : null; // data-href holds the link as written; href is resolved and URL-encoded. @@ -44,6 +50,17 @@ export const applyDiscourseContextBadges = ({ continue; } + // Updated rather than replaced when the target is unchanged: an open + // popover anchored to this badge would otherwise hold a detached element. + if (existing && badgeTargetPath(existing) === target.file.path) { + updateDiscourseContextBadge({ + badge: existing, + nodeType: target.nodeType, + relationCount: target.relationCount, + }); + continue; + } + const badge = createDiscourseContextBadge({ file: target.file, nodeType: target.nodeType, @@ -57,7 +74,6 @@ export const applyDiscourseContextBadges = ({ }), }); - // Replaced, not skipped, or it keeps a count from before the last change. existing?.remove(); link.insertAdjacentElement("afterend", badge); } diff --git a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts index a354e8c29..2f9ca305a 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts @@ -37,7 +37,7 @@ export const refreshDiscourseContextOverlaySurfaces = ( } const sourcePath = leaf.view.file?.path; if (!sourcePath) return; - applyDiscourseContextBadges({ plugin, el, sourcePath }); + applyDiscourseContextBadges({ plugin, el, sourcePath, skipEmbedded: true }); }); }; From c884dabfbcac2c6c4e9c67daf9e32249dc5f02f2 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 8 Sep 2026 17:29:42 -0400 Subject: [PATCH 3/6] ENG-1249 Trim comments that restated their function names Co-Authored-By: Claude Opus 5 --- .../src/utils/discourseContextOverlayPostProcessor.ts | 6 +----- apps/obsidian/src/utils/discourseContextOverlayRefresh.ts | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts index 8d2132d54..5b1a51ff4 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts @@ -9,10 +9,7 @@ import { import { openDiscourseContextPopover } from "~/components/DiscourseContextPopover"; import { resolveDiscourseLinkTarget } from "./discourseLinkUtils"; -/** - * Adds, updates or removes the badge on every discourse-node link in `el`. - * Idempotent: Obsidian reuses rendered sections and re-runs post processors. - */ +/** Idempotent: Obsidian reuses rendered sections and re-runs post processors. */ export const applyDiscourseContextBadges = ({ plugin, el, @@ -79,7 +76,6 @@ export const applyDiscourseContextBadges = ({ } }; -/** Strips every badge under `el`, for when the setting is switched off. */ export const removeDiscourseContextBadges = (el: HTMLElement): void => { el.querySelectorAll(`.${DISCOURSE_CONTEXT_BADGE_CLASS}`).forEach((badge) => badge.remove(), diff --git a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts index 2f9ca305a..47d16bcae 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts @@ -20,8 +20,8 @@ const isDiscourseNodeFile = ( ); /** - * Redraws both surfaces when relations or frontmatter change. Reading view is - * refreshed in place: rerender() blanks a pane that is not currently painting. + * Reading view is refreshed in place: rerender() blanks a pane that is not + * currently painting. */ export const refreshDiscourseContextOverlaySurfaces = ( plugin: DiscourseGraphPlugin, From 536e16ce140214f299129a6edde39a3cfa751318 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 15 Sep 2026 22:16:02 -0400 Subject: [PATCH 4/6] ENG-2250 Let the popover count relations for Reading view badges too Follows the Live Preview fix: the post processor updates a badge in place without rebinding its click listener, so the captured count went stale. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts index 5b1a51ff4..003e03f27 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts @@ -67,7 +67,6 @@ export const applyDiscourseContextBadges = ({ plugin, file, anchor, - relationCount: target.relationCount, }), }); From ee8de8eb088bb5de3e780e2ce5b43d0591a8cc9a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 16 Sep 2026 00:01:40 -0400 Subject: [PATCH 5/6] ENG-2250 Resolve badge links against the file they were written in Walking the .internal-embed chain lets the refresh path badge embedded links instead of skipping them, so disabling and re-enabling the overlay no longer drops badges inside a transclusion until the embed happens to rerender. Co-Authored-By: Claude Opus 5 --- .../discourseContextOverlayPostProcessor.ts | 49 ++++++++++++++++--- .../utils/discourseContextOverlayRefresh.ts | 2 +- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts index 003e03f27..15bee6ca1 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts @@ -1,4 +1,4 @@ -import type { MarkdownPostProcessorContext } from "obsidian"; +import { parseLinktext, type MarkdownPostProcessorContext } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { badgeTargetPath, @@ -9,23 +9,55 @@ import { import { openDiscourseContextPopover } from "~/components/DiscourseContextPopover"; import { resolveDiscourseLinkTarget } from "./discourseLinkUtils"; +/** + * A link inside a transclusion resolves against the embedded file, so walk the + * `.internal-embed` chain outwards to find the file it was actually written in. + */ +const resolveLinkSourcePath = ({ + plugin, + link, + sourcePath, +}: { + plugin: DiscourseGraphPlugin; + link: HTMLElement; + sourcePath: string; +}): string | null => { + const embeds: HTMLElement[] = []; + let embed = link.parentElement?.closest(".internal-embed"); + while (embed) { + embeds.unshift(embed); + embed = embed.parentElement?.closest(".internal-embed"); + } + + let path = sourcePath; + for (const ancestor of embeds) { + const src = ancestor.getAttribute("src"); + if (!src) return null; + const file = plugin.app.metadataCache.getFirstLinkpathDest( + parseLinktext(src).path, + path, + ); + if (!file) return null; + path = file.path; + } + return path; +}; + /** Idempotent: Obsidian reuses rendered sections and re-runs post processors. */ export const applyDiscourseContextBadges = ({ plugin, el, sourcePath, - skipEmbedded = false, }: { plugin: DiscourseGraphPlugin; el: HTMLElement; sourcePath: string; - /** Links inside a transclusion resolve against the embedded file, not `sourcePath`. */ - skipEmbedded?: boolean; }): void => { const links = el.querySelectorAll("a.internal-link"); for (const link of Array.from(links)) { - if (skipEmbedded && link.closest(".internal-embed")) continue; + const linkSourcePath = resolveLinkSourcePath({ plugin, link, sourcePath }); + if (!linkSourcePath) continue; const existing = link.nextElementSibling?.hasClass( DISCOURSE_CONTEXT_BADGE_CLASS, ) @@ -35,12 +67,15 @@ export const applyDiscourseContextBadges = ({ // data-href holds the link as written; href is resolved and URL-encoded. const linktext = link.getAttribute("data-href") ?? link.getAttribute("href"); - if (!linktext) continue; + if (!linktext) { + existing?.remove(); + continue; + } const target = resolveDiscourseLinkTarget({ plugin, linktext, - sourcePath, + sourcePath: linkSourcePath, }); if (!target) { existing?.remove(); diff --git a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts index 47d16bcae..692e92468 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts @@ -37,7 +37,7 @@ export const refreshDiscourseContextOverlaySurfaces = ( } const sourcePath = leaf.view.file?.path; if (!sourcePath) return; - applyDiscourseContextBadges({ plugin, el, sourcePath, skipEmbedded: true }); + applyDiscourseContextBadges({ plugin, el, sourcePath }); }); }; From b849b4e0e410a8104bcf6df5cac02239ad0fff80 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 16 Sep 2026 00:20:05 -0400 Subject: [PATCH 6/6] ENG-2250 Keep context badges out of the plugin's own modal previews NodeSearchModal and NodeTypeSettings render markdown through MarkdownRenderer.render, so the post processor injected badges there and the popover opened on document.body above the modal, where the two dismiss paths conflict. Reading view passes rendered sections in detached, so an attached element is the tell for an in-modal render. Co-Authored-By: Claude Opus 5 --- .../obsidian/src/utils/discourseContextOverlayPostProcessor.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts index 15bee6ca1..ad6fba425 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts @@ -121,5 +121,8 @@ export const createDiscourseContextOverlayPostProcessor = (el: HTMLElement, ctx: MarkdownPostProcessorContext): void => { if (!plugin.settings.showDiscourseContextOverlay) return; if (!ctx.sourcePath) return; + // Rendered sections arrive detached, so an attached one is the plugin's own + // MarkdownRenderer.render inside a modal, where the popover cannot dismiss. + if (el.closest(".modal-container")) return; applyDiscourseContextBadges({ plugin, el, sourcePath: ctx.sourcePath }); };