From e444a0cb98ef1beeed0db180c4ba15c54a444303 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 27 Sep 2026 16:02:47 -0400 Subject: [PATCH 1/3] ENG-2256 Implement new relation menu empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open the relation type menu for node-type pairs with no relation types, showing Add existing… and Create new. The populated menu exposes the same actions behind a "+" flyout. The actions are wired in ENG-2257/ENG-2258. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3J7D2EQ0H3DABC2RRBSB0J8 --- .../canvas/overlays/DragHandleOverlay.tsx | 18 +---- .../canvas/overlays/RelationTypeDropdown.tsx | 75 ++++++++++++++----- .../canvas/utils/relationTypeUtils.ts | 22 ------ 3 files changed, 58 insertions(+), 57 deletions(-) diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx index ce18413202..cb8b78b28b 100644 --- a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -23,7 +23,6 @@ import { showToast } from "~/components/canvas/utils/toastUtils"; import { getDiscourseNodeAtPoint, getDiscourseNodeTypeId, - hasValidRelationTypeForNodePair, } from "~/components/canvas/utils/relationTypeUtils"; import { RelationTypeDropdown } from "./RelationTypeDropdown"; @@ -276,28 +275,17 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { ) { const endTarget = editor.getShape(bindings.end.toId); if (endTarget && endTarget.type === "discourse-node") { - // Check if any relation types are valid for this node pair const startNodeTypeId = getDiscourseNodeTypeId( editor.getShape(bindings.start.toId), ); const endNodeTypeId = getDiscourseNodeTypeId(endTarget); - const hasValidRelationType = - startNodeTypeId && - endNodeTypeId && - hasValidRelationTypeForNodePair({ - settings: plugin.settings, - sourceNodeTypeId: startNodeTypeId, - targetNodeTypeId: endNodeTypeId, - }); - - if (!hasValidRelationType) { + if (!startNodeTypeId || !endNodeTypeId) { cleanupArrow(arrowId); showToast({ severity: "warning", title: "Relation", - description: - "No relation types are defined between these node types", + description: "Both nodes need a node type to create a relation", targetCanvasId: file.path, }); if (sourceNodeRef.current) { @@ -340,7 +328,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { dragCleanupRef.current = null; }; }, - [selectedNode, editor, cleanupArrow, file.path, plugin.settings], + [selectedNode, editor, cleanupArrow, file.path], ); const handleDropdownSelect = useCallback( diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx index 5c494c4019..dab32fd7c6 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { setIcon } from "obsidian"; import { TLShapeId, useEditor, useValue } from "tldraw"; import DiscourseGraphPlugin from "~/index"; import { DiscourseRelationShape } from "~/components/canvas/shapes/DiscourseRelationShape"; @@ -26,6 +27,7 @@ export const RelationTypeDropdown = ({ }: RelationTypeDropdownProps) => { const editor = useEditor(); const dropdownRef = useRef(null); + const [isAddMenuOpen, setIsAddMenuOpen] = useState(false); const arrow = useValue( "dropdownArrow", @@ -105,16 +107,20 @@ export const RelationTypeDropdown = ({ }; }, [onDismiss]); - // Handle Escape key + // Handle Escape key: close the add menu first, then the dropdown useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onDismiss(); + if (e.key !== "Escape") return; + if (isAddMenuOpen) { + e.stopPropagation(); + setIsAddMenuOpen(false); + return; } + onDismiss(); }; window.addEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [onDismiss]); + }, [isAddMenuOpen, onDismiss]); const handleSelect = useCallback( (relationTypeId: string) => { @@ -125,6 +131,16 @@ export const RelationTypeDropdown = ({ if (!dropdownPosition || !arrow) return null; + const actionClassName = + "flex w-full cursor-pointer items-center justify-start rounded border-none bg-transparent px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100"; + const addActions = ( + <> + + + + ); + const hasRelationTypes = validRelationTypes.length > 0; + return (
e.stopPropagation()} >
-
- Relation Type -
- {validRelationTypes.map((rt) => ( - - ))} + )} +
+ {hasRelationTypes + ? validRelationTypes.map((rt) => ( + + )) + : addActions}
+ {/* Outside the scroll container so it isn't clipped, inside dropdownRef so clicks don't dismiss */} + {hasRelationTypes && isAddMenuOpen && ( +
+ {addActions} +
+ )} ); }; diff --git a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts index 1bfa151fd5..b18d81dcdf 100644 --- a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts +++ b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts @@ -187,25 +187,3 @@ export const getCompatibleTargetNodeTypeIds = ({ } return [...targets]; }; - -/** - * Checks whether any valid relation type exists between two node types. - */ -export const hasValidRelationTypeForNodePair = ({ - settings, - sourceNodeTypeId, - targetNodeTypeId, -}: { - settings: RelationTypeSettings; - sourceNodeTypeId: string; - targetNodeTypeId: string; -}): boolean => { - return settings.discourseRelations.some( - (r) => - settings.relationTypes.some((rt) => rt.id === r.relationshipTypeId) && - ((r.sourceId === sourceNodeTypeId && - r.destinationId === targetNodeTypeId) || - (r.sourceId === targetNodeTypeId && - r.destinationId === sourceNodeTypeId)), - ); -}; From b18be10829cbbd3e2becccfcad10c076ee23ff37 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 27 Sep 2026 16:29:17 -0400 Subject: [PATCH 2/3] ENG-2256 Remove pending relation arrows abandoned when the canvas closed The save loop persists the pending arrow while the relation type menu is open. Tag it in shape meta, clear the tag on pick, and delete tagged leftovers when the overlay mounts. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3J8XJJDB6JNC6JZZXN4FMSQ --- .../canvas/overlays/DragHandleOverlay.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx index cb8b78b28b..d1769d326c 100644 --- a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -40,6 +40,8 @@ type HandlePosition = { const HANDLE_RADIUS = 5; const HANDLE_HIT_AREA = 12; const HANDLE_PADDING = 8; // px offset in viewport space, outward from the node edge +// Marks an arrow awaiting a relation type; the canvas save loop persists it +const PENDING_RELATION_META_KEY = "pendingRelationMenu"; /** Page-space edge midpoints and their outward direction vectors. */ const getEdgeMidpoints = (bounds: { @@ -94,6 +96,19 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { }; }, []); + // A pending arrow found on mount was abandoned when the canvas last closed + useEffect(() => { + const abandonedArrowIds = editor + .getCurrentPageShapes() + .filter( + (shape) => + shape.type === "discourse-relation" && + shape.meta[PENDING_RELATION_META_KEY], + ) + .map((shape) => shape.id); + if (abandonedArrowIds.length > 0) editor.deleteShapes(abandonedArrowIds); + }, [editor]); + const selectedNode = useValue( "dragHandleSelectedNode", () => { @@ -163,6 +178,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { type: "discourse-relation", x: startX, y: startY, + meta: { [PENDING_RELATION_META_KEY]: true }, props: { color: DEFAULT_TLDRAW_COLOR, relationTypeId: "", @@ -353,6 +369,7 @@ export const DragHandleOverlay = ({ plugin, file }: DragHandleOverlayProps) => { { id: pendingArrowId, type: "discourse-relation", + meta: { ...shape.meta, [PENDING_RELATION_META_KEY]: false }, props: { relationTypeId, color: relationType.color, From 3ec7d51a54ec5f80391265e3860ee59e57c3c43e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 29 Sep 2026 15:04:54 -0400 Subject: [PATCH 3/3] ENG-2256 Keep the relation type menu above the style panel and inside the canvas Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3Q8WG83DQNA246QGTM0X0YP --- .../canvas/overlays/RelationTypeDropdown.tsx | 55 +++++++++++++++++-- .../utils/__tests__/menuPlacement.test.ts | 42 ++++++++++++++ .../components/canvas/utils/menuPlacement.ts | 52 ++++++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 apps/obsidian/src/components/canvas/utils/__tests__/menuPlacement.test.ts create mode 100644 apps/obsidian/src/components/canvas/utils/menuPlacement.ts diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx index dab32fd7c6..c2b99dccac 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { setIcon } from "obsidian"; import { TLShapeId, useEditor, useValue } from "tldraw"; import DiscourseGraphPlugin from "~/index"; @@ -11,6 +18,7 @@ import { getDiscourseNodeTypeId, getValidRelationTypesForNodePair, } from "~/components/canvas/utils/relationTypeUtils"; +import { clampMenuCentre } from "~/components/canvas/utils/menuPlacement"; type RelationTypeDropdownProps = { arrowId: TLShapeId; @@ -85,6 +93,35 @@ export const RelationTypeDropdown = ({ [editor, arrow?.id], ); + const viewport = useValue( + "dropdownViewport", + () => { + const bounds = editor.getViewportScreenBounds(); + return { width: bounds.w, height: bounds.h }; + }, + [editor], + ); + + // Measured before paint so the first frame is already inside the canvas. + const [menuSize, setMenuSize] = useState<{ + menu: { width: number; height: number }; + flyoutWidth: number; + } | null>(null); + const hasPosition = !!dropdownPosition; + const relationTypeCount = validRelationTypes.length; + useLayoutEffect(() => { + const [menu, flyout] = Array.from( + dropdownRef.current?.children ?? [], + ) as HTMLElement[]; + if (!menu) return; + setMenuSize({ + menu: { width: menu.offsetWidth, height: menu.offsetHeight }, + flyoutWidth: flyout + ? flyout.offsetLeft + flyout.offsetWidth - menu.offsetWidth + : 0, + }); + }, [hasPosition, isAddMenuOpen, relationTypeCount]); + // Handle click outside useEffect(() => { const handlePointerDown = (e: PointerEvent) => { @@ -131,6 +168,15 @@ export const RelationTypeDropdown = ({ if (!dropdownPosition || !arrow) return null; + const centre = menuSize + ? clampMenuCentre({ + anchor: { x: dropdownPosition.left, y: dropdownPosition.top }, + ...menuSize, + viewport, + margin: 8, + }) + : { x: dropdownPosition.left, y: dropdownPosition.top }; + const actionClassName = "flex w-full cursor-pointer items-center justify-start rounded border-none bg-transparent px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100"; const addActions = ( @@ -144,10 +190,11 @@ export const RelationTypeDropdown = ({ return (
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} diff --git a/apps/obsidian/src/components/canvas/utils/__tests__/menuPlacement.test.ts b/apps/obsidian/src/components/canvas/utils/__tests__/menuPlacement.test.ts new file mode 100644 index 0000000000..47a8ef3023 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/__tests__/menuPlacement.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { clampMenuCentre } from "~/components/canvas/utils/menuPlacement"; + +const menu = { width: 160, height: 128 }; +const viewport = { width: 1000, height: 800 }; +const place = (x: number, y: number, flyoutWidth = 0) => + clampMenuCentre({ + anchor: { x, y }, + menu, + flyoutWidth, + viewport, + margin: 8, + }); + +describe("clampMenuCentre", () => { + it("keeps the anchor when the menu fits", () => { + expect(place(500, 400)).toEqual({ x: 500, y: 400 }); + }); + + it("moves the menu inside the top and left edges", () => { + expect(place(10, 10)).toEqual({ x: 88, y: 72 }); + }); + + it("moves the menu inside the bottom and right edges", () => { + expect(place(995, 795)).toEqual({ x: 912, y: 728 }); + }); + + it("leaves room for an open flyout on the right", () => { + expect(place(900, 400, 132)).toEqual({ x: 780, y: 400 }); + }); + + it("keeps the left edge visible when the menu is wider than the viewport", () => { + const narrow = clampMenuCentre({ + anchor: { x: 50, y: 400 }, + menu, + flyoutWidth: 132, + viewport: { width: 300, height: 800 }, + margin: 8, + }); + expect(narrow.x).toBe(88); + }); +}); diff --git a/apps/obsidian/src/components/canvas/utils/menuPlacement.ts b/apps/obsidian/src/components/canvas/utils/menuPlacement.ts new file mode 100644 index 0000000000..9843751536 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/menuPlacement.ts @@ -0,0 +1,52 @@ +type Point = { x: number; y: number }; +type Size = { width: number; height: number }; + +const clampAxis = ({ + centre, + before, + after, + length, + margin, +}: { + centre: number; + before: number; + after: number; + length: number; + margin: number; +}): number => { + const min = margin + before; + const max = length - margin - after; + // Too big to fit: keep the start edge visible, where the header and + are. + if (max < min) return min; + return Math.min(Math.max(centre, min), max); +}; + +/** Centre of a centred menu, moved just enough to keep it and a right-side flyout inside the viewport. */ +export const clampMenuCentre = ({ + anchor, + menu, + flyoutWidth, + viewport, + margin, +}: { + anchor: Point; + menu: Size; + flyoutWidth: number; + viewport: Size; + margin: number; +}): Point => ({ + x: clampAxis({ + centre: anchor.x, + before: menu.width / 2, + after: menu.width / 2 + flyoutWidth, + length: viewport.width, + margin, + }), + y: clampAxis({ + centre: anchor.y, + before: menu.height / 2, + after: menu.height / 2, + length: viewport.height, + margin, + }), +});