import { WorkspaceGeometry } from "./WorkspaceGeometry.js"; /** * Handles drag interactions, ghost previews, and drag-induced transforms. */ export class WorkspaceDragManager { /** * @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Owning workspace instance. */ constructor(workspace) { this.workspace = workspace; } /** * Ensures a ghost preview element exists for palette drags. * * @param {string | null} definitionId Dragged node definition identifier. */ ensurePaletteGhost(definitionId) { const { workspace } = this; const existing = workspace.paletteDragGhost; if (existing && existing.element.isConnected) { if (existing.definitionId === definitionId) { return; } this.removePaletteGhost(); } const element = document.createElement("div"); element.className = "workspace-drop-ghost"; element.setAttribute("role", "presentation"); const label = document.createElement("span"); label.className = "workspace-drop-ghost__label"; const definition = definitionId ? workspace.registry.get(definitionId) : null; label.textContent = definition?.title ?? "Node Preview"; element.appendChild(label); workspace.nodeLayer.appendChild(element); workspace.paletteDragGhost = { element, width: element.offsetWidth, height: element.offsetHeight, definitionId: definitionId ?? null, }; } /** * Updates the palette ghost transform to match the supplied position. * * @param {{ x: number, y: number }} position Snapped workspace position. */ updatePaletteGhost(position) { const { paletteDragGhost } = this.workspace; if (!paletteDragGhost) { return; } paletteDragGhost.element.style.transform = WorkspaceGeometry.positionToTransform(position); } /** * Removes the active palette drag ghost. */ removePaletteGhost() { const { workspace } = this; if ( workspace.paletteDragGhost?.element && workspace.paletteDragGhost.element.isConnected ) { workspace.paletteDragGhost.element.remove(); } workspace.paletteDragGhost = null; } /** * Ensures a ghost preview exists for node drag operations. * * @param {string} nodeId Active node identifier. */ ensureNodeGhost(nodeId) { const { workspace } = this; const nodeElement = workspace.nodeElements.get(nodeId); if (!nodeElement) { return; } const existing = workspace.nodeDragGhost; if ( existing && existing.nodeId === nodeId && existing.element.isConnected ) { return; } this.removeNodeGhost(); const ghost = document.createElement("div"); ghost.className = "workspace-node-ghost"; const zoom = this.workspace.zoomLevel || 1; const rect = nodeElement.getBoundingClientRect(); const unscaledWidth = rect.width || nodeElement.offsetWidth; const unscaledHeight = rect.height || nodeElement.offsetHeight; const width = Math.max(1, unscaledWidth / zoom); const height = Math.max(1, unscaledHeight / zoom); ghost.style.width = `${width}px`; ghost.style.height = `${height}px`; ghost.setAttribute("role", "presentation"); if (nodeElement.parentElement) { nodeElement.parentElement.insertBefore(ghost, nodeElement); } else { workspace.nodeLayer.appendChild(ghost); } workspace.nodeDragGhost = { element: ghost, width, height, nodeId, }; } /** * Updates the node drag ghost to the provided position. * * @param {{ x: number, y: number }} position Snapped workspace position. */ updateNodeGhost(position) { const ghost = this.workspace.nodeDragGhost; if (!ghost) { return; } ghost.element.style.transform = WorkspaceGeometry.positionToTransform(position); } /** * Removes the node drag ghost preview if active. */ removeNodeGhost() { const { workspace } = this; if ( workspace.nodeDragGhost?.element && workspace.nodeDragGhost.element.isConnected ) { workspace.nodeDragGhost.element.remove(); } workspace.nodeDragGhost = null; } /** * Adjusts active drag offsets when the workspace shifts. * * @param {{ x: number, y: number }} delta Workspace-space translation applied externally. */ applyActiveDragShift(delta) { if (!delta || (!delta.x && !delta.y)) { return; } const context = this.workspace.activeNodeDragContext; if (!context) { return; } if (context.lastPointerWorld) { context.lastPointerWorld = { x: context.lastPointerWorld.x + delta.x, y: context.lastPointerWorld.y + delta.y, }; } if (!context.multiDrag) { const ghost = this.workspace.nodeDragGhost; const pointer = context.lastPointerWorld; if (ghost?.nodeId && pointer) { const anchor = context.anchors.get(ghost.nodeId); if (anchor) { this.updateNodeGhost({ x: pointer.x - anchor.ratioX * anchor.width, y: pointer.y - anchor.ratioY * anchor.height, }); } } } } /** * Updates the cached pointer state used during drag operations. * * @param {{ clientX?: number, clientY?: number } | null | undefined} pointer Pointer-like payload. */ rebaseActiveDragPointer(pointer) { if (!pointer) { return; } const context = this.workspace.activeNodeDragContext; if (!context) { return; } const hasClientX = Number.isFinite(pointer.clientX); const hasClientY = Number.isFinite(pointer.clientY); if (!hasClientX && !hasClientY) { return; } const pointerWorld = this.#clientToWorkspace( hasClientX ? pointer.clientX : context.previousClientX, hasClientY ? pointer.clientY : context.previousClientY ); context.lastPointerWorld = pointerWorld; if (hasClientX) { context.previousClientX = pointer.clientX; } if (hasClientY) { context.previousClientY = pointer.clientY; } if (!context.multiDrag) { const ghost = this.workspace.nodeDragGhost; if (ghost?.nodeId) { const anchor = context.anchors.get(ghost.nodeId); if (anchor) { this.updateNodeGhost({ x: pointerWorld.x - anchor.ratioX * anchor.width, y: pointerWorld.y - anchor.ratioY * anchor.height, }); } } } } /** * Applies a snapped position to a node with a subtle easing transition. * * @param {string} nodeId Node identifier. * @param {{ x: number, y: number }} position Target snapped position. */ applyNodeSnapPosition(nodeId, position) { const { workspace } = this; const element = workspace.nodeElements.get(nodeId); if (element) { element.style.transition = "transform 140ms cubic-bezier(0.22, 0.61, 0.36, 1)"; const handleTransitionEnd = () => { element.style.transition = ""; workspace.flushConnectionRefresh(); if (workspace.nodeDragGhost?.nodeId === nodeId) { this.removeNodeGhost(); } }; element.addEventListener("transitionend", handleTransitionEnd, { once: true, }); window.setTimeout(() => { element.removeEventListener("transitionend", handleTransitionEnd); element.style.transition = ""; workspace.flushConnectionRefresh(); if (workspace.nodeDragGhost?.nodeId === nodeId) { this.removeNodeGhost(); } }, 200); } this.updateNodeDomPosition(nodeId, position); workspace.graph.setNodePosition(nodeId, position); workspace.scheduleConnectionRefresh(); } /** * Updates the DOM transform for a node element. * * @param {string} nodeId Node identifier. * @param {{x:number,y:number}} position Coordinates. */ updateNodeDomPosition(nodeId, position) { const { workspace } = this; const element = workspace.nodeElements.get(nodeId); if (!element) { return; } const rotation = workspace.nodeDragRotation.get(nodeId) ?? 0; element.style.transform = WorkspaceGeometry.positionToTransform( position, rotation ); } /** * Initiates a node drag operation. * * @param {PointerEvent} event Pointer event. * @param {string} nodeId Dragged node identifier. */ startNodeDrag(event, nodeId) { const { workspace } = this; const node = workspace.graph.nodes.get(nodeId); if (!node) { return; } const isMouse = event.pointerType === "mouse" || event.pointerType === ""; if (isMouse && event.button !== 0) { return; } if ( !isMouse && event.pointerType !== "touch" && event.pointerType !== "pen" ) { return; } event.stopPropagation(); event.preventDefault(); const selectedIds = workspace.selectedNodeIds.has(nodeId) ? [...workspace.selectedNodeIds] : [nodeId]; const pointerWorld = this.#eventToWorkspacePoint(event); const anchors = new Map(); selectedIds.forEach((id) => { const target = workspace.graph.nodes.get(id); if (!target) { return; } const element = workspace.nodeElements.get(id) ?? null; const { width, height } = this.#measureNodeSize(element); const ratioX = width > 0 ? (pointerWorld.x - target.position.x) / width : 0; const ratioY = height > 0 ? (pointerWorld.y - target.position.y) / height : 0; anchors.set(id, { ratioX, ratioY, width, height, }); }); if (!anchors.has(nodeId)) { const element = workspace.nodeElements.get(nodeId) ?? null; const { width, height } = this.#measureNodeSize(element); const ratioX = width > 0 ? (pointerWorld.x - node.position.x) / width : 0; const ratioY = height > 0 ? (pointerWorld.y - node.position.y) / height : 0; anchors.set(nodeId, { ratioX, ratioY, width, height, }); } const multiDrag = anchors.size > 1; workspace.draggingNode = { nodeId, pointerId: event.pointerId, selection: new Set(anchors.keys()), }; workspace.isDraggingNodes = true; workspace.activeNodeDragContext = { anchors, multiDrag, lastPointerWorld: pointerWorld, previousClientX: event.clientX, previousClientY: event.clientY, }; if (multiDrag) { this.removeNodeGhost(); } else { this.ensureNodeGhost(nodeId); const anchor = anchors.get(nodeId); if (anchor) { const initialPosition = { x: pointerWorld.x - anchor.ratioX * anchor.width, y: pointerWorld.y - anchor.ratioY * anchor.height, }; this.updateNodeGhost(WorkspaceGeometry.snapPositionToGrid(workspace, initialPosition)); } } const draggedElement = workspace.nodeElements.get(nodeId); if (draggedElement) { draggedElement.style.transition = ""; } const handlePointerMove = (moveEvent) => { if (moveEvent.pointerId !== event.pointerId) { return; } const context = workspace.activeNodeDragContext; if (!context) { return; } const pointer = this.#eventToWorkspacePoint(moveEvent); context.lastPointerWorld = pointer; const pendingPositions = new Map(); context.anchors.forEach((anchor, id) => { const width = anchor.width || 1; const height = anchor.height || 1; const updated = { x: pointer.x - anchor.ratioX * width, y: pointer.y - anchor.ratioY * height, }; this.updateNodeDomPosition(id, updated); pendingPositions.set(id, updated); if (!context.multiDrag && id === nodeId) { this.updateNodeGhost(WorkspaceGeometry.snapPositionToGrid(workspace, updated)); } }); pendingPositions.forEach((position, id) => { workspace.graph.setNodePosition(id, position); }); const screenDeltaX = Number.isFinite(moveEvent.movementX) ? moveEvent.movementX : moveEvent.clientX - context.previousClientX; const velocityX = Number.isFinite(moveEvent.movementX) ? moveEvent.movementX : screenDeltaX; pendingPositions.forEach((_, id) => { this.nudgeNodeDragRotation(id, velocityX); }); context.previousClientX = moveEvent.clientX; context.previousClientY = moveEvent.clientY; }; const finalizeSelection = (pointer) => { const context = workspace.activeNodeDragContext; if (!context) { return; } const targetPointer = pointer ?? context.lastPointerWorld; if (!targetPointer) { return; } context.anchors.forEach((anchor, id) => { const width = anchor.width || 1; const height = anchor.height || 1; const raw = { x: targetPointer.x - anchor.ratioX * width, y: targetPointer.y - anchor.ratioY * height, }; const snapped = WorkspaceGeometry.snapPositionToGrid(workspace, raw); this.applyNodeSnapPosition(id, snapped); this.setNodeDragRotation(id, 0); if (!context.multiDrag && id === nodeId) { this.updateNodeGhost(snapped); } }); if (context.multiDrag) { this.removeNodeGhost(); } else { window.setTimeout(() => { this.removeNodeGhost(); }, 160); } workspace.isDraggingNodes = false; workspace.activeNodeDragContext = null; workspace.flushConnectionRefresh(); }; const handlePointerFinish = (finishEvent) => { if (finishEvent.pointerId !== event.pointerId) { return; } window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerFinish); window.removeEventListener("pointercancel", handlePointerFinish); const context = workspace.activeNodeDragContext; let pointer = null; if (context) { const hasClient = Number.isFinite(finishEvent.clientX); const hasClientY = Number.isFinite(finishEvent.clientY); if (hasClient || hasClientY) { pointer = this.#eventToWorkspacePoint(finishEvent); } } finalizeSelection(pointer); workspace.draggingNode = null; workspace.activeNodeDragContext = null; }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerFinish); window.addEventListener("pointercancel", handlePointerFinish); } /** * Applies a momentum impulse into the current drag rotation state. * * @param {string} nodeId Node identifier. * @param {number} horizontalVelocity Horizontal pointer velocity. */ nudgeNodeDragRotation(nodeId, horizontalVelocity) { if (!Number.isFinite(horizontalVelocity)) { return; } const { workspace } = this; const existing = workspace.nodeDragRotation.get(nodeId) ?? 0; const impulse = horizontalVelocity * 0.9; const blended = existing * 0.75 + impulse; const clamped = Math.max(-12, Math.min(12, blended)); this.setNodeDragRotation(nodeId, clamped); } /** * Converts pointer event coordinates into workspace-space values without clamping. * * @param {PointerEvent} event Pointer event reference. * @returns {{ x: number, y: number }} */ #eventToWorkspacePoint(event) { return this.#clientToWorkspace(event.clientX, event.clientY); } /** * Measures a node element and returns its workspace-space dimensions. * * @param {HTMLElement | null} element Node DOM element. * @returns {{ width: number, height: number }} */ #measureNodeSize(element) { const zoom = this.workspace.zoomLevel || 1; if (!element) { return { width: 1, height: 1 }; } const rect = element.getBoundingClientRect(); const rawWidth = rect.width / zoom; const rawHeight = rect.height / zoom; const fallbackWidth = element.offsetWidth || rawWidth; const fallbackHeight = element.offsetHeight || rawHeight; return { width: Math.max(1, Number.isFinite(rawWidth) ? rawWidth : fallbackWidth || 1), height: Math.max(1, Number.isFinite(rawHeight) ? rawHeight : fallbackHeight || 1), }; } /** * Converts client coordinates to workspace-space units. * * @param {number | undefined} clientX Viewport X coordinate. * @param {number | undefined} clientY Viewport Y coordinate. * @returns {{ x: number, y: number }} */ #clientToWorkspace(clientX, clientY) { const rect = this.workspace.workspaceElement.getBoundingClientRect(); const zoom = this.workspace.zoomLevel || 1; const safeX = Number.isFinite(clientX) ? clientX : rect.left; const safeY = Number.isFinite(clientY) ? clientY : rect.top; const pending = this.workspace.navigationManager?.getPendingLayerOffset?.() ?? { x: 0, y: 0 }; return { x: (safeX - rect.left) / zoom - pending.x, y: (safeY - rect.top) / zoom - pending.y, }; } /** * Applies a temporary rotation used during drag interactions. * * @param {string} nodeId Node identifier. * @param {number} angle Rotation in degrees. */ setNodeDragRotation(nodeId, angle) { const { workspace } = this; const sanitized = Number.isFinite(angle) ? angle : 0; const clamped = Math.max(-12, Math.min(12, sanitized)); if (Math.abs(clamped) < 0.01) { workspace.nodeDragRotation.delete(nodeId); } else { workspace.nodeDragRotation.set(nodeId, clamped); } const node = workspace.graph.nodes.get(nodeId); if (node) { this.updateNodeDomPosition(nodeId, node.position); } if (workspace.nodeDragRotation.size > 0) { this.ensureNodeRotationDecay(); } else { this.stopNodeRotationDecay(); } } /** * Ensures the drag rotation damping loop is active. */ ensureNodeRotationDecay() { const { workspace } = this; if (workspace.nodeDragRotationDecayFrame !== null) { return; } const step = () => { let hasActive = false; workspace.nodeDragRotation.forEach((angle, id) => { const node = workspace.graph.nodes.get(id); if (!node) { workspace.nodeDragRotation.delete(id); return; } const damped = angle * 0.85; if (Math.abs(damped) < 0.05) { workspace.nodeDragRotation.delete(id); } else { workspace.nodeDragRotation.set(id, damped); hasActive = true; } this.updateNodeDomPosition(id, node.position); }); if (hasActive) { workspace.nodeDragRotationDecayFrame = window.requestAnimationFrame(step); } else { workspace.nodeDragRotationDecayFrame = null; } }; workspace.nodeDragRotationDecayFrame = window.requestAnimationFrame(step); } /** * Stops the drag rotation damping loop if one is queued. */ stopNodeRotationDecay() { const { workspace } = this; if (workspace.nodeDragRotationDecayFrame !== null) { window.cancelAnimationFrame(workspace.nodeDragRotationDecayFrame); workspace.nodeDragRotationDecayFrame = null; } } }