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
35 changes: 35 additions & 0 deletions apps/obsidian/src/components/AdminPanelSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const AdminPanelSettings = () => {
const [username, setUsername] = useState<string>(
plugin.settings.username || "",
);
const [nodeCardContextMenuEnabled, setNodeCardContextMenuEnabled] =
useState<boolean>(plugin.settings.nodeCardContextMenuEnabled ?? false);

const handleSyncModeToggle = useCallback(
async (newValue: boolean) => {
Expand Down Expand Up @@ -43,6 +45,15 @@ export const AdminPanelSettings = () => {
await updateUsername(plugin, newValue);
};

const handleNodeCardContextMenuToggle = useCallback(
async (newValue: boolean) => {
setNodeCardContextMenuEnabled(newValue);
plugin.settings.nodeCardContextMenuEnabled = newValue;
await plugin.saveSettings();
},
[plugin],
);

const handleLoginHandoff = async () => {
const client = await getLoggedInClient(plugin);
if (!client) {
Expand Down Expand Up @@ -72,6 +83,30 @@ export const AdminPanelSettings = () => {

return (
<div className="general-settings">
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">(BETA) Node card context menu</div>
<div className="setting-item-description">
Show discourse context and styling tabs when a node card is selected
on a canvas
</div>
</div>
<div className="setting-item-control">
<div
className={`checkbox-container ${nodeCardContextMenuEnabled ? "is-enabled" : ""}`}
onClick={() =>
void handleNodeCardContextMenuToggle(!nodeCardContextMenuEnabled)
}
>
<input
type="checkbox"
checked={nodeCardContextMenuEnabled}
aria-label="Enable node card context menu"
readOnly
/>
</div>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">(BETA) Sync mode enable</div>
Expand Down
97 changes: 97 additions & 0 deletions apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createElement, useEffect, useState, type ComponentType } from "react";
import type { TFile } from "obsidian";
import {
DefaultStylePanel,
DefaultStylePanelContent,
useEditor,
useRelevantStyles,
useValue,
type TLUiStylePanelContentProps,
type TLUiStylePanelProps,
} from "tldraw";
import type DiscourseGraphPlugin from "~/index";
import type { DiscourseNodeShape } from "./shapes/DiscourseNodeShape";
import { RelationsPanel } from "./overlays/RelationPanel";

type NodeCardContextMenuProps = TLUiStylePanelProps & {
plugin: DiscourseGraphPlugin;
canvasFile: TFile;
};

const NODE_CARD_CONTEXT_MENU_TABS = [
{ id: "context", label: "Context" },
{ id: "styling", label: "Styling" },
] as const;

type NodeCardContextMenuTab =
(typeof NODE_CARD_CONTEXT_MENU_TABS)[number]["id"];

const DefaultStylePanelComponent =
DefaultStylePanel as unknown as ComponentType<TLUiStylePanelProps>;
const DefaultStylePanelContentComponent =
DefaultStylePanelContent as unknown as ComponentType<TLUiStylePanelContentProps>;

export const NodeCardContextMenu = ({
plugin,
canvasFile,
isMobile,
}: NodeCardContextMenuProps) => {
const editor = useEditor();
const styles = useRelevantStyles();
const isEnabled = plugin.settings.nodeCardContextMenuEnabled ?? false;
const selectedShape = useValue(
"selected shape for node card context menu",
() => editor.getOnlySelectedShape(),
[editor],
);
const selectedNode =
isEnabled && selectedShape?.type === "discourse-node"
? (selectedShape as DiscourseNodeShape)
: null;
const [activeTab, setActiveTab] = useState<NodeCardContextMenuTab>("context");

useEffect(() => {
setActiveTab("context");
}, [selectedNode?.id]);

if (!selectedNode) {
return createElement(DefaultStylePanelComponent, { isMobile });
}

return createElement(
DefaultStylePanelComponent,
{ isMobile },
<div className="dg-node-card-menu">
<div className="grid grid-cols-2 border-b border-[var(--color-divider)] bg-[var(--color-panel)]">
{NODE_CARD_CONTEXT_MENU_TABS.map(({ id, label }) => (
<button
key={id}
type="button"
aria-pressed={activeTab === id}
className={[
"border-b-2 px-3 py-2 text-xs font-semibold",
activeTab === id
? "border-[var(--color-selected)] text-[var(--color-selected)]"
: "border-transparent text-[var(--color-text-3)]",
].join(" ")}
onClick={() => setActiveTab(id)}
>
{label}
</button>
))}
</div>

{activeTab === "context" ? (
<RelationsPanel
plugin={plugin}
canvasFile={canvasFile}
nodeShape={selectedNode}
embedded
includeAllDirections
/>
) : (
createElement(DefaultStylePanelContentComponent, { styles })
)}
Comment on lines +84 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Selecting a node card silently edits the canvas note and can append duplicate link blocks

The relations list is now shown automatically whenever a single node card is selected (RelationsPanel at apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx:85-91), and merely showing it writes new link blocks into the canvas note, so users get unexpected file edits just by clicking a card.
Impact: Clicking a node card can modify the saved canvas note without any user action, and repeated re-renders while dragging can append the same link text over and over.

How rendering the relations list triggers vault writes

Each listed related file is rendered as a RelationFileItem, whose mount effect calls checkExistingRelation (apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx:76-90). checkExistingRelation calls ensureBlockRefForFile (apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx:269-273), which, when no existing block ref points at the target file, appends [[link]]\n^uuid to the canvas markdown via app.vault.process (apps/obsidian/src/components/canvas/stores/assetStore.ts:143-155).

Previously this only ran after the user explicitly opened the floating Relations panel via the overlay button (apps/obsidian/src/components/canvas/overlays/RelationOverlay.tsx:80-101). With the new style-panel menu the panel mounts on every single-node selection.

Additionally, checkExistingRelation is re-created on every RelationsPanel render and is in the effect's dependency array, and NodeCardContextMenu re-renders whenever the selected shape record changes (e.g. while dragging, via useValue at apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx:42-46). Because Obsidian's metadataCache updates asynchronously, consecutive invocations can miss the just-written block and append another duplicate block for the same file.

Prompt for agents
Rendering RelationsPanel now happens automatically on node selection (NodeCardContextMenu Context tab), but the panel's per-item relation check has a side effect: checkExistingRelation in apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx calls ensureBlockRefForFile, which appends a new '[[link]]\n^uuid' block to the canvas markdown file (apps/obsidian/src/components/canvas/stores/assetStore.ts) when none exists. This means merely selecting a card mutates the note. It is also re-run on every re-render because checkExistingRelation is re-created each render and is an effect dependency, and metadataCache lag can cause duplicate appends.

Possible approaches: introduce a read-only lookup (e.g. resolve an existing block ref without creating one) for the existence check, and only call ensureBlockRefForFile when the user actually creates a relation; and/or memoize checkExistingRelation (useCallback) so the per-item effect does not re-run on every render.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

</div>,
);
};
8 changes: 8 additions & 0 deletions apps/obsidian/src/components/canvas/TldrawViewComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
import ToastListener from "./ToastListener";
import { RelationsOverlay } from "./overlays/RelationOverlay";
import { DragHandleOverlay } from "./overlays/DragHandleOverlay";
import { NodeCardContextMenu } from "./NodeCardContextMenu";
import { WHITE_LOGO_SVG } from "~/icons";
import { CustomContextMenu } from "./CustomContextMenu";
import {
Expand Down Expand Up @@ -431,6 +432,13 @@ export const TldrawPreviewComponent = ({
ContextMenu: (props) => (
<CustomContextMenu canvasFile={file} props={props} />
),
StylePanel: (props) => (
<NodeCardContextMenu
plugin={plugin}
canvasFile={file}
{...props}
/>
),
SharePanel: () => {
const tools = useTools();
const isDiscourseNodeToolSelected = useIsToolSelected(
Expand Down
158 changes: 108 additions & 50 deletions apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import type { TFile } from "obsidian";
import type DiscourseGraphPlugin from "~/index";
import {
Expand Down Expand Up @@ -57,7 +57,9 @@ export type RelationsPanelProps = {
plugin: DiscourseGraphPlugin;
canvasFile: TFile;
nodeShape: DiscourseNodeShape;
onClose: () => void;
onClose?: () => void;
embedded?: boolean;
includeAllDirections?: boolean;
};

const RelationFileItem = ({
Expand Down Expand Up @@ -165,6 +167,8 @@ export const RelationsPanel = ({
canvasFile,
nodeShape,
onClose,
embedded = false,
includeAllDirections = false,
}: RelationsPanelProps) => {
const editor = useEditor();
const [groups, setGroups] = useState<GroupedRelation[]>([]);
Expand Down Expand Up @@ -193,7 +197,7 @@ export const RelationsPanel = ({
setError("Linked file not found.");
return;
}
const g = await computeRelations(plugin, file);
const g = await computeRelations(plugin, file, includeAllDirections);
setGroups(g);
} catch (e) {
showToast({
Expand All @@ -208,11 +212,14 @@ export const RelationsPanel = ({
}
};
void load();
}, [plugin, canvasFile, nodeShape.id, nodeShape.props.src, editor]);

const headerTitle = useMemo(() => {
return nodeShape.props.title || "Selected node";
}, [nodeShape.props.title]);
}, [
plugin,
canvasFile,
nodeShape.id,
nodeShape.props.src,
editor,
includeAllDirections,
]);

const ensureNodeShapeForFile = async (
file: TFile,
Expand Down Expand Up @@ -460,21 +467,33 @@ export const RelationsPanel = ({
};

return (
<div className="min-w-80 max-w-md rounded-lg border bg-white p-4 shadow-lg">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Relations</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
aria-label="Close"
>
</button>
</div>

<div className="mb-3">
<div className="text-sm font-medium text-gray-700">{headerTitle}</div>
</div>
<div
className={
embedded
? "p-3"
: "min-w-80 max-w-md rounded-lg border bg-white p-4 shadow-lg"
}
>
{!embedded && (
<>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Relations</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
aria-label="Close"
>
</button>
</div>

<div className="mb-3">
<div className="text-sm font-medium text-gray-700">
{nodeShape.props.title || "Selected node"}
</div>
</div>
</>
)}

{loading ? (
<div className="text-center text-gray-500">Loading relations...</div>
Expand Down Expand Up @@ -521,6 +540,7 @@ export const RelationsPanel = ({
const computeRelations = async (
plugin: DiscourseGraphPlugin,
file: TFile,
includeAllDirections: boolean,
): Promise<GroupedRelation[]> => {
const fileCache = plugin.app.metadataCache.getFileCache(file);
if (!fileCache?.frontmatter) return [];
Expand All @@ -540,38 +560,76 @@ const computeRelations = async (
plugin.settings.discourseRelations.filter(isAcceptedSchema);

for (const relationType of acceptedRelationTypes) {
const typeLevelRelation = acceptedDiscourseRelations.find(
(rel) =>
(rel.sourceId === activeNodeTypeId ||
rel.destinationId === activeNodeTypeId) &&
rel.relationshipTypeId === relationType.id,
const matchingRelations = acceptedDiscourseRelations.filter(
(relation) =>
(relation.sourceId === activeNodeTypeId ||
relation.destinationId === activeNodeTypeId) &&
relation.relationshipTypeId === relationType.id,
);
if (!typeLevelRelation) continue;

const instanceRels = relations.filter((r) => r.type === relationType.id);
const isSource = typeLevelRelation.sourceId === activeNodeTypeId;
const label = isSource ? relationType.label : relationType.complement;
const key = `${relationType.id}-${isSource}`;

if (!result.has(key)) {
result.set(key, {
key,
label,
isSource,
relationTypeId: relationType.id,
linkedFiles: [],
});
}
// Preserve the legacy panel's previous first-match behavior.
const typeLevelRelations = includeAllDirections
? matchingRelations
: matchingRelations.slice(0, 1);

for (const typeLevelRelation of typeLevelRelations) {
const isSource = typeLevelRelation.sourceId === activeNodeTypeId;
const key = `${relationType.id}-${isSource}`;

if (!result.has(key)) {
result.set(key, {
key,
label: isSource ? relationType.label : relationType.complement,
isSource,
relationTypeId: relationType.id,
linkedFiles: [],
});
}

const group = result.get(key)!;
for (const r of instanceRels) {
const otherId = r.source === nodeInstanceId ? r.destination : r.source;
const linked = getFileForNodeInstanceId(plugin, otherId);
if (linked && !group.linkedFiles.some((f) => f.path === linked.path)) {
group.linkedFiles.push(linked);
const group = result.get(key)!;
for (const relation of relations) {
if (relation.type !== relationType.id) continue;
const otherId = getRelationCounterpartId({
relation,
nodeInstanceId,
isSource,
includeAllDirections,
});
if (!otherId) continue;

const linkedFile = getFileForNodeInstanceId(plugin, otherId);
if (
linkedFile &&
!group.linkedFiles.some(({ path }) => path === linkedFile.path)
) {
group.linkedFiles.push(linkedFile);
}
}
}
}

return Array.from(result.values());
};

const getRelationCounterpartId = ({
relation,
nodeInstanceId,
isSource,
includeAllDirections,
}: {
relation: { source: string; destination: string };
nodeInstanceId: string;
isSource: boolean;
includeAllDirections: boolean;
}): string | null => {
if (!includeAllDirections) {
return relation.source === nodeInstanceId
? relation.destination
: relation.source;
}

if (isSource) {
return relation.source === nodeInstanceId ? relation.destination : null;
}

return relation.destination === nodeInstanceId ? relation.source : null;
};
1 change: 1 addition & 0 deletions apps/obsidian/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export const DEFAULT_SETTINGS: Settings = {
spacePassword: undefined,
accountLocalId: undefined,
syncModeEnabled: false,
nodeCardContextMenuEnabled: false,
spaceNames: {},
};

Expand Down
Loading