From 5e1361e4cf6dc3c5d47e2ac4568f9cb447a3d63f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 17:53:10 -0400 Subject: [PATCH 1/3] 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 0309b47b5..e7cbd3b32 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts @@ -1,7 +1,11 @@ -import { debounce, type TFile } from "obsidian"; +import { debounce, MarkdownView, type TFile } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { getNodeTypeIdFromFrontmatter } from "./discourseLinkFrontmatter"; import { refreshMarkdownEditors } from "./markdownViewRefresh"; +import { + applyDiscourseContextBadges, + removeDiscourseContextBadges, +} from "./discourseContextOverlayPostProcessor"; const REFRESH_DEBOUNCE_MS = 300; @@ -14,11 +18,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 b875146aca85ee801abfb25e0e769741d9e28f9e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 18:10:45 -0400 Subject: [PATCH 2/3] 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 e7cbd3b32..7a3a34b9d 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts @@ -36,7 +36,7 @@ export const refreshDiscourseContextOverlaySurfaces = ( } const sourcePath = leaf.view.file?.path; if (!sourcePath) return; - applyDiscourseContextBadges({ plugin, el, sourcePath }); + applyDiscourseContextBadges({ plugin, el, sourcePath, skipEmbedded: true }); }); }; From c6fc65e62b4e380f554503359cd588f33ea78fef Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 8 Sep 2026 17:29:42 -0400 Subject: [PATCH 3/3] 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 7a3a34b9d..7d070149f 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts @@ -19,8 +19,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,