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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
156 changes: 156 additions & 0 deletions apps/obsidian/src/components/DiscourseContextPopover.tsx
Original file line number Diff line number Diff line change
@@ -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(
<PluginProvider plugin={this.plugin}>
<RelationshipSection activeFile={file} />
</PluginProvider>,
);

// 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();
};
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
66 changes: 66 additions & 0 deletions apps/obsidian/src/components/discourseContextBadge.ts
Original file line number Diff line number Diff line change
@@ -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<DiscourseContextBadgeProps, "nodeType" | "relationCount">): 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;
};
1 change: 1 addition & 0 deletions apps/obsidian/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const DEFAULT_SETTINGS: Settings = {
canvasAttachmentsFolderPath: "attachments",
nodeTagHotkey: "\\",
showHelpMenuStatusBarIcon: false,
showDiscourseContextOverlay: true,
spacePassword: undefined,
accountLocalId: undefined,
syncModeEnabled: false,
Expand Down
Loading