diff --git a/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/DragHandleOverlay.tsx index ce18413202..d1769d326c 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"; @@ -41,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: { @@ -95,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", () => { @@ -164,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: "", @@ -276,28 +291,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 +344,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( @@ -365,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, diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx index 5c494c4019..c2b99dccac 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef } 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"; import { DiscourseRelationShape } from "~/components/canvas/shapes/DiscourseRelationShape"; @@ -10,6 +18,7 @@ import { getDiscourseNodeTypeId, getValidRelationTypesForNodePair, } from "~/components/canvas/utils/relationTypeUtils"; +import { clampMenuCentre } from "~/components/canvas/utils/menuPlacement"; type RelationTypeDropdownProps = { arrowId: TLShapeId; @@ -26,6 +35,7 @@ export const RelationTypeDropdown = ({ }: RelationTypeDropdownProps) => { const editor = useEditor(); const dropdownRef = useRef(null); + const [isAddMenuOpen, setIsAddMenuOpen] = useState(false); const arrow = useValue( "dropdownArrow", @@ -83,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) => { @@ -105,16 +144,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,36 +168,75 @@ 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 = ( + <> + + + + ); + const hasRelationTypes = validRelationTypes.length > 0; + return (
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} onClick={(e) => 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/__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, + }), +}); 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)), - ); -};