Skip to content
Open
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
187 changes: 187 additions & 0 deletions apps/obsidian/src/components/DiscourseContextPopover.tsx
Original file line number Diff line number Diff line change
@@ -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(
<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;
// 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();
};
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 @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -364,6 +373,13 @@ const GeneralSettings = () => {
</button>
</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
91 changes: 91 additions & 0 deletions apps/obsidian/src/components/discourseContextBadge.ts
Original file line number Diff line number Diff line change
@@ -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<DiscourseContextBadgeProps, "nodeType" | "relationCount">): 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);
};
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