From f502199d9e05f16fa8f41c95d2a0eebcefd35538 Mon Sep 17 00:00:00 2001 From: fayekelmith Date: Tue, 26 May 2026 17:31:21 +0000 Subject: [PATCH] Generated with Hive: Add declarative edgeContextMenu prop and overlay for CanvasEdge right-clicks --- demo/src/main.tsx | 36 ++++ package-lock.json | 12 +- packages/core/src/edgeContextMenu.ts | 35 ++++ packages/core/src/index.ts | 10 + packages/core/src/types.ts | 46 ++++ .../src/components/EdgeContextMenuOverlay.tsx | 197 ++++++++++++++++++ .../react/src/components/SystemCanvas.tsx | 143 ++++++++----- .../__tests__/useKeyboardShortcuts.test.ts | 2 + .../react/src/hooks/useKeyboardShortcuts.ts | 10 + 9 files changed, 435 insertions(+), 56 deletions(-) create mode 100644 packages/core/src/edgeContextMenu.ts create mode 100644 packages/react/src/components/EdgeContextMenuOverlay.tsx diff --git a/demo/src/main.tsx b/demo/src/main.tsx index 5117324..23d22c3 100644 --- a/demo/src/main.tsx +++ b/demo/src/main.tsx @@ -21,6 +21,7 @@ import type { CanvasNode, CanvasEdge, NodeContextMenuConfig, + EdgeContextMenuConfig, NodeUpdate, EdgeUpdate, } from 'system-canvas' @@ -433,6 +434,40 @@ function App() { [handleNodeUpdate, handleNodeDelete] ) + // --------------------------------------------------------------------- + // Edge context menu — system & gateway modes + // + // Right-clicking an edge in system or gateway mode opens a small menu + // with "Toggle Animation" (flips marching-ants on/off) and "Delete Edge". + // Demonstrates the symmetric `edgeContextMenu` API. + // --------------------------------------------------------------------- + const demoEdgeContextMenu = useMemo( + () => ({ + items: [ + { + id: 'toggle-animation', + label: 'Toggle Animation', + }, + { + id: 'delete-edge', + label: 'Delete Edge', + destructive: true, + }, + ], + onSelect(itemId, edge, ctx) { + switch (itemId) { + case 'toggle-animation': + handleEdgeUpdate(edge.id, { animated: !edge.animated }, ctx.canvasRef ?? undefined) + break + case 'delete-edge': + handleEdgeDelete(edge.id, ctx.canvasRef ?? undefined) + break + } + }, + }), + [handleEdgeUpdate, handleEdgeDelete] + ) + return (
{/* Theme / controls bar */} @@ -598,6 +633,7 @@ function App() { // browser's default right-click behavior so we don't have to // invent menus for every demo. nodeContextMenu={mode === 'showcase' ? showcaseContextMenu : undefined} + edgeContextMenu={['system', 'gateway'].includes(mode) ? demoEdgeContextMenu : undefined} onNodeClick={(node: CanvasNode) => { console.log('Node clicked:', node.id) }} diff --git a/package-lock.json b/package-lock.json index 81d695b..0423bc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4219,7 +4219,7 @@ }, "packages/core": { "name": "system-canvas", - "version": "0.2.10", + "version": "0.2.19", "license": "MIT", "devDependencies": { "typescript": "^5.4.0" @@ -4227,13 +4227,13 @@ }, "packages/react": { "name": "system-canvas-react", - "version": "0.2.10", + "version": "0.2.19", "license": "MIT", "dependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.0", "d3-zoom": "^3.0.0", - "system-canvas": "^0.2.10" + "system-canvas": "^0.2.19" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -4257,13 +4257,13 @@ }, "packages/standalone": { "name": "system-canvas-standalone", - "version": "0.2.10", + "version": "0.2.19", "license": "MIT", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", - "system-canvas": "^0.2.10", - "system-canvas-react": "^0.2.10" + "system-canvas": "^0.2.19", + "system-canvas-react": "^0.2.19" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/packages/core/src/edgeContextMenu.ts b/packages/core/src/edgeContextMenu.ts new file mode 100644 index 0000000..6487f57 --- /dev/null +++ b/packages/core/src/edgeContextMenu.ts @@ -0,0 +1,35 @@ +import type { CanvasEdge, EdgeContextMenuItem, EdgeContextMenuMatchContext } from './types.js' + +/** + * Decide whether a single context-menu item should appear for a given edge. + * + * Rules: + * - No `match` block => matches every edge. + * - `match.when(edge, ctx)` is an arbitrary predicate. + * + * Omitting `match` entirely matches every edge. + */ +export function matchesEdgeContextMenuItem( + item: EdgeContextMenuItem, + edge: CanvasEdge, + ctx: EdgeContextMenuMatchContext +): boolean { + const m = item.match + if (!m) return true + if (m.when && !m.when(edge, ctx)) return false + return true +} + +/** + * Filter a list of items down to the ones that should appear for the + * right-clicked edge. Used internally by ``; + * exposed so consumers building their own menu UI on top of the raw + * `onContextMenu` callback can apply the same filtering rules. + */ +export function filterEdgeContextMenuItems( + items: EdgeContextMenuItem[], + edge: CanvasEdge, + ctx: EdgeContextMenuMatchContext +): EdgeContextMenuItem[] { + return items.filter((item) => matchesEdgeContextMenuItem(item, edge, ctx)) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d889be6..5c9fae8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,10 @@ export type { NodeContextMenuConfig, NodeContextMenuMatchContext, NodeContextMenuSelectContext, + EdgeContextMenuItem, + EdgeContextMenuConfig, + EdgeContextMenuMatchContext, + EdgeContextMenuSelectContext, NodeUpdate, EdgeUpdate, NodeMenuOption, @@ -123,6 +127,12 @@ export { filterContextMenuItems, } from './contextMenu.js' +// Edge context-menu filtering helpers +export { + matchesEdgeContextMenuItem, + filterEdgeContextMenuItems, +} from './edgeContextMenu.js' + // Rollup helpers export { rollupNodes, rollupNodesDeep } from './rollup.js' diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 193a870..6e46202 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1314,6 +1314,52 @@ export interface NodeContextMenuConfig { ) => void } +// --------------------------------------------------------------------------- +// Edge context menu types +// --------------------------------------------------------------------------- + +/** Context passed to edge context-menu predicates. */ +export interface EdgeContextMenuMatchContext { + canvasRef: string | null +} + +/** + * A single item in the declarative edge context menu. + * `CanvasEdge` has no `category` or `type` fields, so only a `when` + * predicate is available in `match`. + */ +export interface EdgeContextMenuItem { + id: string + label: string + icon?: string + destructive?: boolean + /** Only `when` — CanvasEdge has no category or type fields. */ + match?: { + when?: (edge: CanvasEdge, ctx: EdgeContextMenuMatchContext) => boolean + } + /** Optional per-edge disabled state. Item renders but is non-clickable. */ + disabled?: (edge: CanvasEdge, ctx: EdgeContextMenuMatchContext) => boolean +} + +/** + * Context passed to `onSelect` when the user picks an item. Includes the + * screen position of the original right-click so the consumer can spawn a + * follow-up popover or dialog at the same spot. + */ +export interface EdgeContextMenuSelectContext extends EdgeContextMenuMatchContext { + screenPosition: { x: number; y: number } +} + +/** Top-level config for the declarative edge context menu. */ +export interface EdgeContextMenuConfig { + items: EdgeContextMenuItem[] + onSelect: ( + itemId: string, + edge: CanvasEdge, + ctx: EdgeContextMenuSelectContext + ) => void +} + // --------------------------------------------------------------------------- // Editing types // --------------------------------------------------------------------------- diff --git a/packages/react/src/components/EdgeContextMenuOverlay.tsx b/packages/react/src/components/EdgeContextMenuOverlay.tsx new file mode 100644 index 0000000..d473267 --- /dev/null +++ b/packages/react/src/components/EdgeContextMenuOverlay.tsx @@ -0,0 +1,197 @@ +import React, { useEffect, useRef, useState } from 'react' +import type { + CanvasEdge, + CanvasTheme, + ContextMenuTheme, + EdgeContextMenuConfig, + EdgeContextMenuItem, + EdgeContextMenuMatchContext, +} from 'system-canvas' +import { NodeIcon } from './NodeIcon.js' + +/** + * State the overlay needs to render itself for one open instance. The + * library owns this state — the consumer never sees it. + */ +export interface EdgeContextMenuOverlayState { + /** Filtered items (already passed `match` predicates). */ + items: EdgeContextMenuItem[] + edge: CanvasEdge + /** clientX/clientY at the time of the right-click. */ + screenPosition: { x: number; y: number } + /** Canvas the right-clicked edge lives on. `null` for root. */ + canvasRef: string | null +} + +interface EdgeContextMenuOverlayProps { + state: EdgeContextMenuOverlayState | null + config: EdgeContextMenuConfig + theme: CanvasTheme + /** Called whenever the menu should close (outside-click, Esc, item pick). */ + onClose: () => void +} + +/** Approximate menu width — only used for off-right-edge clamping. */ +const ESTIMATED_MENU_WIDTH = 200 +const MIN_MENU_WIDTH = 160 +const VIEWPORT_MARGIN = 8 + +/** + * Floating, dismissible menu rendered above the canvas at the user's + * right-click position. Lives outside the SVG (a regular HTML `
` with + * `position: fixed`) so it isn't clipped by the canvas viewport and + * doesn't interfere with d3-zoom hit-testing. + * + * Dismissal: outside `mousedown`, Escape, scroll, window blur, or after + * the consumer's `onSelect` runs. + */ +export function EdgeContextMenuOverlay({ + state, + config, + theme, + onClose, +}: EdgeContextMenuOverlayProps) { + const rootRef = useRef(null) + const [hoveredId, setHoveredId] = useState(null) + + // Reset hover whenever a new menu opens. + useEffect(() => { + if (state) setHoveredId(null) + }, [state]) + + // Outside-click / Escape / scroll / blur dismissal. Only wired while open. + useEffect(() => { + if (!state) return + function onDown(e: MouseEvent) { + const root = rootRef.current + if (!root) return + if (root.contains(e.target as Node)) return + onClose() + } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + } + function onScroll() { + onClose() + } + window.addEventListener('mousedown', onDown) + window.addEventListener('keydown', onKey) + window.addEventListener('scroll', onScroll, true) + window.addEventListener('blur', onClose) + return () => { + window.removeEventListener('mousedown', onDown) + window.removeEventListener('keydown', onKey) + window.removeEventListener('scroll', onScroll, true) + window.removeEventListener('blur', onClose) + } + }, [state, onClose]) + + if (!state) return null + const cm: ContextMenuTheme | undefined = theme.contextMenu + // If a consumer plugs in a hand-rolled CanvasTheme (not via resolveTheme) + // and forgets the contextMenu block, render nothing rather than throwing. + if (!cm) return null + + const vw = typeof window !== 'undefined' ? window.innerWidth : 0 + const vh = typeof window !== 'undefined' ? window.innerHeight : 0 + const itemHeight = cm.itemPaddingY * 2 + cm.fontSize + 4 + const estimatedHeight = state.items.length * itemHeight + cm.paddingY * 2 + const left = vw + ? Math.min(state.screenPosition.x, vw - ESTIMATED_MENU_WIDTH - VIEWPORT_MARGIN) + : state.screenPosition.x + const top = vh + ? Math.min(state.screenPosition.y, vh - estimatedHeight - VIEWPORT_MARGIN) + : state.screenPosition.y + + const matchCtx: EdgeContextMenuMatchContext = { canvasRef: state.canvasRef } + + const anyIcon = state.items.some((item) => !!item.icon) + + return ( +
{ + e.preventDefault() + e.stopPropagation() + }} + style={{ + position: 'fixed', + left, + top, + zIndex: 1000, + minWidth: MIN_MENU_WIDTH, + padding: `${cm.paddingY}px ${cm.paddingX}px`, + background: cm.background, + color: cm.itemColor, + border: `1px solid ${cm.borderColor}`, + borderRadius: cm.borderRadius, + boxShadow: cm.shadow, + fontFamily: cm.fontFamily, + fontSize: cm.fontSize, + backdropFilter: 'blur(10px)', + userSelect: 'none', + pointerEvents: 'auto', + }} + > + {state.items.map((item) => { + const isDisabled = item.disabled?.(state.edge, matchCtx) ?? false + const isHovered = !isDisabled && hoveredId === item.id + const color = item.destructive ? cm.destructiveItemColor : cm.itemColor + return ( +
!isDisabled && setHoveredId(item.id)} + onMouseLeave={() => setHoveredId((id) => (id === item.id ? null : id))} + onClick={() => { + if (isDisabled) return + config.onSelect(item.id, state.edge, { + canvasRef: state.canvasRef, + screenPosition: state.screenPosition, + }) + onClose() + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: 10, + padding: `${cm.itemPaddingY}px ${cm.itemPaddingX}px`, + borderRadius: Math.max(0, cm.borderRadius - 4), + cursor: isDisabled ? 'not-allowed' : 'pointer', + opacity: isDisabled ? 0.45 : 1, + background: isHovered ? cm.itemHoverBackground : 'transparent', + color, + }} + > + {item.icon ? ( + + + + ) : anyIcon ? ( + + ) : null} + {item.label} +
+ ) + })} +
+ ) +} diff --git a/packages/react/src/components/SystemCanvas.tsx b/packages/react/src/components/SystemCanvas.tsx index 6f84e93..b9b6c67 100644 --- a/packages/react/src/components/SystemCanvas.tsx +++ b/packages/react/src/components/SystemCanvas.tsx @@ -18,6 +18,7 @@ import type { ViewportState, ContextMenuEvent, NodeContextMenuConfig, + EdgeContextMenuConfig, ResolvedNode, NodeUpdate, EdgeUpdate, @@ -32,6 +33,7 @@ import { getNodeMenuOptions, createNodeFromOption, filterContextMenuItems, + filterEdgeContextMenuItems, screenToCanvas, snapToLane, alignNodes, @@ -63,6 +65,10 @@ import { NodeContextMenuOverlay, type NodeContextMenuOverlayState, } from './NodeContextMenuOverlay.js' +import { + EdgeContextMenuOverlay, + type EdgeContextMenuOverlayState, +} from './EdgeContextMenuOverlay.js' import { SearchOverlay } from './SearchOverlay.js' export interface SystemCanvasProps { @@ -150,6 +156,12 @@ export interface SystemCanvasProps { * still suppress the browser default and forward through `onContextMenu`. */ nodeContextMenu?: NodeContextMenuConfig + /** + * Declarative context menu for edge right-clicks. Mirrors `nodeContextMenu` + * but operates on `CanvasEdge` targets. When not set, no library-rendered + * menu appears on edge right-clicks. + */ + edgeContextMenu?: EdgeContextMenuConfig // --- Editing --- /** When true, the canvas becomes editable (add / edit / move / delete). */ @@ -414,6 +426,7 @@ export const SystemCanvas = forwardRef( onContextMenu, onSelectionChange, nodeContextMenu, + edgeContextMenu, editable = false, onNodeAdd, onNodeUpdate, @@ -1262,11 +1275,16 @@ export const SystemCanvas = forwardRef( const [contextMenuState, setContextMenuState] = useState(null) - // Reset the open menu whenever the user navigates between canvases. The - // node it was anchored to may not exist on the new scope; even if it - // does, the menu's screen position would be stale. + // Declarative edge context menu state — mirrors contextMenuState for edges. + const [edgeContextMenuState, setEdgeContextMenuState] = + useState(null) + + // Reset the open menus whenever the user navigates between canvases. The + // node/edge they were anchored to may not exist on the new scope; even if + // they do, the menu's screen position would be stale. useEffect(() => { setContextMenuState(null) + setEdgeContextMenuState(null) }, [currentCanvasRef]) /** @@ -1281,56 +1299,70 @@ export const SystemCanvas = forwardRef( const handleContextMenu = useCallback( (event: ContextMenuEvent) => { onContextMenu?.(event) - if (!nodeContextMenu && !alignDistributeMenu) return - if (event.type !== 'node') return - const node = event.target as CanvasNode | undefined - if (!node) return - - const matchCtx = { canvasRef: currentCanvasRef ?? null } - const matched = nodeContextMenu - ? filterContextMenuItems(nodeContextMenu.items, node, matchCtx) - : [] - - // Inject built-in align/distribute items when the right-clicked node - // is part of the current multi-selection (2+ nodes). - if (alignDistributeMenu && selectedIds.size >= 2 && selectedIds.has(node.id)) { - const canDistribute = selectedIds.size >= 3 - const builtins = [ - { id: '__sys_align_left__', label: 'Align Left' }, - { id: '__sys_align_right__', label: 'Align Right' }, - { id: '__sys_align_top__', label: 'Align Top' }, - { id: '__sys_align_bottom__', label: 'Align Bottom' }, - { id: '__sys_align_centerH__', label: 'Align Center Horizontal' }, - { id: '__sys_align_centerV__', label: 'Align Center Vertical' }, - { - id: '__sys_distribute_h__', - label: 'Distribute Horizontally', - disabled: canDistribute ? undefined : () => !canDistribute, - }, - { - id: '__sys_distribute_v__', - label: 'Distribute Vertically', - disabled: canDistribute ? undefined : () => !canDistribute, - }, - ] - matched.push(...builtins) - } - if (matched.length === 0) { - // No items applied to this node — close any stale menu and let - // the right-click be a no-op (the browser default is already - // suppressed by the underlying interaction hook). - setContextMenuState(null) - return + if (event.type === 'node') { + if (!nodeContextMenu && !alignDistributeMenu) return + const node = event.target as CanvasNode | undefined + if (!node) return + + const matchCtx = { canvasRef: currentCanvasRef ?? null } + const matched = nodeContextMenu + ? filterContextMenuItems(nodeContextMenu.items, node, matchCtx) + : [] + + // Inject built-in align/distribute items when the right-clicked node + // is part of the current multi-selection (2+ nodes). + if (alignDistributeMenu && selectedIds.size >= 2 && selectedIds.has(node.id)) { + const canDistribute = selectedIds.size >= 3 + const builtins = [ + { id: '__sys_align_left__', label: 'Align Left' }, + { id: '__sys_align_right__', label: 'Align Right' }, + { id: '__sys_align_top__', label: 'Align Top' }, + { id: '__sys_align_bottom__', label: 'Align Bottom' }, + { id: '__sys_align_centerH__', label: 'Align Center Horizontal' }, + { id: '__sys_align_centerV__', label: 'Align Center Vertical' }, + { + id: '__sys_distribute_h__', + label: 'Distribute Horizontally', + disabled: canDistribute ? undefined : () => !canDistribute, + }, + { + id: '__sys_distribute_v__', + label: 'Distribute Vertically', + disabled: canDistribute ? undefined : () => !canDistribute, + }, + ] + matched.push(...builtins) + } + + if (matched.length === 0) { + setContextMenuState(null) + return + } + setContextMenuState({ + items: matched, + node, + screenPosition: event.screenPosition, + canvasRef: currentCanvasRef ?? null, + }) + } else if (event.type === 'edge' && edgeContextMenu) { + const edge = event.target as CanvasEdge | undefined + if (!edge) return + const matchCtx = { canvasRef: currentCanvasRef ?? null } + const matched = filterEdgeContextMenuItems(edgeContextMenu.items, edge, matchCtx) + if (matched.length === 0) { + setEdgeContextMenuState(null) + return + } + setEdgeContextMenuState({ + items: matched, + edge, + screenPosition: event.screenPosition, + canvasRef: currentCanvasRef ?? null, + }) } - setContextMenuState({ - items: matched, - node, - screenPosition: event.screenPosition, - canvasRef: currentCanvasRef ?? null, - }) }, - [onContextMenu, nodeContextMenu, currentCanvasRef, alignDistributeMenu, selectedIds] + [onContextMenu, nodeContextMenu, edgeContextMenu, currentCanvasRef, alignDistributeMenu, selectedIds] ) // Effective context menu config — wraps the consumer's config (if any) and @@ -1486,6 +1518,8 @@ export const SystemCanvas = forwardRef( currentCanvasRef, contextMenuState, setContextMenuState, + edgeContextMenuState, + setEdgeContextMenuState, wrappedOnNodeAdd, wrappedOnEdgeAdd, wrappedOnNodeUpdate, @@ -1696,6 +1730,15 @@ export const SystemCanvas = forwardRef( /> )} + {edgeContextMenu && ( + setEdgeContextMenuState(null)} + /> + )} + {/* Search + category filter overlay — rendered last so it sits above all other overlays */} void setSelectedEdgeId: (id: string | null) => void setContextMenuState: (state: NodeContextMenuOverlayState | null) => void + edgeContextMenuState: EdgeContextMenuOverlayState | null + setEdgeContextMenuState: (state: EdgeContextMenuOverlayState | null) => void cancelDrag: () => void } @@ -85,6 +88,8 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions) { setEditingEdgeId, setSelectedEdgeId, setContextMenuState, + edgeContextMenuState, + setEdgeContextMenuState, cancelDrag, } = options @@ -98,6 +103,10 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions) { setContextMenuState(null) return } + if (edgeContextMenuState) { + setEdgeContextMenuState(null) + return + } if (editingId || editingEdgeId) { setEditingId(null) setEditingEdgeId(null) @@ -330,6 +339,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions) { theme, currentCanvasRef, contextMenuState, + edgeContextMenuState, wrappedOnNodeAdd, wrappedOnEdgeAdd, wrappedOnNodeUpdate,