Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/obsidian/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { createImageEmbedHoverExtension } from "~/utils/imageEmbedHoverIcon";
import { createWikilinkDragExtension } from "~/utils/wikilinkDragHandler";
import { createDiscourseContextOverlayExtension } from "~/utils/discourseContextOverlayExtension";
import { createDiscourseContextOverlayPostProcessor } from "~/utils/discourseContextOverlayPostProcessor";
import {
registerDiscourseContextOverlayRefresh,
refreshDiscourseContextOverlaySurfaces,
Expand Down Expand Up @@ -108,6 +109,9 @@ export default class DiscourseGraphPlugin extends Plugin {
}

this.relationsIndex.initialize();
this.registerMarkdownPostProcessor(
createDiscourseContextOverlayPostProcessor(this),
);
registerDiscourseContextOverlayRefresh(this);

registerCommands(this);
Expand Down Expand Up @@ -291,7 +295,10 @@ export default class DiscourseGraphPlugin extends Plugin {
this.setupNodeTagHotkey();
}

/** Applies the overlay setting immediately, without a reload. */
/**
* Re-renders both markdown surfaces so the discourse context overlay appears
* or disappears immediately when its setting is toggled, without a reload.
*/
refreshDiscourseContextOverlay(): void {
refreshDiscourseContextOverlaySurfaces(this);
}
Expand Down
91 changes: 91 additions & 0 deletions apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { MarkdownPostProcessorContext } from "obsidian";
import type DiscourseGraphPlugin from "~/index";
import {
badgeTargetPath,
createDiscourseContextBadge,
updateDiscourseContextBadge,
DISCOURSE_CONTEXT_BADGE_CLASS,
} from "~/components/discourseContextBadge";
import { openDiscourseContextPopover } from "~/components/DiscourseContextPopover";
import { resolveDiscourseLinkTarget } from "./discourseLinkUtils";

/** Idempotent: Obsidian reuses rendered sections and re-runs post processors. */
export const applyDiscourseContextBadges = ({
plugin,
el,
sourcePath,
skipEmbedded = false,
}: {
plugin: DiscourseGraphPlugin;
el: HTMLElement;
sourcePath: string;
/** Links inside a transclusion resolve against the embedded file, not `sourcePath`. */
skipEmbedded?: boolean;
}): void => {
const links = el.querySelectorAll<HTMLAnchorElement>("a.internal-link");

for (const link of Array.from(links)) {
if (skipEmbedded && link.closest(".internal-embed")) continue;
const existing = link.nextElementSibling?.hasClass(
DISCOURSE_CONTEXT_BADGE_CLASS,
)
? (link.nextElementSibling as HTMLElement)
: null;

// data-href holds the link as written; href is resolved and URL-encoded.
const linktext =
link.getAttribute("data-href") ?? link.getAttribute("href");
if (!linktext) continue;

const target = resolveDiscourseLinkTarget({
plugin,
linktext,
sourcePath,
});
if (!target) {
existing?.remove();
continue;
}

// Updated rather than replaced when the target is unchanged: an open
// popover anchored to this badge would otherwise hold a detached element.
if (existing && badgeTargetPath(existing) === target.file.path) {
updateDiscourseContextBadge({
badge: existing,
nodeType: target.nodeType,
relationCount: target.relationCount,
});
continue;
}

const badge = createDiscourseContextBadge({
file: target.file,
nodeType: target.nodeType,
relationCount: target.relationCount,
onActivate: ({ file, anchor }) =>
openDiscourseContextPopover({
plugin,
file,
anchor,
relationCount: target.relationCount,
}),
});

existing?.remove();
link.insertAdjacentElement("afterend", badge);
}
};

export const removeDiscourseContextBadges = (el: HTMLElement): void => {
el.querySelectorAll(`.${DISCOURSE_CONTEXT_BADGE_CLASS}`).forEach((badge) =>
badge.remove(),
);
};

export const createDiscourseContextOverlayPostProcessor =
(plugin: DiscourseGraphPlugin) =>
(el: HTMLElement, ctx: MarkdownPostProcessorContext): void => {
if (!plugin.settings.showDiscourseContextOverlay) return;
if (!ctx.sourcePath) return;
applyDiscourseContextBadges({ plugin, el, sourcePath: ctx.sourcePath });
};
23 changes: 21 additions & 2 deletions apps/obsidian/src/utils/discourseContextOverlayRefresh.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { debounce, type TFile } from "obsidian";
import { debounce, MarkdownView, type TFile } from "obsidian";
import type DiscourseGraphPlugin from "~/index";
import { getNodeTypeIdFromFrontmatter } from "./discourseLinkFrontmatter";
import { refreshMarkdownEditors } from "./markdownViewRefresh";
import {
applyDiscourseContextBadges,
removeDiscourseContextBadges,
} from "./discourseContextOverlayPostProcessor";

const REFRESH_DEBOUNCE_MS = 300;

Expand All @@ -14,11 +18,26 @@ const isDiscourseNodeFile = (
plugin.app.metadataCache.getFileCache(file)?.frontmatter,
);

/** Redraws the overlay when relations or a node's frontmatter change. */
/**
* Reading view is refreshed in place: rerender() blanks a pane that is not
* currently painting.
*/
export const refreshDiscourseContextOverlaySurfaces = (
plugin: DiscourseGraphPlugin,
): void => {
refreshMarkdownEditors(plugin.app);
plugin.app.workspace.iterateAllLeaves((leaf) => {
if (!(leaf.view instanceof MarkdownView)) return;
const el = leaf.view.previewMode?.containerEl;
if (!el) return;
if (!plugin.settings.showDiscourseContextOverlay) {
removeDiscourseContextBadges(el);
return;
}
const sourcePath = leaf.view.file?.path;
if (!sourcePath) return;
applyDiscourseContextBadges({ plugin, el, sourcePath, skipEmbedded: true });
});
};

export const registerDiscourseContextOverlayRefresh = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This setting controls the visibility of identifiers in your note's frontmatter s

This setting controls whether links to discourse nodes carry an inline badge showing how many relations the linked node has.

- When enabled, a badge appears after each link to a discourse node in Live Preview
- When enabled, a badge appears after each link to a discourse node, in both Live Preview and Reading view
- Selecting a badge opens that node's discourse context in a popover, where you can review its relationships and add a new one
- A node with no relations shows a badge reading `0`, and its popover says "No discourse relation found"
- Links to notes that are not discourse nodes never show a badge
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ You can configure a custom hotkey in the Obsidian settings to quickly toggle the

Links to a discourse node show a small badge with the number of relations that node has. Select the badge to open its discourse context in place, without leaving the note you are reading.

The badge appears in Live Preview, on every link to a discourse node. A node with no relations yet shows a badge reading `0`, and opening it says "No discourse relation found" alongside the option to add one. You can turn the badge off in [General settings](/docs/obsidian/configuration/general-settings).
The badge appears in both Live Preview and Reading view, on every link to a discourse node. A node with no relations yet shows a badge reading `0`, and opening it says "No discourse relation found" alongside the option to add one. You can turn the badge off in [General settings](/docs/obsidian/configuration/general-settings).

## Using the discourse context

Expand Down