diff --git a/AGENTS.md b/AGENTS.md index 36bc4fdc3..fcc61091a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,7 @@ When creating or updating a pull request body: - Add comments only when necessary; descriptive names should minimize the need for comments - Explain the why, not the what, focusing on reasoning, trade-offs, and approaches +- Keep any comment to 1-2 lines. A technical choice does not need a paragraph, and a multi-line block explaining one decision is too long: state the constraint, not the narrative that led to it. If it genuinely cannot be said in two lines, it belongs in a doc or a ticket, not above the code - Document limitations, known bugs, or edge cases where behavior may not align with expectations - Prefer sentence case in documentation and feature descriptions; capitalize official product/plugin names and exact UI labels, buttons, or titles, but keep generic feature terms lowercase to emphasize user actions 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 ecfd964be..764434b57 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -197,6 +197,8 @@ const GeneralSettings = () => { const [showHelpMenuStatusBarIcon, setShowHelpMenuStatusBarIcon] = useState( plugin.settings.showHelpMenuStatusBarIcon, ); + const [showDiscourseContextOverlay, setShowDiscourseContextOverlay] = + useState(plugin.settings.showDiscourseContextOverlay); const handleToggleChange = (newValue: boolean) => { setShowIdsInFrontmatter(newValue); @@ -211,6 +213,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); @@ -343,6 +352,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 95fab14a3..a9cf5632b 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 06d169332..059878381 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -20,6 +20,14 @@ import { } from "~/utils/editorMenuUtils"; import { createImageEmbedHoverExtension } from "~/utils/imageEmbedHoverIcon"; import { createWikilinkDragExtension } from "~/utils/wikilinkDragHandler"; +import { createDiscourseContextOverlayExtension } from "~/utils/discourseContextOverlayExtension"; +import { + createDiscourseContextOverlayPostProcessor, + registerDiscourseContextOverlayRefresh, + refreshDiscourseContextOverlaySurfaces, +} from "~/utils/discourseContextOverlayPostProcessor"; +import { refreshMarkdownEditors } from "~/utils/markdownViewRefresh"; +import { closeDiscourseContextPopover } from "~/components/DiscourseContextPopover"; import { registerCommands, createModifyNodeModalSubmitHandler, @@ -35,6 +43,7 @@ import { NodeTagSuggestPopover } from "~/components/NodeTagSuggestModal"; import { InlineNodeTypePicker } from "~/components/InlineNodeTypePicker"; import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase"; import { FileChangeListener } from "~/utils/fileChangeListener"; +import { RelationsIndex } from "~/utils/relationsIndex"; import generateUid from "~/utils/generateUid"; import { migrateFrontmatterRelationsToRelationsJson, @@ -51,6 +60,7 @@ import { export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; + relationsIndex: RelationsIndex = new RelationsIndex(this); private tagNodeHandler: TagNodeHandler | null = null; private fileChangeListener: FileChangeListener | null = null; private activeNodePopover: @@ -98,6 +108,12 @@ export default class DiscourseGraphPlugin extends Plugin { } } + this.relationsIndex.initialize(); + this.registerMarkdownPostProcessor( + createDiscourseContextOverlayPostProcessor(this), + ); + registerDiscourseContextOverlayRefresh(this); + registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); addIcon(DISCOURSE_GRAPH_LOGO_ICON_ID, WHITE_LOGO_SVG); @@ -268,36 +284,25 @@ 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({}); - } - }); - }; - this.registerEvent( - this.app.workspace.on("layout-change", refreshMarkdownEditors), - ); + // 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("active-leaf-change", refreshMarkdownEditors), + this.app.workspace.on("active-leaf-change", refreshEditors), ); // Register editor keydown listener for node tag hotkey this.setupNodeTagHotkey(); } + /** + * 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); + } + setHelpMenuStatusBarItemVisibility(): void { if (!this.settings.showHelpMenuStatusBarIcon) { this.helpMenuStatusBarItem?.remove(); @@ -369,6 +374,7 @@ export default class DiscourseGraphPlugin extends Plugin { this.registerEditorExtension(createImageEmbedHoverExtension(this)); this.registerEditorExtension(createWikilinkDragExtension(this)); + this.registerEditorExtension(createDiscourseContextOverlayExtension(this)); } updateFrontmatterStyles(): void { @@ -488,5 +494,9 @@ export default class DiscourseGraphPlugin extends Plugin { this.fileChangeListener.cleanup(); 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/discourseContextOverlayPostProcessor.ts b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts new file mode 100644 index 000000000..271e54f39 --- /dev/null +++ b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts @@ -0,0 +1,138 @@ +import { + debounce, + MarkdownView, + type MarkdownPostProcessorContext, + type TFile, +} 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"; +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, + ); + +/** + * 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. */ +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 }); + }; + +/** + * 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 = ( + 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/discourseLinkFrontmatter.ts b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts new file mode 100644 index 000000000..168235760 --- /dev/null +++ b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts @@ -0,0 +1,40 @@ +import type { RelationInstance } from "~/types"; + +const asString = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +export const getNodeTypeIdFromFrontmatter = ( + frontmatter: Record | undefined, +): string | undefined => asString(frontmatter?.nodeTypeId); + +/** An imported node is referenced by both its nodeInstanceId and its importedFromRid. */ +export const getEndpointIdsFromFrontmatter = ( + frontmatter: Record | undefined, +): string[] => { + const endpointIds: string[] = []; + const nodeInstanceId = asString(frontmatter?.nodeInstanceId); + const importedFromRid = asString(frontmatter?.importedFromRid); + + if (nodeInstanceId) endpointIds.push(nodeInstanceId); + if (importedFromRid && importedFromRid !== nodeInstanceId) { + endpointIds.push(importedFromRid); + } + + return endpointIds; +}; + +/** + * Counts what the panel would list. Excludes unaccepted imports and relations + * orphaned by a deleted relation type, both of which the panel hides. + */ +export const countDisplayableRelations = ({ + relations, + isConfiguredType, +}: { + relations: RelationInstance[]; + isConfiguredType: (relationTypeId: string) => boolean; +}): number => + relations.filter( + (relation) => + relation.tentative !== false && isConfiguredType(relation.type), + ).length; diff --git a/apps/obsidian/src/utils/discourseLinkUtils.ts b/apps/obsidian/src/utils/discourseLinkUtils.ts new file mode 100644 index 000000000..28cf5e873 --- /dev/null +++ b/apps/obsidian/src/utils/discourseLinkUtils.ts @@ -0,0 +1,58 @@ +import { parseLinktext, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { DiscourseNode } from "~/types"; +import { getNodeTypeById, getRelationTypeById } from "./typeUtils"; +import { + countDisplayableRelations, + getEndpointIdsFromFrontmatter, + getNodeTypeIdFromFrontmatter, +} from "./discourseLinkFrontmatter"; + +export type DiscourseLinkTarget = { + file: TFile; + nodeType: DiscourseNode; + relationCount: number; +}; + +/** + * Resolves a link to a discourse node and its relation count from in-memory + * caches only; avoids getNodeTypeIdForFile, which polls 500ms for frontmatter. + */ +export const resolveDiscourseLinkTarget = ({ + plugin, + linktext, + sourcePath, +}: { + plugin: DiscourseGraphPlugin; + linktext: string; + sourcePath: string; +}): DiscourseLinkTarget | null => { + // Strips any #heading or #^block subpath. + const { path } = parseLinktext(linktext); + if (!path) return null; + + const file = plugin.app.metadataCache.getFirstLinkpathDest(path, sourcePath); + if (!file) return null; + + const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; + + const nodeTypeId = getNodeTypeIdFromFrontmatter(frontmatter); + if (!nodeTypeId) return null; + + const nodeType = getNodeTypeById(plugin, nodeTypeId); + if (!nodeType) return null; + + const endpointIds = getEndpointIdsFromFrontmatter(frontmatter); + if (endpointIds.length === 0) return { file, nodeType, relationCount: 0 }; + + const relations = + plugin.relationsIndex.getRelationsForEndpointIds(endpointIds); + + const relationCount = countDisplayableRelations({ + relations, + isConfiguredType: (relationTypeId) => + !!getRelationTypeById(plugin, relationTypeId), + }); + + return { file, nodeType, relationCount }; +}; diff --git a/apps/obsidian/src/utils/internalLinkParsing.ts b/apps/obsidian/src/utils/internalLinkParsing.ts new file mode 100644 index 000000000..d95edb4f3 --- /dev/null +++ b/apps/obsidian/src/utils/internalLinkParsing.ts @@ -0,0 +1,21 @@ +// Shared by the CM6 extensions that scan raw markdown for internal links. + +/** Embeds are not matched: the leading `!` sits outside, so callers check it. */ +export const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g; + +/** Target of a wikilink or markdown link; any `#subpath` is left for parseLinktext. */ +export const extractLinktext = (match: string): string => { + if (match.startsWith("[[")) { + const inner = match.slice(2, -2); + const pipeIndex = inner.indexOf("|"); + return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner; + } + + const parenOpen = match.lastIndexOf("("); + const rawPath = match.slice(parenOpen + 1, -1); + try { + return decodeURIComponent(rawPath); + } catch { + return rawPath; + } +}; 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/obsidian/src/utils/relationsEndpointIndex.ts b/apps/obsidian/src/utils/relationsEndpointIndex.ts new file mode 100644 index 000000000..f200a392e --- /dev/null +++ b/apps/obsidian/src/utils/relationsEndpointIndex.ts @@ -0,0 +1,54 @@ +import type { RelationInstance } from "~/types"; + +/** + * Groups relations by the ids at either end, so a lookup is a Map hit rather + * than a scan. Self-relations are filed once, not twice. + */ +export const buildEndpointIndex = ( + relations: Record, +): Map => { + const index = new Map(); + + const fileUnder = (endpointId: string, relation: RelationInstance): void => { + const existing = index.get(endpointId); + if (existing) { + existing.push(relation); + return; + } + index.set(endpointId, [relation]); + }; + + for (const relation of Object.values(relations)) { + if (!relation) continue; + if (relation.source) fileUnder(relation.source, relation); + if (relation.destination && relation.destination !== relation.source) { + fileUnder(relation.destination, relation); + } + } + + return index; +}; + +/** Relations touching any of `endpointIds`, deduped: an imported node matches on two ids. */ +export const collectRelations = ({ + index, + endpointIds, +}: { + index: Map; + endpointIds: Iterable; +}): RelationInstance[] => { + const seen = new Set(); + const collected: RelationInstance[] = []; + + for (const endpointId of endpointIds) { + const relations = index.get(endpointId); + if (!relations) continue; + for (const relation of relations) { + if (seen.has(relation.id)) continue; + seen.add(relation.id); + collected.push(relation); + } + } + + return collected; +}; diff --git a/apps/obsidian/src/utils/relationsIndex.ts b/apps/obsidian/src/utils/relationsIndex.ts new file mode 100644 index 000000000..64b154087 --- /dev/null +++ b/apps/obsidian/src/utils/relationsIndex.ts @@ -0,0 +1,112 @@ +import { TAbstractFile, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { RelationInstance } from "~/types"; +import { getRelationsFilePath, loadRelations } from "./relationsStore"; +import { buildEndpointIndex, collectRelations } from "./relationsEndpointIndex"; + +/** + * Parsed snapshot of relations.json so a render path can ask synchronously, + * rebuilt from vault events (which covers our own writes and sync alike). + */ +export class RelationsIndex { + private plugin: DiscourseGraphPlugin; + private index: Map | null = null; + private inFlight: Promise | null = null; + private stale = false; + private unloaded = false; + /** Lets a ViewPlugin, which only sees transactions, detect a changed snapshot. */ + private version = 0; + private subscribers = new Set<() => void>(); + /** Guards against a load that started before an invalidation overwriting a newer one. */ + private generation = 0; + + constructor(plugin: DiscourseGraphPlugin) { + this.plugin = plugin; + } + + initialize(): void { + const invalidateIfRelationsFile = (file: TAbstractFile): void => { + if (!(file instanceof TFile)) return; + if (file.path !== getRelationsFilePath()) return; + this.invalidate(); + }; + + const { vault } = this.plugin.app; + this.plugin.registerEvent(vault.on("modify", invalidateIfRelationsFile)); + this.plugin.registerEvent(vault.on("create", invalidateIfRelationsFile)); + this.plugin.registerEvent(vault.on("delete", invalidateIfRelationsFile)); + + void this.ensureLoaded(); + } + + unload(): void { + this.unloaded = true; + this.subscribers.clear(); + this.index = null; + this.inFlight = null; + this.generation += 1; + } + + /** Changes whenever the snapshot is replaced; see the field comment. */ + getVersion(): number { + return this.version; + } + + /** Fires when the snapshot changes. Returns an unsubscribe function. */ + onChange(subscriber: () => void): () => void { + this.subscribers.add(subscriber); + return () => this.subscribers.delete(subscriber); + } + + async ensureLoaded(): Promise { + if (this.unloaded) return; + if (this.index !== null && !this.stale) return; + if (this.inFlight) return this.inFlight; + + const generation = this.generation; + this.inFlight = (async () => { + try { + const relationsFile = await loadRelations(this.plugin); + // Superseded mid-read; the invalidation already scheduled a reload. + if (generation !== this.generation || this.unloaded) return; + this.index = buildEndpointIndex(relationsFile.relations ?? {}); + this.stale = false; + this.version += 1; + } finally { + // Every path, or ensureLoaded hands out a settled promise forever. + this.inFlight = null; + } + // The skipped invalidation above still needs a load of its own. + if (this.stale && !this.unloaded) { + void this.ensureLoaded(); + return; + } + this.notify(); + })(); + + return this.inFlight; + } + + /** + * Empty while cold, so treat that as "not loaded yet", not "no relations". + * Never schedules a load: that would make notify -> re-render -> read loop. + */ + getRelationsForEndpointIds( + endpointIds: Iterable, + ): RelationInstance[] { + if (this.index === null) return []; + return collectRelations({ index: this.index, endpointIds }); + } + + /** Keeps the old snapshot while reloading, so badges do not flash to 0. */ + private invalidate(): void { + this.generation += 1; + this.inFlight = null; + this.stale = true; + void this.ensureLoaded(); + } + + private notify(): void { + for (const subscriber of this.subscribers) subscriber(); + } +} diff --git a/apps/obsidian/src/utils/wikilinkDragHandler.ts b/apps/obsidian/src/utils/wikilinkDragHandler.ts index 12872979f..a1cde6245 100644 --- a/apps/obsidian/src/utils/wikilinkDragHandler.ts +++ b/apps/obsidian/src/utils/wikilinkDragHandler.ts @@ -10,6 +10,7 @@ import { import { TFile, WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; import type DiscourseGraphPlugin from "~/index"; +import { extractLinktext, INTERNAL_LINK_RE } from "./internalLinkParsing"; const buildObsidianUrl = (vaultName: string, filePath: string): string => { return `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}`; @@ -42,29 +43,6 @@ const setDragData = ( // --- Live Preview --- -/** - * Extract the file path from a link match. - * Handles wikilinks (`[[path]]`, `[[path|alias]]`) and - * markdown links (`[text](path.md)`), decoding URL-encoded paths. - */ -const extractLinkPath = (match: string): string => { - // Wikilink: [[path]] or [[path|alias]] - if (match.startsWith("[[")) { - const inner = match.slice(2, -2); - const pipeIndex = inner.indexOf("|"); - return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner; - } - - // Markdown link: [text](path) - const parenOpen = match.lastIndexOf("("); - const rawPath = match.slice(parenOpen + 1, -1); - try { - return decodeURIComponent(rawPath); - } catch (error) { - return rawPath; - } -}; - /** * Widget that renders a small drag handle next to an internal link. * CM6 widgets get `ignoreEvent() → true` by default, which means @@ -103,10 +81,6 @@ class WikilinkDragHandleWidget extends WidgetType { } } -// Matches wikilinks [[...]] and markdown links [text](path.md). -// Embed exclusion (![[...]] and ![text](...)) is handled in the loop. -const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g; - const hasVisibleCanvasLeaf = (plugin: DiscourseGraphPlugin): boolean => plugin.app.workspace .getLeavesOfType(VIEW_TYPE_TLDRAW_DG_PREVIEW) @@ -133,7 +107,7 @@ const buildWidgetDecorations = ( view.state.doc.sliceString(checkPos, checkPos + 1) === "!"; if (isEmbed) continue; const matchEnd = from + match.index + match[0].length; - const linkPath = extractLinkPath(match[0]); + const linkPath = extractLinktext(match[0]); const widget = new WikilinkDragHandleWidget(linkPath, plugin); widgets.push(Decoration.widget({ widget, side: 1 }).range(matchEnd)); } diff --git a/apps/website/content/obsidian/configuration/general-settings.md b/apps/website/content/obsidian/configuration/general-settings.md index a7f2b13e7..1fcb6e2e2 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 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 +- 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..665d1ba4c 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 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 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.