From 34dc3ebe352455b7ac6d49a4c0b88064206ed476 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 17:52:44 -0400 Subject: [PATCH 1/4] ENG-1249 Add the discourse context overlay in Live Preview Links to a discourse node carry an inline badge with that node's relation count; selecting it opens a popover built on RelationshipSection, the same component the Discourse Context panel renders, so the two cannot disagree. Toggled by a General setting, applied without a reload. The count matches what the panel would list rather than what the store holds: relations awaiting acceptance after an import are excluded, and so are relations whose relation type has been deleted, which the panel drops but relations.json keeps. The badge is plain DOM rather than React so the Reading view counterpart can share it without mounting a React root per link. Co-Authored-By: Claude Opus 5 --- .../components/DiscourseContextPopover.tsx | 156 ++++++++++++++++++ .../src/components/DiscourseContextView.tsx | 16 +- .../src/components/GeneralSettings.tsx | 16 ++ apps/obsidian/src/components/InfoTooltip.tsx | 16 ++ .../src/components/RelationshipSection.tsx | 2 +- .../src/components/discourseContextBadge.ts | 66 ++++++++ apps/obsidian/src/constants.ts | 1 + apps/obsidian/src/index.ts | 43 +++-- apps/obsidian/src/types.ts | 1 + .../utils/discourseContextOverlayExtension.ts | 141 ++++++++++++++++ .../utils/discourseContextOverlayRefresh.ts | 41 +++++ .../obsidian/src/utils/markdownViewRefresh.ts | 24 +++ .../configuration/general-settings.md | 10 ++ .../core-features/discourse-context.md | 8 + 14 files changed, 502 insertions(+), 39 deletions(-) create mode 100644 apps/obsidian/src/components/DiscourseContextPopover.tsx create mode 100644 apps/obsidian/src/components/InfoTooltip.tsx create mode 100644 apps/obsidian/src/components/discourseContextBadge.ts create mode 100644 apps/obsidian/src/utils/discourseContextOverlayExtension.ts create mode 100644 apps/obsidian/src/utils/discourseContextOverlayRefresh.ts create mode 100644 apps/obsidian/src/utils/markdownViewRefresh.ts diff --git a/apps/obsidian/src/components/DiscourseContextPopover.tsx b/apps/obsidian/src/components/DiscourseContextPopover.tsx new file mode 100644 index 000000000..7a7d8e036 --- /dev/null +++ b/apps/obsidian/src/components/DiscourseContextPopover.tsx @@ -0,0 +1,156 @@ +import { TFile } from "obsidian"; +import { createRoot, Root } from "react-dom/client"; +import type DiscourseGraphPlugin from "~/index"; +import { PluginProvider } from "~/components/PluginContext"; +import { RelationshipSection } from "~/components/RelationshipSection"; + +const POPOVER_CLASS = "dg-discourse-context-popover"; +const VIEWPORT_MARGIN = 8; +const EMPTY_MESSAGE = "No discourse relation found"; + +/** Positions the popover under its badge, clamped inside the viewport. */ +const positionPopover = (popover: HTMLElement, anchor: HTMLElement): void => { + // The anchor's own window, or a popout gets clamped to the wrong viewport. + const win = anchor.ownerDocument.defaultView ?? window; + const anchorRect = anchor.getBoundingClientRect(); + const { width, height } = popover.getBoundingClientRect(); + + const left = Math.min( + Math.max(VIEWPORT_MARGIN, anchorRect.left), + Math.max(VIEWPORT_MARGIN, win.innerWidth - width - VIEWPORT_MARGIN), + ); + + const spaceBelow = win.innerHeight - anchorRect.bottom; + const openUpward = + spaceBelow < height + VIEWPORT_MARGIN && anchorRect.top > height; + const top = openUpward + ? Math.max(VIEWPORT_MARGIN, anchorRect.top - height - 4) + : anchorRect.bottom + 4; + + popover.style.left = `${left}px`; + popover.style.top = `${top}px`; +}; + +type PopoverOptions = { + plugin: DiscourseGraphPlugin; + file: TFile; + anchor: HTMLElement; + relationCount: number; +}; + +/** + * Discourse context shown when a badge is selected. Reuses RelationshipSection + * so it cannot disagree with the panel. Only one is open at a time. + */ +class DiscourseContextPopover { + private containerEl: HTMLElement; + private root: Root; + private plugin: DiscourseGraphPlugin; + private win: Window; + private reposition: () => void = () => {}; + private resizeObserver: ResizeObserver | null = null; + private cleanupListeners: (() => void)[] = []; + + constructor({ plugin, file, anchor, relationCount }: PopoverOptions) { + this.plugin = plugin; + const doc = anchor.ownerDocument; + this.win = doc.defaultView ?? window; + this.containerEl = doc.body.createDiv({ cls: POPOVER_CLASS }); + this.containerEl.addClass( + "fixed", + "z-50", + "max-h-[60vh]", + "w-80", + "overflow-y-auto", + "rounded-md", + "border", + "border-solid", + "border-[var(--background-modifier-border)]", + "bg-[var(--background-primary)]", + "p-3", + "shadow-lg", + ); + + // CurrentRelationships renders nothing when empty, leaving a bare button. + if (relationCount === 0) { + this.containerEl.createDiv({ + cls: "mb-2 text-sm text-[var(--text-muted)]", + text: EMPTY_MESSAGE, + }); + } + + const reactHost = this.containerEl.createDiv(); + this.root = createRoot(reactHost); + this.root.render( + + + , + ); + + // A React 18 root commits async, so measure again after paint and on resize. + positionPopover(this.containerEl, anchor); + this.reposition = () => positionPopover(this.containerEl, anchor); + this.win.requestAnimationFrame(this.reposition); + this.resizeObserver = new ResizeObserver(this.reposition); + this.resizeObserver.observe(this.containerEl); + + this.registerDismissListeners(); + } + + private registerDismissListeners(): void { + const doc = this.containerEl.ownerDocument; + const closeIfOutside = (event: MouseEvent): void => { + if (this.containerEl.contains(event.target as Node)) return; + this.close(); + }; + const closeOnEscape = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.preventDefault(); + this.close(); + }; + // Scrolling the note dismisses; scrolling the popover's own content must not. + const closeOnScroll = (event: Event): void => { + if (this.containerEl.contains(event.target as Node)) return; + this.close(); + }; + + // Deferred so the opening click is not read as an outside click. + const attach = this.win.setTimeout(() => { + doc.addEventListener("click", closeIfOutside, true); + }, 0); + + doc.addEventListener("keydown", closeOnEscape); + // Capture phase: scrolling happens inside panes, not on window. + doc.addEventListener("scroll", closeOnScroll, true); + + this.cleanupListeners.push(() => { + this.win.clearTimeout(attach); + doc.removeEventListener("click", closeIfOutside, true); + doc.removeEventListener("keydown", closeOnEscape); + doc.removeEventListener("scroll", closeOnScroll, true); + }); + } + + close(): void { + for (const cleanup of this.cleanupListeners) cleanup(); + this.cleanupListeners = []; + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + // Deferred: unmounting during React's event handling warns. + const root = this.root; + this.win.setTimeout(() => root.unmount(), 0); + this.containerEl.remove(); + if (activePopover === this) activePopover = null; + } +} + +let activePopover: DiscourseContextPopover | null = null; + +export const openDiscourseContextPopover = (options: PopoverOptions): void => { + activePopover?.close(); + activePopover = new DiscourseContextPopover(options); +}; + +export const closeDiscourseContextPopover = (): void => { + activePopover?.close(); +}; diff --git a/apps/obsidian/src/components/DiscourseContextView.tsx b/apps/obsidian/src/components/DiscourseContextView.tsx index c9e7c715b..c52933554 100644 --- a/apps/obsidian/src/components/DiscourseContextView.tsx +++ b/apps/obsidian/src/components/DiscourseContextView.tsx @@ -10,6 +10,7 @@ import { createRoot, Root } from "react-dom/client"; import DiscourseGraphPlugin from "~/index"; import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression"; import { RelationshipSection } from "~/components/RelationshipSection"; +import { InfoTooltip } from "~/components/InfoTooltip"; import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types"; import { PluginProvider, usePlugin } from "~/components/PluginContext"; import { @@ -26,21 +27,6 @@ type DiscourseContextProps = { activeFile: TFile | null; }; -type InfoTooltipProps = { - content: string; -}; - -export const InfoTooltip = ({ content }: InfoTooltipProps) => ( - -); - const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { const plugin = usePlugin(); const [isRefreshing, setIsRefreshing] = useState(false); diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index 15de87147..20ce213e0 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -200,6 +200,8 @@ const GeneralSettings = () => { const [showHelpMenuStatusBarIcon, setShowHelpMenuStatusBarIcon] = useState( plugin.settings.showHelpMenuStatusBarIcon, ); + const [showDiscourseContextOverlay, setShowDiscourseContextOverlay] = + useState(plugin.settings.showDiscourseContextOverlay); const handleToggleChange = (newValue: boolean) => { setShowIdsInFrontmatter(newValue); @@ -214,6 +216,13 @@ const GeneralSettings = () => { void plugin.saveSettings(); }; + const handleDiscourseContextOverlayToggleChange = (newValue: boolean) => { + setShowDiscourseContextOverlay(newValue); + plugin.settings.showDiscourseContextOverlay = newValue; + plugin.refreshDiscourseContextOverlay(); + void plugin.saveSettings(); + }; + const handleFolderPathChange = useCallback( (newValue: string) => { setNodesFolderPath(newValue); @@ -364,6 +373,13 @@ const GeneralSettings = () => { + + ( + +); 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..7a2c2f6fa --- /dev/null +++ b/apps/obsidian/src/components/discourseContextBadge.ts @@ -0,0 +1,66 @@ +import { setIcon, setTooltip, TFile } from "obsidian"; +import type { DiscourseNode } from "~/types"; + +/** Marks a badge so a re-run can find and replace it. */ +export const DISCOURSE_CONTEXT_BADGE_CLASS = "dg-discourse-context-badge"; + +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`; +}; + +/** + * Inline badge next to a link to a discourse node. 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({ text: String(relationCount) }); + + const label = badgeTooltip({ nodeType, relationCount }); + setTooltip(badge, label); + badge.setAttribute("aria-label", label); + badge.setAttribute("role", "button"); + badge.setAttribute("tabindex", "0"); + + 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; +}; 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..43986daac --- /dev/null +++ b/apps/obsidian/src/utils/discourseContextOverlayExtension.ts @@ -0,0 +1,141 @@ +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 } 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, + }), + }); + } + + /** 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); +}; + +/** Renders the badge after each discourse-node link in Live Preview. */ +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..4f84c45a4 --- /dev/null +++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts @@ -0,0 +1,41 @@ +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)); + // "changed", not "resolved": resolved also fires while a preview renders. + plugin.registerEvent( + plugin.app.metadataCache.on("changed", (file) => { + if (!isDiscourseNodeFile(plugin, file)) 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. From 1164c793b2ba126289dec7165cb1dde726d06e84 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 18:10:19 -0400 Subject: [PATCH 2/4] ENG-1249 Update badges in place instead of replacing them Review finding: changing a relation while its popover is open replaced the badge the popover is anchored to, leaving the positioning callback bound to a detached element, so the popover jumped on its next resize. CM6 rebuilds a widget's DOM whenever eq() is false, which a count change always is, so the badge now updates through updateDOM and the anchor stays connected. The same helper serves the Reading view path. Co-Authored-By: Claude Opus 5 --- .../src/components/discourseContextBadge.ts | 32 +++++++++++++++++-- .../utils/discourseContextOverlayExtension.ts | 18 ++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/obsidian/src/components/discourseContextBadge.ts b/apps/obsidian/src/components/discourseContextBadge.ts index 7a2c2f6fa..a8e806212 100644 --- a/apps/obsidian/src/components/discourseContextBadge.ts +++ b/apps/obsidian/src/components/discourseContextBadge.ts @@ -1,8 +1,10 @@ import { setIcon, setTooltip, TFile } from "obsidian"; import type { DiscourseNode } from "~/types"; -/** Marks a badge so a re-run can find and replace it. */ +/** 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; @@ -37,13 +39,14 @@ export const createDiscourseContextBadge = ({ }); setIcon(icon, "network"); - badge.createSpan({ text: String(relationCount) }); + 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. @@ -64,3 +67,28 @@ export const createDiscourseContextBadge = ({ 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); + +/** + * Updates a badge's count without replacing the element, so an open popover + * anchored to it keeps a connected anchor to position against. + */ +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/utils/discourseContextOverlayExtension.ts b/apps/obsidian/src/utils/discourseContextOverlayExtension.ts index 43986daac..88115d6c0 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayExtension.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayExtension.ts @@ -9,7 +9,10 @@ import { } from "@codemirror/view"; import { editorInfoField, editorLivePreviewField } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; -import { createDiscourseContextBadge } from "~/components/discourseContextBadge"; +import { + createDiscourseContextBadge, + updateDiscourseContextBadge, +} from "~/components/discourseContextBadge"; import { openDiscourseContextPopover } from "~/components/DiscourseContextPopover"; import { resolveDiscourseLinkTarget, @@ -50,6 +53,19 @@ class DiscourseContextBadgeWidget extends WidgetType { }); } + /** + * 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; From 226496d762e96ee0bff2a0ba4320592c915466e9 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 18:22:21 -0400 Subject: [PATCH 3/4] ENG-1249 Fix popover dismissal, placement and empty state Four review findings: Picking a node for a new relation dismissed the popover: Obsidian mounts AbstractInputSuggest's list on document.body, so the outside-click guard counted it as outside. Clicks inside a suggestion container are now kept. A popover taller than the space above opened downward and clipped. It now opens toward whichever side has more room. The zero-relation message lives outside React, so adding the first relation from the popover left it beside the new one. It clears itself on the next index change. Removing nodeTypeId from a file suppressed the refresh that should have removed its badges, since the check ran against the new metadata. Files that were nodes are now remembered until they stop being one. Co-Authored-By: Claude Opus 5 --- .../components/DiscourseContextPopover.tsx | 36 +++++++++++++++++-- .../utils/discourseContextOverlayRefresh.ts | 9 ++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/obsidian/src/components/DiscourseContextPopover.tsx b/apps/obsidian/src/components/DiscourseContextPopover.tsx index 7a7d8e036..ff8152a4a 100644 --- a/apps/obsidian/src/components/DiscourseContextPopover.tsx +++ b/apps/obsidian/src/components/DiscourseContextPopover.tsx @@ -3,6 +3,23 @@ import { createRoot, Root } from "react-dom/client"; import type DiscourseGraphPlugin from "~/index"; import { PluginProvider } from "~/components/PluginContext"; import { RelationshipSection } from "~/components/RelationshipSection"; +import { + countDisplayableRelations, + getEndpointIdsFromFrontmatter, +} from "~/utils/discourseLinkFrontmatter"; +import { getRelationTypeById } from "~/utils/typeUtils"; + +const countRelationsForFile = ( + plugin: DiscourseGraphPlugin, + file: TFile, +): number => { + const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; + const endpointIds = getEndpointIdsFromFrontmatter(frontmatter); + return countDisplayableRelations({ + relations: plugin.relationsIndex.getRelationsForEndpointIds(endpointIds), + isConfiguredType: (id) => !!getRelationTypeById(plugin, id), + }); +}; const POPOVER_CLASS = "dg-discourse-context-popover"; const VIEWPORT_MARGIN = 8; @@ -21,8 +38,10 @@ const positionPopover = (popover: HTMLElement, anchor: HTMLElement): void => { ); const spaceBelow = win.innerHeight - anchorRect.bottom; + // Open upward only when that genuinely has more room, so a popover taller + // than either side still lands on the roomier one instead of clipping. const openUpward = - spaceBelow < height + VIEWPORT_MARGIN && anchorRect.top > height; + spaceBelow < height + VIEWPORT_MARGIN && anchorRect.top > spaceBelow; const top = openUpward ? Math.max(VIEWPORT_MARGIN, anchorRect.top - height - 4) : anchorRect.bottom + 4; @@ -49,6 +68,7 @@ class DiscourseContextPopover { private win: Window; private reposition: () => void = () => {}; private resizeObserver: ResizeObserver | null = null; + private emptyEl: HTMLElement | null = null; private cleanupListeners: (() => void)[] = []; constructor({ plugin, file, anchor, relationCount }: PopoverOptions) { @@ -73,10 +93,19 @@ class DiscourseContextPopover { // CurrentRelationships renders nothing when empty, leaving a bare button. if (relationCount === 0) { - this.containerEl.createDiv({ + this.emptyEl = this.containerEl.createDiv({ cls: "mb-2 text-sm text-[var(--text-muted)]", text: EMPTY_MESSAGE, }); + // It lives outside React, so RelationshipSection cannot clear it when the + // first relation is added from this very popover. + this.cleanupListeners.push( + plugin.relationsIndex.onChange(() => { + if (countRelationsForFile(plugin, file) === 0) return; + this.emptyEl?.remove(); + this.emptyEl = null; + }), + ); } const reactHost = this.containerEl.createDiv(); @@ -101,6 +130,9 @@ class DiscourseContextPopover { const doc = this.containerEl.ownerDocument; const closeIfOutside = (event: MouseEvent): void => { if (this.containerEl.contains(event.target as Node)) return; + // AbstractInputSuggest mounts its list on body, so a click picking a node + // for a new relation would otherwise dismiss the popover behind it. + if ((event.target as Element)?.closest?.(".suggestion-container")) return; this.close(); }; const closeOnEscape = (event: KeyboardEvent): void => { diff --git a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts index 4f84c45a4..0309b47b5 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayRefresh.ts @@ -31,10 +31,17 @@ export const registerDiscourseContextOverlayRefresh = ( ); 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)) return; + if (isDiscourseNodeFile(plugin, file)) { + knownNodePaths.add(file.path); + } else if (!knownNodePaths.delete(file.path)) { + return; + } refresh(); }), ); From 83759adc9f39931dd84c1cd2ef2bd92e64bc791f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 8 Sep 2026 17:29:16 -0400 Subject: [PATCH 4/4] ENG-1249 Trim comments that restated their function names Leaves only the constraint each one exists to record. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/DiscourseContextPopover.tsx | 5 ++--- apps/obsidian/src/components/discourseContextBadge.ts | 9 +++------ .../src/utils/discourseContextOverlayExtension.ts | 1 - 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/obsidian/src/components/DiscourseContextPopover.tsx b/apps/obsidian/src/components/DiscourseContextPopover.tsx index ff8152a4a..4fab6bf35 100644 --- a/apps/obsidian/src/components/DiscourseContextPopover.tsx +++ b/apps/obsidian/src/components/DiscourseContextPopover.tsx @@ -25,7 +25,6 @@ const POPOVER_CLASS = "dg-discourse-context-popover"; const VIEWPORT_MARGIN = 8; const EMPTY_MESSAGE = "No discourse relation found"; -/** Positions the popover under its badge, clamped inside the viewport. */ const positionPopover = (popover: HTMLElement, anchor: HTMLElement): void => { // The anchor's own window, or a popout gets clamped to the wrong viewport. const win = anchor.ownerDocument.defaultView ?? window; @@ -58,8 +57,8 @@ type PopoverOptions = { }; /** - * Discourse context shown when a badge is selected. Reuses RelationshipSection - * so it cannot disagree with the panel. Only one is open at a time. + * Reuses RelationshipSection so it cannot disagree with the sidebar panel. + * Only one is open at a time. */ class DiscourseContextPopover { private containerEl: HTMLElement; diff --git a/apps/obsidian/src/components/discourseContextBadge.ts b/apps/obsidian/src/components/discourseContextBadge.ts index a8e806212..9e31609fe 100644 --- a/apps/obsidian/src/components/discourseContextBadge.ts +++ b/apps/obsidian/src/components/discourseContextBadge.ts @@ -22,8 +22,8 @@ const badgeTooltip = ({ }; /** - * Inline badge next to a link to a discourse node. Plain DOM, not React, so both - * render paths share it without mounting a React root per link. + * Plain DOM, not React, so both render paths share it without mounting a React + * root per link. */ export const createDiscourseContextBadge = ({ file, @@ -72,10 +72,7 @@ export const createDiscourseContextBadge = ({ export const badgeTargetPath = (badge: Element): string | null => badge.getAttribute(BADGE_PATH_ATTR); -/** - * Updates a badge's count without replacing the element, so an open popover - * anchored to it keeps a connected anchor to position against. - */ +/** In place, so an open popover anchored to it keeps a connected anchor. */ export const updateDiscourseContextBadge = ({ badge, nodeType, diff --git a/apps/obsidian/src/utils/discourseContextOverlayExtension.ts b/apps/obsidian/src/utils/discourseContextOverlayExtension.ts index 88115d6c0..122a7ce2d 100644 --- a/apps/obsidian/src/utils/discourseContextOverlayExtension.ts +++ b/apps/obsidian/src/utils/discourseContextOverlayExtension.ts @@ -117,7 +117,6 @@ const buildBadgeDecorations = ( return Decoration.set(widgets, true); }; -/** Renders the badge after each discourse-node link in Live Preview. */ export const createDiscourseContextOverlayExtension = ( plugin: DiscourseGraphPlugin, ): ViewPlugin =>