diff --git a/CHANGELOG.md b/CHANGELOG.md index fb28b2de..92d37cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Spaces: allow empty Spaces (no last-node warning/auto-close), add pane context menu action to create an empty Space, and allow archiving a Space without saving its history. (#171) ### 🐞 Fixed +- Workspace canvas: keep window dragging stable at Space edges and preview crowded Space expansion before release without committing transient geometry. (#300) - Sidebar: stabilize Project, Space, and Agent drag sorting with scoped collision detection, consistent drag overlays, and measured upward-drag motion coverage. (#295) - Sidebar: use a continuous shared-list animation for expanded/collapsed mode switches, keep the Space disclosure as one moving button, hide rail text cleanly, preserve list scroll position, add overflow edge fades, and keep label color context menus open for consecutive changes. (#292) - Space Explorer: make image quick previews match the opened image node size and keep preview headers from covering image content. (#287) diff --git a/src/contexts/workspace/presentation/renderer/components/WorkspaceCanvasInner.tsx b/src/contexts/workspace/presentation/renderer/components/WorkspaceCanvasInner.tsx index 5da5cc99..13743413 100644 --- a/src/contexts/workspace/presentation/renderer/components/WorkspaceCanvasInner.tsx +++ b/src/contexts/workspace/presentation/renderer/components/WorkspaceCanvasInner.tsx @@ -135,7 +135,6 @@ export function WorkspaceCanvasInner({ onShowMessage, hideWorktreeMismatchDropWarning: agentSettings.hideWorktreeMismatchDropWarning === true, nodeDragPointerAnchorRef: nodeDragSession.nodeDragPointerAnchorRef, - nodeSpaceFramePreviewRef: nodeDragSession.nodeSpaceFramePreviewRef, }) const agentSupport = workspaceCanvasHooks.useWorkspaceCanvasAgentSupport({ nodesRef: nodeStore.nodesRef, diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.liveProjection.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.liveProjection.ts index 0582350e..e895aec8 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.liveProjection.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.liveProjection.ts @@ -1,8 +1,8 @@ import type { Node } from '@xyflow/react' import type { TerminalNodeData, WorkspaceSpaceRect } from '../../../types' -export const LIVE_DRAG_LAYOUT_MIN_DISTANCE_PX = 24 -export const LIVE_DRAG_LAYOUT_MIN_INTERVAL_MS = 120 +export const LIVE_DRAG_SECONDARY_MIN_DISTANCE_PX = 24 +export const LIVE_DRAG_SECONDARY_MIN_INTERVAL_MS = 120 export interface DragLayoutProjectionInput { currentNodes: Node[] @@ -19,10 +19,11 @@ export interface DragLayoutProjectionResult { nextSpaceFramePreview: ReadonlyMap | null } -export interface LastLiveDragLayoutProjection { +export interface LastLiveDragSecondaryProjection { anchorPoint: { x: number; y: number } | null draggedNodeKey: string projectedAtMs: number + targetSpaceId: string | null } export function readDragLayoutTimeMs(): number { @@ -52,22 +53,24 @@ export function resolveDragLayoutAnchorPoint({ : null } -export function shouldResolveLiveDragLayoutProjection({ +export function shouldResolveLiveDragSecondaryProjection({ lastProjection, draggedNodeKey, anchorPoint, nowMs, + targetSpaceId, }: { - lastProjection: LastLiveDragLayoutProjection | null + lastProjection: LastLiveDragSecondaryProjection | null draggedNodeKey: string anchorPoint: { x: number; y: number } | null nowMs: number + targetSpaceId: string | null }): boolean { - if (!lastProjection) { + if (!lastProjection || lastProjection.draggedNodeKey !== draggedNodeKey) { return true } - if (lastProjection.draggedNodeKey !== draggedNodeKey) { + if (lastProjection.targetSpaceId !== targetSpaceId) { return true } @@ -82,9 +85,12 @@ export function shouldResolveLiveDragLayoutProjection({ return false } - if (distanceSquared >= LIVE_DRAG_LAYOUT_MIN_DISTANCE_PX * LIVE_DRAG_LAYOUT_MIN_DISTANCE_PX) { + if ( + distanceSquared >= + LIVE_DRAG_SECONDARY_MIN_DISTANCE_PX * LIVE_DRAG_SECONDARY_MIN_DISTANCE_PX + ) { return true } - return nowMs - lastProjection.projectedAtMs >= LIVE_DRAG_LAYOUT_MIN_INTERVAL_MS + return nowMs - lastProjection.projectedAtMs >= LIVE_DRAG_SECONDARY_MIN_INTERVAL_MS } diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.ts index bd8850f1..e04f9bc4 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.ts @@ -14,15 +14,20 @@ import { setResolvedSpaceFramePreview, } from './useApplyNodeChanges.helpers' import { projectWorkspaceSpaceDominantLayout } from './useApplyNodeChanges.spaceDominant' -import { projectWorkspaceNodeDropLayout } from './useSpaceOwnership.projectDropLayout' +import { + projectWorkspaceNodeDropLayout, + projectWorkspaceNodeLiveDropLayout, + projectWorkspaceNodePrimaryDropLayout, +} from './useSpaceOwnership.projectDropLayout' +import { resolveSpaceAtPoint } from './useSpaceOwnership.drop.helpers' import { buildDraggedNodeKey, readDragLayoutTimeMs, resolveDragLayoutAnchorPoint, - shouldResolveLiveDragLayoutProjection, + shouldResolveLiveDragSecondaryProjection, type DragLayoutProjectionInput, type DragLayoutProjectionResult, - type LastLiveDragLayoutProjection, + type LastLiveDragSecondaryProjection, } from './useNodeDragSession.liveProjection' import type { WorkspaceCanvasNodeDragSession } from './useNodeDragSession.types' export type { WorkspaceCanvasNodeDragSession } from './useNodeDragSession.types' @@ -50,7 +55,7 @@ export function useWorkspaceCanvasNodeDragSession({ nodeId: string offset: { x: number; y: number } } | null>(null) - const nodeSpaceFramePreviewRef = useRef | null>(null) + // Visual-only for ordinary node drops; ownership always recomputes from durable Spaces. const [nodeSpaceFramePreview, setNodeSpaceFramePreview] = useState | null>(null) const dragSpaceDominantRef = useRef(false) const dragSpaceFramePreviewRef = useRef | null>(null) + const committableSpaceFramePreviewRef = useRef | null>( + null, + ) const pendingReleasePositionByIdRef = useRef | null>(null) const latestDragLayoutProjectionInputRef = useRef(null) - const lastLiveDragLayoutProjectionRef = useRef(null) - const liveDragLayoutFrameRef = useRef(null) + const lastLiveDragSecondaryProjectionRef = useRef(null) const setNodeSpaceFramePreviewState = useCallback( - (updater: React.SetStateAction | null>) => { - setNodeSpaceFramePreview(current => { - const next = - typeof updater === 'function' - ? ( - updater as ( - current: ReadonlyMap | null, - ) => ReadonlyMap | null - )(current) - : updater - - nodeSpaceFramePreviewRef.current = next - return next - }) - }, + (next: React.SetStateAction | null>) => + setNodeSpaceFramePreview(next), [], ) const resetLiveDragLayoutProjection = useCallback(() => { latestDragLayoutProjectionInputRef.current = null - lastLiveDragLayoutProjectionRef.current = null - if (liveDragLayoutFrameRef.current !== null) { - window.cancelAnimationFrame?.(liveDragLayoutFrameRef.current) - liveDragLayoutFrameRef.current = null - } - }, []) - - const markLiveDragLayoutProjectionFrame = useCallback(() => { - if ( - liveDragLayoutFrameRef.current !== null || - typeof window.requestAnimationFrame !== 'function' - ) { - return - } - - liveDragLayoutFrameRef.current = window.requestAnimationFrame(() => { - liveDragLayoutFrameRef.current = null - }) + lastLiveDragSecondaryProjectionRef.current = null }, []) const resolveDragLayoutProjection = useCallback( - ({ - currentNodes, - draggedNodeIds, - desiredDraggedPositionById, - dropFlowPoint, - anchorNodeId, - anchorIsSelected, - }: DragLayoutProjectionInput): DragLayoutProjectionResult => { + ( + { + currentNodes, + draggedNodeIds, + desiredDraggedPositionById, + dropFlowPoint, + anchorNodeId, + anchorIsSelected, + }: DragLayoutProjectionInput, + strategy: 'primary' | 'live' | 'final', + ): DragLayoutProjectionResult => { const baselinePositionById = dragBaselinePositionByIdRef.current const baselineSpaceRectById = dragBaselineSpaceRectByIdRef.current const resolvedAnchorNodeId = anchorNodeId ?? draggedNodeIds[0] ?? null @@ -153,6 +133,7 @@ export function useWorkspaceCanvasNodeDragSession({ }) dragSpaceFramePreviewRef.current = projected.nextSpaceFramePreview + committableSpaceFramePreviewRef.current = projected.nextSpaceFramePreview setResolvedSpaceFramePreview(setNodeSpaceFramePreviewState, projected.nextSpaceFramePreview) pendingReleasePositionByIdRef.current = null @@ -168,13 +149,22 @@ export function useWorkspaceCanvasNodeDragSession({ } } + // Ordinary node previews are visual-only and can never be committed as a + // Space-dominant frame, even if selection changes during the drag. + committableSpaceFramePreviewRef.current = null const baselineNodes = buildDragBaselineNodes({ nodes: currentNodes, baselinePositionById, shiftNodeIds: null, }) - const projected = projectWorkspaceNodeDropLayout({ + const projectDropLayout = + strategy === 'primary' + ? projectWorkspaceNodePrimaryDropLayout + : strategy === 'live' + ? projectWorkspaceNodeLiveDropLayout + : projectWorkspaceNodeDropLayout + const projected = projectDropLayout({ nodes: baselineNodes, spaces: spacesRef.current, draggedNodeIds, @@ -206,16 +196,22 @@ export function useWorkspaceCanvasNodeDragSession({ } } - const nextSpaceFramePreview = hasChanged - ? new Map( - projected.nextSpaces - .filter(space => Boolean(space.rect)) - .map(space => [space.id, space.rect!] as const), - ) - : null - - dragSpaceFramePreviewRef.current = nextSpaceFramePreview - setResolvedSpaceFramePreview(setNodeSpaceFramePreviewState, nextSpaceFramePreview) + const nextSpaceFramePreview = + strategy === 'primary' + ? dragSpaceFramePreviewRef.current + : hasChanged + ? new Map( + projected.nextSpaces + .filter(space => Boolean(space.rect)) + .map(space => [space.id, space.rect!] as const), + ) + : null + + if (strategy !== 'primary') { + dragSpaceFramePreviewRef.current = nextSpaceFramePreview + committableSpaceFramePreviewRef.current = null + setResolvedSpaceFramePreview(setNodeSpaceFramePreviewState, nextSpaceFramePreview) + } return { nextNodes, @@ -233,6 +229,7 @@ export function useWorkspaceCanvasNodeDragSession({ const clearNodeDragProjection = useCallback(() => { dragSpaceFramePreviewRef.current = null + committableSpaceFramePreviewRef.current = null pendingReleasePositionByIdRef.current = null resetLiveDragLayoutProjection() setResolvedSnapGuides(setSnapGuides, null) @@ -252,6 +249,7 @@ export function useWorkspaceCanvasNodeDragSession({ const selectedSpaceIdsAtStart = dragSelectedSpaceIdsRef.current ?? selectedSpaceIdsRef.current dragSpaceDominantRef.current = selectedSpaceIdsAtStart.length > 0 dragSpaceFramePreviewRef.current = null + committableSpaceFramePreviewRef.current = null pendingReleasePositionByIdRef.current = null resetLiveDragLayoutProjection() }, @@ -344,43 +342,55 @@ export function useWorkspaceCanvasNodeDragSession({ desiredDraggedPositionById: nextDraggedPositionById, }) const nowMs = readDragLayoutTimeMs() - const shouldResolveProjection = shouldResolveLiveDragLayoutProjection({ - lastProjection: lastLiveDragLayoutProjectionRef.current, + const draggedNodeIdSet = new Set(draggedNodeIds) + const desiredDraggedNodes = currentNodes.flatMap(node => { + if (!draggedNodeIdSet.has(node.id)) { + return [] + } + + const desiredPosition = nextDraggedPositionById.get(node.id) + return desiredPosition ? [{ ...node, position: desiredPosition }] : [] + }) + const desiredDraggedRect = unionWorkspaceNodeRects(desiredDraggedNodes) + const targetPoint = + draggedNodeIds.length === 1 && + dropFlowPoint && + Number.isFinite(dropFlowPoint.x) && + Number.isFinite(dropFlowPoint.y) + ? dropFlowPoint + : desiredDraggedRect + ? { + x: desiredDraggedRect.x + desiredDraggedRect.width * 0.5, + y: desiredDraggedRect.y + desiredDraggedRect.height * 0.5, + } + : null + const targetSpaceId = targetPoint + ? (resolveSpaceAtPoint(spacesRef.current, targetPoint)?.id ?? null) + : null + const shouldResolveSecondary = shouldResolveLiveDragSecondaryProjection({ + lastProjection: lastLiveDragSecondaryProjectionRef.current, draggedNodeKey, anchorPoint, nowMs, + targetSpaceId, }) - if (!shouldResolveProjection) { - return { - nextNodes: currentNodes, - nextDraggedNodePositionById: new Map( - draggedNodeIds.flatMap(nodeId => { - const next = nextDraggedPositionById.get(nodeId) - return next ? ([[nodeId, next]] as const) : [] - }), - ), - nextSpaceFramePreview: dragSpaceFramePreviewRef.current, + if (shouldResolveSecondary) { + lastLiveDragSecondaryProjectionRef.current = { + anchorPoint: anchorPoint ? { ...anchorPoint } : null, + draggedNodeKey, + projectedAtMs: nowMs, + targetSpaceId, } } - const projected = resolveDragLayoutProjection(projectionInput) - lastLiveDragLayoutProjectionRef.current = { - anchorPoint: anchorPoint ? { ...anchorPoint } : null, - draggedNodeKey, - projectedAtMs: nowMs, - } - markLiveDragLayoutProjectionFrame() - - return projected + // Never skip primary: deferred peer layout must not expose raw edge-crossing positions. + return resolveDragLayoutProjection( + projectionInput, + shouldResolveSecondary ? 'live' : 'primary', + ) }, - [ - magneticSnappingEnabledRef, - markLiveDragLayoutProjectionFrame, - resolveDragLayoutProjection, - setSnapGuides, - spacesRef, - ], + [magneticSnappingEnabledRef, resolveDragLayoutProjection, setSnapGuides, spacesRef], ) const applyPendingReleaseProjection = useCallback( @@ -412,19 +422,22 @@ export function useWorkspaceCanvasNodeDragSession({ desiredDraggedPositionById.set(nodeId, { x: position.x, y: position.y }) } - const finalProjection = resolveDragLayoutProjection({ - ...latestInput, - currentNodes: releaseNodes, - desiredDraggedPositionById, - }) + const finalProjection = resolveDragLayoutProjection( + { + ...latestInput, + currentNodes: releaseNodes, + desiredDraggedPositionById, + }, + 'final', + ) return finalProjection.nextNodes }, [resolveDragLayoutProjection], ) const endNodeDragSession = useCallback(() => { - if (dragSpaceDominantRef.current && dragSpaceFramePreviewRef.current) { - const rectOverrideById = dragSpaceFramePreviewRef.current + if (dragSpaceDominantRef.current && committableSpaceFramePreviewRef.current) { + const rectOverrideById = committableSpaceFramePreviewRef.current const previousSpaces = spacesRef.current let hasSpaceChange = false const nextSpaces = previousSpaces.map(space => { @@ -465,7 +478,6 @@ export function useWorkspaceCanvasNodeDragSession({ return { nodeDragPointerAnchorRef, nodeSpaceFramePreview, - nodeSpaceFramePreviewRef, dragBaselinePositionByIdRef, dragBaselineSpaceRectByIdRef, beginNodeDragSession, diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.types.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.types.ts index 5eed50a5..7cd26000 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.types.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.types.ts @@ -8,7 +8,6 @@ export interface WorkspaceCanvasNodeDragSession { offset: { x: number; y: number } } | null> nodeSpaceFramePreview: ReadonlyMap | null - nodeSpaceFramePreviewRef: MutableRefObject | null> dragBaselinePositionByIdRef: MutableRefObject | null> dragBaselineSpaceRectByIdRef: MutableRefObject | null> beginNodeDragSession: (currentNodes: Node[]) => void diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceExplorer.quickPreviewActions.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceExplorer.quickPreviewActions.ts index bf88c664..0d27f3b0 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceExplorer.quickPreviewActions.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceExplorer.quickPreviewActions.ts @@ -147,11 +147,9 @@ export function useWorkspaceCanvasSpaceExplorerQuickPreviewActions(options: { let released = false let cleanedUp = false let latestDraggedNodePositionById = new Map() - let latestSpaceFramePreview: ReadonlyMap | null = null const sequence = options.beginTransientRequest() const clearDragProjection = () => { - latestSpaceFramePreview = null options.nodeDragSession.clearNodeDragProjection() } @@ -177,7 +175,6 @@ export function useWorkspaceCanvasSpaceExplorerQuickPreviewActions(options: { desiredDraggedPositionById: new Map([[materializedNodeId!, desiredPosition]]), dropFlowPoint, }) - latestSpaceFramePreview = projected.nextSpaceFramePreview latestDraggedNodePositionById = projected.nextDraggedNodePositionById return projected.nextNodes }, @@ -217,7 +214,6 @@ export function useWorkspaceCanvasSpaceExplorerQuickPreviewActions(options: { dragStartSpaceRectById, dropFlowPoint: options.reactFlow.screenToFlowPosition(latestClient), fallbackNodes: fallbackNode ? [fallbackNode] : [], - spaceRectOverrideById: latestSpaceFramePreview, }) options.nodeDragSession.endNodeDragSession() cleanup() diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectDropLayout.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectDropLayout.ts index a78b547a..aace68f5 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectDropLayout.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectDropLayout.ts @@ -2,7 +2,12 @@ import type { Node } from '@xyflow/react' import type { TerminalNodeData, WorkspaceSpaceRect, WorkspaceSpaceState } from '../../../types' import { expandSpaceToFitOwnedNodesAndPushAway } from '../../../utils/spaceAutoResize' import { reassignNodesAcrossSpaces } from './useSpaceOwnership.drop.helpers' -import { projectWorkspaceNodeDragLayout } from './useSpaceOwnership.projectLayout' +import { + projectWorkspaceNodeDragLayout, + projectWorkspaceNodeLiveDragLayout, + type ProjectedNodeDragLayout, +} from './useSpaceOwnership.projectLayout' +import { projectWorkspaceNodePrimaryDragLayout } from './useSpaceOwnership.projectLayout.primary' export interface ProjectedWorkspaceNodeDropLayout { targetSpaceId: string | null @@ -11,6 +16,18 @@ export interface ProjectedWorkspaceNodeDropLayout { hasSpaceChange: boolean } +interface ProjectWorkspaceNodeDropLayoutInput { + nodes: Node[] + spaces: WorkspaceSpaceState[] + draggedNodeIds: string[] + draggedNodePositionById: Map + dragDx?: number + dragDy?: number + dropFlowPoint?: { x: number; y: number } | null +} + +type DragProjector = (input: ProjectWorkspaceNodeDropLayoutInput) => ProjectedNodeDragLayout | null + function rectEquals(a: WorkspaceSpaceRect | null, b: WorkspaceSpaceRect | null): boolean { if (a === b) { return true @@ -23,23 +40,43 @@ function rectEquals(a: WorkspaceSpaceRect | null, b: WorkspaceSpaceRect | null): return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height } -export function projectWorkspaceNodeDropLayout({ - nodes, +function mergeProjectedSpaceRects({ spaces, - draggedNodeIds, - draggedNodePositionById, - dragDx = 0, - dragDy = 0, - dropFlowPoint, + projectedSpaces, }: { - nodes: Node[] spaces: WorkspaceSpaceState[] - draggedNodeIds: string[] - draggedNodePositionById: Map - dragDx?: number - dragDy?: number - dropFlowPoint?: { x: number; y: number } | null + projectedSpaces: WorkspaceSpaceState[] +}): WorkspaceSpaceState[] { + const projectedSpaceById = new Map(projectedSpaces.map(space => [space.id, space])) + return spaces.map(space => { + const projectedRect = projectedSpaceById.get(space.id)?.rect ?? null + return projectedRect && !rectEquals(projectedRect, space.rect) + ? { ...space, rect: { ...projectedRect } } + : space + }) +} + +function projectWorkspaceNodeDropLayoutWith({ + input, + projectDrag, + previewOnly = false, + preserveDraggedNodePositions = false, +}: { + input: ProjectWorkspaceNodeDropLayoutInput + projectDrag: DragProjector + previewOnly?: boolean + preserveDraggedNodePositions?: boolean }): ProjectedWorkspaceNodeDropLayout { + const { + nodes, + spaces, + draggedNodeIds, + draggedNodePositionById, + dragDx = 0, + dragDy = 0, + dropFlowPoint, + } = input + if (draggedNodeIds.length === 0) { return { targetSpaceId: null, @@ -49,7 +86,7 @@ export function projectWorkspaceNodeDropLayout({ } } - const projectedDrag = projectWorkspaceNodeDragLayout({ + const projectedDrag = projectDrag({ nodes, spaces, draggedNodeIds, @@ -115,13 +152,20 @@ export function projectWorkspaceNodeDropLayout({ gap: 0, }) + const draggedNodeIdSet = new Set(draggedNodeIds) nodeRects = nodeRects.map(item => { - const next = nodePositionById.get(item.id) - if (!next) { - return item + if (preserveDraggedNodePositions && draggedNodeIdSet.has(item.id)) { + const primaryPosition = projectedDrag.nextNodePositionById.get(item.id) + if (primaryPosition) { + return { + id: item.id, + rect: { ...item.rect, x: primaryPosition.x, y: primaryPosition.y }, + } + } } - return { id: item.id, rect: { ...item.rect, x: next.x, y: next.y } } + const next = nodePositionById.get(item.id) + return next ? { id: item.id, rect: { ...item.rect, x: next.x, y: next.y } } : item }) const beforeRectById = new Map( @@ -129,22 +173,28 @@ export function projectWorkspaceNodeDropLayout({ .filter(space => Boolean(space.rect)) .map(space => [space.id, space.rect!] as const), ) + const hasRectChange = pushedSpaces.some( + space => Boolean(space.rect) && !rectEquals(space.rect, beforeRectById.get(space.id) ?? null), + ) - const hasRectChange = pushedSpaces.some(space => { - if (!space.rect) { - return false - } - - return !rectEquals(space.rect, beforeRectById.get(space.id) ?? null) - }) + const projectedSpaces = hasRectChange + ? pushedSpaces + : hasSpaceChange + ? reassignedSpaces + : spaces + const nextSpaces = previewOnly + ? hasRectChange + ? mergeProjectedSpaceRects({ spaces, projectedSpaces }) + : spaces + : projectedSpaces return { targetSpaceId, nextNodePositionById: new Map( nodeRects.map(item => [item.id, { x: item.rect.x, y: item.rect.y }]), ), - nextSpaces: hasRectChange ? pushedSpaces : hasSpaceChange ? reassignedSpaces : spaces, - hasSpaceChange: hasRectChange || hasSpaceChange, + nextSpaces, + hasSpaceChange: hasRectChange || (!previewOnly && hasSpaceChange), } } @@ -153,7 +203,73 @@ export function projectWorkspaceNodeDropLayout({ nextNodePositionById: new Map( nodeRects.map(item => [item.id, { x: item.rect.x, y: item.rect.y }]), ), - nextSpaces: hasSpaceChange ? reassignedSpaces : spaces, - hasSpaceChange, + nextSpaces: previewOnly ? spaces : hasSpaceChange ? reassignedSpaces : spaces, + hasSpaceChange: previewOnly ? false : hasSpaceChange, + } +} + +export function projectWorkspaceNodeLiveDropLayout( + input: ProjectWorkspaceNodeDropLayoutInput, +): ProjectedWorkspaceNodeDropLayout { + return projectWorkspaceNodeDropLayoutWith({ + input, + projectDrag: projectWorkspaceNodeLiveDragLayout, + previewOnly: true, + preserveDraggedNodePositions: true, + }) +} + +export function projectWorkspaceNodePrimaryDropLayout( + input: ProjectWorkspaceNodeDropLayoutInput, +): ProjectedWorkspaceNodeDropLayout { + return projectWorkspaceNodeTransientDropLayout( + input, + projectWorkspaceNodePrimaryDragLayout(input), + false, + ) +} + +function projectWorkspaceNodeTransientDropLayout( + input: ProjectWorkspaceNodeDropLayoutInput, + projected: Pick | null, + restoreBaselinePositions: boolean, +): ProjectedWorkspaceNodeDropLayout { + if (input.draggedNodeIds.length === 0) { + return { + targetSpaceId: null, + nextNodePositionById: new Map(), + nextSpaces: input.spaces, + hasSpaceChange: false, + } + } + + const nextNodePositionById = restoreBaselinePositions + ? new Map(input.nodes.map(node => [node.id, { ...node.position }])) + : new Map() + + if (projected) { + projected.nextNodePositionById.forEach((position, nodeId) => { + nextNodePositionById.set(nodeId, position) + }) + } else { + input.draggedNodeIds.forEach(nodeId => { + const position = input.draggedNodePositionById.get(nodeId) + if (position) { + nextNodePositionById.set(nodeId, position) + } + }) } + + return { + targetSpaceId: projected?.targetSpaceId ?? null, + nextNodePositionById, + nextSpaces: input.spaces, + hasSpaceChange: false, + } +} + +export function projectWorkspaceNodeDropLayout( + input: ProjectWorkspaceNodeDropLayoutInput, +): ProjectedWorkspaceNodeDropLayout { + return projectWorkspaceNodeDropLayoutWith({ input, projectDrag: projectWorkspaceNodeDragLayout }) } diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.live.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.live.ts new file mode 100644 index 00000000..eda45278 --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.live.ts @@ -0,0 +1,132 @@ +import type { WorkspaceSpaceRect } from '../../../types' +import { + pushAwayLayout, + SPACE_NODE_PADDING, + type LayoutDirection, + type LayoutItem, +} from '../../../utils/spaceLayout' + +const LAYOUT_EPSILON = 0.001 + +function rectsIntersect(a: WorkspaceSpaceRect, b: WorkspaceSpaceRect): boolean { + return !( + a.x + a.width <= b.x + LAYOUT_EPSILON || + a.x >= b.x + b.width - LAYOUT_EPSILON || + a.y + a.height <= b.y + LAYOUT_EPSILON || + a.y >= b.y + b.height - LAYOUT_EPSILON + ) +} + +function rectFitsInside( + rect: WorkspaceSpaceRect, + bounds: WorkspaceSpaceRect, + padding: number, +): boolean { + return ( + rect.x >= bounds.x + padding - LAYOUT_EPSILON && + rect.y >= bounds.y + padding - LAYOUT_EPSILON && + rect.x + rect.width <= bounds.x + bounds.width - padding + LAYOUT_EPSILON && + rect.y + rect.height <= bounds.y + bounds.height - padding + LAYOUT_EPSILON + ) +} + +function passesBoundedCapacityLowerBound({ + items, + targetSpaceRect, + padding, +}: { + items: LayoutItem[] + targetSpaceRect: WorkspaceSpaceRect + padding: number +}): boolean { + const innerWidth = Math.max(0, targetSpaceRect.width - padding * 2) + const innerHeight = Math.max(0, targetSpaceRect.height - padding * 2) + const innerArea = innerWidth * innerHeight + let requiredArea = 0 + + for (const item of items) { + if (item.rect.width > innerWidth || item.rect.height > innerHeight) { + return false + } + + requiredArea += Math.max(0, item.rect.width) * Math.max(0, item.rect.height) + if (requiredArea > innerArea + LAYOUT_EPSILON) { + return false + } + } + + return true +} + +function isValidBoundedLayout({ + items, + targetSpaceRect, + padding, +}: { + items: LayoutItem[] + targetSpaceRect: WorkspaceSpaceRect + padding: number +}): boolean { + for (let index = 0; index < items.length; index += 1) { + const item = items[index] + if (!item || !rectFitsInside(item.rect, targetSpaceRect, padding)) { + return false + } + + for (let peerIndex = index + 1; peerIndex < items.length; peerIndex += 1) { + const peer = items[peerIndex] + if (!peer || item.groupId === peer.groupId) { + continue + } + + if (rectsIntersect(item.rect, peer.rect)) { + return false + } + } + } + + return true +} + +export function resolveLiveTargetSpaceLayout({ + items, + pinnedGroupIds, + sourceGroupIds, + directions, + targetSpaceRect, + padding = SPACE_NODE_PADDING, +}: { + items: LayoutItem[] + pinnedGroupIds: string[] + sourceGroupIds: string[] + directions: LayoutDirection[] + targetSpaceRect: WorkspaceSpaceRect + padding?: number +}): LayoutItem[] { + // This is only a necessary capacity check. The postcondition below still + // decides whether the bounded rectangle packing actually succeeded. + if (passesBoundedCapacityLowerBound({ items, targetSpaceRect, padding })) { + const bounded = pushAwayLayout({ + items, + pinnedGroupIds, + sourceGroupIds, + directions, + gap: 0, + bounds: { rect: targetSpaceRect, padding }, + }) + + if (isValidBoundedLayout({ items: bounded, targetSpaceRect, padding })) { + return bounded + } + } + + // Overflow is intentional here: the derived Space frame expands around this + // layout on the same secondary projection, before any durable state changes. + return pushAwayLayout({ + items, + pinnedGroupIds, + sourceGroupIds, + directions, + gap: 0, + }) +} diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.primary.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.primary.ts new file mode 100644 index 00000000..3c8d4fee --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.primary.ts @@ -0,0 +1,235 @@ +import type { Node } from '@xyflow/react' +import type { TerminalNodeData, WorkspaceSpaceRect, WorkspaceSpaceState } from '../../../types' +import { + SPACE_NODE_PADDING, + type LayoutDirection, + type LayoutItem, +} from '../../../utils/spaceLayout' +import { + computeBoundingRect, + resolveDeltaToKeepRectInsideRect, + resolveDeltaToKeepRectOutsideRects, +} from './useSpaceOwnership.helpers' +import { resolveSpaceAtPoint } from './useSpaceOwnership.drop.helpers' +import { resolveNearestNonOverlappingRectWithinBounds } from './useSpaceOwnership.projectLayout.bounded.placeRect' + +export interface ProjectedNodePrimaryDragLayout { + targetSpaceId: string | null + targetSpaceRect: WorkspaceSpaceRect | null + nextNodePositionById: Map + constrainedDraggedNodes: Array> + childSpaceObstacleItems: LayoutItem[] + directions: LayoutDirection[] + dropCenter: { x: number; y: number } +} + +export interface ProjectWorkspaceNodeDragLayoutInput { + nodes: Array> + spaces: WorkspaceSpaceState[] + draggedNodeIds: string[] + draggedNodePositionById: Map + dragDx?: number + dragDy?: number + dropFlowPoint?: { x: number; y: number } | null +} + +export function buildWorkspaceDragDirectionPreference(dx: number, dy: number): LayoutDirection[] { + const ordered: LayoutDirection[] = [] + const xDirection = dx >= 0 ? ('x+' as const) : ('x-' as const) + const yDirection = dy >= 0 ? ('y+' as const) : ('y-' as const) + + if (Math.abs(dx) >= Math.abs(dy)) { + ordered.push(xDirection, yDirection) + } else { + ordered.push(yDirection, xDirection) + } + + if (!ordered.includes('x+')) { + ordered.push('x+') + } + if (!ordered.includes('x-')) { + ordered.push('x-') + } + if (!ordered.includes('y+')) { + ordered.push('y+') + } + if (!ordered.includes('y-')) { + ordered.push('y-') + } + + return ordered +} + +function buildChildSpaceObstacleItems( + spaces: WorkspaceSpaceState[], + parentSpaceId: string, +): LayoutItem[] { + return spaces + .filter(space => (space.parentSpaceId ?? null) === parentSpaceId && Boolean(space.rect)) + .map(space => ({ + id: space.id, + kind: 'space' as const, + groupId: `space:${space.id}`, + rect: { ...space.rect! }, + })) +} + +function applyDelta( + nodes: Array>, + delta: { dx: number; dy: number }, +): Array> { + if (delta.dx === 0 && delta.dy === 0) { + return nodes + } + + return nodes.map(node => ({ + ...node, + position: { + x: node.position.x + delta.dx, + y: node.position.y + delta.dy, + }, + })) +} + +function resolveDraggedNodesWithinTargetSpace({ + draggedNodes, + dropRect, + targetSpaceRect, + obstacleItems, + directions, +}: { + draggedNodes: Array> + dropRect: WorkspaceSpaceRect + targetSpaceRect: WorkspaceSpaceRect + obstacleItems: LayoutItem[] + directions: LayoutDirection[] +}): Array> { + if (obstacleItems.length === 0) { + const { dx, dy } = resolveDeltaToKeepRectInsideRect( + dropRect, + targetSpaceRect, + SPACE_NODE_PADDING, + ) + return applyDelta(draggedNodes, { dx, dy }) + } + + const placedDropRect = resolveNearestNonOverlappingRectWithinBounds({ + desired: dropRect, + obstacles: obstacleItems.map(item => item.rect), + bounds: { + left: targetSpaceRect.x + SPACE_NODE_PADDING, + top: targetSpaceRect.y + SPACE_NODE_PADDING, + right: targetSpaceRect.x + targetSpaceRect.width - SPACE_NODE_PADDING, + bottom: targetSpaceRect.y + targetSpaceRect.height - SPACE_NODE_PADDING, + }, + directions, + }) + + if (!placedDropRect) { + const insideDelta = resolveDeltaToKeepRectInsideRect( + dropRect, + targetSpaceRect, + SPACE_NODE_PADDING, + ) + const insideDraggedNodes = applyDelta(draggedNodes, insideDelta) + const insideDropRect = { + ...dropRect, + x: dropRect.x + insideDelta.dx, + y: dropRect.y + insideDelta.dy, + } + + return applyDelta( + insideDraggedNodes, + resolveDeltaToKeepRectOutsideRects( + insideDropRect, + obstacleItems.map(item => item.rect), + ), + ) + } + + return applyDelta(draggedNodes, { + dx: placedDropRect.x - dropRect.x, + dy: placedDropRect.y - dropRect.y, + }) +} + +export function projectWorkspaceNodePrimaryDragLayout({ + nodes, + spaces, + draggedNodeIds, + draggedNodePositionById, + dragDx = 0, + dragDy = 0, + dropFlowPoint, +}: ProjectWorkspaceNodeDragLayoutInput): ProjectedNodePrimaryDragLayout | null { + if (draggedNodeIds.length === 0) { + return null + } + + const nodeById = new Map(nodes.map(node => [node.id, node])) + const draggedNodes = draggedNodeIds + .map(nodeId => { + const node = nodeById.get(nodeId) + if (!node) { + return null + } + + const desiredPosition = draggedNodePositionById.get(nodeId) + return desiredPosition ? { ...node, position: desiredPosition } : node + }) + .filter((node): node is Node => Boolean(node)) + + const dropRect = computeBoundingRect(draggedNodes) + if (!dropRect) { + return null + } + + const dropCenter = { + x: dropRect.x + dropRect.width * 0.5, + y: dropRect.y + dropRect.height * 0.5, + } + const dropTargetPoint = + draggedNodeIds.length > 1 + ? dropCenter + : dropFlowPoint && Number.isFinite(dropFlowPoint.x) && Number.isFinite(dropFlowPoint.y) + ? dropFlowPoint + : dropCenter + const targetSpace = resolveSpaceAtPoint(spaces, dropTargetPoint) + const targetSpaceId = targetSpace?.id ?? null + const targetSpaceRect = targetSpace?.rect ?? null + const directions = buildWorkspaceDragDirectionPreference(dragDx, dragDy) + const childSpaceObstacleItems = targetSpaceId + ? buildChildSpaceObstacleItems(spaces, targetSpaceId) + : [] + + const constrainedDraggedNodes = + targetSpaceId && targetSpaceRect + ? resolveDraggedNodesWithinTargetSpace({ + draggedNodes, + dropRect, + targetSpaceRect, + obstacleItems: childSpaceObstacleItems, + directions, + }) + : applyDelta( + draggedNodes, + resolveDeltaToKeepRectOutsideRects( + dropRect, + spaces + .map(space => space.rect) + .filter((rect): rect is WorkspaceSpaceRect => Boolean(rect)), + ), + ) + + return { + targetSpaceId, + targetSpaceRect, + nextNodePositionById: new Map( + constrainedDraggedNodes.map(node => [node.id, { ...node.position }]), + ), + constrainedDraggedNodes, + childSpaceObstacleItems, + directions, + dropCenter, + } +} diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.ts index 6b0d0c28..9f4f4fb0 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout.ts @@ -1,27 +1,26 @@ import type { Node } from '@xyflow/react' -import type { TerminalNodeData, WorkspaceSpaceRect, WorkspaceSpaceState } from '../../../types' -import { - pushAwayLayout, - SPACE_NODE_PADDING, - type LayoutDirection, - type LayoutItem, -} from '../../../utils/spaceLayout' -import { - computeBoundingRect, - resolveDeltaToKeepRectInsideRect, - resolveDeltaToKeepRectOutsideRects, -} from './useSpaceOwnership.helpers' -import { resolveSpaceAtPoint } from './useSpaceOwnership.drop.helpers' +import type { TerminalNodeData, WorkspaceSpaceState } from '../../../types' +import { pushAwayLayout, SPACE_NODE_PADDING, type LayoutItem } from '../../../utils/spaceLayout' import { resolveBoundedSpaceNodeLayout } from './useSpaceOwnership.projectLayout.bounded' -import { resolveNearestNonOverlappingRectWithinBounds } from './useSpaceOwnership.projectLayout.bounded.placeRect' +import { resolveLiveTargetSpaceLayout } from './useSpaceOwnership.projectLayout.live' +import { + buildWorkspaceDragDirectionPreference, + projectWorkspaceNodePrimaryDragLayout, + type ProjectedNodePrimaryDragLayout, + type ProjectWorkspaceNodeDragLayoutInput, +} from './useSpaceOwnership.projectLayout.primary' import { buildOwningSpaceIdByNodeId } from './workspaceLayoutPolicy' +export { projectWorkspaceNodePrimaryDragLayout } from './useSpaceOwnership.projectLayout.primary' + export interface ProjectedNodeDragLayout { targetSpaceId: string | null nextNodePositionById: Map nextSpaces: WorkspaceSpaceState[] } +type ProjectionStrategy = 'live' | 'final' + function buildSpaceRectItems(spaces: WorkspaceSpaceState[]): LayoutItem[] { return spaces .filter(space => Boolean(space.rect)) @@ -33,20 +32,6 @@ function buildSpaceRectItems(spaces: WorkspaceSpaceState[]): LayoutItem[] { })) } -function buildChildSpaceObstacleItems( - spaces: WorkspaceSpaceState[], - parentSpaceId: string, -): LayoutItem[] { - return spaces - .filter(space => (space.parentSpaceId ?? null) === parentSpaceId && Boolean(space.rect)) - .map(space => ({ - id: space.id, - kind: 'space' as const, - groupId: `space:${space.id}`, - rect: { ...space.rect! }, - })) -} - function buildNodeItems(nodes: Array>): LayoutItem[] { return nodes.map(node => ({ id: node.id, @@ -61,253 +46,191 @@ function buildNodeItems(nodes: Array>): LayoutItem[] { })) } -function buildDragDirectionPreference(dx: number, dy: number): LayoutDirection[] { - const ordered: LayoutDirection[] = [] - const xDirection = dx >= 0 ? ('x+' as const) : ('x-' as const) - const yDirection = dy >= 0 ? ('y+' as const) : ('y-' as const) - - if (Math.abs(dx) >= Math.abs(dy)) { - ordered.push(xDirection, yDirection) - } else { - ordered.push(yDirection, xDirection) - } - - if (!ordered.includes('x+')) { - ordered.push('x+') - } - if (!ordered.includes('x-')) { - ordered.push('x-') - } - if (!ordered.includes('y+')) { - ordered.push('y+') - } - if (!ordered.includes('y-')) { - ordered.push('y-') - } - - return ordered -} - -function applyDelta(nodes: Array>, delta: { dx: number; dy: number }) { - if (delta.dx === 0 && delta.dy === 0) { - return nodes - } - - return nodes.map(node => ({ - ...node, - position: { - x: node.position.x + delta.dx, - y: node.position.y + delta.dy, - }, - })) +function collectProjectedNodePositions(items: LayoutItem[]): Map { + return new Map( + items + .filter(item => item.kind === 'node') + .map(item => [item.id, { x: item.rect.x, y: item.rect.y }]), + ) } -function resolveDraggedNodesWithinTargetSpace({ - draggedNodes, - dropRect, - targetSpaceRect, - obstacleItems, - directions, +function projectRootNodeLayout({ + nodes, + spaces, + primary, + draggedNodeIdSet, + dragDx, + dragDy, }: { - draggedNodes: Array> - dropRect: WorkspaceSpaceRect - targetSpaceRect: WorkspaceSpaceRect - obstacleItems: LayoutItem[] - directions: LayoutDirection[] -}): Array> { - if (obstacleItems.length === 0) { - const { dx, dy } = resolveDeltaToKeepRectInsideRect( - dropRect, - targetSpaceRect, - SPACE_NODE_PADDING, - ) - return applyDelta(draggedNodes, { dx, dy }) - } - - const placedDropRect = resolveNearestNonOverlappingRectWithinBounds({ - desired: dropRect, - obstacles: obstacleItems.map(item => item.rect), - bounds: { - left: targetSpaceRect.x + SPACE_NODE_PADDING, - top: targetSpaceRect.y + SPACE_NODE_PADDING, - right: targetSpaceRect.x + targetSpaceRect.width - SPACE_NODE_PADDING, - bottom: targetSpaceRect.y + targetSpaceRect.height - SPACE_NODE_PADDING, - }, - directions, + nodes: Array> + spaces: WorkspaceSpaceState[] + primary: ProjectedNodePrimaryDragLayout + draggedNodeIdSet: Set + dragDx: number + dragDy: number +}): ProjectedNodeDragLayout { + const owningSpaceIdByNodeId = buildOwningSpaceIdByNodeId(spaces) + const otherNodes = nodes.filter( + node => !draggedNodeIdSet.has(node.id) && !owningSpaceIdByNodeId.has(node.id), + ) + const pinnedNodeIds = primary.constrainedDraggedNodes.map(node => node.id) + const spaceItems = buildSpaceRectItems(spaces) + const pinnedSpaceIds = spaces.filter(space => Boolean(space.rect)).map(space => space.id) + const rootDirections = buildWorkspaceDragDirectionPreference(Math.abs(dragDx), Math.abs(dragDy)) + const pushed = pushAwayLayout({ + items: [...spaceItems, ...buildNodeItems([...primary.constrainedDraggedNodes, ...otherNodes])], + pinnedGroupIds: [...pinnedNodeIds, ...pinnedSpaceIds], + sourceGroupIds: pinnedNodeIds, + directions: rootDirections, + gap: 0, }) - if (!placedDropRect) { - const { dx, dy } = resolveDeltaToKeepRectInsideRect( - dropRect, - targetSpaceRect, - SPACE_NODE_PADDING, - ) - return applyDelta(draggedNodes, { dx, dy }) + return { + targetSpaceId: primary.targetSpaceId, + nextNodePositionById: collectProjectedNodePositions(pushed), + nextSpaces: spaces, } - - return applyDelta(draggedNodes, { - dx: placedDropRect.x - dropRect.x, - dy: placedDropRect.y - dropRect.y, - }) } -export function projectWorkspaceNodeDragLayout({ +function projectTargetSpaceNodeLayout({ nodes, spaces, - draggedNodeIds, - draggedNodePositionById, - dragDx = 0, - dragDy = 0, - dropFlowPoint, + primary, + draggedNodeIdSet, + dragDx, + dragDy, + strategy, }: { - nodes: Node[] + nodes: Array> spaces: WorkspaceSpaceState[] - draggedNodeIds: string[] - draggedNodePositionById: Map - dragDx?: number - dragDy?: number - dropFlowPoint?: { x: number; y: number } | null -}): ProjectedNodeDragLayout | null { - if (draggedNodeIds.length === 0) { - return null - } - - const nodeById = new Map(nodes.map(node => [node.id, node])) - const draggedNodes = draggedNodeIds - .map(nodeId => { - const node = nodeById.get(nodeId) - if (!node) { - return null - } - - const desiredPosition = draggedNodePositionById.get(nodeId) - if (!desiredPosition) { - return node - } - - if (node.position.x === desiredPosition.x && node.position.y === desiredPosition.y) { - return node - } - - return { - ...node, - position: desiredPosition, - } - }) - .filter((node): node is Node => Boolean(node)) - - const dropRect = computeBoundingRect(draggedNodes) - if (!dropRect) { - return null - } - - const dropCenter = { - x: dropRect.x + dropRect.width * 0.5, - y: dropRect.y + dropRect.height * 0.5, - } - - const dropTargetPoint = - dropRect && draggedNodeIds.length > 1 - ? dropCenter - : dropFlowPoint && Number.isFinite(dropFlowPoint.x) && Number.isFinite(dropFlowPoint.y) - ? dropFlowPoint - : dropCenter - - const targetSpace = resolveSpaceAtPoint(spaces, dropTargetPoint) - const targetSpaceId = targetSpace?.id ?? null - const targetSpaceRect = targetSpace?.rect ?? null - + primary: ProjectedNodePrimaryDragLayout + draggedNodeIdSet: Set + dragDx: number + dragDy: number + strategy: ProjectionStrategy +}): ProjectedNodeDragLayout { + const targetSpaceId = primary.targetSpaceId! + const targetSpaceRect = primary.targetSpaceRect! const owningSpaceIdByNodeId = buildOwningSpaceIdByNodeId(spaces) - const draggedNodeIdSet = new Set(draggedNodeIds) - - const directions = buildDragDirectionPreference(dragDx, dragDy) - - if (!targetSpaceId || !targetSpaceRect) { - const otherNodes = nodes.filter( - node => !draggedNodeIdSet.has(node.id) && !owningSpaceIdByNodeId.has(node.id), - ) - - const { dx: baseDx, dy: baseDy } = resolveDeltaToKeepRectOutsideRects( - dropRect, - spaces.map(space => space.rect).filter((rect): rect is WorkspaceSpaceRect => Boolean(rect)), - ) - - const constrainedDraggedNodes = applyDelta(draggedNodes, { dx: baseDx, dy: baseDy }) - const pinnedNodeIds = constrainedDraggedNodes.map(node => node.id) - - const spaceItems = buildSpaceRectItems(spaces) - const pinnedSpaceIds = spaces.filter(space => Boolean(space.rect)).map(space => space.id) - const rootDirections = buildDragDirectionPreference(Math.abs(dragDx), Math.abs(dragDy)) - - const pushed = pushAwayLayout({ - items: [...spaceItems, ...buildNodeItems([...constrainedDraggedNodes, ...otherNodes])], - pinnedGroupIds: [...pinnedNodeIds, ...pinnedSpaceIds], - sourceGroupIds: pinnedNodeIds, - directions: rootDirections, - gap: 0, - }) - - const nextNodePositionById = new Map( - pushed - .filter(item => item.kind === 'node') - .map(item => [item.id, { x: item.rect.x, y: item.rect.y }]), - ) - - return { targetSpaceId, nextNodePositionById, nextSpaces: spaces } - } - const otherNodes = nodes.filter( node => !draggedNodeIdSet.has(node.id) && owningSpaceIdByNodeId.get(node.id) === targetSpaceId, ) + const pinnedNodeIds = primary.constrainedDraggedNodes.map(node => node.id) + const nodeItems = buildNodeItems([...primary.constrainedDraggedNodes, ...otherNodes]) + + if (strategy === 'live') { + const childObstacleGroupIds = primary.childSpaceObstacleItems.map(item => item.groupId) + const projected = resolveLiveTargetSpaceLayout({ + items: [...primary.childSpaceObstacleItems, ...nodeItems], + pinnedGroupIds: [...pinnedNodeIds, ...childObstacleGroupIds], + sourceGroupIds: [...pinnedNodeIds, ...childObstacleGroupIds], + directions: primary.directions, + targetSpaceRect, + }) - const childSpaceObstacleItems = buildChildSpaceObstacleItems(spaces, targetSpaceId) - const constrainedDraggedNodes = resolveDraggedNodesWithinTargetSpace({ - draggedNodes, - dropRect, - targetSpaceRect, - obstacleItems: childSpaceObstacleItems, - directions, - }) - - const pinnedNodeIds = constrainedDraggedNodes.map(node => node.id) + return { + targetSpaceId, + nextNodePositionById: collectProjectedNodePositions(projected), + nextSpaces: spaces, + } + } - const items = buildNodeItems([...constrainedDraggedNodes, ...otherNodes]) const bounded = resolveBoundedSpaceNodeLayout({ - items, + items: nodeItems, pinnedNodeIds, targetSpaceRect, - dropCenter, - directions, + dropCenter: primary.dropCenter, + directions: primary.directions, dragDx, dragDy, }) const pushed = bounded ?? pushAwayLayout({ - items, + items: nodeItems, pinnedGroupIds: pinnedNodeIds, sourceGroupIds: pinnedNodeIds, - directions, + directions: primary.directions, gap: 0, }) - const projected = - childSpaceObstacleItems.length > 0 + primary.childSpaceObstacleItems.length > 0 ? pushAwayLayout({ - items: [...childSpaceObstacleItems, ...pushed], - pinnedGroupIds: [...pinnedNodeIds, ...childSpaceObstacleItems.map(item => item.groupId)], - sourceGroupIds: [...pinnedNodeIds, ...childSpaceObstacleItems.map(item => item.groupId)], - directions, + items: [...primary.childSpaceObstacleItems, ...pushed], + pinnedGroupIds: [ + ...pinnedNodeIds, + ...primary.childSpaceObstacleItems.map(item => item.groupId), + ], + sourceGroupIds: [ + ...pinnedNodeIds, + ...primary.childSpaceObstacleItems.map(item => item.groupId), + ], + directions: primary.directions, gap: 0, bounds: { rect: targetSpaceRect, padding: SPACE_NODE_PADDING }, }) : pushed - const nextNodePositionById = new Map( - projected - .filter(item => item.kind === 'node') - .map(item => [item.id, { x: item.rect.x, y: item.rect.y }]), - ) + return { + targetSpaceId, + nextNodePositionById: collectProjectedNodePositions(projected), + nextSpaces: spaces, + } +} + +function projectWorkspaceNodeDragLayoutWithStrategy( + input: ProjectWorkspaceNodeDragLayoutInput, + strategy: ProjectionStrategy, +): ProjectedNodeDragLayout | null { + const primary = projectWorkspaceNodePrimaryDragLayout(input) + if (!primary) { + return null + } + + const dragDx = input.dragDx ?? 0 + const dragDy = input.dragDy ?? 0 + const draggedNodeIdSet = new Set(input.draggedNodeIds) + + const projected = + primary.targetSpaceId && primary.targetSpaceRect + ? projectTargetSpaceNodeLayout({ + nodes: input.nodes, + spaces: input.spaces, + primary, + draggedNodeIdSet, + dragDx, + dragDy, + strategy, + }) + : projectRootNodeLayout({ + nodes: input.nodes, + spaces: input.spaces, + primary, + draggedNodeIdSet, + dragDx, + dragDy, + }) + + if (strategy !== 'live') { + return projected + } + + const nextNodePositionById = new Map(projected.nextNodePositionById) + primary.nextNodePositionById.forEach((position, nodeId) => { + nextNodePositionById.set(nodeId, position) + }) + + return { ...projected, nextNodePositionById } +} + +export function projectWorkspaceNodeLiveDragLayout( + input: ProjectWorkspaceNodeDragLayoutInput, +): ProjectedNodeDragLayout | null { + return projectWorkspaceNodeDragLayoutWithStrategy(input, 'live') +} - return { targetSpaceId, nextNodePositionById, nextSpaces: spaces } +export function projectWorkspaceNodeDragLayout( + input: ProjectWorkspaceNodeDragLayoutInput, +): ProjectedNodeDragLayout | null { + return projectWorkspaceNodeDragLayoutWithStrategy(input, 'final') } diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.ts index cfa40a7b..92ad0892 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.ts @@ -33,7 +33,6 @@ export function useWorkspaceCanvasSpaceOwnership({ onShowMessage, hideWorktreeMismatchDropWarning, nodeDragPointerAnchorRef, - nodeSpaceFramePreviewRef, }: { workspacePath: string reactFlow: ReactFlowInstance, Edge> @@ -53,7 +52,6 @@ export function useWorkspaceCanvasSpaceOwnership({ nodeId: string offset: { x: number; y: number } } | null> - nodeSpaceFramePreviewRef?: React.MutableRefObject | null> }): { finalizeDraggedNodeDrop: (input: { draggedNodeIds: string[] @@ -88,33 +86,9 @@ export function useWorkspaceCanvasSpaceOwnership({ const dragStartAllNodePositionByIdRef = useRef | null>(null) const dragStartSpaceRectByIdRef = useRef | null>(null) const resolveDropTargetSpaceAtPoint = useCallback( - (point: { x: number; y: number }): WorkspaceSpaceState | null => { - const preview = nodeSpaceFramePreviewRef?.current ?? null - const sourceSpaces = spacesRef.current - const effectiveSpaces = preview - ? sourceSpaces.map(space => { - const rect = preview.get(space.id) ?? null - if (!rect) { - return space - } - - if ( - space.rect && - rect.x === space.rect.x && - rect.y === space.rect.y && - rect.width === space.rect.width && - rect.height === space.rect.height - ) { - return space - } - - return { ...space, rect: { ...rect } } - }) - : sourceSpaces - - return resolveSpaceAtPointFromHelpers(effectiveSpaces, point) - }, - [nodeSpaceFramePreviewRef, spacesRef], + (point: { x: number; y: number }): WorkspaceSpaceState | null => + resolveSpaceAtPointFromHelpers(spacesRef.current, point), + [spacesRef], ) const applyOwnershipForDrop = useWorkspaceCanvasApplyOwnershipForDrop({ @@ -436,17 +410,10 @@ export function useWorkspaceCanvasSpaceOwnership({ dragStartSpaceRectById, dropFlowPoint: dropPoint, fallbackNodes, - spaceRectOverrideById: nodeSpaceFramePreviewRef?.current ?? null, }) dragSelectedSpaceIdsRef.current = null }, - [ - dragSelectedSpaceIdsRef, - finalizeDraggedNodeDrop, - nodeSpaceFramePreviewRef, - nodeDragPointerAnchorRef, - reactFlow, - ], + [dragSelectedSpaceIdsRef, finalizeDraggedNodeDrop, nodeDragPointerAnchorRef, reactFlow], ) const handleSelectionDragStop = useCallback( @@ -498,17 +465,10 @@ export function useWorkspaceCanvasSpaceOwnership({ dragStartSpaceRectById, dropFlowPoint: dropPoint, fallbackNodes, - spaceRectOverrideById: nodeSpaceFramePreviewRef?.current ?? null, }) dragSelectedSpaceIdsRef.current = null }, - [ - dragSelectedSpaceIdsRef, - finalizeDraggedNodeDrop, - nodeSpaceFramePreviewRef, - nodeDragPointerAnchorRef, - reactFlow, - ], + [dragSelectedSpaceIdsRef, finalizeDraggedNodeDrop, nodeDragPointerAnchorRef, reactFlow], ) return { diff --git a/tests/e2e/workspace-canvas.spaces.crowded-drop.spec.ts b/tests/e2e/workspace-canvas.spaces.crowded-drop.spec.ts index 2c2473aa..b4aa6ee0 100644 --- a/tests/e2e/workspace-canvas.spaces.crowded-drop.spec.ts +++ b/tests/e2e/workspace-canvas.spaces.crowded-drop.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test' import { + beginDragMouse, clearAndSeedWorkspace, - dragHeaderDragSurfaceTo, launchApp, readCanvasViewport, readLocatorClientRect, @@ -10,8 +10,9 @@ import { } from './workspace-canvas.helpers' test.describe('Workspace Canvas - Spaces (Crowded Drop)', () => { - test('expands a crowded space when dropping a window into it', async () => { + test('previews crowded Space expansion before committing it on release', async () => { const { electronApp, window } = await launchApp() + let releaseHeldDrag: (() => Promise) | null = null try { await clearAndSeedWorkspace( @@ -60,6 +61,10 @@ test.describe('Workspace Canvas - Spaces (Crowded Drop)', () => { const draggedNode = window.locator('.note-node').filter({ hasText: 'drag note' }).first() await expect(draggedNode).toBeVisible() + const dragSurface = draggedNode.getByTestId('note-node-header-drag-surface') + const dragSurfaceBox = await readLocatorClientRect(dragSurface) + const spaceRegion = window.getByTestId('workspace-space-drag-space-full-right').locator('..') + const initialSpaceClientRect = await readLocatorClientRect(spaceRegion) const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)) @@ -69,13 +74,68 @@ test.describe('Workspace Canvas - Spaces (Crowded Drop)', () => { y: 460, } - await dragHeaderDragSurfaceTo(window, draggedNode.locator('.note-node__header'), pane, { - sourcePosition: { x: 80, y: 16 }, - targetPosition: { - x: clamp(viewport.x + dropFlowPoint.x * viewport.zoom, 40, paneBox.width - 40), - y: clamp(viewport.y + dropFlowPoint.y * viewport.zoom, 40, paneBox.height - 40), - }, + const target = { + x: paneBox.x + clamp(viewport.x + dropFlowPoint.x * viewport.zoom, 40, paneBox.width - 40), + y: paneBox.y + clamp(viewport.y + dropFlowPoint.y * viewport.zoom, 40, paneBox.height - 40), + } + const drag = await beginDragMouse(window, { + start: { x: dragSurfaceBox.x + 80, y: dragSurfaceBox.y + 16 }, + initialTarget: target, }) + releaseHeldDrag = drag.release + await drag.moveTo(target, { steps: 16, settleAfterMoveMs: 180 }) + + await expect + .poll(async () => { + const previewRect = await readLocatorClientRect(spaceRegion) + return { + expanded: + previewRect.width > initialSpaceClientRect.width + 2 || + previewRect.height > initialSpaceClientRect.height + 2, + initial: initialSpaceClientRect, + preview: previewRect, + } + }) + .toMatchObject({ expanded: true }) + const previewSpaceClientRect = await readLocatorClientRect(spaceRegion) + + // The frame is derived UI while the pointer is held; durable ownership, + // geometry, and node position must remain at their drag baseline. + await window.waitForTimeout(180) + const durableDuringPreview = await window.evaluate(async () => { + const raw = await window.opencoveApi.persistence.readWorkspaceStateRaw() + if (!raw) { + return null + } + + const parsed = JSON.parse(raw) as { + workspaces?: Array<{ + nodes?: Array<{ id?: string; position?: { x?: number; y?: number } }> + spaces?: Array<{ + id?: string + nodeIds?: string[] + rect?: { x?: number; y?: number; width?: number; height?: number } | null + }> + }> + } + const workspace = parsed.workspaces?.[0] + const space = workspace?.spaces?.find(item => item.id === 'space-full') + const dragNode = workspace?.nodes?.find(item => item.id === 'space-full-drag-node') + + return { + nodeIds: space?.nodeIds ?? null, + rect: space?.rect ?? null, + dragPosition: dragNode?.position ?? null, + } + }) + expect(durableDuringPreview).toEqual({ + nodeIds: ['space-full-static-node'], + rect: { x: 120, y: 120, width: 520, height: 360 }, + dragPosition: { x: 140, y: 560 }, + }) + + await drag.release() + releaseHeldDrag = null await expect .poll(async () => { @@ -212,7 +272,21 @@ test.describe('Workspace Canvas - Spaces (Crowded Drop)', () => { ) }) .toEqual(expect.objectContaining({ ok: true })) + + await expect + .poll(async () => { + const committedRect = await readLocatorClientRect(spaceRegion) + return Math.max( + Math.abs(committedRect.x - previewSpaceClientRect.x), + Math.abs(committedRect.y - previewSpaceClientRect.y), + Math.abs(committedRect.width - previewSpaceClientRect.width), + Math.abs(committedRect.height - previewSpaceClientRect.height), + ) + }) + // Release recomputes from durable state and may normalize legacy padding. + .toBeLessThanOrEqual(8) } finally { + await releaseHeldDrag?.().catch(() => undefined) await electronApp.close() } }) diff --git a/tests/e2e/workspace-canvas.spaces.edge-drag-smoothness.spec.ts b/tests/e2e/workspace-canvas.spaces.edge-drag-smoothness.spec.ts new file mode 100644 index 00000000..31120288 --- /dev/null +++ b/tests/e2e/workspace-canvas.spaces.edge-drag-smoothness.spec.ts @@ -0,0 +1,173 @@ +import { expect, test, type Page } from '@playwright/test' +import { + clearAndSeedWorkspace, + launchApp, + readLocatorClientRect, + testWorkspacePath, +} from './workspace-canvas.helpers' + +async function readSpaceNodeIds(window: Page, spaceId: string): Promise { + return await window.evaluate(async id => { + const raw = await window.opencoveApi.persistence.readWorkspaceStateRaw() + if (!raw) { + return null + } + + const state = JSON.parse(raw) as { + workspaces?: Array<{ + spaces?: Array<{ + id?: string + nodeIds?: string[] + }> + }> + } + + const space = state.workspaces?.[0]?.spaces?.find(entry => entry.id === id) + return Array.isArray(space?.nodeIds) ? space.nodeIds : null + }, spaceId) +} + +async function readNodePosition( + window: Page, + nodeId: string, +): Promise<{ x: number; y: number } | null> { + return await window.evaluate(async id => { + const raw = await window.opencoveApi.persistence.readWorkspaceStateRaw() + if (!raw) { + return null + } + + const state = JSON.parse(raw) as { + workspaces?: Array<{ + nodes?: Array<{ + id?: string + position?: { x?: number; y?: number } + }> + }> + } + const position = state.workspaces?.[0]?.nodes?.find(entry => entry.id === id)?.position + return typeof position?.x === 'number' && typeof position.y === 'number' + ? { x: position.x, y: position.y } + : null + }, nodeId) +} + +test.describe('Workspace Canvas - Spaces (Edge Drag Smoothness)', () => { + test('keeps a root window stable while slowly dragging along a space edge', async () => { + const { electronApp, window } = await launchApp() + let mouseIsDown = false + + try { + await clearAndSeedWorkspace( + window, + [ + { + id: 'edge-drag-root-note', + title: 'edge-drag-root-note', + position: { x: 160, y: 260 }, + width: 280, + height: 180, + kind: 'note', + task: { text: 'edge drag root note' }, + }, + ], + { + settings: { canvasInputMode: 'mouse' }, + spaces: [ + { + id: 'edge-drag-space', + name: 'Edge Drag Space', + directoryPath: testWorkspacePath, + nodeIds: [], + rect: { x: 520, y: 180, width: 620, height: 520 }, + }, + ], + activeSpaceId: null, + }, + ) + + const rootNode = window + .locator('.note-node') + .filter({ hasText: 'edge drag root note' }) + .first() + const dragSurface = rootNode.getByTestId('note-node-header-drag-surface') + const spaceRegion = window + .locator('.workspace-space-region') + .filter({ hasText: 'Edge Drag Space' }) + .first() + + const rootBox = await readLocatorClientRect(rootNode) + const dragSurfaceBox = await readLocatorClientRect(dragSurface) + const spaceBox = await readLocatorClientRect(spaceRegion) + const start = { + x: dragSurfaceBox.x + dragSurfaceBox.width / 2, + y: dragSurfaceBox.y + dragSurfaceBox.height / 2, + } + const edgePointerX = spaceBox.x - 8 + const edgePointerStartY = start.y + + expect(rootBox.x + rootBox.width).toBeLessThan(spaceBox.x) + expect(edgePointerX).toBeLessThan(spaceBox.x) + + await window.mouse.move(start.x, start.y) + await window.mouse.down() + mouseIsDown = true + await window.waitForTimeout(16) + + // Trigger the drag first, then use a single large move to reach the exact edge position. + // The following small moves verify that every coalesced live frame preserves that position. + await window.mouse.move(start.x + 8, start.y) + await window.waitForTimeout(24) + await window.mouse.move(edgePointerX, edgePointerStartY) + await window.waitForTimeout(40) + + const edgeBaseline = await readLocatorClientRect(rootNode) + expect(edgeBaseline.x + edgeBaseline.width).toBeLessThanOrEqual(spaceBox.x + 2) + + const samples = [edgeBaseline] + for (let step = 1; step <= 12; step += 1) { + // eslint-disable-next-line no-await-in-loop -- sequential moves model adjacent drag frames + await window.mouse.move(edgePointerX, edgePointerStartY + step * 3) + // eslint-disable-next-line no-await-in-loop -- each sample must observe the preceding move + await window.waitForTimeout(24) + + // eslint-disable-next-line no-await-in-loop -- geometry order is the regression assertion + const sample = await readLocatorClientRect(rootNode) + const previous = samples[samples.length - 1] + samples.push(sample) + + expect(sample.x + sample.width).toBeLessThanOrEqual(spaceBox.x + 2) + expect(Math.abs(sample.x - edgeBaseline.x)).toBeLessThanOrEqual(2) + expect(sample.y).toBeGreaterThanOrEqual(previous.y - 1) + } + + expect(samples[samples.length - 1].y - edgeBaseline.y).toBeGreaterThan(24) + + await window.mouse.up() + mouseIsDown = false + + await expect.poll(async () => await readSpaceNodeIds(window, 'edge-drag-space')).toEqual([]) + await expect + .poll(async () => { + const position = await readNodePosition(window, 'edge-drag-root-note') + return position + ? { + stayedOutside: position.x + 280 <= 522, + movedHorizontally: position.x > 200, + movedVertically: position.y > 284, + } + : null + }) + .toEqual({ + stayedOutside: true, + movedHorizontally: true, + movedVertically: true, + }) + } finally { + if (mouseIsDown) { + await window.mouse.up().catch(() => undefined) + } + await electronApp.close() + } + }) +}) diff --git a/tests/unit/contexts/applyNodeChanges.liveCommit.spec.tsx b/tests/unit/contexts/applyNodeChanges.liveCommit.spec.tsx index e228cd98..65e97bb3 100644 --- a/tests/unit/contexts/applyNodeChanges.liveCommit.spec.tsx +++ b/tests/unit/contexts/applyNodeChanges.liveCommit.spec.tsx @@ -31,6 +31,23 @@ vi.mock('@xyflow/react', () => { } }) +vi.mock( + '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectDropLayout', + async importOriginal => { + const actual = + await importOriginal< + typeof import('../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectDropLayout') + >() + + return { + ...actual, + projectWorkspaceNodeDropLayout: vi.fn(actual.projectWorkspaceNodeDropLayout), + projectWorkspaceNodeLiveDropLayout: vi.fn(actual.projectWorkspaceNodeLiveDropLayout), + projectWorkspaceNodePrimaryDropLayout: vi.fn(actual.projectWorkspaceNodePrimaryDropLayout), + } + }, +) + describe('useWorkspaceCanvasApplyNodeChanges live and commit publishing', () => { it('publishes live drag positions without committing until release', async () => { const liveNodesChange = vi.fn() @@ -40,6 +57,12 @@ describe('useWorkspaceCanvasApplyNodeChanges live and commit publishing', () => await import('../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useApplyNodeChanges') const { useWorkspaceCanvasNodeDragSession } = await import('../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession') + const projectionModule = + await import('../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectDropLayout') + const liveProjection = vi.mocked(projectionModule.projectWorkspaceNodeLiveDropLayout) + const finalProjection = vi.mocked(projectionModule.projectWorkspaceNodeDropLayout) + liveProjection.mockClear() + finalProjection.mockClear() const initialNodes: Node[] = [ { @@ -155,6 +178,8 @@ describe('useWorkspaceCanvasApplyNodeChanges live and commit publishing', () => }) expect(liveNodesChange).toHaveBeenCalledTimes(1) expect(committedNodesChange).not.toHaveBeenCalled() + expect(liveProjection).toHaveBeenCalledTimes(1) + expect(finalProjection).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Drop' })) @@ -162,5 +187,126 @@ describe('useWorkspaceCanvasApplyNodeChanges live and commit publishing', () => expect(screen.getByTestId('node-position')).toHaveTextContent('70,80') }) expect(committedNodesChange).toHaveBeenCalledTimes(1) + expect(finalProjection).toHaveBeenCalledTimes(1) + }) + + it('retains a derived Space preview on primary-only frames and never commits it', async () => { + const onSpacesChange = vi.fn() + const onRequestPersistFlush = vi.fn() + const { useWorkspaceCanvasNodeDragSession } = + await import('../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession') + + const createNode = ({ + id, + position, + text, + }: { + id: string + position: { x: number; y: number } + text: string + }): Node => ({ + id, + type: 'terminalNode', + position, + data: { + sessionId: `${id}-session`, + title: id, + width: 460, + height: 300, + kind: 'note', + status: null, + startedAt: null, + endedAt: null, + exitCode: null, + lastError: null, + scrollback: null, + agent: null, + task: null, + note: { text }, + }, + }) + const nodes = [ + createNode({ id: 'static', position: { x: 24, y: 24 }, text: 'static' }), + createNode({ id: 'drag', position: { x: 24, y: 480 }, text: 'drag' }), + ] + const spaces: WorkspaceSpaceState[] = [ + { + id: 'space', + name: 'space', + directoryPath: '/tmp/space', + targetMountId: null, + nodeIds: ['static'], + rect: { x: 0, y: 0, width: 520, height: 360 }, + }, + ] + + function Harness() { + const spacesRef = useRef(spaces) + const selectedSpaceIdsRef = useRef([]) + const dragSelectedSpaceIdsRef = useRef(null) + const magneticSnappingEnabledRef = useRef(false) + const [, setSnapGuides] = useState(null) + const session = useWorkspaceCanvasNodeDragSession({ + workspaceId: 'workspace-space-preview-retention', + spacesRef, + selectedSpaceIdsRef, + dragSelectedSpaceIdsRef, + magneticSnappingEnabledRef, + setSnapGuides, + onSpacesChange, + onRequestPersistFlush, + }) + const project = () => + session.projectNodeDrag({ + currentNodes: nodes, + draggedNodeIds: ['drag'], + desiredDraggedPositionById: new Map([['drag', { x: 24, y: 24 }]]), + dropFlowPoint: { x: 260, y: 180 }, + anchorNodeId: 'drag', + }) + + return ( +
+
+ {session.nodeSpaceFramePreview?.get('space')?.height ?? 'none'} +
+ + + +
+ ) + } + + render() + fireEvent.click(screen.getByRole('button', { name: 'Secondary' })) + await waitFor(() => { + expect(Number(screen.getByTestId('preview-height').textContent)).toBeGreaterThan(360) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Primary' })) + await waitFor(() => { + expect(Number(screen.getByTestId('preview-height').textContent)).toBeGreaterThan(360) + }) + expect(onSpacesChange).not.toHaveBeenCalled() + expect(onRequestPersistFlush).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'End' })) + await waitFor(() => { + expect(screen.getByTestId('preview-height')).toHaveTextContent('none') + }) + expect(onSpacesChange).not.toHaveBeenCalled() + expect(onRequestPersistFlush).not.toHaveBeenCalled() }) }) diff --git a/tests/unit/contexts/workspaceNodeLiveDragProjection.spec.ts b/tests/unit/contexts/workspaceNodeLiveDragProjection.spec.ts new file mode 100644 index 00000000..2c21c1d6 --- /dev/null +++ b/tests/unit/contexts/workspaceNodeLiveDragProjection.spec.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspaceSpaceRect } from '../../../src/contexts/workspace/presentation/renderer/types' +import { + projectWorkspaceNodeLiveDragLayout, + projectWorkspaceNodePrimaryDragLayout, +} from '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectLayout' +import { projectWorkspaceNodeLiveDropLayout } from '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectDropLayout' +import { shouldResolveLiveDragSecondaryProjection } from '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodeDragSession.liveProjection' +import { + createNode, + createSpace, + expectInsideSpaceWithPadding, + expectProjectedPosition, + intersects, + rectForPosition, +} from './workspaceNodeProjection.testUtils' + +describe('workspace node live drag projection', () => { + it('throttles only secondary layout work for slow movement', () => { + const lastProjection = { + anchorPoint: { x: 100, y: 100 }, + draggedNodeKey: 'drag', + projectedAtMs: 1_000, + targetSpaceId: 'space-a', + } + + expect( + shouldResolveLiveDragSecondaryProjection({ + lastProjection, + draggedNodeKey: 'drag', + anchorPoint: { x: 103, y: 100 }, + nowMs: 1_024, + targetSpaceId: 'space-a', + }), + ).toBe(false) + expect( + shouldResolveLiveDragSecondaryProjection({ + lastProjection, + draggedNodeKey: 'drag', + anchorPoint: { x: 124, y: 100 }, + nowMs: 1_024, + targetSpaceId: 'space-a', + }), + ).toBe(true) + expect( + shouldResolveLiveDragSecondaryProjection({ + lastProjection, + draggedNodeKey: 'drag', + anchorPoint: { x: 103, y: 100 }, + nowMs: 1_120, + targetSpaceId: 'space-a', + }), + ).toBe(true) + expect( + shouldResolveLiveDragSecondaryProjection({ + lastProjection, + draggedNodeKey: 'drag', + anchorPoint: { x: 102, y: 100 }, + nowMs: 1_024, + targetSpaceId: 'space-b', + }), + ).toBe(true) + }) + + it('keeps slow root drags fully outside a Space without horizontal snap-back', () => { + const spaceRect: WorkspaceSpaceRect = { x: 100, y: 100, width: 400, height: 300 } + const space = createSpace({ id: 'space', rect: spaceRect, nodeIds: [] }) + const dragNode = createNode({ id: 'drag', position: { x: 560, y: 200 } }) + const nodes = [dragNode] + let previousPrimaryX = Number.NEGATIVE_INFINITY + let previousLiveX = Number.NEGATIVE_INFINITY + + for (let step = 0; step <= 4; step += 1) { + const desiredPosition = { x: 456 + step, y: 200 } + const input = { + nodes, + spaces: [space], + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', desiredPosition]]), + dragDx: desiredPosition.x - dragNode.position.x, + dragDy: 0, + dropFlowPoint: { x: 501 + step, y: 220 }, + } + + const primary = projectWorkspaceNodePrimaryDragLayout(input) + const live = projectWorkspaceNodeLiveDragLayout(input) + const primaryPosition = expectProjectedPosition(primary, 'drag') + const livePosition = expectProjectedPosition(live, 'drag') + + expect(primary?.targetSpaceId, `primary step ${step}: root target`).toBeNull() + expect(live?.targetSpaceId, `live step ${step}: root target`).toBeNull() + expect( + intersects(rectForPosition(dragNode, primaryPosition), spaceRect), + `primary step ${step}: active rect must not straddle Space`, + ).toBe(false) + expect( + intersects(rectForPosition(dragNode, livePosition), spaceRect), + `live step ${step}: active rect must not straddle Space`, + ).toBe(false) + expect( + primaryPosition.x, + `primary step ${step}: no horizontal backtrack`, + ).toBeGreaterThanOrEqual(previousPrimaryX) + expect(livePosition.x, `live step ${step}: no horizontal backtrack`).toBeGreaterThanOrEqual( + previousLiveX, + ) + + previousPrimaryX = primaryPosition.x + previousLiveX = livePosition.x + } + }) + + it('keeps the active window fully inside the target Space padding on every slow edge frame', () => { + const spaceRect: WorkspaceSpaceRect = { x: 100, y: 100, width: 400, height: 300 } + const space = createSpace({ id: 'space', rect: spaceRect, nodeIds: ['drag'] }) + const dragNode = createNode({ id: 'drag', position: { x: 220, y: 180 } }) + const nodes = [dragNode] + + for (let step = 0; step <= 4; step += 1) { + const desiredPosition = { x: 376 + step, y: 180 } + const input = { + nodes, + spaces: [space], + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', desiredPosition]]), + dragDx: desiredPosition.x - dragNode.position.x, + dragDy: 0, + dropFlowPoint: { x: 495 + step, y: 220 }, + } + + const primary = projectWorkspaceNodePrimaryDragLayout(input) + const live = projectWorkspaceNodeLiveDragLayout(input) + const primaryPosition = expectProjectedPosition(primary, 'drag') + const livePosition = expectProjectedPosition(live, 'drag') + + expect(primary?.targetSpaceId, `primary step ${step}: target`).toBe('space') + expect(live?.targetSpaceId, `live step ${step}: target`).toBe('space') + expectInsideSpaceWithPadding( + rectForPosition(dragNode, primaryPosition), + spaceRect, + `primary step ${step}`, + ) + expectInsideSpaceWithPadding( + rectForPosition(dragNode, livePosition), + spaceRect, + `live step ${step}`, + ) + expect(livePosition, `live step ${step}: secondary must preserve active`).toEqual( + primaryPosition, + ) + } + }) + + it('never lets live peer push-away overwrite the active primary position', () => { + const spaceRect: WorkspaceSpaceRect = { x: 0, y: 0, width: 700, height: 420 } + const space = createSpace({ id: 'space', rect: spaceRect, nodeIds: ['drag', 'peer'] }) + const dragNode = createNode({ + id: 'drag', + position: { x: 80, y: 120 }, + width: 180, + height: 120, + }) + const peerNode = createNode({ + id: 'peer', + position: { x: 360, y: 120 }, + width: 180, + height: 120, + }) + const desiredPosition = { x: 260, y: 120 } + const input = { + nodes: [dragNode, peerNode], + spaces: [space], + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', desiredPosition]]), + dragDx: desiredPosition.x - dragNode.position.x, + dragDy: 0, + dropFlowPoint: { x: 300, y: 180 }, + } + + const primary = projectWorkspaceNodePrimaryDragLayout(input) + const live = projectWorkspaceNodeLiveDragLayout(input) + const liveDrop = projectWorkspaceNodeLiveDropLayout(input) + const primaryPosition = expectProjectedPosition(primary, 'drag') + const liveActivePosition = expectProjectedPosition(live, 'drag') + const livePeerPosition = expectProjectedPosition(live, 'peer') + const liveDropActivePosition = expectProjectedPosition(liveDrop, 'drag') + + expect(liveActivePosition).toEqual(primaryPosition) + expect(liveDropActivePosition).toEqual(primaryPosition) + expect(liveDrop.nextSpaces).toBe(input.spaces) + expect(liveDrop.hasSpaceChange).toBe(false) + expect( + intersects( + rectForPosition(dragNode, liveActivePosition), + rectForPosition(peerNode, livePeerPosition), + ), + 'live peer projection should move the peer around the protected active window', + ).toBe(false) + }) + + it('restores peer layout from the previous target on the next secondary projection', () => { + const space = createSpace({ + id: 'space-a', + rect: { x: 0, y: 0, width: 700, height: 400 }, + nodeIds: ['drag', 'peer-a'], + }) + const dragNode = createNode({ + id: 'drag', + position: { x: 80, y: 120 }, + width: 180, + height: 120, + }) + const peerNode = createNode({ + id: 'peer-a', + position: { x: 360, y: 120 }, + width: 180, + height: 120, + }) + const baselineNodes = [dragNode, peerNode] + const insideProjection = projectWorkspaceNodeLiveDropLayout({ + nodes: baselineNodes, + spaces: [space], + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', { x: 260, y: 120 }]]), + dragDx: 180, + dragDy: 0, + dropFlowPoint: { x: 300, y: 180 }, + }) + const pushedPeerPosition = expectProjectedPosition(insideProjection, 'peer-a') + expect(pushedPeerPosition).not.toEqual(peerNode.position) + + const liveNodes = baselineNodes.map(node => ({ + ...node, + position: insideProjection.nextNodePositionById.get(node.id) ?? node.position, + })) + const rootProjection = projectWorkspaceNodeLiveDropLayout({ + nodes: baselineNodes, + spaces: [space], + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', { x: 760, y: 120 }]]), + dragDx: 680, + dragDy: 0, + dropFlowPoint: { x: 800, y: 180 }, + }) + const restoredNodes = liveNodes.map(node => ({ + ...node, + position: rootProjection.nextNodePositionById.get(node.id) ?? node.position, + })) + + expect(rootProjection.targetSpaceId).toBeNull() + expect(restoredNodes.find(node => node.id === 'peer-a')?.position).toEqual(peerNode.position) + }) + + it('switches parent to child target immediately from the latest pointer position', () => { + const parentRect: WorkspaceSpaceRect = { x: 0, y: 0, width: 800, height: 500 } + const childRect: WorkspaceSpaceRect = { x: 320, y: 120, width: 260, height: 220 } + const parent = createSpace({ id: 'parent', rect: parentRect, nodeIds: ['drag'] }) + const child = createSpace({ + id: 'child', + rect: childRect, + nodeIds: [], + parentSpaceId: 'parent', + }) + const dragNode = createNode({ id: 'drag', position: { x: 120, y: 170 }, width: 100 }) + const desiredPosition = { x: 280, y: 170 } + const baseInput = { + nodes: [dragNode], + spaces: [parent, child], + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', desiredPosition]]), + dragDx: desiredPosition.x - dragNode.position.x, + dragDy: 0, + } + + const primaryBefore = projectWorkspaceNodePrimaryDragLayout({ + ...baseInput, + dropFlowPoint: { x: 319, y: 210 }, + }) + const liveBefore = projectWorkspaceNodeLiveDragLayout({ + ...baseInput, + dropFlowPoint: { x: 319, y: 210 }, + }) + const primaryAfter = projectWorkspaceNodePrimaryDragLayout({ + ...baseInput, + dropFlowPoint: { x: 321, y: 210 }, + }) + const liveAfter = projectWorkspaceNodeLiveDragLayout({ + ...baseInput, + dropFlowPoint: { x: 321, y: 210 }, + }) + + expect(primaryBefore?.targetSpaceId).toBe('parent') + expect(liveBefore?.targetSpaceId).toBe('parent') + expect( + intersects( + rectForPosition(dragNode, expectProjectedPosition(primaryBefore, 'drag')), + childRect, + ), + 'parent primary must keep the active window out of the child before pointer entry', + ).toBe(false) + expect( + intersects(rectForPosition(dragNode, expectProjectedPosition(liveBefore, 'drag')), childRect), + 'parent live projection must keep the active window out of the child before pointer entry', + ).toBe(false) + + expect(primaryAfter?.targetSpaceId).toBe('child') + expect(liveAfter?.targetSpaceId).toBe('child') + expectInsideSpaceWithPadding( + rectForPosition(dragNode, expectProjectedPosition(primaryAfter, 'drag')), + childRect, + 'child primary after a 2px pointer transition', + ) + expectInsideSpaceWithPadding( + rectForPosition(dragNode, expectProjectedPosition(liveAfter, 'drag')), + childRect, + 'child live after a 2px pointer transition', + ) + }) +}) diff --git a/tests/unit/contexts/workspaceNodeLiveSpaceExpansion.spec.ts b/tests/unit/contexts/workspaceNodeLiveSpaceExpansion.spec.ts new file mode 100644 index 00000000..5e5d37bc --- /dev/null +++ b/tests/unit/contexts/workspaceNodeLiveSpaceExpansion.spec.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { + projectWorkspaceNodeDropLayout, + projectWorkspaceNodeLiveDropLayout, +} from '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useSpaceOwnership.projectDropLayout' +import { + createNode, + createSpace, + expectInsideSpaceWithPadding, + expectProjectedPosition, + intersects, + rectForPosition, +} from './workspaceNodeProjection.testUtils' + +describe('workspace node live Space expansion', () => { + it('derives crowded Space expansion during live preview without mutating durable ownership', () => { + const space = createSpace({ + id: 'space', + rect: { x: 0, y: 0, width: 520, height: 360 }, + nodeIds: ['static'], + }) + const staticNode = createNode({ + id: 'static', + position: { x: 24, y: 24 }, + width: 460, + height: 300, + }) + const dragNode = createNode({ + id: 'drag', + position: { x: 24, y: 480 }, + width: 460, + height: 300, + }) + const spaces = [space] + const input = { + nodes: [staticNode, dragNode], + spaces, + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', { x: 24, y: 24 }]]), + dragDx: 0, + dragDy: -456, + dropFlowPoint: { x: 260, y: 180 }, + } + + const live = projectWorkspaceNodeLiveDropLayout(input) + const final = projectWorkspaceNodeDropLayout(input) + const liveSpaceRect = live.nextSpaces[0]?.rect + const liveStaticPosition = expectProjectedPosition(live, 'static') + const liveDragPosition = expectProjectedPosition(live, 'drag') + + expect(live.targetSpaceId).toBe('space') + expect(live.nextSpaces).not.toBe(spaces) + expect(live.hasSpaceChange).toBe(true) + expect(live.nextSpaces[0]?.nodeIds).toEqual(['static']) + expect(liveSpaceRect?.height).toBeGreaterThan(360) + expectInsideSpaceWithPadding( + rectForPosition(staticNode, liveStaticPosition), + liveSpaceRect!, + 'static inside live preview', + ) + expectInsideSpaceWithPadding( + rectForPosition(dragNode, liveDragPosition), + liveSpaceRect!, + 'drag inside live preview', + ) + expect( + intersects( + rectForPosition(staticNode, liveStaticPosition), + rectForPosition(dragNode, liveDragPosition), + ), + ).toBe(false) + + // Live state is a reversible projection; the durable input stays authoritative. + expect(spaces[0]?.nodeIds).toEqual(['static']) + expect(spaces[0]?.rect).toEqual({ x: 0, y: 0, width: 520, height: 360 }) + + expect(final.targetSpaceId).toBe('space') + expect(final.hasSpaceChange).toBe(true) + expect(final.nextSpaces[0]?.nodeIds).toEqual(['static', 'drag']) + expect(final.nextSpaces[0]?.rect?.height).toBeGreaterThan(360) + expect(final.nextSpaces[0]?.rect).toEqual(live.nextSpaces[0]?.rect) + }) + + it('previews required ancestor expansion when a crowded child Space grows', () => { + const parent = createSpace({ + id: 'parent', + rect: { x: 0, y: 0, width: 500, height: 350 }, + nodeIds: [], + }) + const child = createSpace({ + id: 'child', + rect: { x: 24, y: 24, width: 300, height: 200 }, + nodeIds: ['static'], + parentSpaceId: 'parent', + }) + const staticNode = createNode({ + id: 'static', + position: { x: 48, y: 48 }, + width: 252, + height: 152, + }) + const dragNode = createNode({ + id: 'drag', + position: { x: 48, y: 480 }, + width: 252, + height: 152, + }) + const spaces = [parent, child] + + const live = projectWorkspaceNodeLiveDropLayout({ + nodes: [staticNode, dragNode], + spaces, + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', { x: 48, y: 48 }]]), + dragDx: 0, + dragDy: -432, + dropFlowPoint: { x: 100, y: 100 }, + }) + const liveChildRect = live.nextSpaces.find(space => space.id === 'child')?.rect + const liveParentRect = live.nextSpaces.find(space => space.id === 'parent')?.rect + const liveStaticPosition = expectProjectedPosition(live, 'static') + const liveDragPosition = expectProjectedPosition(live, 'drag') + + expect(live.targetSpaceId).toBe('child') + expect(liveChildRect?.width).toBeGreaterThan(300) + expect(liveParentRect?.width).toBeGreaterThan(500) + expectInsideSpaceWithPadding( + rectForPosition(staticNode, liveStaticPosition), + liveChildRect!, + 'static inside child preview', + ) + expectInsideSpaceWithPadding( + rectForPosition(dragNode, liveDragPosition), + liveChildRect!, + 'drag inside child preview', + ) + expect( + intersects( + rectForPosition(staticNode, liveStaticPosition), + rectForPosition(dragNode, liveDragPosition), + ), + ).toBe(false) + expectInsideSpaceWithPadding(liveChildRect!, liveParentRect!, 'child inside parent preview') + expect(spaces.find(space => space.id === 'child')?.nodeIds).toEqual(['static']) + expect(spaces.find(space => space.id === 'parent')?.rect?.width).toBe(500) + }) + + it('expands around a child obstacle when no bounded parent placement exists', () => { + const parent = createSpace({ + id: 'parent', + rect: { x: 0, y: 0, width: 320, height: 240 }, + nodeIds: [], + }) + const child = createSpace({ + id: 'child', + rect: { x: 24, y: 24, width: 272, height: 192 }, + nodeIds: [], + parentSpaceId: 'parent', + }) + const dragNode = createNode({ + id: 'drag', + position: { x: 400, y: 80 }, + width: 120, + height: 80, + }) + const live = projectWorkspaceNodeLiveDropLayout({ + nodes: [dragNode], + spaces: [parent, child], + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', { x: 24, y: 80 }]]), + dragDx: -376, + dragDy: 0, + dropFlowPoint: { x: 10, y: 120 }, + }) + const liveParentRect = live.nextSpaces.find(space => space.id === 'parent')?.rect + const activePosition = expectProjectedPosition(live, 'drag') + + expect(live.targetSpaceId).toBe('parent') + expect(liveParentRect).toBeDefined() + expect(intersects(rectForPosition(dragNode, activePosition), child.rect!)).toBe(false) + expectInsideSpaceWithPadding( + rectForPosition(dragNode, activePosition), + liveParentRect!, + 'active inside expanded parent', + ) + expectInsideSpaceWithPadding(child.rect!, liveParentRect!, 'child inside expanded parent') + }) + + it('keeps an 81-window packed preview within one 60 Hz frame', () => { + const staticNodes = Array.from({ length: 80 }, (_, index) => + createNode({ + id: `static-${index}`, + position: { + x: 24 + (index % 8) * 80, + y: 24 + Math.floor(index / 8) * 80, + }, + width: 80, + height: 80, + }), + ) + const dragNode = createNode({ + id: 'drag', + position: { x: 24, y: 920 }, + width: 80, + height: 80, + }) + const space = createSpace({ + id: 'space', + rect: { x: 0, y: 0, width: 688, height: 848 }, + nodeIds: staticNodes.map(node => node.id), + }) + const input = { + nodes: [...staticNodes, dragNode], + spaces: [space], + draggedNodeIds: ['drag'], + draggedNodePositionById: new Map([['drag', { x: 24, y: 24 }]]), + dragDx: 0, + dragDy: -896, + dropFlowPoint: { x: 64, y: 64 }, + } + + for (let warmup = 0; warmup < 5; warmup += 1) { + projectWorkspaceNodeLiveDropLayout(input) + } + + const durations: number[] = [] + let projection = projectWorkspaceNodeLiveDropLayout(input) + for (let sample = 0; sample < 30; sample += 1) { + const startedAt = performance.now() + projection = projectWorkspaceNodeLiveDropLayout(input) + durations.push(performance.now() - startedAt) + } + + durations.sort((left, right) => left - right) + const p95 = durations[Math.ceil(durations.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY + expect(p95).toBeLessThan(16.7) + expect(projection.nextSpaces[0]?.rect?.height).toBeGreaterThan(848) + }) +}) diff --git a/tests/unit/contexts/workspaceNodeProjection.testUtils.ts b/tests/unit/contexts/workspaceNodeProjection.testUtils.ts new file mode 100644 index 00000000..46b5a660 --- /dev/null +++ b/tests/unit/contexts/workspaceNodeProjection.testUtils.ts @@ -0,0 +1,126 @@ +import type { Node } from '@xyflow/react' +import { expect } from 'vitest' +import type { + TerminalNodeData, + WorkspaceSpaceRect, + WorkspaceSpaceState, +} from '../../../src/contexts/workspace/presentation/renderer/types' +import { SPACE_NODE_PADDING } from '../../../src/contexts/workspace/presentation/renderer/utils/spaceLayout' + +const baseNode = { + type: 'terminalNode', + data: { + sessionId: 's1', + title: 'terminal', + width: 120, + height: 80, + kind: 'terminal', + status: null, + startedAt: null, + endedAt: null, + exitCode: null, + lastError: null, + scrollback: null, + agent: null, + task: null, + note: null, + } satisfies TerminalNodeData, +} + +type DragProjection = { + targetSpaceId: string | null + nextNodePositionById: Map +} | null + +export function createNode({ + id, + position, + width = baseNode.data.width, + height = baseNode.data.height, +}: { + id: string + position: { x: number; y: number } + width?: number + height?: number +}): Node { + return { + ...baseNode, + id, + position, + data: { + ...baseNode.data, + sessionId: `session-${id}`, + title: id, + width, + height, + }, + } +} + +export function createSpace({ + id, + rect, + nodeIds, + parentSpaceId, +}: { + id: string + rect: WorkspaceSpaceRect + nodeIds: string[] + parentSpaceId?: string +}): WorkspaceSpaceState { + return { + id, + name: id, + directoryPath: `/tmp/${id}`, + targetMountId: null, + parentSpaceId, + nodeIds, + rect, + } +} + +export function rectForPosition( + node: Node, + position: { x: number; y: number }, +): WorkspaceSpaceRect { + return { + x: position.x, + y: position.y, + width: node.data.width, + height: node.data.height, + } +} + +export function intersects(a: WorkspaceSpaceRect, b: WorkspaceSpaceRect): boolean { + return !( + a.x + a.width <= b.x || + a.x >= b.x + b.width || + a.y + a.height <= b.y || + a.y >= b.y + b.height + ) +} + +export function expectProjectedPosition( + projection: DragProjection, + nodeId: string, +): { x: number; y: number } { + expect(projection, `${nodeId}: projection should exist`).not.toBeNull() + const position = projection?.nextNodePositionById.get(nodeId) + expect(position, `${nodeId}: projected position should exist`).toBeDefined() + return position! +} + +export function expectInsideSpaceWithPadding( + rect: WorkspaceSpaceRect, + spaceRect: WorkspaceSpaceRect, + label: string, +): void { + expect(rect.x, `${label}: left`).toBeGreaterThanOrEqual(spaceRect.x + SPACE_NODE_PADDING) + expect(rect.y, `${label}: top`).toBeGreaterThanOrEqual(spaceRect.y + SPACE_NODE_PADDING) + expect(rect.x + rect.width, `${label}: right`).toBeLessThanOrEqual( + spaceRect.x + spaceRect.width - SPACE_NODE_PADDING, + ) + expect(rect.y + rect.height, `${label}: bottom`).toBeLessThanOrEqual( + spaceRect.y + spaceRect.height - SPACE_NODE_PADDING, + ) +}