mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 02:36:04 +10:00
653 lines
19 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|