Skip to content
175 changes: 175 additions & 0 deletions apps/obsidian/src/components/DiscourseContextPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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, pulling it back inside the window when
* it would overflow. Measured after mount because the content height depends on
* how many relations the node has.
*/
const positionPopover = (popover: HTMLElement, anchor: HTMLElement): void => {
// Geometry has to come from the window the anchor is in, not the main one, or
// a popover opened in a popout window 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;
};

/**
* The discourse context shown when a badge is selected.
*
* Reuses RelationshipSection, the same component the Discourse Context panel
* renders, so the two can never disagree about a node's relations. It needs
* only a TFile and PluginProvider — no workspace leaf — which is what makes it
* reusable here.
*
* Only one popover exists at a time; opening another closes the previous one.
*/
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 at all when a node has none, so
// without this the popover would open on an unexplained "Add a new
// relation" button. Created before the React host so it reads above it.
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(
<PluginProvider plugin={this.plugin}>
<RelationshipSection activeFile={file} />
</PluginProvider>,
);

// A React 18 root does not commit synchronously, so measuring now would
// size an empty box and the flip-up-when-near-the-bottom check would never
// fire. Re-measured after paint, and again as the relation list fills in.
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 moves the badge out from under the popover, so the
// popover follows it away. Scrolling *within* the popover must not dismiss
// it — its own content scrolls, and reaching "Add a new relation" requires
// exactly that.
const closeOnScroll = (event: Event): void => {
if (this.containerEl.contains(event.target as Node)) return;
this.close();
};

// Deferred so the click that opened the popover does not immediately
// dismiss it as an outside click.
const attach = this.win.setTimeout(() => {
doc.addEventListener("click", closeIfOutside, true);
}, 0);

doc.addEventListener("keydown", closeOnEscape);
// Capture phase, since scrolling happens inside panes rather than 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;
// Unmounting during React's own event handling warns, so defer it.
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();
};
16 changes: 1 addition & 15 deletions apps/obsidian/src/components/DiscourseContextView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,21 +27,6 @@ type DiscourseContextProps = {
activeFile: TFile | null;
};

type InfoTooltipProps = {
content: string;
};

export const InfoTooltip = ({ content }: InfoTooltipProps) => (
<button
ref={(el) => {
if (el) setTooltip(el, content);
}}
className="clickable-icon text-muted hover:text-normal flex h-4 w-4 items-center justify-center"
>
<div ref={(el) => (el && setIcon(el, "info")) || undefined} />
</button>
);

const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
const plugin = usePlugin();
const [isRefreshing, setIsRefreshing] = useState(false);
Expand Down
16 changes: 16 additions & 0 deletions apps/obsidian/src/components/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -343,6 +352,13 @@ const GeneralSettings = () => {
</div>
</div>

<ToggleSetting
name="Show discourse context overlay"
description="Shows a badge next to links to discourse nodes with how many relations each one has. Select a badge to open its discourse context."
checked={showDiscourseContextOverlay}
onChange={handleDiscourseContextOverlayToggleChange}
/>

<ToggleSetting
name="Show help menu icon in status bar"
description="Adds a Discourse Graph icon to the status bar that opens a menu with feedback, docs, community, and settings links."
Expand Down
16 changes: 16 additions & 0 deletions apps/obsidian/src/components/InfoTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { setIcon, setTooltip } from "obsidian";

type InfoTooltipProps = {
content: string;
};

export const InfoTooltip = ({ content }: InfoTooltipProps) => (
<button
ref={(el) => {
if (el) setTooltip(el, content);
}}
className="clickable-icon text-muted hover:text-normal flex h-4 w-4 items-center justify-center"
>
<div ref={(el) => (el && setIcon(el, "info")) || undefined} />
</button>
);
2 changes: 1 addition & 1 deletion apps/obsidian/src/components/RelationshipSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
removeRelationBySourceDestinationType,
updateRelation,
} from "~/utils/relationsStore";
import { InfoTooltip } from "./DiscourseContextView";
import { InfoTooltip } from "./InfoTooltip";

type RelationTypeOption = {
id: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,7 @@ const resolveObsidianUrlToFile = (

let abstract = plugin.app.vault.getAbstractFileByPath(parsed.filePath);
if (!(abstract instanceof TFile) && !parsed.filePath.endsWith(".md")) {
abstract = plugin.app.vault.getAbstractFileByPath(
`${parsed.filePath}.md`,
);
abstract = plugin.app.vault.getAbstractFileByPath(`${parsed.filePath}.md`);
}
return abstract instanceof TFile ? abstract : null;
};
Expand All @@ -80,7 +78,8 @@ const isDiscourseNodeFile = (
): boolean => {
if (!file.path.endsWith(".md")) return false;
const frontmatter = getFrontmatterForFile(plugin.app, file);
const nodeTypeId = (frontmatter as { nodeTypeId?: string } | null)?.nodeTypeId;
const nodeTypeId = (frontmatter as { nodeTypeId?: string } | null)
?.nodeTypeId;
if (!nodeTypeId || typeof nodeTypeId !== "string") return false;
return !!getNodeTypeById(plugin, nodeTypeId);
};
Expand Down Expand Up @@ -116,7 +115,9 @@ export const handleExternalUrlContent = async ({
if (url.startsWith(OBSIDIAN_URL_PREFIX)) {
const parsed = parseObsidianOpenUrl(url);
if (!parsed) {
new Notice("Invalid Obsidian link. Only discourse nodes can be dropped on the canvas.");
new Notice(
"Invalid Obsidian link. Only discourse nodes can be dropped on the canvas.",
);
return;
}

Expand Down Expand Up @@ -182,7 +183,9 @@ const createDiscourseNodeShapeAtPoint = async ({

if (existing) {
editor.setSelectedShapes([existing.id]);
editor.zoomToSelection({ animation: { duration: editor.options.animationMediumMs } });
editor.zoomToSelection({
animation: { duration: editor.options.animationMediumMs },
});
return;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/obsidian/src/components/canvas/utils/toastUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ export const showToast = ({
keepOpen: false,
};
dispatchToastEvent(toast, targetCanvasId);
};
};
74 changes: 74 additions & 0 deletions apps/obsidian/src/components/discourseContextBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { setIcon, setTooltip, TFile } from "obsidian";
import type { DiscourseNode } from "~/types";

/**
* Marks a badge in the DOM. Both render paths check for this before adding one,
* since Obsidian re-runs post processors over already-rendered sections.
*/
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<DiscourseContextBadgeProps, "nodeType" | "relationCount">): string => {
const relations = relationCount === 1 ? "relation" : "relations";
return `${nodeType.name}: ${relationCount} ${relations} — open discourse context`;
};

/**
* The inline badge shown next to a link to a discourse node.
*
* Plain DOM rather than React so the CodeMirror widget and the Reading view
* post processor can share one implementation — neither has a React root, and
* mounting one per link would be far too heavy. Tailwind utilities work here
* because they compile to ordinary global classes.
*/
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 => {
// Stops Obsidian from following the link the badge sits next to.
event.preventDefault();
event.stopPropagation();
onActivate({ file, anchor: badge });
};

// Without this the mousedown still lands in the editor and moves the caret,
// which in Live Preview expands the raw [[...]] markup 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;
};
Loading