mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 10:46:06 +10:00
updated graph rendering to be more efficient. added smooth scrolling, added color type, mmb drag for cutting
This commit is contained in:
@@ -565,9 +565,10 @@ export class WorkspaceDragManager {
|
||||
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,
|
||||
y: (safeY - rect.top) / zoom,
|
||||
x: (safeX - rect.left) / zoom - pending.x,
|
||||
y: (safeY - rect.top) / zoom - pending.y,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,6 @@ export class WorkspaceHistoryManager {
|
||||
this.#pendingReason = null;
|
||||
|
||||
this.#workspace.applySerializedWorkspace(snapshot.workspace);
|
||||
this.#workspace.applyHistoryView(snapshot.view);
|
||||
this.#workspace.applyHistorySelection(snapshot.selection);
|
||||
|
||||
this.isReady = wasReady;
|
||||
|
||||
@@ -13,6 +13,10 @@ export class WorkspaceNavigationManager {
|
||||
#zoomConfig;
|
||||
#panState;
|
||||
#lastPanTimestamp;
|
||||
#targetZoomLevel;
|
||||
#smoothZoomFocus;
|
||||
#smoothZoomRafId;
|
||||
#pendingLayerOffset;
|
||||
|
||||
/**
|
||||
* @param {import("../BlueprintWorkspace.js").BlueprintWorkspace} workspace Owning workspace instance.
|
||||
@@ -39,6 +43,10 @@ export class WorkspaceNavigationManager {
|
||||
this.#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };
|
||||
this.#panState = null;
|
||||
this.#lastPanTimestamp = 0;
|
||||
this.#targetZoomLevel = 1;
|
||||
this.#smoothZoomFocus = null;
|
||||
this.#smoothZoomRafId = null;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,18 +56,29 @@ export class WorkspaceNavigationManager {
|
||||
return this.#zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the accumulated node-layer translate offset that is pending commit
|
||||
* during a smooth zoom animation. This must be accounted for when converting
|
||||
* screen/client coordinates to world coordinates mid-lerp.
|
||||
*
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
getPendingLayerOffset() {
|
||||
return { x: this.#pendingLayerOffset.x, y: this.#pendingLayerOffset.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the zoom level within configured bounds.
|
||||
*
|
||||
* @param {number} nextZoom Requested zoom multiplier.
|
||||
* @param {{ silent?: boolean }} [options] Behaviour overrides.
|
||||
* @param {{ silent?: boolean, skipConnectionRender?: boolean }} [options] Behaviour overrides.
|
||||
* @returns {boolean} Whether the zoom value changed.
|
||||
*/
|
||||
setZoomLevel(nextZoom, options = {}) {
|
||||
if (!Number.isFinite(nextZoom)) {
|
||||
return false;
|
||||
}
|
||||
const { silent = false } = options;
|
||||
const { silent = false, skipConnectionRender = false } = options;
|
||||
const clamped = Math.max(
|
||||
this.#zoomConfig.min,
|
||||
Math.min(this.#zoomConfig.max, nextZoom)
|
||||
@@ -68,8 +87,25 @@ export class WorkspaceNavigationManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel any running smooth zoom so programmatic calls take immediate effect.
|
||||
if (this.#smoothZoomRafId !== null) {
|
||||
cancelAnimationFrame(this.#smoothZoomRafId);
|
||||
this.#smoothZoomRafId = null;
|
||||
this.#smoothZoomFocus = null;
|
||||
// Commit any pending layer offset before the snap.
|
||||
const pending = this.#pendingLayerOffset;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
if (Math.abs(pending.x) > 0.0001 || Math.abs(pending.y) > 0.0001) {
|
||||
this.setBackgroundOffset({
|
||||
x: this.#backgroundOffset.x - pending.x,
|
||||
y: this.#backgroundOffset.y - pending.y,
|
||||
});
|
||||
this.translateWorkspace(pending);
|
||||
}
|
||||
}
|
||||
this.#targetZoomLevel = clamped;
|
||||
this.#zoomLevel = clamped;
|
||||
this.updateZoomDisplay();
|
||||
this.updateZoomDisplay(skipConnectionRender);
|
||||
if (!silent) {
|
||||
this.#markViewDirty("view", 200);
|
||||
this.#schedulePersist();
|
||||
@@ -86,24 +122,31 @@ export class WorkspaceNavigationManager {
|
||||
|
||||
/**
|
||||
* Updates workspace transforms to match the stored zoom level.
|
||||
*
|
||||
* @param {boolean} [skipConnectionRender] When true, skips the connection re-render pass.
|
||||
*/
|
||||
updateZoomDisplay() {
|
||||
updateZoomDisplay(skipConnectionRender = false) {
|
||||
const zoom = this.#zoomLevel || 1;
|
||||
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
|
||||
|
||||
const tx = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
|
||||
const ty = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
|
||||
|
||||
if (nodeLayer) {
|
||||
nodeLayer.style.transformOrigin = "0 0";
|
||||
nodeLayer.style.transform = `scale(${zoom})`;
|
||||
nodeLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
|
||||
}
|
||||
|
||||
if (connectionLayer) {
|
||||
connectionLayer.style.transformOrigin = "0 0";
|
||||
connectionLayer.style.transform = `scale(${zoom})`;
|
||||
connectionLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
|
||||
}
|
||||
|
||||
workspaceElement.style.setProperty("--workspace-zoom", `${zoom}`);
|
||||
this.#applyBackgroundOffset();
|
||||
this.#renderConnections();
|
||||
if (!skipConnectionRender) {
|
||||
this.#renderConnections();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +177,109 @@ export class WorkspaceNavigationManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a smooth animated zoom step centred on a screen-space point.
|
||||
* Subsequent calls before the animation settles accumulate the target zoom.
|
||||
*
|
||||
* @param {{ x: number, y: number }} screenPoint Focus point in workspace-relative screen coordinates.
|
||||
* @param {number} direction Positive to zoom in, negative to zoom out.
|
||||
* @param {{ clientX?: number, clientY?: number }} [pointer] Pointer coordinates relative to the viewport.
|
||||
*/
|
||||
pushSmoothZoom(screenPoint, direction, pointer = {}) {
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = direction * this.#zoomConfig.step;
|
||||
this.#targetZoomLevel = Math.max(
|
||||
this.#zoomConfig.min,
|
||||
Math.min(this.#zoomConfig.max, this.#targetZoomLevel + step)
|
||||
);
|
||||
this.#smoothZoomFocus = { screenPoint, pointer };
|
||||
|
||||
if (this.#smoothZoomRafId === null) {
|
||||
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances one frame of the smooth zoom animation, easing toward the target.
|
||||
*/
|
||||
#tickSmoothZoom() {
|
||||
this.#smoothZoomRafId = null;
|
||||
|
||||
if (!this.#smoothZoomFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.#targetZoomLevel;
|
||||
const current = this.#zoomLevel;
|
||||
const diff = target - current;
|
||||
const settled = Math.abs(diff) < 0.001;
|
||||
const newZoom = settled
|
||||
? target
|
||||
: Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, current + diff * 0.25));
|
||||
|
||||
const { screenPoint, pointer } = this.#smoothZoomFocus;
|
||||
const previousZoom = current;
|
||||
|
||||
// screenPointToWorld still uses the old zoom at this point, which is correct.
|
||||
const worldPoint = this.screenPointToWorld(screenPoint);
|
||||
this.#zoomLevel = newZoom;
|
||||
|
||||
const scale = previousZoom / newZoom;
|
||||
const shift = {
|
||||
x: worldPoint.x * (scale - 1),
|
||||
y: worldPoint.y * (scale - 1),
|
||||
};
|
||||
|
||||
// Accumulate into a pending layer offset — O(1), no per-node work each frame.
|
||||
this.#pendingLayerOffset.x += shift.x;
|
||||
this.#pendingLayerOffset.y += shift.y;
|
||||
|
||||
// Rebase active interactions if needed.
|
||||
if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) {
|
||||
const { dragManager } = this.#workspace;
|
||||
if (dragManager?.rebaseActiveDragPointer) {
|
||||
dragManager.rebaseActiveDragPointer(pointer);
|
||||
}
|
||||
this.#rebasePanGestureAfterZoom(pointer);
|
||||
}
|
||||
|
||||
// Apply zoom + pending pivot as a single CSS transform on the node layer — O(1).
|
||||
// Combined with background offset for pan.
|
||||
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
|
||||
const ox = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * newZoom;
|
||||
const oy = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * newZoom;
|
||||
if (nodeLayer) {
|
||||
nodeLayer.style.transformOrigin = "0 0";
|
||||
nodeLayer.style.transform = `translate(${ox}px, ${oy}px) scale(${newZoom})`;
|
||||
}
|
||||
if (connectionLayer) {
|
||||
connectionLayer.style.transformOrigin = "0 0";
|
||||
connectionLayer.style.transform = `translate(${ox}px, ${oy}px) scale(${newZoom})`;
|
||||
}
|
||||
workspaceElement.style.setProperty("--workspace-zoom", `${newZoom}`);
|
||||
this.#applyBackgroundOffset();
|
||||
this.#renderConnections();
|
||||
|
||||
if (settled) {
|
||||
this.#smoothZoomFocus = null;
|
||||
// Commit accumulated pivot to node positions in one pass, then restore clean transforms.
|
||||
const finalOffset = this.#pendingLayerOffset;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
if (Math.abs(finalOffset.x) > 0.0001 || Math.abs(finalOffset.y) > 0.0001) {
|
||||
this.translateWorkspace(finalOffset);
|
||||
}
|
||||
// Restore clean scale-only transform now that node positions are committed.
|
||||
this.updateZoomDisplay(false);
|
||||
this.#markViewDirty("view", 0);
|
||||
this.#schedulePersist();
|
||||
} else {
|
||||
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies zoom centred at a screen-space location.
|
||||
*
|
||||
@@ -149,7 +295,7 @@ export class WorkspaceNavigationManager {
|
||||
const previousZoom = this.#zoomLevel || 1;
|
||||
const worldPoint = this.screenPointToWorld(screenPoint);
|
||||
const nextZoom = previousZoom + direction * this.#zoomConfig.step;
|
||||
const changed = this.setZoomLevel(nextZoom);
|
||||
const changed = this.setZoomLevel(nextZoom, { skipConnectionRender: true });
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
@@ -172,8 +318,9 @@ export class WorkspaceNavigationManager {
|
||||
dragManager.rebaseActiveDragPointer(pointer);
|
||||
}
|
||||
this.#rebasePanGestureAfterZoom(pointer);
|
||||
this.#renderConnections();
|
||||
}
|
||||
|
||||
this.#renderConnections();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +344,18 @@ export class WorkspaceNavigationManager {
|
||||
return this.#backgroundOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total effective viewport offset, including any pending zoom pivot.
|
||||
*
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
getEffectiveOffset() {
|
||||
return {
|
||||
x: this.#backgroundOffset.x + this.#pendingLayerOffset.x,
|
||||
y: this.#backgroundOffset.y + this.#pendingLayerOffset.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a workspace translation to all nodes and the background.
|
||||
*
|
||||
@@ -212,22 +371,7 @@ export class WorkspaceNavigationManager {
|
||||
y: this.#backgroundOffset.y + delta.y,
|
||||
});
|
||||
|
||||
const { graph, dragManager } = this.#workspace;
|
||||
const panState = this.#panState;
|
||||
const panDelta = panState ? panState.lastDelta : null;
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
const origin = panState?.nodeOrigins.get(node.id) ?? node.position;
|
||||
const baseDelta = panDelta ?? { x: 0, y: 0 };
|
||||
const next = {
|
||||
x: origin.x + baseDelta.x + delta.x,
|
||||
y: origin.y + baseDelta.y + delta.y,
|
||||
};
|
||||
graph.setNodePosition(node.id, next);
|
||||
if (dragManager?.updateNodeDomPosition) {
|
||||
dragManager.updateNodeDomPosition(node.id, next);
|
||||
}
|
||||
});
|
||||
const { dragManager } = this.#workspace;
|
||||
|
||||
if (dragManager?.applyActiveDragShift) {
|
||||
dragManager.applyActiveDragShift(delta);
|
||||
@@ -381,24 +525,16 @@ export class WorkspaceNavigationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dragManager, contextMenuManager, graph, nodeElements } =
|
||||
this.#workspace;
|
||||
const { dragManager, contextMenuManager } = this.#workspace;
|
||||
|
||||
dragManager?.removeNodeGhost();
|
||||
dragManager?.removePaletteGhost();
|
||||
|
||||
const nodeOrigins = new Map();
|
||||
graph.nodes.forEach((node) => {
|
||||
nodeOrigins.set(node.id, { x: node.position.x, y: node.position.y });
|
||||
});
|
||||
|
||||
const state = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
lastDelta: { x: 0, y: 0 },
|
||||
hasMoved: false,
|
||||
nodeOrigins,
|
||||
backgroundOrigin: { ...this.#backgroundOffset },
|
||||
};
|
||||
|
||||
@@ -413,7 +549,6 @@ export class WorkspaceNavigationManager {
|
||||
x: deltaXScreen,
|
||||
y: deltaYScreen,
|
||||
});
|
||||
this.#panState.lastDelta = worldDelta;
|
||||
|
||||
if (!this.#panState.hasMoved) {
|
||||
const distance = Math.hypot(deltaXScreen, deltaYScreen);
|
||||
@@ -422,7 +557,7 @@ export class WorkspaceNavigationManager {
|
||||
}
|
||||
moveEvent.preventDefault();
|
||||
this.#panState.hasMoved = true;
|
||||
contextMenuManager?.hide();
|
||||
contextMenuManager?.hide();
|
||||
}
|
||||
|
||||
if (!this.#panState.hasMoved) {
|
||||
@@ -434,20 +569,7 @@ export class WorkspaceNavigationManager {
|
||||
y: this.#panState.backgroundOrigin.y + worldDelta.y,
|
||||
};
|
||||
this.setBackgroundOffset(backgroundOffset);
|
||||
|
||||
this.#panState.nodeOrigins.forEach((origin, nodeId) => {
|
||||
const element = nodeElements.get(nodeId);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const next = {
|
||||
x: origin.x + worldDelta.x,
|
||||
y: origin.y + worldDelta.y,
|
||||
};
|
||||
element.style.transform = WorkspaceGeometry.positionToTransform(next);
|
||||
});
|
||||
|
||||
this.#renderConnections();
|
||||
this.updateZoomDisplay();
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent) => {
|
||||
@@ -467,17 +589,6 @@ export class WorkspaceNavigationManager {
|
||||
}
|
||||
|
||||
this.#lastPanTimestamp = this.#timestamp();
|
||||
const delta = finalState.lastDelta;
|
||||
this.setBackgroundOffset({
|
||||
x: finalState.backgroundOrigin.x + delta.x,
|
||||
y: finalState.backgroundOrigin.y + delta.y,
|
||||
});
|
||||
finalState.nodeOrigins.forEach((origin, nodeId) => {
|
||||
graph.setNodePosition(nodeId, {
|
||||
x: origin.x + delta.x,
|
||||
y: origin.y + delta.y,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
this.#panState = state;
|
||||
@@ -527,8 +638,8 @@ export class WorkspaceNavigationManager {
|
||||
*/
|
||||
#applyBackgroundOffset() {
|
||||
const zoom = this.#zoomLevel || 1;
|
||||
const scaledX = this.#backgroundOffset.x * zoom;
|
||||
const scaledY = this.#backgroundOffset.y * zoom;
|
||||
const scaledX = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
|
||||
const scaledY = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
|
||||
const position = `${scaledX}px ${scaledY}px`;
|
||||
this.#workspace.workspaceElement.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
|
||||
}
|
||||
@@ -551,13 +662,6 @@ export class WorkspaceNavigationManager {
|
||||
x: this.#panState.backgroundOrigin.x + shift.x,
|
||||
y: this.#panState.backgroundOrigin.y + shift.y,
|
||||
};
|
||||
|
||||
this.#panState.nodeOrigins.forEach((origin, nodeId, map) => {
|
||||
map.set(nodeId, {
|
||||
x: origin.x + shift.x,
|
||||
y: origin.y + shift.y,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
227
scripts/ui/workspace/WorkspaceNodeContextMenu.js
Normal file
227
scripts/ui/workspace/WorkspaceNodeContextMenu.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles node-specific context menu interactions (right-click on nodes).
|
||||
*/
|
||||
export class WorkspaceNodeContextMenu {
|
||||
#workspace;
|
||||
/** @type {HTMLDivElement | null} */
|
||||
#container;
|
||||
/** @type {boolean} */
|
||||
#isVisible;
|
||||
/** @type {string | null} */
|
||||
#targetNodeId;
|
||||
|
||||
/**
|
||||
* @param {BlueprintWorkspace} workspace Owning workspace instance.
|
||||
*/
|
||||
constructor(workspace) {
|
||||
this.#workspace = workspace;
|
||||
this.#container = null;
|
||||
this.#isVisible = false;
|
||||
this.#targetNodeId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the node context menu DOM if it does not already exist.
|
||||
*/
|
||||
initialize() {
|
||||
if (this.#container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "node-context-menu";
|
||||
container.setAttribute("role", "menu");
|
||||
container.setAttribute("aria-label", "Node actions");
|
||||
|
||||
const deleteItem = this.#createMenuItem("Delete", () => {
|
||||
this.#handleDelete();
|
||||
});
|
||||
deleteItem.dataset.action = "delete";
|
||||
container.appendChild(deleteItem);
|
||||
|
||||
const copyItem = this.#createMenuItem("Copy", () => {
|
||||
this.#handleCopy();
|
||||
});
|
||||
copyItem.dataset.action = "copy";
|
||||
container.appendChild(copyItem);
|
||||
|
||||
const refreshItem = this.#createMenuItem("Refresh", () => {
|
||||
this.#handleRefresh();
|
||||
});
|
||||
refreshItem.dataset.action = "refresh";
|
||||
container.appendChild(refreshItem);
|
||||
|
||||
this.#workspace.workspaceElement.appendChild(container);
|
||||
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (!this.#isVisible) {
|
||||
return;
|
||||
}
|
||||
if (!(event.target instanceof Node)) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
if (!container.contains(event.target)) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!this.#isVisible) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
this.#container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a menu item button with the specified label and click handler.
|
||||
*
|
||||
* @param {string} label Menu item label.
|
||||
* @param {() => void} onClick Click handler.
|
||||
* @returns {HTMLButtonElement}
|
||||
*/
|
||||
#createMenuItem(label, onClick) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "node-context-menu-item";
|
||||
button.setAttribute("role", "menuitem");
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", () => {
|
||||
onClick();
|
||||
this.hide();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the context menu is currently displayed.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isVisible() {
|
||||
return this.#isVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the provided target node resides inside the context menu.
|
||||
*
|
||||
* @param {Node | null} target Target node.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTargetInside(target) {
|
||||
return Boolean(
|
||||
this.#container && target && this.#container.contains(target)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the node context menu at the given viewport coordinates.
|
||||
*
|
||||
* @param {number} clientX Viewport X coordinate.
|
||||
* @param {number} clientY Viewport Y coordinate.
|
||||
* @param {string} nodeId Target node identifier.
|
||||
*/
|
||||
show(clientX, clientY, nodeId) {
|
||||
this.initialize();
|
||||
|
||||
if (!this.#container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRect =
|
||||
this.#workspace.workspaceElement.getBoundingClientRect();
|
||||
const relativeX = Math.max(0, clientX - workspaceRect.left);
|
||||
const relativeY = Math.max(0, clientY - workspaceRect.top);
|
||||
|
||||
this.#targetNodeId = nodeId;
|
||||
|
||||
this.#container.classList.add("is-visible");
|
||||
this.#container.style.left = `${relativeX}px`;
|
||||
this.#container.style.top = `${relativeY}px`;
|
||||
this.#isVisible = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.#container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuRect = this.#container.getBoundingClientRect();
|
||||
let adjustedLeft = relativeX;
|
||||
let adjustedTop = relativeY;
|
||||
|
||||
if (menuRect.right > workspaceRect.right) {
|
||||
adjustedLeft -= menuRect.right - workspaceRect.right;
|
||||
}
|
||||
if (menuRect.bottom > workspaceRect.bottom) {
|
||||
adjustedTop -= menuRect.bottom - workspaceRect.bottom;
|
||||
}
|
||||
|
||||
adjustedLeft = Math.max(0, adjustedLeft);
|
||||
adjustedTop = Math.max(0, adjustedTop);
|
||||
|
||||
this.#container.style.left = `${adjustedLeft}px`;
|
||||
this.#container.style.top = `${adjustedTop}px`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Conceals the node context menu and clears its state.
|
||||
*/
|
||||
hide() {
|
||||
if (!this.#container || !this.#isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#container.classList.remove("is-visible");
|
||||
this.#isVisible = false;
|
||||
this.#targetNodeId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the delete action for the target node.
|
||||
*/
|
||||
#handleDelete() {
|
||||
if (!this.#targetNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#workspace.removeNode(this.#targetNodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the copy action for the target node.
|
||||
*/
|
||||
#handleCopy() {
|
||||
if (!this.#targetNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasSelected = this.#workspace.selectedNodeIds.has(this.#targetNodeId);
|
||||
if (!wasSelected) {
|
||||
this.#workspace.selectNode(this.#targetNodeId);
|
||||
}
|
||||
|
||||
this.#workspace.clipboardManager.copySelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the refresh action for the target node.
|
||||
*/
|
||||
#handleRefresh() {
|
||||
if (!this.#targetNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#workspace.refreshNode(this.#targetNodeId);
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,36 @@ export class WorkspaceNodeRenderer {
|
||||
article.classList.remove("is-hovered");
|
||||
});
|
||||
|
||||
article.addEventListener("pointerdown", (event) => {
|
||||
if (event.button === 2) {
|
||||
this.#workspace.navigationManager?.beginPan(event);
|
||||
}
|
||||
});
|
||||
|
||||
article.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.#workspace.navigationManager?.isPanRecent(300)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = /** @type {EventTarget | null} */ (event.target);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest(".pin")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#workspace.selectedNodeIds.has(node.id)) {
|
||||
this.#callbacks.selectNode(node.id);
|
||||
}
|
||||
|
||||
this.#workspace.nodeContextMenu.show(event.clientX, event.clientY, node.id);
|
||||
});
|
||||
|
||||
this.#workspace.nodeLayer.appendChild(article);
|
||||
this.#workspace.nodeElements.set(node.id, article);
|
||||
this.#inlineEditors.syncNode(node.id);
|
||||
|
||||
257
scripts/ui/workspace/WorkspaceWireCutManager.js
Normal file
257
scripts/ui/workspace/WorkspaceWireCutManager.js
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ x: number, y: number }} Point
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* pointerId: number,
|
||||
* startClient: Point,
|
||||
* currentClient: Point,
|
||||
* cutLine: SVGLineElement,
|
||||
* hasMoved: boolean,
|
||||
* }} CutState
|
||||
*/
|
||||
|
||||
/**
|
||||
* Drives the middle-mouse wire-cut gesture.
|
||||
* Hold middle mouse and drag a line across wires to disconnect them.
|
||||
*/
|
||||
export class WorkspaceWireCutManager {
|
||||
/** @type {BlueprintWorkspace} */
|
||||
#workspace;
|
||||
/** @type {CutState | null} */
|
||||
#state;
|
||||
|
||||
/**
|
||||
* @param {BlueprintWorkspace} workspace Owning workspace instance.
|
||||
*/
|
||||
constructor(workspace) {
|
||||
this.#workspace = workspace;
|
||||
this.#state = null;
|
||||
|
||||
this.handleMove = this.handleMove.bind(this);
|
||||
this.handleUp = this.handleUp.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a cut gesture from a middle-mouse pointerdown event.
|
||||
*
|
||||
* @param {PointerEvent} event Middle-mouse pointerdown event.
|
||||
*/
|
||||
beginCut(event) {
|
||||
if (this.#state) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const cutLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||
cutLine.classList.add("wire-cut-line");
|
||||
|
||||
const svgPos = this.#clientToSvg(event.clientX, event.clientY);
|
||||
cutLine.setAttribute("x1", String(svgPos.x));
|
||||
cutLine.setAttribute("y1", String(svgPos.y));
|
||||
cutLine.setAttribute("x2", String(svgPos.x));
|
||||
cutLine.setAttribute("y2", String(svgPos.y));
|
||||
this.#workspace.connectionLayer.appendChild(cutLine);
|
||||
|
||||
this.#state = {
|
||||
pointerId: event.pointerId,
|
||||
startClient: { x: event.clientX, y: event.clientY },
|
||||
currentClient: { x: event.clientX, y: event.clientY },
|
||||
cutLine,
|
||||
hasMoved: false,
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", this.handleMove);
|
||||
window.addEventListener("pointerup", this.handleUp);
|
||||
window.addEventListener("pointercancel", this.handleUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a cut gesture is currently active.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isCutting() {
|
||||
return this.#state !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts viewport client coordinates to SVG-layer space.
|
||||
*
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
* @returns {Point}
|
||||
*/
|
||||
#clientToSvg(clientX, clientY) {
|
||||
const zoom = this.#workspace.zoomLevel || 1;
|
||||
const rect = this.#workspace.workspaceElement.getBoundingClientRect();
|
||||
return {
|
||||
x: (clientX - rect.left) / zoom,
|
||||
y: (clientY - rect.top) / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
handleMove(event) {
|
||||
if (!this.#state || event.pointerId !== this.#state.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#state.currentClient = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const dx = event.clientX - this.#state.startClient.x;
|
||||
const dy = event.clientY - this.#state.startClient.y;
|
||||
if (!this.#state.hasMoved && Math.hypot(dx, dy) >= 4) {
|
||||
this.#state.hasMoved = true;
|
||||
}
|
||||
|
||||
const start = this.#clientToSvg(this.#state.startClient.x, this.#state.startClient.y);
|
||||
const end = this.#clientToSvg(event.clientX, event.clientY);
|
||||
|
||||
this.#state.cutLine.setAttribute("x1", String(start.x));
|
||||
this.#state.cutLine.setAttribute("y1", String(start.y));
|
||||
this.#state.cutLine.setAttribute("x2", String(end.x));
|
||||
this.#state.cutLine.setAttribute("y2", String(end.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
handleUp(event) {
|
||||
if (!this.#state || event.pointerId !== this.#state.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("pointermove", this.handleMove);
|
||||
window.removeEventListener("pointerup", this.handleUp);
|
||||
window.removeEventListener("pointercancel", this.handleUp);
|
||||
|
||||
const state = this.#state;
|
||||
this.#state = null;
|
||||
state.cutLine.remove();
|
||||
|
||||
if (!state.hasMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const p1 = this.#clientToSvg(state.startClient.x, state.startClient.y);
|
||||
const p2 = this.#clientToSvg(state.currentClient.x, state.currentClient.y);
|
||||
this.#cutIntersecting(p1, p2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all connections whose bezier curves intersect the given line segment.
|
||||
*
|
||||
* @param {Point} p1 Start of cut segment in SVG space.
|
||||
* @param {Point} p2 End of cut segment in SVG space.
|
||||
*/
|
||||
#cutIntersecting(p1, p2) {
|
||||
const { connectionElements, graph, historyManager } = this.#workspace;
|
||||
/** @type {string[]} */
|
||||
const toRemove = [];
|
||||
|
||||
connectionElements.forEach((pathEl, connectionId) => {
|
||||
const d = pathEl.getAttribute("d");
|
||||
if (!d) {
|
||||
return;
|
||||
}
|
||||
if (this.#bezierIntersectsSegment(d, p1, p2)) {
|
||||
toRemove.push(connectionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!toRemove.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch all removals into a single undo step.
|
||||
historyManager?.withSuspended(() => {
|
||||
toRemove.forEach((id) => graph.removeConnection(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the cubic bezier described by a path `d` attribute crosses the segment p1→p2.
|
||||
*
|
||||
* @param {string} d SVG path `d` attribute value.
|
||||
* @param {Point} p1
|
||||
* @param {Point} p2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#bezierIntersectsSegment(d, p1, p2) {
|
||||
const m = d.match(
|
||||
/M\s*([\d.\-e]+)\s+([\d.\-e]+)\s+C\s*([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)/i
|
||||
);
|
||||
if (!m) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bp0 = { x: parseFloat(m[1]), y: parseFloat(m[2]) };
|
||||
const bc1 = { x: parseFloat(m[3]), y: parseFloat(m[4]) };
|
||||
const bc2 = { x: parseFloat(m[5]), y: parseFloat(m[6]) };
|
||||
const bp3 = { x: parseFloat(m[7]), y: parseFloat(m[8]) };
|
||||
|
||||
const SAMPLES = 32;
|
||||
let prev = bp0;
|
||||
for (let i = 1; i <= SAMPLES; i++) {
|
||||
const t = i / SAMPLES;
|
||||
const curr = this.#cubicBezierPoint(bp0, bc1, bc2, bp3, t);
|
||||
if (this.#segmentsIntersect(prev, curr, p1, p2)) {
|
||||
return true;
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a cubic bezier at parameter t.
|
||||
*
|
||||
* @param {Point} p0
|
||||
* @param {Point} c1
|
||||
* @param {Point} c2
|
||||
* @param {Point} p3
|
||||
* @param {number} t
|
||||
* @returns {Point}
|
||||
*/
|
||||
#cubicBezierPoint(p0, c1, c2, p3, t) {
|
||||
const mt = 1 - t;
|
||||
return {
|
||||
x: mt * mt * mt * p0.x + 3 * mt * mt * t * c1.x + 3 * mt * t * t * c2.x + t * t * t * p3.x,
|
||||
y: mt * mt * mt * p0.y + 3 * mt * mt * t * c1.y + 3 * mt * t * t * c2.y + t * t * t * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if line segments a1→a2 and b1→b2 cross each other.
|
||||
*
|
||||
* @param {Point} a1
|
||||
* @param {Point} a2
|
||||
* @param {Point} b1
|
||||
* @param {Point} b2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#segmentsIntersect(a1, a2, b1, b2) {
|
||||
const dx1 = a2.x - a1.x;
|
||||
const dy1 = a2.y - a1.y;
|
||||
const dx2 = b2.x - b1.x;
|
||||
const dy2 = b2.y - b1.y;
|
||||
const denom = dx1 * dy2 - dy1 * dx2;
|
||||
if (Math.abs(denom) < 1e-10) {
|
||||
return false;
|
||||
}
|
||||
const dx3 = b1.x - a1.x;
|
||||
const dy3 = b1.y - a1.y;
|
||||
const t = (dx3 * dy2 - dy3 * dx2) / denom;
|
||||
const u = (dx3 * dy1 - dy3 * dx1) / denom;
|
||||
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user