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..5b1a51ff4 --- /dev/null +++ b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts @@ -0,0 +1,91 @@ +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"; +import { resolveDiscourseLinkTarget } from "./discourseLinkUtils"; + +/** 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 existing = link.nextElementSibling?.hasClass( + DISCOURSE_CONTEXT_BADGE_CLASS, + ) + ? (link.nextElementSibling as HTMLElement) + : 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; + } + + // 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, + relationCount: target.relationCount, + onActivate: ({ file, anchor }) => + openDiscourseContextPopover({ + plugin, + file, + anchor, + relationCount: target.relationCount, + }), + }); + + existing?.remove(); + link.insertAdjacentElement("afterend", badge); + } +}; + +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..7d070149f 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. */ +/** + * 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, skipEmbedded: true }); + }); }; 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