Files
picoGraph/scripts/ui/workspace/WorkspaceDragManager.js
Max Litruv Boonzaayer e64f3e881e Initial commit
moved from private version control
2025-10-19 21:12:41 +11:00

653 lines
19 KiB
JavaScript

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;
return {
x: (safeX - rect.left) / zoom,
y: (safeY - rect.top) / zoom,
};
}
/**
* 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;
}
}
}