update to node site

This commit is contained in:
2026-04-15 05:02:45 +10:00
parent 3c45406f6f
commit 7e7e6f2c22
710 changed files with 10532 additions and 8324 deletions

View File

@@ -0,0 +1,698 @@
/**
* @typedef {import('./WorkspaceNavigator.js').WorkspaceNavigator} WorkspaceNavigator
* @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer
*/
/**
* Handles pointer-driven drag movement and sway animation for world-space items.
*
* Ported directly from Picograph's WorkspaceDragManager + WorkspaceGeometry:
* - Anchor-ratio drag (cursor grabs exactly where it hits the element)
* - Ghost placeholder for nodes while dragging
* - movementX-preferred velocity → rotation blend (±7° clamp, grab-point pivot)
* - 0.85×/frame rotation decay RAF loop
* - RAF-coalesced connection refresh during drag, immediate flush on drop
* - Grid snapping on ghost preview and drop (dragging remains smooth)
*/
export class ItemDragger {
/** Grid size for snapping (matches CSS --grid-size) */
static GRID_SIZE = 20;
/** @type {HTMLElement} */
#canvas;
/** @type {WorkspaceNavigator} */
#nav;
/** @type {NodeRenderer} */
#nodeRenderer;
/** @type {boolean} */
#enabled = false;
/** @type {{ id: string, el: HTMLElement, pos: { x: number, y: number } }[]} */
#images = [];
/**
* Per-item apply-transform callbacks.
*
* @type {Map<string, (x: number, y: number, rotation: number) => void>}
*/
#applyTransform = new Map();
/** @type {Map<string, { x: number, y: number }>} */
#itemPositions = new Map();
// ─── Rotation (matches workspace.nodeDragRotation in Picograph) ────────────
/** @type {Map<string, number>} */
#dragRotation = new Map();
/**
* Stores the grab-ratio pivot point per item, used to set transform-origin during rotation.
*
* @type {Map<string, { ratioX: number, ratioY: number }>}
*/
#dragOrigin = new Map();
/** @type {number | null} */
#decayFrame = null;
// ─── Ghost (node drag placeholder) ────────────────────────────────────────
/** @type {{ el: HTMLElement, nodeId: string, width: number, height: number } | null} */
#ghost = null;
// ─── Connection refresh coalescing ─────────────────────────────────────────
/** @type {number | null} */
#connRefreshFrame = null;
// ─── Edge-pan (auto-scroll while dragging near canvas edges) ──────────────
/**
* @type {{ clientX: number, clientY: number,
* anchors: Map<string, {ratioX: number, ratioY: number, width: number, height: number}>,
* primaryId: string, useGhost: boolean } | null}
*/
#edgePanState = null;
/** @type {number | null} */
#edgePanFrame = null;
/** @type {boolean} */
#dragging = false;
/** @type {(() => void) | null} */
#cancelActiveDrag = null;
// ─── Active item (debug panel) ─────────────────────────────────────────────
/** @type {string | null} */
#activeId = null;
/** @type {{ x: number, y: number } | null} */
#activePos = null;
/** @type {((id: string | null, pos: { x: number, y: number } | null) => void) | null} */
#onActiveItemChange = null;
/** @type {(() => void) | null} */
#onItemMoved = null;
/**
* @param {HTMLElement} canvas - The workspace canvas element.
* @param {WorkspaceNavigator} nav
* @param {NodeRenderer} nodeRenderer
* @param {((id: string | null, pos: { x: number, y: number } | null) => void) | null} [onActiveItemChange]
* @param {(() => void) | null} [onItemMoved]
*/
constructor(canvas, nav, nodeRenderer, onActiveItemChange = null, onItemMoved = null) {
this.#canvas = canvas;
this.#nav = nav;
this.#nodeRenderer = nodeRenderer;
this.#onActiveItemChange = onActiveItemChange;
this.#onItemMoved = onItemMoved;
}
// ─── Public ───────────────────────────────────────────────────────────────
/** @returns {string | null} */
get activeId() { return this.#activeId; }
/** @returns {{ x: number, y: number } | null} */
get activePos() { return this.#activePos; }
/** True while a drag gesture is actively in progress. */
get isDragging() { return this.#dragging; }
/**
* Immediately drops any active drag, snapping the item to grid at its current position.
* Call this when a pinch-to-zoom starts so the two gestures don't fight each other.
*/
cancelDrag() {
this.#cancelActiveDrag?.();
}
/** @param {boolean} v */
set enabled(v) {
this.#enabled = v;
this.#images.forEach(({ el }) => {
el.style.pointerEvents = v ? "auto" : "none";
el.style.cursor = v ? "grab" : "";
});
this.#nodeRenderer.getNodeElements().forEach((el) => {
el.dataset.draggable = v ? "true" : "false";
});
if (!v) {
this.#removeGhost();
this.#stopDecay();
}
}
/** @returns {boolean} */
get enabled() { return this.#enabled; }
/**
* Registers an image element as draggable.
*
* @param {string} id
* @param {HTMLElement} el
* @param {{ x: number, y: number }} initialPos
*/
registerImage(id, el, initialPos) {
const entry = { id, el, pos: { ...initialPos } };
this.#images.push(entry);
this.#itemPositions.set(id, { ...initialPos });
this.#applyTransform.set(id, (x, y, rot) => {
entry.pos.x = x;
entry.pos.y = y;
el.style.transform = this.#buildTransform(x, y, rot);
});
el.addEventListener("pointerdown", (e) => {
if (!this.#enabled) return;
e.stopPropagation();
const pointerWorld = this.#clientToWorkspace(e.clientX, e.clientY);
const zoom = this.#nav.zoomLevel || 1;
const rect = el.getBoundingClientRect();
const width = rect.width / zoom || 512;
const height = rect.height / zoom || 512;
const ratioX = width > 0 ? (pointerWorld.x - entry.pos.x) / width : 0;
const ratioY = height > 0 ? (pointerWorld.y - entry.pos.y) / height : 0;
this.#beginDrag(id, e, el, new Map([[id, { ratioX, ratioY, width, height }]]), false);
});
}
/**
* Registers a node element as draggable. Call after the node has been added to NodeRenderer.
*
* @param {string} nodeId
*/
registerNode(nodeId) {
const el = this.#nodeRenderer.getNodeElements().get(nodeId);
const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId);
if (!el || !node) return;
this.#itemPositions.set(nodeId, { ...node.position });
this.#applyTransform.set(nodeId, (x, y, rot) => {
this.#nodeRenderer.setNodeTransform(nodeId, x, y, rot);
});
el.addEventListener("pointerdown", (e) => {
if (!this.#enabled) return;
const target = /** @type {HTMLElement} */ (e.target);
if (target.closest(".pin-handle")) return;
e.stopPropagation();
const pointerWorld = this.#clientToWorkspace(e.clientX, e.clientY);
const zoom = this.#nav.zoomLevel || 1;
const rect = el.getBoundingClientRect();
const width = Math.max(1, rect.width / zoom);
const height = Math.max(1, rect.height / zoom);
const ratioX = width > 0 ? (pointerWorld.x - node.position.x) / width : 0;
const ratioY = height > 0 ? (pointerWorld.y - node.position.y) / height : 0;
this.#beginDrag(nodeId, e, el, new Map([[nodeId, { ratioX, ratioY, width, height }]]), true);
});
}
// ─── Drag ─────────────────────────────────────────────────────────────────
/**
* @param {string} primaryId
* @param {PointerEvent} event
* @param {HTMLElement} primaryEl
* @param {Map<string, { ratioX: number, ratioY: number, width: number, height: number }>} anchors
* @param {boolean} useGhost
*/
#beginDrag(primaryId, event, primaryEl, anchors, useGhost) {
// Don't start a drag while a pinch-to-zoom is already running.
// This can happen when both fingers land simultaneously: touchstart (pinch)
// fires before pointerdown, so the guard here catches the race.
if (this.#nav.isPinching) return;
anchors.forEach((_, id) => this.#dragRotation.delete(id));
// Stop any previous edge-pan and mark drag as active
this.#stopEdgePan();
this.#dragging = true;
// Store grab-point ratios and apply them as transform-origin for pivot rotation
anchors.forEach((anchor, id) => {
this.#dragOrigin.set(id, { ratioX: anchor.ratioX, ratioY: anchor.ratioY });
const el = this.#getItemElement(id);
if (el) el.style.transformOrigin = `${anchor.ratioX * 100}% ${anchor.ratioY * 100}%`;
});
primaryEl.style.transition = "";
let prevClientX = event.clientX;
let prevClientY = event.clientY;
let prevMoveTime = performance.now();
// Ghost placeholder (nodes only)
if (useGhost) {
const anchor = anchors.get(primaryId);
if (anchor) {
const initialPointer = this.#clientToWorkspace(event.clientX, event.clientY);
const ghostPos = {
x: initialPointer.x - anchor.ratioX * anchor.width,
y: initialPointer.y - anchor.ratioY * anchor.height,
};
this.#ensureGhost(primaryId, primaryEl, anchor.width, anchor.height, ghostPos);
}
}
this.#setActive(primaryId, this.#itemPositions.get(primaryId) ?? null);
// Initialise edge-pan state so #tickEdgePan can reference anchors without closure mutation
this.#edgePanState = { clientX: event.clientX, clientY: event.clientY, anchors, primaryId, useGhost };
const handlePointerMove = (/** @type {PointerEvent} */ moveE) => {
if (moveE.pointerId !== event.pointerId) return;
const pointer = this.#clientToWorkspace(moveE.clientX, moveE.clientY);
anchors.forEach((anchor, id) => {
const newX = pointer.x - anchor.ratioX * anchor.width;
const newY = pointer.y - anchor.ratioY * anchor.height;
this.#itemPositions.set(id, { x: newX, y: newY });
const rot = this.#dragRotation.get(id) ?? 0;
this.#applyTransform.get(id)?.(newX, newY, rot);
if (useGhost && id === primaryId) {
this.#updateGhost(newX, newY);
}
});
this.#setActive(primaryId, this.#itemPositions.get(primaryId) ?? null);
// Update edge-pan pointer so the RAF loop knows where the pointer is
this.#edgePanState.clientX = moveE.clientX;
this.#edgePanState.clientY = moveE.clientY;
if (this.#edgePanFrame === null) {
this.#edgePanFrame = requestAnimationFrame(() => this.#tickEdgePan());
}
// Compute time-normalised velocity so touch (coalesced, less frequent events) and
// mouse (per-frame events) produce the same rotation magnitude at the same finger/cursor speed.
// Target frame budget: 16 ms. Clamp dt to 860 ms to avoid spikes on tab-switch etc.
const now = performance.now();
const dt = Math.max(8, Math.min(60, now - prevMoveTime));
prevMoveTime = now;
const rawDelta = moveE.clientX - prevClientX;
const velocityX = (rawDelta / dt) * 16;
anchors.forEach((_, id) => this.#nudgeRotation(id, velocityX));
prevClientX = moveE.clientX;
prevClientY = moveE.clientY;
this.#scheduleConnectionRefresh();
};
const handlePointerFinish = (/** @type {PointerEvent} */ finishE) => {
if (finishE.pointerId !== event.pointerId) return;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerFinish);
window.removeEventListener("pointercancel", handlePointerFinish);
this.#cancelActiveDrag = null;
this.#stopEdgePan();
this.#dragging = false;
// Commit position from final pointer coords, snapped to grid
if (Number.isFinite(finishE.clientX)) {
const pointer = this.#clientToWorkspace(finishE.clientX, finishE.clientY);
anchors.forEach((anchor, id) => {
const rawX = pointer.x - anchor.ratioX * anchor.width;
const rawY = pointer.y - anchor.ratioY * anchor.height;
const snapped = this.#snapToGrid(rawX, rawY);
this.#itemPositions.set(id, { x: snapped.x, y: snapped.y });
});
}
// Zero out rotations with snap-back transition, then decay
anchors.forEach((_, id) => this.#setDragRotation(id, 0));
if (useGhost) {
const pos = this.#itemPositions.get(primaryId);
if (pos) this.#updateGhost(pos.x, pos.y);
window.setTimeout(() => this.#removeGhost(), 160);
}
// Keep selection active after drag ends
this.#flushConnectionRefresh();
};
// Exposed so external callers (e.g. pinch-to-zoom start) can drop the drag cleanly
this.#cancelActiveDrag = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerFinish);
window.removeEventListener("pointercancel", handlePointerFinish);
this.#cancelActiveDrag = null;
this.#stopEdgePan();
this.#dragging = false;
// Snap current live positions to grid
anchors.forEach((_, id) => {
const pos = this.#itemPositions.get(id);
if (!pos) return;
const snapped = this.#snapToGrid(pos.x, pos.y);
this.#itemPositions.set(id, snapped);
const rot = this.#dragRotation.get(id) ?? 0;
this.#applyTransform.get(id)?.(snapped.x, snapped.y, rot);
});
anchors.forEach((_, id) => this.#setDragRotation(id, 0));
if (useGhost) {
const pos = this.#itemPositions.get(primaryId);
if (pos) this.#updateGhost(pos.x, pos.y);
window.setTimeout(() => this.#removeGhost(), 160);
}
this.#flushConnectionRefresh();
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerFinish);
window.addEventListener("pointercancel", handlePointerFinish);
}
// ─── Ghost ────────────────────────────────────────────────────────────────
/**
* @param {string} nodeId
* @param {HTMLElement} nodeEl
* @param {number} width
* @param {number} height
* @param {{ x: number, y: number }} pos
*/
#ensureGhost(nodeId, nodeEl, width, height, pos) {
this.#removeGhost();
const el = document.createElement("div");
el.className = "node-drag-ghost";
el.style.width = `${width}px`;
el.style.height = `${height}px`;
el.style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0)`;
el.setAttribute("role", "presentation");
if (nodeEl.parentElement) {
nodeEl.parentElement.insertBefore(el, nodeEl);
} else {
nodeEl.appendChild(el);
}
this.#ghost = { el, nodeId, width, height };
}
/**
* @param {number} x
* @param {number} y
*/
#updateGhost(x, y) {
if (!this.#ghost) return;
const snapped = this.#snapToGrid(x, y);
this.#ghost.el.style.transform = `translate3d(${snapped.x}px, ${snapped.y}px, 0)`;
}
#removeGhost() {
if (!this.#ghost) return;
if (this.#ghost.el.isConnected) this.#ghost.el.remove();
this.#ghost = null;
}
// ─── Edge-pan (auto-scroll while dragging near canvas edges) ─────────────
/**
* Stops the edge-pan RAF loop and clears its state.
*/
#stopEdgePan() {
if (this.#edgePanFrame !== null) {
cancelAnimationFrame(this.#edgePanFrame);
this.#edgePanFrame = null;
}
this.#edgePanState = null;
}
/**
* RAF loop that scrolls the viewport when the drag pointer is within 10% of any canvas edge,
* then re-positions the dragged item so it stays under the pointer.
* Runs continuously while the pointer stays in the edge zone; stops when it leaves.
*/
#tickEdgePan() {
this.#edgePanFrame = null;
const state = this.#edgePanState;
if (!state) return;
const rect = this.#canvas.getBoundingClientRect();
const { clientX, clientY, anchors, primaryId, useGhost } = state;
const edgeW = rect.width * 0.10;
const edgeH = rect.height * 0.10;
/** Max pan speed in screen pixels per frame */
const MAX_SPEED = 14;
const cx = clientX - rect.left;
const cy = clientY - rect.top;
let screenDx = 0;
let screenDy = 0;
if (cx < edgeW) screenDx = MAX_SPEED * (1 - cx / edgeW);
if (cx > rect.width - edgeW) screenDx = -MAX_SPEED * (1 - (rect.width - cx) / edgeW);
if (cy < edgeH) screenDy = MAX_SPEED * (1 - cy / edgeH);
if (cy > rect.height - edgeH) screenDy = -MAX_SPEED * (1 - (rect.height - cy) / edgeH);
if (screenDx !== 0 || screenDy !== 0) {
const zoom = this.#nav.zoomLevel || 1;
this.#nav.panBy(screenDx / zoom, screenDy / zoom);
// Re-derive world position from the (now-updated) viewport so node follows pointer
const pointer = this.#clientToWorkspace(clientX, clientY);
anchors.forEach((anchor, id) => {
const newX = pointer.x - anchor.ratioX * anchor.width;
const newY = pointer.y - anchor.ratioY * anchor.height;
this.#itemPositions.set(id, { x: newX, y: newY });
const rot = this.#dragRotation.get(id) ?? 0;
this.#applyTransform.get(id)?.(newX, newY, rot);
if (useGhost && id === primaryId) {
this.#updateGhost(newX, newY);
}
});
this.#scheduleConnectionRefresh();
this.#edgePanFrame = requestAnimationFrame(() => this.#tickEdgePan());
}
// Pointer outside the edge zone — loop pauses; next pointermove will restart it if needed
}
// ─── Rotation ─────────────────────────────────────────────────────────────
/**
* Blends horizontal velocity into an item's rotation state.
* Exact port of nudgeNodeDragRotation from WorkspaceDragManager.
*
* @param {string} id
* @param {number} velocityX
*/
#nudgeRotation(id, velocityX) {
if (!Number.isFinite(velocityX)) return;
const existing = this.#dragRotation.get(id) ?? 0;
const blended = existing * 0.92 + velocityX * 0.18;
this.#setDragRotation(id, blended);
}
/**
* Applies or clears a rotation and updates the item's transform.
* Exact port of setNodeDragRotation from WorkspaceDragManager.
*
* @param {string} id
* @param {number} angle
*/
#setDragRotation(id, angle) {
const sanitized = Number.isFinite(angle) ? angle : 0;
const clamped = Math.max(-7, Math.min(7, sanitized));
if (Math.abs(clamped) < 0.01) {
this.#dragRotation.delete(id);
this.#dragOrigin.delete(id);
const el = this.#getItemElement(id);
if (el) el.style.transformOrigin = "";
} else {
this.#dragRotation.set(id, clamped);
}
const pos = this.#itemPositions.get(id);
if (pos) {
this.#applyTransform.get(id)?.(pos.x, pos.y, clamped);
}
if (this.#dragRotation.size > 0) {
this.#ensureDecay();
} else {
this.#stopDecay();
}
}
/**
* Returns the DOM element for a registered item (image or node).
*
* @param {string} id
* @returns {HTMLElement | null}
*/
#getItemElement(id) {
const img = this.#images.find(i => i.id === id);
if (img) return img.el;
return this.#nodeRenderer.getNodeElements().get(id) ?? null;
}
// ─── Decay ────────────────────────────────────────────────────────────────
/**
* Ensures the rotation decay RAF loop is running.
* Exact port of ensureNodeRotationDecay from WorkspaceDragManager.
*/
#ensureDecay() {
if (this.#decayFrame !== null) return;
const step = () => {
let hasActive = false;
this.#dragRotation.forEach((angle, id) => {
const pos = this.#itemPositions.get(id);
if (!pos) { this.#dragRotation.delete(id); return; }
const damped = angle * 0.85;
if (Math.abs(damped) < 0.05) {
this.#dragRotation.delete(id);
this.#dragOrigin.delete(id);
const el = this.#getItemElement(id);
if (el) el.style.transformOrigin = "";
this.#applyTransform.get(id)?.(pos.x, pos.y, 0);
} else {
this.#dragRotation.set(id, damped);
this.#applyTransform.get(id)?.(pos.x, pos.y, damped);
hasActive = true;
}
});
this.#scheduleConnectionRefresh();
if (hasActive) {
this.#decayFrame = requestAnimationFrame(step);
} else {
this.#decayFrame = null;
this.#flushConnectionRefresh();
}
};
this.#decayFrame = requestAnimationFrame(step);
}
/**
* Stops the decay loop if running.
* Exact port of stopNodeRotationDecay from WorkspaceDragManager.
*/
#stopDecay() {
if (this.#decayFrame === null) return;
cancelAnimationFrame(this.#decayFrame);
this.#decayFrame = null;
}
// ─── Connection refresh coalescing ────────────────────────────────────────
/**
* Queues a single RAF to refresh connections.
* Matches scheduleConnectionRefresh in BlueprintWorkspace.
*/
#scheduleConnectionRefresh() {
if (this.#connRefreshFrame !== null) return;
this.#connRefreshFrame = requestAnimationFrame(() => {
this.#connRefreshFrame = null;
this.#onItemMoved?.();
});
}
/**
* Immediately refreshes connections, cancelling any pending RAF.
* Matches flushConnectionRefresh in BlueprintWorkspace.
*/
#flushConnectionRefresh() {
if (this.#connRefreshFrame !== null) {
cancelAnimationFrame(this.#connRefreshFrame);
this.#connRefreshFrame = null;
}
this.#onItemMoved?.();
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/**
* Snaps a world-space position to the grid.
*
* @param {number} x
* @param {number} y
* @returns {{ x: number, y: number }}
*/
#snapToGrid(x, y) {
const gridSize = ItemDragger.GRID_SIZE;
return {
x: Math.round(x / gridSize) * gridSize,
y: Math.round(y / gridSize) * gridSize,
};
}
/**
* Converts client coordinates to world space.
* Exact port of clientToWorkspace from WorkspaceDragManager.
*
* @param {number} clientX
* @param {number} clientY
* @returns {{ x: number, y: number }}
*/
#clientToWorkspace(clientX, clientY) {
const rect = this.#canvas.getBoundingClientRect();
const zoom = Math.max(0.01, this.#nav.zoomLevel || 1);
const offset = this.#nav.getEffectiveOffset();
return {
x: (clientX - rect.left) / zoom - offset.x,
y: (clientY - rect.top) / zoom - offset.y,
};
}
/**
* Builds a CSS transform matching WorkspaceGeometry.positionToTransform in Picograph.
*
* @param {number} x
* @param {number} y
* @param {number} rotation
* @returns {string}
*/
#buildTransform(x, y, rotation) {
const rotZ = Number.isFinite(rotation) ? rotation : 0;
if (!rotZ) return `translate3d(${x}px, ${y}px, 0)`;
return `translate3d(${x}px, ${y}px, 0) rotate(${rotZ}deg)`;
}
/**
* @param {string | null} id
* @param {{ x: number, y: number } | null} pos
*/
#setActive(id, pos) {
this.#activeId = id;
this.#activePos = pos;
this.#onActiveItemChange?.(id, pos);
}
/**
* Clear the current selection
*/
clearSelection() {
this.#setActive(null, null);
}
/**
* Get the currently active item ID
* @returns {string | null}
*/
getActiveId() {
return this.#activeId;
}
}