From b13ee4c3aae841fc84d497a21acfaf4c51773197 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 27 Jul 2026 21:34:15 +0530 Subject: [PATCH 1/5] ENG-2038 Add admin-flagged node card context menu --- apps/obsidian/package.json | 5 +- .../src/components/AdminPanelSettings.tsx | 44 ++++ .../components/canvas/NodeCardContextMenu.tsx | 204 ++++++++++++++++++ .../components/canvas/TldrawViewComponent.tsx | 8 + .../nodeCardContextMenuModel.test.ts | 197 +++++++++++++++++ .../canvas/nodeCardContextMenuModel.ts | 166 ++++++++++++++ .../canvas/overlays/RelationPanel.tsx | 184 ++++++++++------ apps/obsidian/src/constants.ts | 4 + apps/obsidian/src/types.ts | 1 + apps/obsidian/styles.css | 117 ++++++++++ apps/obsidian/vitest.config.mts | 17 ++ pnpm-lock.yaml | 180 ++++++++-------- 12 files changed, 972 insertions(+), 155 deletions(-) create mode 100644 apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx create mode 100644 apps/obsidian/src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts create mode 100644 apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts create mode 100644 apps/obsidian/vitest.config.mts diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index 1b715d839..68e11749a 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -10,7 +10,9 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts --version 0.1.0", - "check-types": "tsc --noEmit --skipLibCheck" + "check-types": "tsc --noEmit --skipLibCheck", + "test": "vitest run --config vitest.config.mts", + "test:watch": "vitest --config vitest.config.mts" }, "keywords": [], "author": "", @@ -35,6 +37,7 @@ "tsx": "^4.19.2", "typescript": "5.5.4", "uuidv7": "1.1.0", + "vitest": "catalog:", "zod": "^3.24.1" }, "dependencies": { diff --git a/apps/obsidian/src/components/AdminPanelSettings.tsx b/apps/obsidian/src/components/AdminPanelSettings.tsx index e8e6b8af3..58844f97e 100644 --- a/apps/obsidian/src/components/AdminPanelSettings.tsx +++ b/apps/obsidian/src/components/AdminPanelSettings.tsx @@ -5,6 +5,10 @@ import { updateUsername } from "~/utils/supabaseContext"; import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase"; import { nextRoot } from "@repo/utils/execContext"; import { getLoggedInClient } from "~/utils/supabaseContext"; +import { + FEATURE_FLAGS, + NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, +} from "~/constants"; export const AdminPanelSettings = () => { const plugin = usePlugin(); @@ -14,6 +18,10 @@ export const AdminPanelSettings = () => { const [username, setUsername] = useState( plugin.settings.username || "", ); + const [nodeCardContextMenuEnabled, setNodeCardContextMenuEnabled] = + useState( + plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] ?? false, + ); const handleSyncModeToggle = useCallback( async (newValue: boolean) => { @@ -43,6 +51,18 @@ export const AdminPanelSettings = () => { await updateUsername(plugin, newValue); }; + const handleNodeCardContextMenuToggle = useCallback( + async (newValue: boolean) => { + setNodeCardContextMenuEnabled(newValue); + plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] = newValue; + await plugin.saveSettings(); + window.dispatchEvent( + new Event(NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT), + ); + }, + [plugin], + ); + const handleLoginHandoff = async () => { const client = await getLoggedInClient(plugin); if (!client) { @@ -72,6 +92,30 @@ export const AdminPanelSettings = () => { return (
+
+
+
(BETA) Node card context menu
+
+ Show discourse context and styling tabs when a node card is selected + on a canvas +
+
+
+
+ void handleNodeCardContextMenuToggle(!nodeCardContextMenuEnabled) + } + > + +
+
+
(BETA) Sync mode enable
diff --git a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx new file mode 100644 index 000000000..e72881122 --- /dev/null +++ b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx @@ -0,0 +1,204 @@ +import { + createElement, + useCallback, + useEffect, + useId, + useReducer, + useRef, + useState, + type KeyboardEvent, + type ComponentType, + type RefObject, +} from "react"; +import type { TFile } from "obsidian"; +import { + DefaultStylePanel, + useEditor, + usePassThroughWheelEvents, + useValue, + type TLUiStylePanelProps, +} from "tldraw"; +import type DiscourseGraphPlugin from "~/index"; +import { + FEATURE_FLAGS, + NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, +} from "~/constants"; +import type { DiscourseNodeShape } from "./shapes/DiscourseNodeShape"; +import { RelationsPanel } from "./overlays/RelationPanel"; +import { + createNodeCardContextMenuState, + nodeCardContextMenuReducer, + shouldShowNodeCardContextMenu, + type NodeCardContextMenuTab, +} from "./nodeCardContextMenuModel"; + +type NodeCardContextMenuProps = TLUiStylePanelProps & { + plugin: DiscourseGraphPlugin; + canvasFile: TFile; +}; + +const TABS: NodeCardContextMenuTab[] = ["context", "styling"]; +const DefaultStylePanelComponent = + DefaultStylePanel as unknown as ComponentType; + +export const NodeCardContextMenu = ({ + plugin, + canvasFile, + isMobile, +}: NodeCardContextMenuProps) => { + const editor = useEditor(); + const panelRef = useRef(null); + const tabButtonRefs = useRef< + Partial> + >({}); + const tabIdPrefix = useId(); + const [isEnabled, setIsEnabled] = useState( + plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] ?? false, + ); + + usePassThroughWheelEvents(panelRef as RefObject); + + useEffect(() => { + const syncFlag = () => { + setIsEnabled( + plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] ?? false, + ); + }; + window.addEventListener( + NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, + syncFlag, + ); + return () => { + window.removeEventListener( + NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, + syncFlag, + ); + }; + }, [plugin]); + + const selectedShape = useValue( + "selected shape for node card context menu", + () => editor.getOnlySelectedShape(), + [editor], + ); + const showNodeCardContextMenu = shouldShowNodeCardContextMenu({ + isEnabled, + selectedShapeType: selectedShape?.type, + }); + const selectedNode = showNodeCardContextMenu + ? (selectedShape as DiscourseNodeShape) + : null; + + const [state, dispatch] = useReducer( + nodeCardContextMenuReducer, + selectedNode?.id ?? null, + createNodeCardContextMenuState, + ); + + useEffect(() => { + dispatch({ + type: "sync-selection", + selectedShapeId: selectedNode?.id ?? null, + }); + }, [selectedNode?.id]); + + const selectTab = useCallback((tab: NodeCardContextMenuTab) => { + dispatch({ type: "select-tab", tab }); + }, []); + + const handleTabKeyDown = useCallback( + (event: KeyboardEvent) => { + const currentIndex = TABS.indexOf(state.activeTab); + let nextTab: NodeCardContextMenuTab | undefined; + + if (event.key === "ArrowLeft") { + nextTab = TABS[(currentIndex - 1 + TABS.length) % TABS.length]; + } else if (event.key === "ArrowRight") { + nextTab = TABS[(currentIndex + 1) % TABS.length]; + } else if (event.key === "Home") { + nextTab = TABS[0]; + } else if (event.key === "End") { + nextTab = TABS[TABS.length - 1]; + } + + if (!nextTab) return; + event.preventDefault(); + selectTab(nextTab); + tabButtonRefs.current[nextTab]?.focus(); + }, + [selectTab, state.activeTab], + ); + + if (!selectedNode) { + return createElement(DefaultStylePanelComponent, { isMobile }); + } + + return ( +
{ + if (!isMobile) { + editor.updateInstanceState({ isChangingStyle: false }); + } + }} + > +
+ {TABS.map((tab) => { + const isActive = state.activeTab === tab; + return ( + + ); + })} +
+ +
+ {state.activeTab === "context" ? ( + + ) : ( +
+ {createElement(DefaultStylePanelComponent, { isMobile: true })} +
+ )} +
+
+ ); +}; diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index d553a3487..fa336c316 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -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 { @@ -431,6 +432,13 @@ export const TldrawPreviewComponent = ({ ContextMenu: (props) => ( ), + StylePanel: (props) => ( + + ), SharePanel: () => { const tools = useTools(); const isDiscourseNodeToolSelected = useIsToolSelected( diff --git a/apps/obsidian/src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts b/apps/obsidian/src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts new file mode 100644 index 000000000..04135b6c5 --- /dev/null +++ b/apps/obsidian/src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createNodeCardContextMenuState, + groupRelationsByType, + nodeCardContextMenuReducer, + runRelationCanvasAction, + shouldShowNodeCardContextMenu, +} from "../nodeCardContextMenuModel"; + +describe("shouldShowNodeCardContextMenu", () => { + it("keeps the default panel when the admin flag is off", () => { + expect( + shouldShowNodeCardContextMenu({ + isEnabled: false, + selectedShapeType: "discourse-node", + }), + ).toBe(false); + }); + + it("keeps the default panel for regular tldraw shapes", () => { + expect( + shouldShowNodeCardContextMenu({ + isEnabled: true, + selectedShapeType: "geo", + }), + ).toBe(false); + }); + + it("shows the menu for a selected discourse node when enabled", () => { + expect( + shouldShowNodeCardContextMenu({ + isEnabled: true, + selectedShapeType: "discourse-node", + }), + ).toBe(true); + }); +}); + +describe("nodeCardContextMenuReducer", () => { + it("opens on Context and switches tabs", () => { + const initialState = createNodeCardContextMenuState("shape:one"); + const stylingState = nodeCardContextMenuReducer(initialState, { + type: "select-tab", + tab: "styling", + }); + + expect(initialState.activeTab).toBe("context"); + expect(stylingState.activeTab).toBe("styling"); + }); + + it("keeps the chosen tab for the same node and resets for a new node", () => { + const stylingState = { + activeTab: "styling" as const, + selectedShapeId: "shape:one", + }; + + expect( + nodeCardContextMenuReducer(stylingState, { + type: "sync-selection", + selectedShapeId: "shape:one", + }), + ).toBe(stylingState); + + expect( + nodeCardContextMenuReducer(stylingState, { + type: "sync-selection", + selectedShapeId: "shape:two", + }), + ).toEqual({ + activeTab: "context", + selectedShapeId: "shape:two", + }); + }); +}); + +describe("groupRelationsByType", () => { + it("groups incoming and outgoing relations and preserves empty relation types", () => { + const linkedFiles = new Map([ + ["node:claim", { path: "Claims/A claim.md" }], + ["node:evidence", { path: "Evidence/An observation.md" }], + ]); + + const groups = groupRelationsByType({ + activeNodeTypeId: "type:question", + nodeInstanceId: "node:question", + relationTypes: [ + { + id: "relation:supports", + label: "supports", + complement: "is supported by", + }, + { + id: "relation:informs", + label: "informs", + complement: "is informed by", + }, + ], + discourseRelations: [ + { + sourceId: "type:question", + destinationId: "type:claim", + relationshipTypeId: "relation:supports", + }, + { + sourceId: "type:evidence", + destinationId: "type:question", + relationshipTypeId: "relation:supports", + }, + { + sourceId: "type:question", + destinationId: "type:evidence", + relationshipTypeId: "relation:informs", + }, + ], + relations: [ + { + type: "relation:supports", + source: "node:question", + destination: "node:claim", + }, + { + type: "relation:supports", + source: "node:evidence", + destination: "node:question", + }, + { + type: "relation:supports", + source: "node:question", + destination: "node:claim", + }, + { + type: "relation:supports", + source: "node:unrelated-a", + destination: "node:unrelated-b", + }, + ], + getLinkedFile: (nodeInstanceId) => + linkedFiles.get(nodeInstanceId) ?? null, + }); + + expect(groups).toEqual([ + { + key: "relation:supports-source", + label: "supports", + isSource: true, + relationTypeId: "relation:supports", + linkedFiles: [{ path: "Claims/A claim.md" }], + }, + { + key: "relation:supports-destination", + label: "is supported by", + isSource: false, + relationTypeId: "relation:supports", + linkedFiles: [{ path: "Evidence/An observation.md" }], + }, + { + key: "relation:informs-source", + label: "informs", + isSource: true, + relationTypeId: "relation:informs", + linkedFiles: [], + }, + ]); + }); +}); + +describe("runRelationCanvasAction", () => { + it("adds a relation that is not on the canvas", async () => { + const add = vi.fn().mockResolvedValue(undefined); + const remove = vi.fn().mockResolvedValue(undefined); + + await expect( + runRelationCanvasAction({ + hasExistingRelation: false, + add, + remove, + }), + ).resolves.toBe("add"); + expect(add).toHaveBeenCalledOnce(); + expect(remove).not.toHaveBeenCalled(); + }); + + it("removes a relation that is already on the canvas", async () => { + const add = vi.fn().mockResolvedValue(undefined); + const remove = vi.fn().mockResolvedValue(undefined); + + await expect( + runRelationCanvasAction({ + hasExistingRelation: true, + add, + remove, + }), + ).resolves.toBe("remove"); + expect(remove).toHaveBeenCalledOnce(); + expect(add).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts b/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts new file mode 100644 index 000000000..ca9ffb1b9 --- /dev/null +++ b/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts @@ -0,0 +1,166 @@ +import type { + DiscourseRelation, + DiscourseRelationType, + RelationInstance, +} from "~/types"; + +export type NodeCardContextMenuTab = "context" | "styling"; + +export type NodeCardContextMenuState = { + activeTab: NodeCardContextMenuTab; + selectedShapeId: string | null; +}; + +export type NodeCardContextMenuAction = + | { + type: "select-tab"; + tab: NodeCardContextMenuTab; + } + | { + type: "sync-selection"; + selectedShapeId: string | null; + }; + +export const createNodeCardContextMenuState = ( + selectedShapeId: string | null, +): NodeCardContextMenuState => ({ + activeTab: "context", + selectedShapeId, +}); + +export const nodeCardContextMenuReducer = ( + state: NodeCardContextMenuState, + action: NodeCardContextMenuAction, +): NodeCardContextMenuState => { + if (action.type === "select-tab") { + return { + ...state, + activeTab: action.tab, + }; + } + + if (action.selectedShapeId === state.selectedShapeId) { + return state; + } + + return createNodeCardContextMenuState(action.selectedShapeId); +}; + +export const shouldShowNodeCardContextMenu = ({ + isEnabled, + selectedShapeType, +}: { + isEnabled: boolean; + selectedShapeType: string | undefined; +}): boolean => isEnabled && selectedShapeType === "discourse-node"; + +export type RelationCanvasAction = "add" | "remove"; + +export const getRelationCanvasAction = ( + hasExistingRelation: boolean, +): RelationCanvasAction => (hasExistingRelation ? "remove" : "add"); + +export const runRelationCanvasAction = async ({ + hasExistingRelation, + add, + remove, +}: { + hasExistingRelation: boolean; + add: () => Promise; + remove: () => Promise; +}): Promise => { + const action = getRelationCanvasAction(hasExistingRelation); + await (action === "add" ? add() : remove()); + return action; +}; + +type RelationTypeSummary = Pick< + DiscourseRelationType, + "id" | "label" | "complement" +>; + +type DiscourseRelationSummary = Pick< + DiscourseRelation, + "sourceId" | "destinationId" | "relationshipTypeId" +>; + +type RelationInstanceSummary = Pick< + RelationInstance, + "type" | "source" | "destination" +>; + +export type GroupedRelation = { + key: string; + label: string; + isSource: boolean; + relationTypeId: string; + linkedFiles: TLinkedFile[]; +}; + +export const groupRelationsByType = < + TLinkedFile extends { + path: string; + }, +>({ + activeNodeTypeId, + nodeInstanceId, + relationTypes, + discourseRelations, + relations, + getLinkedFile, +}: { + activeNodeTypeId: string; + nodeInstanceId: string; + relationTypes: RelationTypeSummary[]; + discourseRelations: DiscourseRelationSummary[]; + relations: RelationInstanceSummary[]; + getLinkedFile: (nodeInstanceId: string) => TLinkedFile | null; +}): GroupedRelation[] => { + const result: GroupedRelation[] = []; + + for (const relationType of relationTypes) { + const directions = new Set(); + + for (const relation of discourseRelations) { + if (relation.relationshipTypeId !== relationType.id) continue; + if (relation.sourceId === activeNodeTypeId) directions.add(true); + if (relation.destinationId === activeNodeTypeId) directions.add(false); + } + + for (const isSource of directions) { + const group: GroupedRelation = { + key: `${relationType.id}-${isSource ? "source" : "destination"}`, + label: isSource ? relationType.label : relationType.complement, + isSource, + relationTypeId: relationType.id, + linkedFiles: [], + }; + + for (const relation of relations) { + if (relation.type !== relationType.id) continue; + + const otherNodeInstanceId = isSource + ? relation.source === nodeInstanceId + ? relation.destination + : null + : relation.destination === nodeInstanceId + ? relation.source + : null; + + if (!otherNodeInstanceId) continue; + + const linkedFile = getLinkedFile(otherNodeInstanceId); + if ( + linkedFile && + !group.linkedFiles.some(({ path }) => path === linkedFile.path) + ) { + group.linkedFiles.push(linkedFile); + } + } + + result.push(group); + } + } + + return result; +}; diff --git a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx index d1b53007f..45f2aef55 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx @@ -26,18 +26,19 @@ import { getFileForNodeInstanceId, addRelation, } from "~/utils/relationsStore"; +import { + getRelationCanvasAction, + groupRelationsByType, + runRelationCanvasAction, + type GroupedRelation, +} from "~/components/canvas/nodeCardContextMenuModel"; -type GroupedRelation = { - key: string; - label: string; - isSource: boolean; - relationTypeId: string; - linkedFiles: TFile[]; -}; +type CanvasGroupedRelation = GroupedRelation; type RelationFileItemProps = { file: TFile; - group: GroupedRelation; + group: CanvasGroupedRelation; + variant: RelationsPanelVariant; checkExistingRelation: ( targetFile: TFile, relationTypeId: string, @@ -57,16 +58,19 @@ export type RelationsPanelProps = { plugin: DiscourseGraphPlugin; canvasFile: TFile; nodeShape: DiscourseNodeShape; - onClose: () => void; + onClose?: () => void; + variant?: RelationsPanelVariant; }; const RelationFileItem = ({ file, group, + variant, checkExistingRelation, handleCreateRelationTo, handleDeleteRelation, }: RelationFileItemProps) => { + const isNodeCardContext = variant === "node-card-context"; const [hasExistingRelation, setHasExistingRelation] = useState< boolean | null >(null); @@ -91,21 +95,17 @@ const RelationFileItem = ({ const handleButtonClick = async (e: React.MouseEvent) => { e.preventDefault(); - if (isLoading) return; + if (isLoading || hasExistingRelation === null) return; setIsLoading(true); try { - if (hasExistingRelation) { - await handleDeleteRelation(file, group.relationTypeId); - setHasExistingRelation(false); - } else { - await handleCreateRelationTo( - file, - group.relationTypeId, - group.isSource, - ); - setHasExistingRelation(true); - } + const action = await runRelationCanvasAction({ + hasExistingRelation, + add: () => + handleCreateRelationTo(file, group.relationTypeId, group.isSource), + remove: () => handleDeleteRelation(file, group.relationTypeId), + }); + setHasExistingRelation(action === "add"); } catch (e) { showToast({ severity: "error", @@ -121,28 +121,42 @@ const RelationFileItem = ({ const getButtonProps = () => { if (hasExistingRelation === null) { return { - className: - "ml-2 rounded bg-gray-300 px-2 py-0.5 text-xs text-white cursor-not-allowed", + className: isNodeCardContext + ? "dg-relation-visibility-toggle dg-relation-visibility-toggle--checking ml-2 cursor-not-allowed rounded px-2 py-0.5 text-xs" + : "ml-2 rounded bg-gray-300 px-2 py-0.5 text-xs text-white cursor-not-allowed", title: "Checking relation status...", + "aria-label": `Checking whether ${file.basename} is on the canvas`, + "data-visibility-action": "checking", disabled: true, children: "?", }; } - if (hasExistingRelation) { + const action = getRelationCanvasAction(hasExistingRelation); + if (action === "remove") { return { - className: - "ml-2 rounded bg-red-500 px-2 py-0.5 text-xs text-white hover:bg-red-600 disabled:bg-red-300", - title: "Remove this relation from canvas", + className: isNodeCardContext + ? "dg-relation-visibility-toggle dg-relation-visibility-toggle--remove ml-2 rounded px-2 py-0.5 text-xs" + : "ml-2 rounded bg-red-500 px-2 py-0.5 text-xs text-white hover:bg-red-600 disabled:bg-red-300", + title: isNodeCardContext + ? "Remove from canvas" + : "Remove this relation from canvas", + "aria-label": `Remove ${file.basename} from the canvas`, + "data-visibility-action": action, disabled: isLoading, children: "−", }; } return { - className: - "ml-2 rounded bg-blue-500 px-2 py-0.5 text-xs text-white hover:bg-blue-600 disabled:bg-blue-300", - title: "Add this relation to canvas", + className: isNodeCardContext + ? "dg-relation-visibility-toggle dg-relation-visibility-toggle--add ml-2 rounded px-2 py-0.5 text-xs" + : "ml-2 rounded bg-blue-500 px-2 py-0.5 text-xs text-white hover:bg-blue-600 disabled:bg-blue-300", + title: isNodeCardContext + ? "Add to canvas" + : "Add this relation to canvas", + "aria-label": `Add ${file.basename} to the canvas`, + "data-visibility-action": action, disabled: isLoading, children: "+", }; @@ -155,21 +169,29 @@ const RelationFileItem = ({ {file.basename} - -
- -
-
{headerTitle}
-
+
+ {!isNodeCardContext && ( + <> +
+

Relations

+ +
+ +
+
+ {headerTitle} +
+
+ + )} {loading ? (
Loading relations...
@@ -502,6 +537,7 @@ export const RelationsPanel = ({ key={f.path} file={f} group={group} + variant={variant} checkExistingRelation={checkExistingRelation} handleCreateRelationTo={handleCreateRelationTo} handleDeleteRelation={handleDeleteRelationShape} @@ -521,7 +557,8 @@ export const RelationsPanel = ({ const computeRelations = async ( plugin: DiscourseGraphPlugin, file: TFile, -): Promise => { + variant: RelationsPanelVariant, +): Promise => { const fileCache = plugin.app.metadataCache.getFileCache(file); if (!fileCache?.frontmatter) return []; @@ -532,31 +569,44 @@ const computeRelations = async ( if (!nodeInstanceId) return []; const relations = await getRelationsForNodeInstanceId(plugin, nodeInstanceId); - const result = new Map(); - const acceptedRelationTypes = plugin.settings.relationTypes.filter(isAcceptedSchema); const acceptedDiscourseRelations = plugin.settings.discourseRelations.filter(isAcceptedSchema); + if (variant === "node-card-context") { + return groupRelationsByType({ + activeNodeTypeId, + nodeInstanceId, + relationTypes: acceptedRelationTypes, + discourseRelations: acceptedDiscourseRelations, + relations, + getLinkedFile: (linkedNodeInstanceId) => + getFileForNodeInstanceId(plugin, linkedNodeInstanceId), + }); + } + + const result = new Map(); + for (const relationType of acceptedRelationTypes) { const typeLevelRelation = acceptedDiscourseRelations.find( - (rel) => - (rel.sourceId === activeNodeTypeId || - rel.destinationId === activeNodeTypeId) && - rel.relationshipTypeId === relationType.id, + (relation) => + (relation.sourceId === activeNodeTypeId || + relation.destinationId === activeNodeTypeId) && + relation.relationshipTypeId === relationType.id, ); if (!typeLevelRelation) continue; - const instanceRels = relations.filter((r) => r.type === relationType.id); + const instanceRelations = relations.filter( + (relation) => relation.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, + label: isSource ? relationType.label : relationType.complement, isSource, relationTypeId: relationType.id, linkedFiles: [], @@ -564,11 +614,17 @@ const computeRelations = async ( } 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); + for (const relation of instanceRelations) { + const otherId = + relation.source === nodeInstanceId + ? relation.destination + : relation.source; + const linkedFile = getFileForNodeInstanceId(plugin, otherId); + if ( + linkedFile && + !group.linkedFiles.some(({ path }) => path === linkedFile.path) + ) { + group.linkedFiles.push(linkedFile); } } } diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts index a47526a23..c5c4f9a9a 100644 --- a/apps/obsidian/src/constants.ts +++ b/apps/obsidian/src/constants.ts @@ -121,15 +121,19 @@ export const DEFAULT_SETTINGS: Settings = { spacePassword: undefined, accountLocalId: undefined, syncModeEnabled: false, + nodeCardContextMenuEnabled: false, spaceNames: {}, }; export const FEATURE_FLAGS = { // settings for these features are in the Admin Panel (hidden tab in Settings, toggle with Ctrl+Shift+A) DATABASE_SYNC: "databaseSync", + NODE_CARD_CONTEXT_MENU: "nodeCardContextMenuEnabled", } as const; export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]; +export const NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT = + "dg:node-card-context-menu-flag-changed"; export const FRONTMATTER_KEY = "tldr-dg"; export const TLDATA_DELIMITER_START = "!!!_START_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!"; diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index f7bc3fc41..45eeca898 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -70,6 +70,7 @@ export type Settings = { spacePassword?: string; accountLocalId?: string; syncModeEnabled?: boolean; + nodeCardContextMenuEnabled?: boolean; /** Maps spaceUri (e.g. "obsidian:abc123") to human-readable name (e.g. "My Vault") */ spaceNames?: Record; username?: string; diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 567e1539b..489d945fa 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -100,3 +100,120 @@ body.dg-hide-frontmatter-ids .metadata-property[data-property-key^="rel_" i] { border: none !important; box-shadow: none !important; } + +.dg-node-card-menu { + width: min(20rem, calc(100vw - 1rem)) !important; + max-width: min(20rem, calc(100vw - 1rem)) !important; + overflow: hidden; +} + +.dg-node-card-menu__tabs { + position: sticky; + top: 0; + z-index: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-bottom: 1px solid var(--color-divider); + background: var(--color-panel); +} + +.tldraw__editor .dg-node-card-menu__tab { + position: relative; + min-height: 2.5rem; + padding: 0.625rem 0.75rem; + color: var(--color-text-2); + font-size: 0.75rem; + font-weight: 600; + text-align: center; +} + +.tldraw__editor .dg-node-card-menu__tab:hover, +.tldraw__editor .dg-node-card-menu__tab:focus-visible, +.tldraw__editor .dg-node-card-menu__tab.is-active { + color: var(--interactive-accent, var(--color-selected)); +} + +.tldraw__editor .dg-node-card-menu__tab.is-active::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2px; + background: var(--interactive-accent, var(--color-selected)); + content: ""; +} + +.dg-node-card-menu__panel { + max-height: min(32rem, calc(100vh - 6rem)); + overflow-y: auto; + overscroll-behavior: contain; +} + +.dg-node-card-menu__relations { + padding: 0.75rem; + color: var(--color-text); +} + +.dg-node-card-menu__relations > div { + color: var(--color-text-2); + font-size: 0.75rem; +} + +.dg-node-card-menu__relations > ul > li { + border-color: var(--color-divider); + background: var(--color-panel); +} + +.dg-node-card-menu__relations li { + min-width: 0; +} + +.dg-node-card-menu__relations a { + min-width: 0; + overflow: hidden; + color: var(--interactive-accent, var(--color-selected)); + text-overflow: ellipsis; + white-space: nowrap; +} + +.tldraw__editor .dg-relation-visibility-toggle { + display: inline-flex; + flex: 0 0 1.5rem; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin-left: auto; + border-radius: var(--radius-2); + font-size: 1rem; + font-weight: 600; + line-height: 1; +} + +.tldraw__editor .dg-relation-visibility-toggle--add { + color: var(--interactive-accent, var(--color-selected)); +} + +.tldraw__editor .dg-relation-visibility-toggle--remove { + color: var(--text-error, #dc2626); +} + +.tldraw__editor .dg-relation-visibility-toggle:hover:not(:disabled), +.tldraw__editor .dg-relation-visibility-toggle:focus-visible { + outline: 1px solid currentColor; + outline-offset: -1px; +} + +.tldraw__editor .dg-relation-visibility-toggle:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.dg-node-card-menu__styling > .tlui-style-panel { + width: 100% !important; + max-width: none !important; + margin: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} diff --git a/apps/obsidian/vitest.config.mts b/apps/obsidian/vitest.config.mts new file mode 100644 index 000000000..afe94ec9f --- /dev/null +++ b/apps/obsidian/vitest.config.mts @@ -0,0 +1,17 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, + resolve: { + alias: { + "~": path.resolve(dirname, "src"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e80fe1c3a..88bc74bc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: uuidv7: specifier: 1.1.0 version: 1.1.0 + vitest: + specifier: 'catalog:' + version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) zod: specifier: ^3.24.1 version: 3.25.76 @@ -352,7 +355,7 @@ importers: version: 3.5.2(react@18.2.0) roamjs-components: specifier: 0.88.3 - version: 0.88.3(323501797697e57d5f8d07d52746f463) + version: 0.88.3(3c8392fa5d45274567f4bde943ec1631) tldraw: specifier: 2.4.6 version: 2.4.6(patch_hash=56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -410,13 +413,13 @@ importers: version: 1.61.1 tailwindcss: specifier: ^3.4.17 - version: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) + version: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) tsx: specifier: ^4.19.2 version: 4.20.5 vitest: specifier: 'catalog:' - version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) + version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) apps/tldraw-sync-worker: dependencies: @@ -644,7 +647,7 @@ importers: version: 53.1.0(rollup@4.60.3)(typescript@5.9.2) vitest: specifier: 'catalog:' - version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2)) packages/eslint-config: devDependencies: @@ -698,10 +701,10 @@ importers: version: link:../typescript-config tailwindcss: specifier: ^3.4.1 - version: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) + version: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4))) packages/types: {} @@ -15927,22 +15930,22 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} - '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)))(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))(zod@3.25.76)': + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4))(zod@3.25.76)': dependencies: '@aws-sdk/client-lambda': 3.882.0 '@aws-sdk/client-s3': 3.882.0 - '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) + '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) archiver: 5.3.2 axios: 0.27.2(debug@4.4.3) debug: 4.4.3 dotenv: 16.6.1 esbuild: 0.17.14 patch-package: 6.5.1 - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) - ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.9.3) + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) + ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.5.4) zod: 3.25.76 - '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))': + '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4))': dependencies: '@playwright/test': 1.29.0 '@testing-library/react': 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -15952,7 +15955,7 @@ snapshots: debug: 4.4.3 dotenv: 16.6.1 jsdom: 20.0.3 - ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.9.3) + ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.5.4) '@selderee/plugin-htmlparser2@0.11.0': dependencies: @@ -17694,10 +17697,10 @@ snapshots: '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.5.4) eslint-config-prettier: 9.1.2(eslint@8.57.1) - eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.32.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-playwright: 1.8.3(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1) @@ -17729,6 +17732,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.11.1(@types/node@22.20.0)(typescript@5.5.4) + vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) + '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.6 @@ -17738,14 +17750,14 @@ snapshots: msw: 2.11.1(@types/node@22.20.0)(typescript@5.5.4) vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2))': + '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.11.1(@types/node@22.20.0)(typescript@5.9.3) - vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) + msw: 2.11.1(@types/node@22.20.0)(typescript@5.9.2) + vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2) '@vitest/pretty-format@4.1.6': dependencies: @@ -19409,9 +19421,9 @@ snapshots: eslint-plugin-turbo: 2.5.6(eslint@8.57.1)(turbo@2.5.6) turbo: 2.5.6 - eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)): + eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0): dependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-import-resolver-node@0.3.9: dependencies: @@ -19421,7 +19433,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1(supports-color@8.1.1) @@ -19432,18 +19444,18 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.5.4) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -19453,7 +19465,7 @@ snapshots: eslint: 8.57.1 ignore: 5.3.2 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -19464,7 +19476,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -21976,7 +21988,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3): + msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 @@ -21997,7 +22009,7 @@ snapshots: type-fest: 4.41.0 yargs: 17.7.2 optionalDependencies: - typescript: 5.9.3 + typescript: 5.9.2 transitivePeerDependencies: - '@types/node' optional: true @@ -22675,14 +22687,6 @@ snapshots: postcss: 8.5.6 ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.5.4) - postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)): - dependencies: - lilconfig: 3.1.3 - yaml: 2.8.1 - optionalDependencies: - postcss: 8.5.6 - ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.9.3) - postcss-nested@6.2.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -23625,12 +23629,12 @@ snapshots: dependencies: glob: 7.2.3 - roamjs-components@0.88.3(323501797697e57d5f8d07d52746f463): + roamjs-components@0.88.3(3c8392fa5d45274567f4bde943ec1631): dependencies: '@blueprintjs/core': 3.50.4(patch_hash=51c5847e0a73a1be0cc263036ff64d8fada46f3b65831ed938dbca5eecf3edc0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@blueprintjs/datetime': 3.23.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@blueprintjs/select': 3.19.1(patch_hash=5b2821b0bf7274e9b64d7824648c596b9e73c61f421d699a6d4c494f12f62355)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)))(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))(zod@3.25.76) + '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4))(zod@3.25.76) '@types/crypto-js': 4.1.1 '@types/cytoscape': 3.21.9 '@types/file-saver': 2.0.5 @@ -24424,10 +24428,6 @@ snapshots: dependencies: tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) - tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))): - dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)): dependencies: '@alloc/quick-lru': 5.2.0 @@ -24455,33 +24455,6 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)): - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.0.1(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) - postcss-nested: 6.2.0(postcss@8.5.6) - postcss-selector-parser: 6.1.2 - resolve: 1.22.10 - sucrase: 3.35.0 - transitivePeerDependencies: - - ts-node - tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -24733,24 +24706,6 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.20.0 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - ts-toolbelt@6.15.5: {} ts-toolbelt@9.6.0: {} @@ -25244,6 +25199,21 @@ snapshots: tsx: 4.20.5 yaml: 2.8.2 + vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2): + dependencies: + esbuild: 0.27.0 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.6 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.20.0 + fsevents: 2.3.3 + jiti: 1.21.7 + tsx: 4.20.6 + yaml: 2.8.2 + vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.0 @@ -25259,6 +25229,36 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@edge-runtime/vm': 3.2.0 + '@opentelemetry/api': 1.9.0 + '@types/node': 22.20.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - msw + vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.6 @@ -25289,10 +25289,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)): + vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) + '@vitest/mocker': 4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -25309,7 +25309,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) + vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@edge-runtime/vm': 3.2.0 From b6f04e167af40c86232406eac081551224db8f2f Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 27 Jul 2026 21:46:27 +0530 Subject: [PATCH 2/5] ENG-2038 Tighten node card context menu scope --- apps/obsidian/package.json | 4 +- .../components/canvas/NodeCardContextMenu.tsx | 180 ++++-------- .../nodeCardContextMenuModel.test.ts | 259 ++++++------------ .../canvas/nodeCardContextMenuModel.ts | 157 +++++------ .../canvas/overlays/RelationPanel.tsx | 186 ++++--------- apps/obsidian/styles.css | 117 -------- apps/obsidian/vitest.config.mts | 17 -- pnpm-lock.yaml | 180 ++++++------ 8 files changed, 353 insertions(+), 747 deletions(-) delete mode 100644 apps/obsidian/vitest.config.mts diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index 68e11749a..759eec089 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -11,8 +11,7 @@ "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts --version 0.1.0", "check-types": "tsc --noEmit --skipLibCheck", - "test": "vitest run --config vitest.config.mts", - "test:watch": "vitest --config vitest.config.mts" + "test": "tsx --test src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts" }, "keywords": [], "author": "", @@ -37,7 +36,6 @@ "tsx": "^4.19.2", "typescript": "5.5.4", "uuidv7": "1.1.0", - "vitest": "catalog:", "zod": "^3.24.1" }, "dependencies": { diff --git a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx index e72881122..f99add051 100644 --- a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx +++ b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx @@ -1,21 +1,18 @@ import { createElement, - useCallback, useEffect, - useId, useReducer, - useRef, useState, - type KeyboardEvent, type ComponentType, - type RefObject, } from "react"; import type { TFile } from "obsidian"; import { DefaultStylePanel, + DefaultStylePanelContent, useEditor, - usePassThroughWheelEvents, + useRelevantStyles, useValue, + type TLUiStylePanelContentProps, type TLUiStylePanelProps, } from "tldraw"; import type DiscourseGraphPlugin from "~/index"; @@ -26,7 +23,6 @@ import { import type { DiscourseNodeShape } from "./shapes/DiscourseNodeShape"; import { RelationsPanel } from "./overlays/RelationPanel"; import { - createNodeCardContextMenuState, nodeCardContextMenuReducer, shouldShowNodeCardContextMenu, type NodeCardContextMenuTab, @@ -37,9 +33,10 @@ type NodeCardContextMenuProps = TLUiStylePanelProps & { canvasFile: TFile; }; -const TABS: NodeCardContextMenuTab[] = ["context", "styling"]; const DefaultStylePanelComponent = DefaultStylePanel as unknown as ComponentType; +const DefaultStylePanelContentComponent = + DefaultStylePanelContent as unknown as ComponentType; export const NodeCardContextMenu = ({ plugin, @@ -47,158 +44,91 @@ export const NodeCardContextMenu = ({ isMobile, }: NodeCardContextMenuProps) => { const editor = useEditor(); - const panelRef = useRef(null); - const tabButtonRefs = useRef< - Partial> - >({}); - const tabIdPrefix = useId(); + const styles = useRelevantStyles(); const [isEnabled, setIsEnabled] = useState( plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] ?? false, ); - - usePassThroughWheelEvents(panelRef as RefObject); + const selectedShape = useValue( + "selected shape for node card context menu", + () => editor.getOnlySelectedShape(), + [editor], + ); + const selectedNode = shouldShowNodeCardContextMenu( + isEnabled, + selectedShape?.type, + ) + ? (selectedShape as DiscourseNodeShape) + : null; + const [menuState, dispatch] = useReducer(nodeCardContextMenuReducer, { + activeTab: "context", + selectedShapeId: selectedNode?.id ?? null, + }); useEffect(() => { - const syncFlag = () => { + const updateFlag = () => setIsEnabled( plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] ?? false, ); - }; window.addEventListener( NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, - syncFlag, + updateFlag, ); - return () => { + return () => window.removeEventListener( NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, - syncFlag, + updateFlag, ); - }; }, [plugin]); - const selectedShape = useValue( - "selected shape for node card context menu", - () => editor.getOnlySelectedShape(), - [editor], - ); - const showNodeCardContextMenu = shouldShowNodeCardContextMenu({ - isEnabled, - selectedShapeType: selectedShape?.type, - }); - const selectedNode = showNodeCardContextMenu - ? (selectedShape as DiscourseNodeShape) - : null; - - const [state, dispatch] = useReducer( - nodeCardContextMenuReducer, - selectedNode?.id ?? null, - createNodeCardContextMenuState, - ); - useEffect(() => { dispatch({ - type: "sync-selection", + type: "select-node", selectedShapeId: selectedNode?.id ?? null, }); }, [selectedNode?.id]); - const selectTab = useCallback((tab: NodeCardContextMenuTab) => { - dispatch({ type: "select-tab", tab }); - }, []); - - const handleTabKeyDown = useCallback( - (event: KeyboardEvent) => { - const currentIndex = TABS.indexOf(state.activeTab); - let nextTab: NodeCardContextMenuTab | undefined; - - if (event.key === "ArrowLeft") { - nextTab = TABS[(currentIndex - 1 + TABS.length) % TABS.length]; - } else if (event.key === "ArrowRight") { - nextTab = TABS[(currentIndex + 1) % TABS.length]; - } else if (event.key === "Home") { - nextTab = TABS[0]; - } else if (event.key === "End") { - nextTab = TABS[TABS.length - 1]; - } - - if (!nextTab) return; - event.preventDefault(); - selectTab(nextTab); - tabButtonRefs.current[nextTab]?.focus(); - }, - [selectTab, state.activeTab], - ); - if (!selectedNode) { return createElement(DefaultStylePanelComponent, { isMobile }); } - return ( -
{ - if (!isMobile) { - editor.updateInstanceState({ isChangingStyle: false }); - } - }} - > -
- {TABS.map((tab) => { - const isActive = state.activeTab === tab; - return ( - - ); - })} + const selectTab = (tab: NodeCardContextMenuTab) => + dispatch({ type: "select-tab", tab }); + + return createElement( + DefaultStylePanelComponent, + { isMobile }, + <> +
+ {(["context", "styling"] as const).map((tab) => ( + + ))}
-
- {state.activeTab === "context" ? ( +
+ {menuState.activeTab === "context" ? ( ) : ( -
- {createElement(DefaultStylePanelComponent, { isMobile: true })} -
+ createElement(DefaultStylePanelContentComponent, { styles }) )}
-
+ , ); }; diff --git a/apps/obsidian/src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts b/apps/obsidian/src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts index 04135b6c5..340067e69 100644 --- a/apps/obsidian/src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts +++ b/apps/obsidian/src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts @@ -1,197 +1,116 @@ -import { describe, expect, it, vi } from "vitest"; +import assert from "node:assert/strict"; +import { test } from "node:test"; import { - createNodeCardContextMenuState, groupRelationsByType, nodeCardContextMenuReducer, runRelationCanvasAction, shouldShowNodeCardContextMenu, } from "../nodeCardContextMenuModel"; -describe("shouldShowNodeCardContextMenu", () => { - it("keeps the default panel when the admin flag is off", () => { - expect( - shouldShowNodeCardContextMenu({ - isEnabled: false, - selectedShapeType: "discourse-node", - }), - ).toBe(false); - }); - - it("keeps the default panel for regular tldraw shapes", () => { - expect( - shouldShowNodeCardContextMenu({ - isEnabled: true, - selectedShapeType: "geo", - }), - ).toBe(false); - }); - - it("shows the menu for a selected discourse node when enabled", () => { - expect( - shouldShowNodeCardContextMenu({ - isEnabled: true, - selectedShapeType: "discourse-node", - }), - ).toBe(true); - }); +void test("the menu requires both the flag and a discourse-node selection", () => { + assert.equal(shouldShowNodeCardContextMenu(false, "discourse-node"), false); + assert.equal(shouldShowNodeCardContextMenu(true, "geo"), false); + assert.equal(shouldShowNodeCardContextMenu(true, "discourse-node"), true); }); -describe("nodeCardContextMenuReducer", () => { - it("opens on Context and switches tabs", () => { - const initialState = createNodeCardContextMenuState("shape:one"); - const stylingState = nodeCardContextMenuReducer(initialState, { - type: "select-tab", - tab: "styling", - }); - - expect(initialState.activeTab).toBe("context"); - expect(stylingState.activeTab).toBe("styling"); +void test("Context is the default tab and selecting another node resets it", () => { + const initial = { + activeTab: "context" as const, + selectedShapeId: "shape:one", + }; + const styling = nodeCardContextMenuReducer(initial, { + type: "select-tab", + tab: "styling", }); - it("keeps the chosen tab for the same node and resets for a new node", () => { - const stylingState = { - activeTab: "styling" as const, - selectedShapeId: "shape:one", - }; - - expect( - nodeCardContextMenuReducer(stylingState, { - type: "sync-selection", - selectedShapeId: "shape:one", - }), - ).toBe(stylingState); - - expect( - nodeCardContextMenuReducer(stylingState, { - type: "sync-selection", - selectedShapeId: "shape:two", - }), - ).toEqual({ - activeTab: "context", + assert.equal(styling.activeTab, "styling"); + assert.deepEqual( + nodeCardContextMenuReducer(styling, { + type: "select-node", selectedShapeId: "shape:two", - }); - }); + }), + { activeTab: "context", selectedShapeId: "shape:two" }, + ); }); -describe("groupRelationsByType", () => { - it("groups incoming and outgoing relations and preserves empty relation types", () => { - const linkedFiles = new Map([ - ["node:claim", { path: "Claims/A claim.md" }], - ["node:evidence", { path: "Evidence/An observation.md" }], - ]); - - const groups = groupRelationsByType({ - activeNodeTypeId: "type:question", - nodeInstanceId: "node:question", - relationTypes: [ - { - id: "relation:supports", - label: "supports", - complement: "is supported by", - }, - { - id: "relation:informs", - label: "informs", - complement: "is informed by", - }, - ], - discourseRelations: [ - { - sourceId: "type:question", - destinationId: "type:claim", - relationshipTypeId: "relation:supports", - }, - { - sourceId: "type:evidence", - destinationId: "type:question", - relationshipTypeId: "relation:supports", - }, - { - sourceId: "type:question", - destinationId: "type:evidence", - relationshipTypeId: "relation:informs", - }, - ], - relations: [ - { - type: "relation:supports", - source: "node:question", - destination: "node:claim", - }, - { - type: "relation:supports", - source: "node:evidence", - destination: "node:question", - }, - { - type: "relation:supports", - source: "node:question", - destination: "node:claim", - }, - { - type: "relation:supports", - source: "node:unrelated-a", - destination: "node:unrelated-b", - }, - ], - getLinkedFile: (nodeInstanceId) => - linkedFiles.get(nodeInstanceId) ?? null, - }); - - expect(groups).toEqual([ +void test("relations are grouped by direction and duplicate files are removed", () => { + const files = new Map([ + ["claim", { path: "Claims/Claim.md" }], + ["evidence", { path: "Evidence/Evidence.md" }], + ]); + const groups = groupRelationsByType({ + activeNodeTypeId: "question-type", + nodeInstanceId: "question", + relationTypes: [ + { id: "supports", label: "supports", complement: "is supported by" }, + { id: "informs", label: "informs", complement: "is informed by" }, + ], + discourseRelations: [ { - key: "relation:supports-source", - label: "supports", - isSource: true, - relationTypeId: "relation:supports", - linkedFiles: [{ path: "Claims/A claim.md" }], + sourceId: "question-type", + destinationId: "claim-type", + relationshipTypeId: "supports", }, { - key: "relation:supports-destination", - label: "is supported by", - isSource: false, - relationTypeId: "relation:supports", - linkedFiles: [{ path: "Evidence/An observation.md" }], + sourceId: "evidence-type", + destinationId: "question-type", + relationshipTypeId: "supports", }, { - key: "relation:informs-source", - label: "informs", - isSource: true, - relationTypeId: "relation:informs", - linkedFiles: [], + sourceId: "question-type", + destinationId: "claim-type", + relationshipTypeId: "informs", }, - ]); + ], + relations: [ + { type: "supports", source: "question", destination: "claim" }, + { type: "supports", source: "question", destination: "claim" }, + { type: "supports", source: "evidence", destination: "question" }, + ], + getLinkedFile: (id) => files.get(id) ?? null, + includeAllDirections: true, }); -}); -describe("runRelationCanvasAction", () => { - it("adds a relation that is not on the canvas", async () => { - const add = vi.fn().mockResolvedValue(undefined); - const remove = vi.fn().mockResolvedValue(undefined); + assert.deepEqual( + groups.map(({ label, isSource, linkedFiles }) => ({ + label, + isSource, + paths: linkedFiles.map(({ path }) => path), + })), + [ + { label: "supports", isSource: true, paths: ["Claims/Claim.md"] }, + { + label: "is supported by", + isSource: false, + paths: ["Evidence/Evidence.md"], + }, + { label: "informs", isSource: true, paths: [] }, + ], + ); +}); - await expect( - runRelationCanvasAction({ - hasExistingRelation: false, - add, - remove, - }), - ).resolves.toBe("add"); - expect(add).toHaveBeenCalledOnce(); - expect(remove).not.toHaveBeenCalled(); +void test("the canvas action adds an absent relation", async () => { + let added = false; + await runRelationCanvasAction({ + hasExistingRelation: false, + add: () => { + added = true; + return Promise.resolve(); + }, + remove: () => Promise.reject(new Error("remove should not run")), }); + assert.equal(added, true); +}); - it("removes a relation that is already on the canvas", async () => { - const add = vi.fn().mockResolvedValue(undefined); - const remove = vi.fn().mockResolvedValue(undefined); - - await expect( - runRelationCanvasAction({ - hasExistingRelation: true, - add, - remove, - }), - ).resolves.toBe("remove"); - expect(remove).toHaveBeenCalledOnce(); - expect(add).not.toHaveBeenCalled(); +void test("the canvas action removes an existing relation", async () => { + let removed = false; + await runRelationCanvasAction({ + hasExistingRelation: true, + add: () => Promise.reject(new Error("add should not run")), + remove: () => { + removed = true; + return Promise.resolve(); + }, }); + assert.equal(removed, true); }); diff --git a/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts b/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts index ca9ffb1b9..ece8366ee 100644 --- a/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts +++ b/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts @@ -11,54 +11,25 @@ export type NodeCardContextMenuState = { selectedShapeId: string | null; }; -export type NodeCardContextMenuAction = - | { - type: "select-tab"; - tab: NodeCardContextMenuTab; - } - | { - type: "sync-selection"; - selectedShapeId: string | null; - }; - -export const createNodeCardContextMenuState = ( - selectedShapeId: string | null, -): NodeCardContextMenuState => ({ - activeTab: "context", - selectedShapeId, -}); - export const nodeCardContextMenuReducer = ( state: NodeCardContextMenuState, - action: NodeCardContextMenuAction, + action: + | { type: "select-tab"; tab: NodeCardContextMenuTab } + | { type: "select-node"; selectedShapeId: string | null }, ): NodeCardContextMenuState => { if (action.type === "select-tab") { - return { - ...state, - activeTab: action.tab, - }; + return { ...state, activeTab: action.tab }; } - if (action.selectedShapeId === state.selectedShapeId) { - return state; - } - - return createNodeCardContextMenuState(action.selectedShapeId); + return action.selectedShapeId === state.selectedShapeId + ? state + : { activeTab: "context", selectedShapeId: action.selectedShapeId }; }; -export const shouldShowNodeCardContextMenu = ({ - isEnabled, - selectedShapeType, -}: { - isEnabled: boolean; - selectedShapeType: string | undefined; -}): boolean => isEnabled && selectedShapeType === "discourse-node"; - -export type RelationCanvasAction = "add" | "remove"; - -export const getRelationCanvasAction = ( - hasExistingRelation: boolean, -): RelationCanvasAction => (hasExistingRelation ? "remove" : "add"); +export const shouldShowNodeCardContextMenu = ( + isEnabled: boolean, + selectedShapeType?: string, +) => isEnabled && selectedShapeType === "discourse-node"; export const runRelationCanvasAction = async ({ hasExistingRelation, @@ -68,68 +39,64 @@ export const runRelationCanvasAction = async ({ hasExistingRelation: boolean; add: () => Promise; remove: () => Promise; -}): Promise => { - const action = getRelationCanvasAction(hasExistingRelation); - await (action === "add" ? add() : remove()); - return action; +}) => { + await (hasExistingRelation ? remove() : add()); }; -type RelationTypeSummary = Pick< - DiscourseRelationType, - "id" | "label" | "complement" ->; - -type DiscourseRelationSummary = Pick< - DiscourseRelation, - "sourceId" | "destinationId" | "relationshipTypeId" ->; - -type RelationInstanceSummary = Pick< - RelationInstance, - "type" | "source" | "destination" ->; +type GroupedRelationInput = { + activeNodeTypeId: string; + nodeInstanceId: string; + relationTypes: Pick[]; + discourseRelations: Pick< + DiscourseRelation, + "sourceId" | "destinationId" | "relationshipTypeId" + >[]; + relations: Pick[]; + getLinkedFile: (nodeInstanceId: string) => TFile | null; + includeAllDirections?: boolean; +}; -export type GroupedRelation = { +type GroupedRelation = { key: string; label: string; isSource: boolean; relationTypeId: string; - linkedFiles: TLinkedFile[]; + linkedFiles: TFile[]; }; -export const groupRelationsByType = < - TLinkedFile extends { - path: string; - }, ->({ +export const groupRelationsByType = ({ activeNodeTypeId, nodeInstanceId, relationTypes, discourseRelations, relations, getLinkedFile, -}: { - activeNodeTypeId: string; - nodeInstanceId: string; - relationTypes: RelationTypeSummary[]; - discourseRelations: DiscourseRelationSummary[]; - relations: RelationInstanceSummary[]; - getLinkedFile: (nodeInstanceId: string) => TLinkedFile | null; -}): GroupedRelation[] => { - const result: GroupedRelation[] = []; + includeAllDirections = false, +}: GroupedRelationInput): GroupedRelation[] => { + const result = new Map>(); for (const relationType of relationTypes) { - const directions = new Set(); - - for (const relation of discourseRelations) { - if (relation.relationshipTypeId !== relationType.id) continue; - if (relation.sourceId === activeNodeTypeId) directions.add(true); - if (relation.destinationId === activeNodeTypeId) directions.add(false); - } + const matchingRelations = discourseRelations.filter( + ({ sourceId, destinationId, relationshipTypeId }) => + relationshipTypeId === relationType.id && + (sourceId === activeNodeTypeId || destinationId === activeNodeTypeId), + ); + const directions = includeAllDirections + ? new Set( + matchingRelations.flatMap(({ sourceId, destinationId }) => [ + ...(sourceId === activeNodeTypeId ? [true] : []), + ...(destinationId === activeNodeTypeId ? [false] : []), + ]), + ) + : new Set( + matchingRelations[0] + ? [matchingRelations[0].sourceId === activeNodeTypeId] + : [], + ); for (const isSource of directions) { - const group: GroupedRelation = { - key: `${relationType.id}-${isSource ? "source" : "destination"}`, + const group: GroupedRelation = { + key: `${relationType.id}-${isSource}`, label: isSource ? relationType.label : relationType.complement, isSource, relationTypeId: relationType.id, @@ -138,18 +105,16 @@ export const groupRelationsByType = < for (const relation of relations) { if (relation.type !== relationType.id) continue; - - const otherNodeInstanceId = isSource - ? relation.source === nodeInstanceId + const otherId = includeAllDirections + ? isSource && relation.source === nodeInstanceId ? relation.destination - : null - : relation.destination === nodeInstanceId - ? relation.source - : null; - - if (!otherNodeInstanceId) continue; - - const linkedFile = getLinkedFile(otherNodeInstanceId); + : !isSource && relation.destination === nodeInstanceId + ? relation.source + : null + : relation.source === nodeInstanceId + ? relation.destination + : relation.source; + const linkedFile = otherId ? getLinkedFile(otherId) : null; if ( linkedFile && !group.linkedFiles.some(({ path }) => path === linkedFile.path) @@ -158,9 +123,9 @@ export const groupRelationsByType = < } } - result.push(group); + result.set(group.key, group); } } - return result; + return [...result.values()]; }; diff --git a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx index 45f2aef55..e8e819019 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx @@ -27,18 +27,21 @@ import { addRelation, } from "~/utils/relationsStore"; import { - getRelationCanvasAction, groupRelationsByType, runRelationCanvasAction, - type GroupedRelation, } from "~/components/canvas/nodeCardContextMenuModel"; -type CanvasGroupedRelation = GroupedRelation; +type GroupedRelation = { + key: string; + label: string; + isSource: boolean; + relationTypeId: string; + linkedFiles: TFile[]; +}; type RelationFileItemProps = { file: TFile; - group: CanvasGroupedRelation; - variant: RelationsPanelVariant; + group: GroupedRelation; checkExistingRelation: ( targetFile: TFile, relationTypeId: string, @@ -59,18 +62,16 @@ export type RelationsPanelProps = { canvasFile: TFile; nodeShape: DiscourseNodeShape; onClose?: () => void; - variant?: RelationsPanelVariant; + embedded?: boolean; }; const RelationFileItem = ({ file, group, - variant, checkExistingRelation, handleCreateRelationTo, handleDeleteRelation, }: RelationFileItemProps) => { - const isNodeCardContext = variant === "node-card-context"; const [hasExistingRelation, setHasExistingRelation] = useState< boolean | null >(null); @@ -95,17 +96,17 @@ const RelationFileItem = ({ const handleButtonClick = async (e: React.MouseEvent) => { e.preventDefault(); - if (isLoading || hasExistingRelation === null) return; + if (isLoading) return; setIsLoading(true); try { - const action = await runRelationCanvasAction({ - hasExistingRelation, + await runRelationCanvasAction({ + hasExistingRelation: Boolean(hasExistingRelation), add: () => handleCreateRelationTo(file, group.relationTypeId, group.isSource), remove: () => handleDeleteRelation(file, group.relationTypeId), }); - setHasExistingRelation(action === "add"); + setHasExistingRelation(!hasExistingRelation); } catch (e) { showToast({ severity: "error", @@ -121,42 +122,28 @@ const RelationFileItem = ({ const getButtonProps = () => { if (hasExistingRelation === null) { return { - className: isNodeCardContext - ? "dg-relation-visibility-toggle dg-relation-visibility-toggle--checking ml-2 cursor-not-allowed rounded px-2 py-0.5 text-xs" - : "ml-2 rounded bg-gray-300 px-2 py-0.5 text-xs text-white cursor-not-allowed", + className: + "ml-2 rounded bg-gray-300 px-2 py-0.5 text-xs text-white cursor-not-allowed", title: "Checking relation status...", - "aria-label": `Checking whether ${file.basename} is on the canvas`, - "data-visibility-action": "checking", disabled: true, children: "?", }; } - const action = getRelationCanvasAction(hasExistingRelation); - if (action === "remove") { + if (hasExistingRelation) { return { - className: isNodeCardContext - ? "dg-relation-visibility-toggle dg-relation-visibility-toggle--remove ml-2 rounded px-2 py-0.5 text-xs" - : "ml-2 rounded bg-red-500 px-2 py-0.5 text-xs text-white hover:bg-red-600 disabled:bg-red-300", - title: isNodeCardContext - ? "Remove from canvas" - : "Remove this relation from canvas", - "aria-label": `Remove ${file.basename} from the canvas`, - "data-visibility-action": action, + className: + "ml-2 rounded bg-red-500 px-2 py-0.5 text-xs text-white hover:bg-red-600 disabled:bg-red-300", + title: "Remove this relation from canvas", disabled: isLoading, children: "−", }; } return { - className: isNodeCardContext - ? "dg-relation-visibility-toggle dg-relation-visibility-toggle--add ml-2 rounded px-2 py-0.5 text-xs" - : "ml-2 rounded bg-blue-500 px-2 py-0.5 text-xs text-white hover:bg-blue-600 disabled:bg-blue-300", - title: isNodeCardContext - ? "Add to canvas" - : "Add this relation to canvas", - "aria-label": `Add ${file.basename} to the canvas`, - "data-visibility-action": action, + className: + "ml-2 rounded bg-blue-500 px-2 py-0.5 text-xs text-white hover:bg-blue-600 disabled:bg-blue-300", + title: "Add this relation to canvas", disabled: isLoading, children: "+", }; @@ -169,29 +156,22 @@ const RelationFileItem = ({ {file.basename} - -
- -
-
- {headerTitle} -
-
- - )} +
+

Relations

+ +
+ +
+
{headerTitle}
+
{loading ? (
Loading relations...
@@ -537,7 +512,6 @@ export const RelationsPanel = ({ key={f.path} file={f} group={group} - variant={variant} checkExistingRelation={checkExistingRelation} handleCreateRelationTo={handleCreateRelationTo} handleDeleteRelation={handleDeleteRelationShape} @@ -557,8 +531,8 @@ export const RelationsPanel = ({ const computeRelations = async ( plugin: DiscourseGraphPlugin, file: TFile, - variant: RelationsPanelVariant, -): Promise => { + includeAllDirections: boolean, +): Promise => { const fileCache = plugin.app.metadataCache.getFileCache(file); if (!fileCache?.frontmatter) return []; @@ -574,60 +548,14 @@ const computeRelations = async ( const acceptedDiscourseRelations = plugin.settings.discourseRelations.filter(isAcceptedSchema); - if (variant === "node-card-context") { - return groupRelationsByType({ - activeNodeTypeId, - nodeInstanceId, - relationTypes: acceptedRelationTypes, - discourseRelations: acceptedDiscourseRelations, - relations, - getLinkedFile: (linkedNodeInstanceId) => - getFileForNodeInstanceId(plugin, linkedNodeInstanceId), - }); - } - - const result = new Map(); - - for (const relationType of acceptedRelationTypes) { - const typeLevelRelation = acceptedDiscourseRelations.find( - (relation) => - (relation.sourceId === activeNodeTypeId || - relation.destinationId === activeNodeTypeId) && - relation.relationshipTypeId === relationType.id, - ); - if (!typeLevelRelation) continue; - - const instanceRelations = relations.filter( - (relation) => relation.type === relationType.id, - ); - 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 relation of instanceRelations) { - const otherId = - relation.source === nodeInstanceId - ? relation.destination - : relation.source; - const linkedFile = getFileForNodeInstanceId(plugin, otherId); - if ( - linkedFile && - !group.linkedFiles.some(({ path }) => path === linkedFile.path) - ) { - group.linkedFiles.push(linkedFile); - } - } - } - - return Array.from(result.values()); + return groupRelationsByType({ + activeNodeTypeId, + nodeInstanceId, + relationTypes: acceptedRelationTypes, + discourseRelations: acceptedDiscourseRelations, + relations, + getLinkedFile: (linkedNodeInstanceId) => + getFileForNodeInstanceId(plugin, linkedNodeInstanceId), + includeAllDirections, + }); }; diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 489d945fa..567e1539b 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -100,120 +100,3 @@ body.dg-hide-frontmatter-ids .metadata-property[data-property-key^="rel_" i] { border: none !important; box-shadow: none !important; } - -.dg-node-card-menu { - width: min(20rem, calc(100vw - 1rem)) !important; - max-width: min(20rem, calc(100vw - 1rem)) !important; - overflow: hidden; -} - -.dg-node-card-menu__tabs { - position: sticky; - top: 0; - z-index: 1; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - border-bottom: 1px solid var(--color-divider); - background: var(--color-panel); -} - -.tldraw__editor .dg-node-card-menu__tab { - position: relative; - min-height: 2.5rem; - padding: 0.625rem 0.75rem; - color: var(--color-text-2); - font-size: 0.75rem; - font-weight: 600; - text-align: center; -} - -.tldraw__editor .dg-node-card-menu__tab:hover, -.tldraw__editor .dg-node-card-menu__tab:focus-visible, -.tldraw__editor .dg-node-card-menu__tab.is-active { - color: var(--interactive-accent, var(--color-selected)); -} - -.tldraw__editor .dg-node-card-menu__tab.is-active::after { - position: absolute; - right: 0; - bottom: 0; - left: 0; - height: 2px; - background: var(--interactive-accent, var(--color-selected)); - content: ""; -} - -.dg-node-card-menu__panel { - max-height: min(32rem, calc(100vh - 6rem)); - overflow-y: auto; - overscroll-behavior: contain; -} - -.dg-node-card-menu__relations { - padding: 0.75rem; - color: var(--color-text); -} - -.dg-node-card-menu__relations > div { - color: var(--color-text-2); - font-size: 0.75rem; -} - -.dg-node-card-menu__relations > ul > li { - border-color: var(--color-divider); - background: var(--color-panel); -} - -.dg-node-card-menu__relations li { - min-width: 0; -} - -.dg-node-card-menu__relations a { - min-width: 0; - overflow: hidden; - color: var(--interactive-accent, var(--color-selected)); - text-overflow: ellipsis; - white-space: nowrap; -} - -.tldraw__editor .dg-relation-visibility-toggle { - display: inline-flex; - flex: 0 0 1.5rem; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - margin-left: auto; - border-radius: var(--radius-2); - font-size: 1rem; - font-weight: 600; - line-height: 1; -} - -.tldraw__editor .dg-relation-visibility-toggle--add { - color: var(--interactive-accent, var(--color-selected)); -} - -.tldraw__editor .dg-relation-visibility-toggle--remove { - color: var(--text-error, #dc2626); -} - -.tldraw__editor .dg-relation-visibility-toggle:hover:not(:disabled), -.tldraw__editor .dg-relation-visibility-toggle:focus-visible { - outline: 1px solid currentColor; - outline-offset: -1px; -} - -.tldraw__editor .dg-relation-visibility-toggle:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -.dg-node-card-menu__styling > .tlui-style-panel { - width: 100% !important; - max-width: none !important; - margin: 0; - border-radius: 0; - background: transparent; - box-shadow: none; -} diff --git a/apps/obsidian/vitest.config.mts b/apps/obsidian/vitest.config.mts deleted file mode 100644 index afe94ec9f..000000000 --- a/apps/obsidian/vitest.config.mts +++ /dev/null @@ -1,17 +0,0 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { defineConfig } from "vitest/config"; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.test.ts"], - }, - resolve: { - alias: { - "~": path.resolve(dirname, "src"), - }, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88bc74bc4..e80fe1c3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,9 +215,6 @@ importers: uuidv7: specifier: 1.1.0 version: 1.1.0 - vitest: - specifier: 'catalog:' - version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) zod: specifier: ^3.24.1 version: 3.25.76 @@ -355,7 +352,7 @@ importers: version: 3.5.2(react@18.2.0) roamjs-components: specifier: 0.88.3 - version: 0.88.3(3c8392fa5d45274567f4bde943ec1631) + version: 0.88.3(323501797697e57d5f8d07d52746f463) tldraw: specifier: 2.4.6 version: 2.4.6(patch_hash=56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -413,13 +410,13 @@ importers: version: 1.61.1 tailwindcss: specifier: ^3.4.17 - version: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) + version: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) tsx: specifier: ^4.19.2 version: 4.20.5 vitest: specifier: 'catalog:' - version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) + version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) apps/tldraw-sync-worker: dependencies: @@ -647,7 +644,7 @@ importers: version: 53.1.0(rollup@4.60.3)(typescript@5.9.2) vitest: specifier: 'catalog:' - version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2)) + version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) packages/eslint-config: devDependencies: @@ -701,10 +698,10 @@ importers: version: link:../typescript-config tailwindcss: specifier: ^3.4.1 - version: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) + version: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4))) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))) packages/types: {} @@ -15930,22 +15927,22 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} - '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4))(zod@3.25.76)': + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)))(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))(zod@3.25.76)': dependencies: '@aws-sdk/client-lambda': 3.882.0 '@aws-sdk/client-s3': 3.882.0 - '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) + '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) archiver: 5.3.2 axios: 0.27.2(debug@4.4.3) debug: 4.4.3 dotenv: 16.6.1 esbuild: 0.17.14 patch-package: 6.5.1 - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) - ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.5.4) + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) + ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.9.3) zod: 3.25.76 - '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4))': + '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))': dependencies: '@playwright/test': 1.29.0 '@testing-library/react': 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -15955,7 +15952,7 @@ snapshots: debug: 4.4.3 dotenv: 16.6.1 jsdom: 20.0.3 - ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.5.4) + ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.9.3) '@selderee/plugin-htmlparser2@0.11.0': dependencies: @@ -17697,10 +17694,10 @@ snapshots: '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.5.4) eslint-config-prettier: 9.1.2(eslint@8.57.1) - eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.32.0) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-playwright: 1.8.3(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1) @@ -17732,15 +17729,6 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 4.1.6 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.11.1(@types/node@22.20.0)(typescript@5.5.4) - vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) - '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.6 @@ -17750,14 +17738,14 @@ snapshots: msw: 2.11.1(@types/node@22.20.0)(typescript@5.5.4) vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2))': + '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.11.1(@types/node@22.20.0)(typescript@5.9.2) - vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2) + msw: 2.11.1(@types/node@22.20.0)(typescript@5.9.3) + vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) '@vitest/pretty-format@4.1.6': dependencies: @@ -19421,9 +19409,9 @@ snapshots: eslint-plugin-turbo: 2.5.6(eslint@8.57.1)(turbo@2.5.6) turbo: 2.5.6 - eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0): + eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)): dependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-import-resolver-node@0.3.9: dependencies: @@ -19433,7 +19421,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1(supports-color@8.1.1) @@ -19444,18 +19432,18 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.5.4) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -19465,7 +19453,7 @@ snapshots: eslint: 8.57.1 ignore: 5.3.2 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -19476,7 +19464,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -21988,7 +21976,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2): + msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 @@ -22009,7 +21997,7 @@ snapshots: type-fest: 4.41.0 yargs: 17.7.2 optionalDependencies: - typescript: 5.9.2 + typescript: 5.9.3 transitivePeerDependencies: - '@types/node' optional: true @@ -22687,6 +22675,14 @@ snapshots: postcss: 8.5.6 ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.5.4) + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)): + dependencies: + lilconfig: 3.1.3 + yaml: 2.8.1 + optionalDependencies: + postcss: 8.5.6 + ts-node: 10.9.2(@types/node@22.20.0)(typescript@5.9.3) + postcss-nested@6.2.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -23629,12 +23625,12 @@ snapshots: dependencies: glob: 7.2.3 - roamjs-components@0.88.3(3c8392fa5d45274567f4bde943ec1631): + roamjs-components@0.88.3(323501797697e57d5f8d07d52746f463): dependencies: '@blueprintjs/core': 3.50.4(patch_hash=51c5847e0a73a1be0cc263036ff64d8fada46f3b65831ed938dbca5eecf3edc0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@blueprintjs/datetime': 3.23.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@blueprintjs/select': 3.19.1(patch_hash=5b2821b0bf7274e9b64d7824648c596b9e73c61f421d699a6d4c494f12f62355)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4))(zod@3.25.76) + '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)))(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))(zod@3.25.76) '@types/crypto-js': 4.1.1 '@types/cytoscape': 3.21.9 '@types/file-saver': 2.0.5 @@ -24428,6 +24424,10 @@ snapshots: dependencies: tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)) + tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3))): + dependencies: + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) + tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4)): dependencies: '@alloc/quick-lru': 5.2.0 @@ -24455,6 +24455,33 @@ snapshots: transitivePeerDependencies: - ts-node + tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.0.1(postcss@8.5.6) + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3)) + postcss-nested: 6.2.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + resolve: 1.22.10 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -24706,6 +24733,24 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + ts-node@10.9.2(@types/node@22.20.0)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.20.0 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + ts-toolbelt@6.15.5: {} ts-toolbelt@9.6.0: {} @@ -25199,21 +25244,6 @@ snapshots: tsx: 4.20.5 yaml: 2.8.2 - vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2): - dependencies: - esbuild: 0.27.0 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.6 - rollup: 4.60.3 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 22.20.0 - fsevents: 2.3.3 - jiti: 1.21.7 - tsx: 4.20.6 - yaml: 2.8.2 - vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.0 @@ -25229,36 +25259,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)): - dependencies: - '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) - '@vitest/pretty-format': 4.1.6 - '@vitest/runner': 4.1.6 - '@vitest/snapshot': 4.1.6 - '@vitest/spy': 4.1.6 - '@vitest/utils': 4.1.6 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@edge-runtime/vm': 3.2.0 - '@opentelemetry/api': 1.9.0 - '@types/node': 22.20.0 - jsdom: 20.0.3 - transitivePeerDependencies: - - msw - vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.6 @@ -25289,10 +25289,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2)): + vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.2))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/mocker': 4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -25309,7 +25309,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@edge-runtime/vm': 3.2.0 From 84237b94ea8623c45fdcf3112ec8b45f75eac3d4 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 27 Jul 2026 21:51:06 +0530 Subject: [PATCH 3/5] ENG-2038 Remove Obsidian test scaffolding --- apps/obsidian/package.json | 3 +- .../components/canvas/NodeCardContextMenu.tsx | 46 ++---- .../nodeCardContextMenuModel.test.ts | 116 ---------------- .../canvas/nodeCardContextMenuModel.ts | 131 ------------------ .../canvas/overlays/RelationPanel.tsx | 85 +++++++++--- 5 files changed, 78 insertions(+), 303 deletions(-) delete mode 100644 apps/obsidian/src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts delete mode 100644 apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index 759eec089..1b715d839 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -10,8 +10,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts --version 0.1.0", - "check-types": "tsc --noEmit --skipLibCheck", - "test": "tsx --test src/components/canvas/__tests__/nodeCardContextMenuModel.test.ts" + "check-types": "tsc --noEmit --skipLibCheck" }, "keywords": [], "author": "", diff --git a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx index f99add051..80e4f1aaf 100644 --- a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx +++ b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx @@ -1,10 +1,4 @@ -import { - createElement, - useEffect, - useReducer, - useState, - type ComponentType, -} from "react"; +import { createElement, useEffect, useState, type ComponentType } from "react"; import type { TFile } from "obsidian"; import { DefaultStylePanel, @@ -22,17 +16,14 @@ import { } from "~/constants"; import type { DiscourseNodeShape } from "./shapes/DiscourseNodeShape"; import { RelationsPanel } from "./overlays/RelationPanel"; -import { - nodeCardContextMenuReducer, - shouldShowNodeCardContextMenu, - type NodeCardContextMenuTab, -} from "./nodeCardContextMenuModel"; type NodeCardContextMenuProps = TLUiStylePanelProps & { plugin: DiscourseGraphPlugin; canvasFile: TFile; }; +type NodeCardContextMenuTab = "context" | "styling"; + const DefaultStylePanelComponent = DefaultStylePanel as unknown as ComponentType; const DefaultStylePanelContentComponent = @@ -53,16 +44,11 @@ export const NodeCardContextMenu = ({ () => editor.getOnlySelectedShape(), [editor], ); - const selectedNode = shouldShowNodeCardContextMenu( - isEnabled, - selectedShape?.type, - ) - ? (selectedShape as DiscourseNodeShape) - : null; - const [menuState, dispatch] = useReducer(nodeCardContextMenuReducer, { - activeTab: "context", - selectedShapeId: selectedNode?.id ?? null, - }); + const selectedNode = + isEnabled && selectedShape?.type === "discourse-node" + ? (selectedShape as DiscourseNodeShape) + : null; + const [activeTab, setActiveTab] = useState("context"); useEffect(() => { const updateFlag = () => @@ -81,19 +67,13 @@ export const NodeCardContextMenu = ({ }, [plugin]); useEffect(() => { - dispatch({ - type: "select-node", - selectedShapeId: selectedNode?.id ?? null, - }); + setActiveTab("context"); }, [selectedNode?.id]); if (!selectedNode) { return createElement(DefaultStylePanelComponent, { isMobile }); } - const selectTab = (tab: NodeCardContextMenuTab) => - dispatch({ type: "select-tab", tab }); - return createElement( DefaultStylePanelComponent, { isMobile }, @@ -103,14 +83,14 @@ export const NodeCardContextMenu = ({ @@ -118,7 +98,7 @@ export const NodeCardContextMenu = ({
- {menuState.activeTab === "context" ? ( + {activeTab === "context" ? ( { - assert.equal(shouldShowNodeCardContextMenu(false, "discourse-node"), false); - assert.equal(shouldShowNodeCardContextMenu(true, "geo"), false); - assert.equal(shouldShowNodeCardContextMenu(true, "discourse-node"), true); -}); - -void test("Context is the default tab and selecting another node resets it", () => { - const initial = { - activeTab: "context" as const, - selectedShapeId: "shape:one", - }; - const styling = nodeCardContextMenuReducer(initial, { - type: "select-tab", - tab: "styling", - }); - - assert.equal(styling.activeTab, "styling"); - assert.deepEqual( - nodeCardContextMenuReducer(styling, { - type: "select-node", - selectedShapeId: "shape:two", - }), - { activeTab: "context", selectedShapeId: "shape:two" }, - ); -}); - -void test("relations are grouped by direction and duplicate files are removed", () => { - const files = new Map([ - ["claim", { path: "Claims/Claim.md" }], - ["evidence", { path: "Evidence/Evidence.md" }], - ]); - const groups = groupRelationsByType({ - activeNodeTypeId: "question-type", - nodeInstanceId: "question", - relationTypes: [ - { id: "supports", label: "supports", complement: "is supported by" }, - { id: "informs", label: "informs", complement: "is informed by" }, - ], - discourseRelations: [ - { - sourceId: "question-type", - destinationId: "claim-type", - relationshipTypeId: "supports", - }, - { - sourceId: "evidence-type", - destinationId: "question-type", - relationshipTypeId: "supports", - }, - { - sourceId: "question-type", - destinationId: "claim-type", - relationshipTypeId: "informs", - }, - ], - relations: [ - { type: "supports", source: "question", destination: "claim" }, - { type: "supports", source: "question", destination: "claim" }, - { type: "supports", source: "evidence", destination: "question" }, - ], - getLinkedFile: (id) => files.get(id) ?? null, - includeAllDirections: true, - }); - - assert.deepEqual( - groups.map(({ label, isSource, linkedFiles }) => ({ - label, - isSource, - paths: linkedFiles.map(({ path }) => path), - })), - [ - { label: "supports", isSource: true, paths: ["Claims/Claim.md"] }, - { - label: "is supported by", - isSource: false, - paths: ["Evidence/Evidence.md"], - }, - { label: "informs", isSource: true, paths: [] }, - ], - ); -}); - -void test("the canvas action adds an absent relation", async () => { - let added = false; - await runRelationCanvasAction({ - hasExistingRelation: false, - add: () => { - added = true; - return Promise.resolve(); - }, - remove: () => Promise.reject(new Error("remove should not run")), - }); - assert.equal(added, true); -}); - -void test("the canvas action removes an existing relation", async () => { - let removed = false; - await runRelationCanvasAction({ - hasExistingRelation: true, - add: () => Promise.reject(new Error("add should not run")), - remove: () => { - removed = true; - return Promise.resolve(); - }, - }); - assert.equal(removed, true); -}); diff --git a/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts b/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts deleted file mode 100644 index ece8366ee..000000000 --- a/apps/obsidian/src/components/canvas/nodeCardContextMenuModel.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { - DiscourseRelation, - DiscourseRelationType, - RelationInstance, -} from "~/types"; - -export type NodeCardContextMenuTab = "context" | "styling"; - -export type NodeCardContextMenuState = { - activeTab: NodeCardContextMenuTab; - selectedShapeId: string | null; -}; - -export const nodeCardContextMenuReducer = ( - state: NodeCardContextMenuState, - action: - | { type: "select-tab"; tab: NodeCardContextMenuTab } - | { type: "select-node"; selectedShapeId: string | null }, -): NodeCardContextMenuState => { - if (action.type === "select-tab") { - return { ...state, activeTab: action.tab }; - } - - return action.selectedShapeId === state.selectedShapeId - ? state - : { activeTab: "context", selectedShapeId: action.selectedShapeId }; -}; - -export const shouldShowNodeCardContextMenu = ( - isEnabled: boolean, - selectedShapeType?: string, -) => isEnabled && selectedShapeType === "discourse-node"; - -export const runRelationCanvasAction = async ({ - hasExistingRelation, - add, - remove, -}: { - hasExistingRelation: boolean; - add: () => Promise; - remove: () => Promise; -}) => { - await (hasExistingRelation ? remove() : add()); -}; - -type GroupedRelationInput = { - activeNodeTypeId: string; - nodeInstanceId: string; - relationTypes: Pick[]; - discourseRelations: Pick< - DiscourseRelation, - "sourceId" | "destinationId" | "relationshipTypeId" - >[]; - relations: Pick[]; - getLinkedFile: (nodeInstanceId: string) => TFile | null; - includeAllDirections?: boolean; -}; - -type GroupedRelation = { - key: string; - label: string; - isSource: boolean; - relationTypeId: string; - linkedFiles: TFile[]; -}; - -export const groupRelationsByType = ({ - activeNodeTypeId, - nodeInstanceId, - relationTypes, - discourseRelations, - relations, - getLinkedFile, - includeAllDirections = false, -}: GroupedRelationInput): GroupedRelation[] => { - const result = new Map>(); - - for (const relationType of relationTypes) { - const matchingRelations = discourseRelations.filter( - ({ sourceId, destinationId, relationshipTypeId }) => - relationshipTypeId === relationType.id && - (sourceId === activeNodeTypeId || destinationId === activeNodeTypeId), - ); - const directions = includeAllDirections - ? new Set( - matchingRelations.flatMap(({ sourceId, destinationId }) => [ - ...(sourceId === activeNodeTypeId ? [true] : []), - ...(destinationId === activeNodeTypeId ? [false] : []), - ]), - ) - : new Set( - matchingRelations[0] - ? [matchingRelations[0].sourceId === activeNodeTypeId] - : [], - ); - - for (const isSource of directions) { - const group: GroupedRelation = { - key: `${relationType.id}-${isSource}`, - label: isSource ? relationType.label : relationType.complement, - isSource, - relationTypeId: relationType.id, - linkedFiles: [], - }; - - for (const relation of relations) { - if (relation.type !== relationType.id) continue; - const otherId = includeAllDirections - ? isSource && relation.source === nodeInstanceId - ? relation.destination - : !isSource && relation.destination === nodeInstanceId - ? relation.source - : null - : relation.source === nodeInstanceId - ? relation.destination - : relation.source; - const linkedFile = otherId ? getLinkedFile(otherId) : null; - if ( - linkedFile && - !group.linkedFiles.some(({ path }) => path === linkedFile.path) - ) { - group.linkedFiles.push(linkedFile); - } - } - - result.set(group.key, group); - } - } - - return [...result.values()]; -}; diff --git a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx index e8e819019..9b656cbe7 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx @@ -26,10 +26,6 @@ import { getFileForNodeInstanceId, addRelation, } from "~/utils/relationsStore"; -import { - groupRelationsByType, - runRelationCanvasAction, -} from "~/components/canvas/nodeCardContextMenuModel"; type GroupedRelation = { key: string; @@ -100,13 +96,17 @@ const RelationFileItem = ({ setIsLoading(true); try { - await runRelationCanvasAction({ - hasExistingRelation: Boolean(hasExistingRelation), - add: () => - handleCreateRelationTo(file, group.relationTypeId, group.isSource), - remove: () => handleDeleteRelation(file, group.relationTypeId), - }); - setHasExistingRelation(!hasExistingRelation); + if (hasExistingRelation) { + await handleDeleteRelation(file, group.relationTypeId); + setHasExistingRelation(false); + } else { + await handleCreateRelationTo( + file, + group.relationTypeId, + group.isSource, + ); + setHasExistingRelation(true); + } } catch (e) { showToast({ severity: "error", @@ -543,19 +543,62 @@ const computeRelations = async ( if (!nodeInstanceId) return []; const relations = await getRelationsForNodeInstanceId(plugin, nodeInstanceId); + const result = new Map(); + const acceptedRelationTypes = plugin.settings.relationTypes.filter(isAcceptedSchema); const acceptedDiscourseRelations = plugin.settings.discourseRelations.filter(isAcceptedSchema); - return groupRelationsByType({ - activeNodeTypeId, - nodeInstanceId, - relationTypes: acceptedRelationTypes, - discourseRelations: acceptedDiscourseRelations, - relations, - getLinkedFile: (linkedNodeInstanceId) => - getFileForNodeInstanceId(plugin, linkedNodeInstanceId), - includeAllDirections, - }); + for (const relationType of acceptedRelationTypes) { + const matchingRelations = acceptedDiscourseRelations.filter( + (relation) => + (relation.sourceId === activeNodeTypeId || + relation.destinationId === activeNodeTypeId) && + relation.relationshipTypeId === relationType.id, + ); + 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 relation of relations) { + if (relation.type !== relationType.id) continue; + const otherId = includeAllDirections + ? isSource && relation.source === nodeInstanceId + ? relation.destination + : !isSource && relation.destination === nodeInstanceId + ? relation.source + : null + : relation.source === nodeInstanceId + ? relation.destination + : relation.source; + 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()); }; From 91862c1bc880fb1d218e034ee6ec8e7b80d1d5a4 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 27 Jul 2026 22:38:18 +0530 Subject: [PATCH 4/5] ENG-2038 Tighten node card context menu --- .../src/components/AdminPanelSettings.tsx | 13 +-- .../components/canvas/NodeCardContextMenu.tsx | 57 +++++------ .../canvas/overlays/RelationPanel.tsx | 95 ++++++++++++------- apps/obsidian/src/constants.ts | 3 - apps/obsidian/styles.css | 6 ++ 5 files changed, 93 insertions(+), 81 deletions(-) diff --git a/apps/obsidian/src/components/AdminPanelSettings.tsx b/apps/obsidian/src/components/AdminPanelSettings.tsx index 58844f97e..2ee1b91dd 100644 --- a/apps/obsidian/src/components/AdminPanelSettings.tsx +++ b/apps/obsidian/src/components/AdminPanelSettings.tsx @@ -5,10 +5,6 @@ import { updateUsername } from "~/utils/supabaseContext"; import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase"; import { nextRoot } from "@repo/utils/execContext"; import { getLoggedInClient } from "~/utils/supabaseContext"; -import { - FEATURE_FLAGS, - NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, -} from "~/constants"; export const AdminPanelSettings = () => { const plugin = usePlugin(); @@ -19,9 +15,7 @@ export const AdminPanelSettings = () => { plugin.settings.username || "", ); const [nodeCardContextMenuEnabled, setNodeCardContextMenuEnabled] = - useState( - plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] ?? false, - ); + useState(plugin.settings.nodeCardContextMenuEnabled ?? false); const handleSyncModeToggle = useCallback( async (newValue: boolean) => { @@ -54,11 +48,8 @@ export const AdminPanelSettings = () => { const handleNodeCardContextMenuToggle = useCallback( async (newValue: boolean) => { setNodeCardContextMenuEnabled(newValue); - plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] = newValue; + plugin.settings.nodeCardContextMenuEnabled = newValue; await plugin.saveSettings(); - window.dispatchEvent( - new Event(NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT), - ); }, [plugin], ); diff --git a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx index 80e4f1aaf..35d231773 100644 --- a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx +++ b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx @@ -10,10 +10,6 @@ import { type TLUiStylePanelProps, } from "tldraw"; import type DiscourseGraphPlugin from "~/index"; -import { - FEATURE_FLAGS, - NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, -} from "~/constants"; import type { DiscourseNodeShape } from "./shapes/DiscourseNodeShape"; import { RelationsPanel } from "./overlays/RelationPanel"; @@ -22,8 +18,16 @@ type NodeCardContextMenuProps = TLUiStylePanelProps & { canvasFile: TFile; }; -type NodeCardContextMenuTab = "context" | "styling"; +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"]; +// tldraw is typed against React 19 while Obsidian runs React 18. The casts avoid +// adding TS2786 errors to this file when rendering these components. const DefaultStylePanelComponent = DefaultStylePanel as unknown as ComponentType; const DefaultStylePanelContentComponent = @@ -36,9 +40,7 @@ export const NodeCardContextMenu = ({ }: NodeCardContextMenuProps) => { const editor = useEditor(); const styles = useRelevantStyles(); - const [isEnabled, setIsEnabled] = useState( - plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] ?? false, - ); + const isEnabled = plugin.settings.nodeCardContextMenuEnabled ?? false; const selectedShape = useValue( "selected shape for node card context menu", () => editor.getOnlySelectedShape(), @@ -50,22 +52,6 @@ export const NodeCardContextMenu = ({ : null; const [activeTab, setActiveTab] = useState("context"); - useEffect(() => { - const updateFlag = () => - setIsEnabled( - plugin.settings[FEATURE_FLAGS.NODE_CARD_CONTEXT_MENU] ?? false, - ); - window.addEventListener( - NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, - updateFlag, - ); - return () => - window.removeEventListener( - NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT, - updateFlag, - ); - }, [plugin]); - useEffect(() => { setActiveTab("context"); }, [selectedNode?.id]); @@ -77,38 +63,39 @@ export const NodeCardContextMenu = ({ return createElement( DefaultStylePanelComponent, { isMobile }, - <> +
- {(["context", "styling"] as const).map((tab) => ( + {NODE_CARD_CONTEXT_MENU_TABS.map(({ id, label }) => ( ))}
-
+
{activeTab === "context" ? ( ) : ( createElement(DefaultStylePanelContentComponent, { styles }) )}
- , +
, ); }; diff --git a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx index 9b656cbe7..49ff5638e 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx @@ -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 { @@ -59,6 +59,7 @@ export type RelationsPanelProps = { nodeShape: DiscourseNodeShape; onClose?: () => void; embedded?: boolean; + includeAllDirections?: boolean; }; const RelationFileItem = ({ @@ -167,6 +168,7 @@ export const RelationsPanel = ({ nodeShape, onClose, embedded = false, + includeAllDirections = false, }: RelationsPanelProps) => { const editor = useEditor(); const [groups, setGroups] = useState([]); @@ -195,7 +197,7 @@ export const RelationsPanel = ({ setError("Linked file not found."); return; } - const g = await computeRelations(plugin, file, embedded); + const g = await computeRelations(plugin, file, includeAllDirections); setGroups(g); } catch (e) { showToast({ @@ -210,11 +212,14 @@ export const RelationsPanel = ({ } }; void load(); - }, [plugin, canvasFile, nodeShape.id, nodeShape.props.src, editor, embedded]); - - 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, @@ -469,22 +474,26 @@ export const RelationsPanel = ({ : "min-w-80 max-w-md rounded-lg border bg-white p-4 shadow-lg" } > -
-

Relations

- -
- -
-
{headerTitle}
-
+ {!embedded && ( + <> +
+

Relations

+ +
+ +
+
+ {nodeShape.props.title || "Selected node"} +
+
+ + )} {loading ? (
Loading relations...
@@ -557,6 +566,7 @@ const computeRelations = async ( relation.destinationId === activeNodeTypeId) && relation.relationshipTypeId === relationType.id, ); + // Preserve the legacy panel's previous first-match behavior. const typeLevelRelations = includeAllDirections ? matchingRelations : matchingRelations.slice(0, 1); @@ -578,15 +588,12 @@ const computeRelations = async ( const group = result.get(key)!; for (const relation of relations) { if (relation.type !== relationType.id) continue; - const otherId = includeAllDirections - ? isSource && relation.source === nodeInstanceId - ? relation.destination - : !isSource && relation.destination === nodeInstanceId - ? relation.source - : null - : relation.source === nodeInstanceId - ? relation.destination - : relation.source; + const otherId = getRelationCounterpartId({ + relation, + nodeInstanceId, + isSource, + includeAllDirections, + }); if (!otherId) continue; const linkedFile = getFileForNodeInstanceId(plugin, otherId); @@ -602,3 +609,27 @@ const computeRelations = async ( 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; +}; diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts index c5c4f9a9a..ce7ea3c3c 100644 --- a/apps/obsidian/src/constants.ts +++ b/apps/obsidian/src/constants.ts @@ -128,12 +128,9 @@ export const DEFAULT_SETTINGS: Settings = { export const FEATURE_FLAGS = { // settings for these features are in the Admin Panel (hidden tab in Settings, toggle with Ctrl+Shift+A) DATABASE_SYNC: "databaseSync", - NODE_CARD_CONTEXT_MENU: "nodeCardContextMenuEnabled", } as const; export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]; -export const NODE_CARD_CONTEXT_MENU_FLAG_CHANGED_EVENT = - "dg:node-card-context-menu-flag-changed"; export const FRONTMATTER_KEY = "tldr-dg"; export const TLDATA_DELIMITER_START = "!!!_START_OF_TLDRAW_DG_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!"; diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 567e1539b..4b1f5691f 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -1,6 +1,12 @@ @tailwind components; @tailwind utilities; +/* tldraw otherwise caps the style panel at 148px. */ +.tlui-style-panel:has(.dg-node-card-menu) { + width: min(20rem, calc(100vw - 1rem)); + max-width: min(20rem, calc(100vw - 1rem)); +} + .accent-border-bottom { border-bottom: 2px solid var(--interactive-accent) !important; } From fe0df5ac968db2b7ec0c38147b4c21ef943c77df Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 27 Jul 2026 22:51:39 +0530 Subject: [PATCH 5/5] ENG-2038 Remove redundant menu wrapper --- .../components/canvas/NodeCardContextMenu.tsx | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx index 35d231773..0c452a3fd 100644 --- a/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx +++ b/apps/obsidian/src/components/canvas/NodeCardContextMenu.tsx @@ -26,8 +26,6 @@ const NODE_CARD_CONTEXT_MENU_TABS = [ type NodeCardContextMenuTab = (typeof NODE_CARD_CONTEXT_MENU_TABS)[number]["id"]; -// tldraw is typed against React 19 while Obsidian runs React 18. The casts avoid -// adding TS2786 errors to this file when rendering these components. const DefaultStylePanelComponent = DefaultStylePanel as unknown as ComponentType; const DefaultStylePanelContentComponent = @@ -83,19 +81,17 @@ export const NodeCardContextMenu = ({ ))}
-
- {activeTab === "context" ? ( - - ) : ( - createElement(DefaultStylePanelContentComponent, { styles }) - )} -
+ {activeTab === "context" ? ( + + ) : ( + createElement(DefaultStylePanelContentComponent, { styles }) + )}
, ); };