diff --git a/apps/obsidian/src/components/DiscourseContextPopover.tsx b/apps/obsidian/src/components/DiscourseContextPopover.tsx new file mode 100644 index 000000000..4fab6bf35 --- /dev/null +++ b/apps/obsidian/src/components/DiscourseContextPopover.tsx @@ -0,0 +1,187 @@ +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"; +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; +const EMPTY_MESSAGE = "No discourse relation found"; + +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; + // 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 > spaceBelow; + 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; +}; + +/** + * Reuses RelationshipSection so it cannot disagree with the sidebar 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 emptyEl: HTMLElement | 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.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(); + 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; + // 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 => { + 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..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.