diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd90a8a..3b56c55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,7 @@ jobs: - run: pnpm --filter @arielcharts/shared build - run: pnpm exec playwright install --with-deps chromium - run: pnpm test:e2e-workspace-ux + - run: pnpm test:e2e-subgraphs protected-collaboration: runs-on: ubuntu-latest diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9e8dab3..86ba09e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -527,6 +527,9 @@ code { } .workspace-settings-heading { + position: sticky; + z-index: 2; + top: 0; display: flex; align-items: center; justify-content: space-between; @@ -534,6 +537,8 @@ code { min-height: 48px; padding: 0 14px; border-bottom: 1px solid var(--line-subtle); + background: var(--surface-raised); + box-shadow: none; } .workspace-settings-heading h2, @@ -1185,6 +1190,7 @@ code { color: var(--ink-muted); } +.diagram-canvas-svg--reactflow svg g.cluster, .diagram-canvas-svg--reactflow svg g.node, .diagram-canvas-svg--reactflow svg .edgePaths, .diagram-canvas-svg--reactflow svg .edgeLabels, @@ -2260,14 +2266,6 @@ code { overscroll-behavior: contain; } - .workspace-settings-heading { - position: sticky; - z-index: 2; - top: 0; - background: var(--surface-raised); - box-shadow: none; - } - .workspace-share-button { width: 44px; height: 44px; diff --git a/apps/web/src/app/globals.test.ts b/apps/web/src/app/globals.test.ts index 7dbfc23..8236247 100644 --- a/apps/web/src/app/globals.test.ts +++ b/apps/web/src/app/globals.test.ts @@ -42,3 +42,9 @@ describe('revision history selection CSS contracts', () => { expect(css.slice(currentLastItem, currentLastItem + 140)).toMatch(/border-bottom-color:\s*var\(--selection\);/u); }); }); + +describe('editable section presentation', () => { + it('hides stale Mermaid cluster geometry behind the derived interactive section layer', () => { + expect(css).toMatch(/\.diagram-canvas-svg--reactflow svg g\.cluster,[^}]*\{\s*opacity:\s*0;/u); + }); +}); diff --git a/apps/web/src/components/diagram-canvas.test.ts b/apps/web/src/components/diagram-canvas.test.ts index f932fba..870fb60 100644 --- a/apps/web/src/components/diagram-canvas.test.ts +++ b/apps/web/src/components/diagram-canvas.test.ts @@ -4,7 +4,7 @@ import type { DiagramLink } from '../lib/diagram-mutations'; import type { MermaidPresentation } from '../lib/mermaid-presentation'; import type { SvgHitMap } from '../lib/svg-hit-map'; import { getCanvasEdgeMarker } from '../lib/mermaid-presentation'; -import { areMermaidPresentationsEqual, areSvgHitMapsEqual, getCanvasHistoryShortcut, getCanonicalSelectionAttribute, getFlowEdgePresentation, getFlowSelectionChange, getGraphMembershipKey, getNodeClickSelection, getRendererInteractionMode, isSameNodeSelection, shouldEnableCanvasMarquee, shouldHandleCanvasShortcut, shouldHandleCanvasSingleKeyShortcut, shouldRestoreCanvasFocusAfterPaste } from './diagram-canvas'; +import { areMermaidPresentationsEqual, areSvgHitMapsEqual, getCanvasHistoryShortcut, getCanonicalSelectionAttribute, getFlowEdgePresentation, getFlowSelectionChange, getGraphMembershipKey, getNodeClickSelection, getRendererInteractionMode, isSameNodeSelection, shouldEnableCanvasMarquee, shouldHandleCanvasShortcut, shouldHandleCanvasSingleKeyShortcut, shouldHandleGlobalCanvasRenameShortcut, shouldRestoreCanvasFocusAfterPaste } from './diagram-canvas'; const canvasSource = readFileSync(new URL('./diagram-canvas.tsx', import.meta.url), 'utf8'); const workspaceSource = readFileSync(new URL('./session-workspace.tsx', import.meta.url), 'utf8'); @@ -118,10 +118,20 @@ describe('shouldHandleCanvasShortcut', () => { }); describe('shouldHandleCanvasSingleKeyShortcut', () => { - it('keeps letter and camera shortcuts off toolbar controls and focused nodes', () => { - expect(shouldHandleCanvasSingleKeyShortcut(true, true)).toBe(true); - expect(shouldHandleCanvasSingleKeyShortcut(false, true)).toBe(false); - expect(shouldHandleCanvasSingleKeyShortcut(true, false)).toBe(false); + it('accepts canvas descendants while excluding typing and toolbar ownership', () => { + expect(shouldHandleCanvasSingleKeyShortcut(true, true, false, false)).toBe(true); + expect(shouldHandleCanvasSingleKeyShortcut(false, true, false, false)).toBe(false); + expect(shouldHandleCanvasSingleKeyShortcut(true, false, false, false)).toBe(false); + expect(shouldHandleCanvasSingleKeyShortcut(true, true, true, false)).toBe(false); + expect(shouldHandleCanvasSingleKeyShortcut(true, true, false, true)).toBe(false); + }); +}); + +describe('shouldHandleGlobalCanvasRenameShortcut', () => { + it('leaves F2 with a focused node or section instead of renaming a different selected node', () => { + expect(shouldHandleGlobalCanvasRenameShortcut(false, true)).toBe(false); + expect(shouldHandleGlobalCanvasRenameShortcut(true, false)).toBe(false); + expect(shouldHandleGlobalCanvasRenameShortcut(false, false)).toBe(true); }); }); diff --git a/apps/web/src/components/diagram-canvas.tsx b/apps/web/src/components/diagram-canvas.tsx index 1f8b37f..3b102e6 100644 --- a/apps/web/src/components/diagram-canvas.tsx +++ b/apps/web/src/components/diagram-canvas.tsx @@ -46,6 +46,7 @@ import { getCanvasDotGridGeometry } from '../lib/canvas-dot-grid'; import { beginCanvasMousePan, CanvasMousePanController } from '../lib/canvas-mouse-pan'; import { getDiagramEdgeIdentityForFlowEdge, getFlowEdgeId, getVisibleDiagramLinks } from '../lib/diagram-flow-identity'; import type { DiagramNodePositions, NodePositionsSyncMode } from '../lib/diagram-layout'; +import { canRenameFlowchartSubgraphDeclaration, getFlowchartCanvasBounds, getInteractiveSubgraphBounds, getNestedSubgraphNodeIds, getSubgraphLabel } from '../lib/diagram-subgraphs'; import { applyControlledNodeChanges, createControlledNodeComposer, @@ -80,6 +81,7 @@ export interface DiagramCanvasProps { graph: FlowchartSnapshot | null; interactionMode?: 'select' | 'connect'; isFlowchart?: boolean; + mermaidSource?: string; nodePositions?: DiagramNodePositions; preserveCamera?: boolean; readOnly?: boolean; @@ -95,6 +97,7 @@ export interface DiagramCanvasProps { onDeleteNodes?: (nodeIds: string[]) => void; onEditEdgeLabel?: (edge: DiagramEdgeIdentity, label?: string) => void; onEditNodeLabel?: (nodeId: string, newLabel: string) => void; + onEditSubgraphLabel?: (subgraphId: string, newLabel: string) => void; onGroupNodes?: (nodeIds: string[], label: string) => void; onInteractionModeChange?: (mode: 'select' | 'connect') => void; onNodeDrag?: (positions: DiagramNodePositions) => void; @@ -125,6 +128,14 @@ interface PendingEdge { target: string; } +interface SubgraphDragState { + begun: boolean; + initialPositions: DiagramNodePositions; + latestPositions: DiagramNodePositions; + origin: SvgPoint; + pointerId: number; +} + interface MermaidFlowNodeData extends Record { ariaLabel: string; label: string; @@ -250,10 +261,22 @@ export function shouldHandleCanvasShortcut( } export function shouldHandleCanvasSingleKeyShortcut( - targetIsCanvas: boolean, - activeElementIsCanvas: boolean, + targetIsInCanvas: boolean, + activeElementIsInCanvas: boolean, + targetIsExcluded: boolean, + activeElementIsExcluded: boolean, ): boolean { - return targetIsCanvas && activeElementIsCanvas; + return targetIsInCanvas + && activeElementIsInCanvas + && !targetIsExcluded + && !activeElementIsExcluded; +} + +export function shouldHandleGlobalCanvasRenameShortcut( + defaultPrevented: boolean, + targetHasLocalRenameHandler: boolean, +): boolean { + return !defaultPrevented && !targetHasLocalRenameHandler; } export function getCanvasHistoryShortcut( @@ -312,7 +335,7 @@ function canStartTouchCanvasGesture(target: EventTarget | null, root: HTMLDivEle return false; } - if (target.closest('a, button, input, select, textarea, [contenteditable="true"], [role="button"], [data-testid*="toolbar"], .react-flow__node, .react-flow__edge, .react-flow__handle')) { + if (target.closest('a, button, input, select, textarea, [contenteditable="true"], [role="button"], [data-subgraph-drag-target="true"], [data-testid*="toolbar"], .react-flow__node, .react-flow__edge, .react-flow__handle')) { return false; } @@ -324,6 +347,10 @@ function canStartMouseCanvasPan(target: EventTarget | null, root: HTMLDivElement return false; } + if (target.closest('[data-subgraph-drag-target="true"]')) { + return true; + } + return !target.closest('a, button, input, select, textarea, [contenteditable="true"], [role="button"], [data-testid*="toolbar"]'); } @@ -333,6 +360,7 @@ export function DiagramCanvas({ graph, interactionMode, isFlowchart = true, + mermaidSource = '', nodePositions, preserveCamera = false, onAddEdge, @@ -344,6 +372,7 @@ export function DiagramCanvas({ onDeleteNodes, onEditEdgeLabel, onEditNodeLabel, + onEditSubgraphLabel, onGroupNodes, onInteractionModeChange, onNodeDrag, @@ -381,6 +410,8 @@ export function DiagramCanvas({ const [liveNodePositions, setLiveNodePositions] = useState({}); const [flowNodeRuntime, setFlowNodeRuntime] = useState({}); const activeDragNodeIdsRef = useRef(new Set()); + const subgraphDragRef = useRef(null); + const cancelSubgraphEditRef = useRef(false); const controlledNodeComposer = useMemo(() => createControlledNodeComposer(), []); const persistedNodePositions = nodePositions ?? uncontrolledNodePositions; const persistedNodePositionsRef = useRef(persistedNodePositions); @@ -411,6 +442,9 @@ export function DiagramCanvas({ const [selectedEdgeIdentity, setSelectedEdgeIdentity] = useState(null); const [editingEdgeIdentity, setEditingEdgeIdentity] = useState(null); const [editingEdgeLabel, setEditingEdgeLabel] = useState(''); + const [selectedSubgraphId, setSelectedSubgraphId] = useState(null); + const [editingSubgraphId, setEditingSubgraphId] = useState(null); + const [editingSubgraphLabel, setEditingSubgraphLabel] = useState(''); const [shapePickerOpen, setShapePickerOpen] = useState(false); const [connectSourceId, setConnectSourceId] = useState(null); const [connectionPreviewSourceId, setConnectionPreviewSourceId] = useState(null); @@ -504,6 +538,26 @@ export function DiagramCanvas({ }, [graph?.links]); const nodeById = useMemo(() => new Map(graph?.nodes.map((node) => [node.id, node]) ?? []), [graph?.nodes]); + const subgraphById = useMemo(() => new Map(graph?.subgraphs.map((subgraph) => [subgraph.id, subgraph]) ?? []), [graph?.subgraphs]); + const subgraphMemberNodeIds = useMemo(() => new Map( + (graph?.subgraphs ?? []).map((subgraph) => [ + subgraph.id, + getNestedSubgraphNodeIds(subgraph.id, graph?.subgraphs ?? [], graph?.nodeIds ?? []), + ]), + ), [graph?.nodeIds, graph?.subgraphs]); + const interactiveSubgraphBounds = useMemo(() => { + const next = new Map(); + if (!hitMap || !interactiveNodeBounds) return next; + for (const [subgraphId, bounds] of hitMap.subgraphs) { + next.set(subgraphId, getInteractiveSubgraphBounds( + bounds, + hitMap.nodes, + interactiveNodeBounds, + subgraphMemberNodeIds.get(subgraphId) ?? [], + )); + } + return next; + }, [hitMap, interactiveNodeBounds, subgraphMemberNodeIds]); const graphMembershipKey = getGraphMembershipKey( graph?.nodes.map((node) => node.id) ?? [], graph?.subgraphs.map((subgraph) => subgraph.id) ?? [], @@ -534,6 +588,9 @@ export function DiagramCanvas({ return getBoundsUnion(boundsList); }, [hitMap, interactiveNodeBounds, selection]); + const useReactFlowRenderer = isFlowchart && Boolean( + graph?.nodes.some((node) => interactiveNodeBounds?.has(node.id)), + ); const graphBounds = useMemo(() => { if (!hitMap) { @@ -544,13 +601,14 @@ export function DiagramCanvas({ return hitMap.viewBox; } - const nodeBounds = interactiveNodeBounds ? [...interactiveNodeBounds.values()] : [...hitMap.nodes.values()]; - const allBounds = nodeBounds.length > 0 - ? [...nodeBounds, ...hitMap.subgraphs.values()] - : [...hitMap.subgraphs.values(), ...[...hitMap.edges.values()].map((edge) => edge.bounds)]; - - return getBoundsUnion(allBounds) ?? hitMap.viewBox; - }, [hitMap, interactiveNodeBounds, isFlowchart]); + return getFlowchartCanvasBounds( + interactiveNodeBounds ?? hitMap.nodes, + hitMap.subgraphs, + interactiveSubgraphBounds, + [...hitMap.edges.values()].map((edge) => edge.bounds), + useReactFlowRenderer, + ) ?? hitMap.viewBox; + }, [hitMap, interactiveNodeBounds, interactiveSubgraphBounds, isFlowchart, useReactFlowRenderer]); const remoteSelectionsByNodeId = useMemo(() => { const selections = new Map(); @@ -634,7 +692,6 @@ export function DiagramCanvas({ ...mermaidPresentation.edges.flatMap((presentation) => presentation.stroke ? [presentation.stroke] : []), ])], [mermaidPresentation.edges]); - const useReactFlowRenderer = isFlowchart && flowNodes.length > 0; const controlledFlowNodes = useMemo( () => controlledNodeComposer.compose(flowNodes, flowNodeRuntime), [controlledNodeComposer, flowNodeRuntime, flowNodes], @@ -653,6 +710,16 @@ export function DiagramCanvas({ return toScreenRect(selectedBounds, viewport); }, [selectedBounds, viewport]); + const selectedSubgraphBounds = selectedSubgraphId + ? interactiveSubgraphBounds.get(selectedSubgraphId) ?? null + : null; + const screenSelectedSubgraphBounds = useMemo(() => ( + selectedSubgraphBounds ? toScreenRect(selectedSubgraphBounds, viewport) : null + ), [selectedSubgraphBounds, viewport]); + const selectedSubgraphCanRename = Boolean( + selectedSubgraphId && canRenameFlowchartSubgraphDeclaration(mermaidSource, selectedSubgraphId), + ); + const editingNode = useMemo(() => { if (!graph || !editingNodeId) { return null; @@ -765,6 +832,18 @@ export function DiagramCanvas({ top: selectedToolbarPosition.top, zIndex: 30, }; + const selectedSubgraphToolbarPosition = getSafeToolbarPosition({ + anchor: screenSelectedSubgraphBounds + ? { x: screenSelectedSubgraphBounds.x + (screenSelectedSubgraphBounds.width / 2), y: screenSelectedSubgraphBounds.y } + : { x: 16, y: 16 }, + canvas: canvasViewport, + toolbar: { height: 34, width: 44 }, + }); + const subgraphToolbarStyle: CSSProperties = { + ...toolbarStyle, + left: selectedSubgraphToolbarPosition.left, + top: selectedSubgraphToolbarPosition.top, + }; const setSelection = useCallback((nodeIds: string[]) => { if (readOnly) { @@ -1123,6 +1202,16 @@ export function DiagramCanvas({ } }, [focusedNodeId, selection]); + useEffect(() => { + if (selectedSubgraphId && !subgraphById.has(selectedSubgraphId)) { + setSelectedSubgraphId(null); + } + if (editingSubgraphId && !subgraphById.has(editingSubgraphId)) { + setEditingSubgraphId(null); + setEditingSubgraphLabel(''); + } + }, [editingSubgraphId, selectedSubgraphId, subgraphById]); + useEffect(() => { if (selectedEdgeIdentity && selectedEdgeIndex === null) { setSelectedEdgeIdentity(null); @@ -1185,6 +1274,9 @@ export function DiagramCanvas({ connectionStartNodeIdRef.current = null; setConnectionPreviewSourceId(null); setShowGroupPrompt(false); + setSelectedSubgraphId(null); + setEditingSubgraphId(null); + setEditingSubgraphLabel(''); }, [isFlowchart, mode, setMode]); useEffect(() => { @@ -1226,6 +1318,9 @@ export function DiagramCanvas({ setSelectedEdgeIdentity(null); setEditingEdgeIdentity(null); setEditingEdgeLabel(''); + setSelectedSubgraphId(null); + setEditingSubgraphId(null); + setEditingSubgraphLabel(''); setMode('select'); canvas?.focus(); } @@ -1236,9 +1331,11 @@ export function DiagramCanvas({ const isModifierShortcut = event.metaKey || event.ctrlKey; const key = event.key.toLowerCase(); - const canvasContainerOwnsFocus = shouldHandleCanvasSingleKeyShortcut( - event.target === canvas, - document.activeElement === canvas, + const canvasOwnsSingleKeyFocus = shouldHandleCanvasSingleKeyShortcut( + event.target instanceof Node && Boolean(canvas?.contains(event.target)), + document.activeElement instanceof Node && Boolean(canvas?.contains(document.activeElement)), + isCanvasSingleKeyShortcutExcluded(event.target), + isCanvasSingleKeyShortcutExcluded(document.activeElement), ); const historyShortcut = getCanvasHistoryShortcut(event.key, isModifierShortcut, event.shiftKey); if (historyShortcut && !readOnly) { @@ -1284,13 +1381,13 @@ export function DiagramCanvas({ setShowGroupPrompt(true); } - if (!isModifierShortcut && canvasContainerOwnsFocus && canEditStructure && key === 'n') { + if (!isModifierShortcut && canvasOwnsSingleKeyFocus && canEditStructure && key === 'n') { event.preventDefault(); onAddNode?.(DEFAULT_NEW_NODE_LABEL, DEFAULT_NEW_NODE_SHAPE); return; } - if (!isModifierShortcut && canvasContainerOwnsFocus && canEditStructure && key === 'c') { + if (!isModifierShortcut && canvasOwnsSingleKeyFocus && canEditStructure && key === 'c') { event.preventDefault(); setPendingEdge(null); setPendingEdgeLabel(''); @@ -1299,31 +1396,40 @@ export function DiagramCanvas({ return; } - if (!isModifierShortcut && canvasContainerOwnsFocus && canEditStructure && hasPersistedLayout && key === 's') { + if (!isModifierShortcut && canvasOwnsSingleKeyFocus && canEditStructure && hasPersistedLayout && key === 's') { event.preventDefault(); simplifyLayout(); return; } - if (!isModifierShortcut && canvasContainerOwnsFocus && key === 'f') { + if (!isModifierShortcut && canvasOwnsSingleKeyFocus && key === 'f') { event.preventDefault(); fitToDiagram(true); return; } - if (!isModifierShortcut && canvasContainerOwnsFocus && (event.key === '+' || event.key === '=')) { + if (!isModifierShortcut && canvasOwnsSingleKeyFocus && (event.key === '+' || event.key === '=')) { event.preventDefault(); zoomCanvas(1.1); return; } - if (!isModifierShortcut && canvasContainerOwnsFocus && event.key === '-') { + if (!isModifierShortcut && canvasOwnsSingleKeyFocus && event.key === '-') { event.preventDefault(); zoomCanvas(0.9); return; } - if (!isModifierShortcut && canvasContainerOwnsFocus && event.key === 'F2' && canEditStructure && selection.length === 1) { + const targetHasLocalRenameHandler = event.target instanceof Element + && event.target.closest('.react-flow__node, [data-subgraph-drag-target="true"]') !== null; + if ( + !isModifierShortcut + && canvasOwnsSingleKeyFocus + && event.key === 'F2' + && canEditStructure + && selection.length === 1 + && shouldHandleGlobalCanvasRenameShortcut(event.defaultPrevented, targetHasLocalRenameHandler) + ) { const selectedNode = nodeById.get(selection[0] ?? ''); if (selectedNode) { event.preventDefault(); @@ -1603,6 +1709,8 @@ export function DiagramCanvas({ setToolbarOpen(false); setShapePickerOpen(false); setEditingNodeId(null); + setSelectedSubgraphId(null); + setEditingSubgraphId(null); }, [isPanning, setSelection]); const handleNodeActivation = useCallback((nodeId: string) => { @@ -1614,6 +1722,8 @@ export function DiagramCanvas({ setEditingEdgeIdentity(null); setFocusedNodeId(nodeId); setToolbarOpen(true); + setSelectedSubgraphId(null); + setEditingSubgraphId(null); if (mode === 'connect') { const activation = getConnectNodeActivation(nodeId, connectSourceId, interactiveNodeBounds); @@ -1647,6 +1757,102 @@ export function DiagramCanvas({ setEditingNodeId(null); }, [canEditStructure, editingLabel, editingNodeId, onEditNodeLabel]); + const selectSubgraph = useCallback((subgraphId: string) => { + if (!canEditStructure || !subgraphById.has(subgraphId)) return; + setSelection([]); + setSelectedEdgeIdentity(null); + setEditingEdgeIdentity(null); + setEditingNodeId(null); + setShapePickerOpen(false); + setToolbarOpen(false); + setSelectedSubgraphId(subgraphId); + }, [canEditStructure, setSelection, subgraphById]); + + const openSubgraphEditor = useCallback((subgraphId: string) => { + const subgraph = subgraphById.get(subgraphId); + if (!canEditStructure || !subgraph || !canRenameFlowchartSubgraphDeclaration(mermaidSource, subgraphId)) return; + cancelSubgraphEditRef.current = false; + setSelectedSubgraphId(subgraphId); + setEditingSubgraphId(subgraphId); + setEditingSubgraphLabel(getSubgraphLabel(subgraph)); + }, [canEditStructure, mermaidSource, subgraphById]); + + const commitSubgraphEdit = useCallback(() => { + if (cancelSubgraphEditRef.current) { + cancelSubgraphEditRef.current = false; + return; + } + if (!canEditStructure || !editingSubgraphId) return; + const current = subgraphById.get(editingSubgraphId); + onEditSubgraphLabel?.( + editingSubgraphId, + editingSubgraphLabel.trim() || (current ? getSubgraphLabel(current) : editingSubgraphId), + ); + setEditingSubgraphId(null); + setEditingSubgraphLabel(''); + }, [canEditStructure, editingSubgraphId, editingSubgraphLabel, onEditSubgraphLabel, subgraphById]); + + const handleSubgraphPointerDown = useCallback((event: ReactPointerEvent, subgraphId: string) => { + if (!canEditStructure || spacePressed || event.button !== 0 || !interactiveNodeBounds) return; + const initialPositions: DiagramNodePositions = {}; + for (const nodeId of subgraphMemberNodeIds.get(subgraphId) ?? []) { + const bounds = interactiveNodeBounds.get(nodeId); + if (bounds) initialPositions[nodeId] = { x: bounds.x, y: bounds.y }; + } + if (Object.keys(initialPositions).length === 0) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + selectSubgraph(subgraphId); + subgraphDragRef.current = { + begun: false, + initialPositions, + latestPositions: initialPositions, + origin: { x: event.clientX, y: event.clientY }, + pointerId: event.pointerId, + }; + }, [canEditStructure, interactiveNodeBounds, selectSubgraph, spacePressed, subgraphMemberNodeIds]); + + const handleSubgraphPointerMove = useCallback((event: ReactPointerEvent) => { + const drag = subgraphDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const dx = (event.clientX - drag.origin.x) / viewport.zoom; + const dy = (event.clientY - drag.origin.y) / viewport.zoom; + if (!drag.begun) { + if ((dx * dx) + (dy * dy) < 9) return; + if (onNodeDragStart?.(drag.initialPositions) === false) { + subgraphDragRef.current = null; + return; + } + drag.begun = true; + Object.keys(drag.initialPositions).forEach((nodeId) => activeDragNodeIdsRef.current.add(nodeId)); + } + event.preventDefault(); + const positions = Object.fromEntries(Object.entries(drag.initialPositions).map(([nodeId, position]) => [ + nodeId, + { x: position.x + dx, y: position.y + dy }, + ])); + drag.latestPositions = positions; + setLiveNodePositions((current) => ({ ...current, ...positions })); + onNodeDrag?.(positions); + }, [onNodeDrag, onNodeDragStart, viewport.zoom]); + + const handleSubgraphPointerEnd = useCallback((event: ReactPointerEvent) => { + const drag = subgraphDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + subgraphDragRef.current = null; + if (!drag.begun) return; + const nodeIds = Object.keys(drag.latestPositions); + nodeIds.forEach((nodeId) => activeDragNodeIdsRef.current.delete(nodeId)); + if (onNodeDragStop) onNodeDragStop(drag.latestPositions); + else setNodePositions((current) => ({ ...current, ...drag.latestPositions }), 'merge', drag.latestPositions); + setLiveNodePositions((current) => { + const next = { ...current }; + nodeIds.forEach((nodeId) => delete next[nodeId]); + return next; + }); + }, [onNodeDragStop, setNodePositions]); + const commitEdgeEdit = useCallback(() => { if (!canEditStructure || !editingEdgeIdentity) { return; @@ -1698,6 +1904,10 @@ export function DiagramCanvas({ return; } + if (nextSelection.length > 0) { + setSelectedSubgraphId(null); + setEditingSubgraphId(null); + } setSelection(nextSelection); }, [canEditStructure, setSelection]); @@ -1863,6 +2073,7 @@ export function DiagramCanvas({ } if (event.key === 'F2') { event.preventDefault(); + event.stopPropagation(); const node = nodeById.get(nodeId); if (node && !readOnly) { openNodeEditor(node); @@ -2085,12 +2296,16 @@ export function DiagramCanvas({ setToolbarOpen(false); setShapePickerOpen(false); setEditingNodeId(null); + setSelectedSubgraphId(null); + setEditingSubgraphId(null); setSelectedEdgeIdentity(edgeIdentity); }} onEdgeDoubleClick={(event, edge) => { event.stopPropagation(); const edgeIdentity = graph ? getDiagramEdgeIdentityForFlowEdge(graph.links, edge.id) : null; if (edgeIdentity) { + setSelectedSubgraphId(null); + setEditingSubgraphId(null); setSelectedEdgeIdentity(edgeIdentity); openEdgeEditor(edgeIdentity); } @@ -2131,6 +2346,115 @@ export function DiagramCanvas({ ) : null} + {useReactFlowRenderer ? ( +
+ {(graph?.subgraphs ?? []).map((subgraph) => { + const bounds = interactiveSubgraphBounds.get(subgraph.id); + if (!bounds) return null; + const screenBounds = toScreenRect(bounds, viewport); + const selected = selectedSubgraphId === subgraph.id; + const label = getSubgraphLabel(subgraph); + const edgeStyle: CSSProperties = { + cursor: 'grab', + pointerEvents: 'auto', + position: 'absolute', + }; + return ( +
+ + {([ + { left: -4, right: -4, top: -4, height: 8 }, + { bottom: -4, left: -4, right: -4, height: 8 }, + { bottom: 4, left: -4, top: 4, width: 8 }, + { bottom: 4, right: -4, top: 4, width: 8 }, + ] as CSSProperties[]).map((position, index) => ( +
+ ); + })} +
+ ) : null} + ) : null)} + {isFlowchart && !readOnly && selectedSubgraphId && selectedSubgraphCanRename && screenSelectedSubgraphBounds ? ( +
+ { openSubgraphEditor(selectedSubgraphId); }}> + + +
+ ) : null} + {isFlowchart && !readOnly && toolbarOpen && selection.length > 0 ? (
{selection.length === 1 ? ( @@ -2503,6 +2835,47 @@ export function DiagramCanvas({
) : null} + {editingSubgraphId && screenSelectedSubgraphBounds ? ( +
+ { setEditingSubgraphLabel(event.target.value); }} + onFocus={(event) => { event.currentTarget.select(); }} + onKeyDown={(event) => { + if (event.key === 'Enter') commitSubgraphEdit(); + if (event.key === 'Escape') { + cancelSubgraphEditRef.current = true; + setEditingSubgraphId(null); + setEditingSubgraphLabel(''); + } + }} + style={{ + background: 'var(--surface-canvas)', + border: '1px solid var(--control-border)', + borderBottomColor: 'var(--selection)', + borderRadius: 8, + color: 'var(--ink)', + fontFamily: 'var(--font-mono)', + fontSize: 12, + outline: 'none', + padding: '8px 10px', + width: '100%', + }} + value={editingSubgraphLabel} + /> +
+ ) : null} + {editingNode && editingNodeBounds ? (