updated graph rendering to be more efficient. added smooth scrolling, added color type, mmb drag for cutting

This commit is contained in:
2026-03-21 06:57:26 +11:00
parent 1f5da03474
commit ae374db9fb
54 changed files with 9147 additions and 138 deletions

View File

@@ -6,11 +6,13 @@ import { WorkspaceClipboardManager } from "./workspace/WorkspaceClipboardManager
import { WorkspacePersistenceManager } from "./workspace/WorkspacePersistenceManager.js";
import { WorkspaceMarqueeManager } from "./workspace/WorkspaceMarqueeManager.js";
import { WorkspaceContextMenuManager } from "./workspace/WorkspaceContextMenuManager.js";
import { WorkspaceNodeContextMenu } from "./workspace/WorkspaceNodeContextMenu.js";
import { WorkspacePaletteManager } from "./workspace/WorkspacePaletteManager.js";
import { WorkspaceDropManager } from "./workspace/WorkspaceDropManager.js";
import { WorkspaceInlineEditorManager } from "./workspace/WorkspaceInlineEditorManager.js";
import { WorkspaceNodeRenderer } from "./workspace/WorkspaceNodeRenderer.js";
import { WorkspaceNavigationManager } from "./workspace/WorkspaceNavigationManager.js";
import { WorkspaceWireCutManager } from "./workspace/WorkspaceWireCutManager.js";
import { renderEventList } from "./workspace/renderEventList.js";
import { renderVariableList } from "./workspace/renderVariableList.js";
import {
@@ -18,7 +20,7 @@ import {
} from "./WorkspaceSpawnShortcuts.js";
/**
* @typedef {'any'|'number'|'string'|'boolean'|'table'} VariableType
* @typedef {'any'|'number'|'string'|'boolean'|'table'|'color'} VariableType
*/
/** @type {Array<{ value: VariableType, label: string }>} */
@@ -28,6 +30,7 @@ const VARIABLE_TYPE_DEFINITIONS = [
{ value: "string", label: "String" },
{ value: "boolean", label: "Boolean" },
{ value: "table", label: "Table" },
{ value: "color", label: "Color" },
];
const SUPPORTED_VARIABLE_TYPES = new Set(
@@ -175,6 +178,8 @@ export class BlueprintWorkspace {
this.nodeDragRotationDecayFrame = null;
/** @type {number | null} */
this.connectionRefreshFrame = null;
/** @type {number | null} */
this.periodicUpdateTimer = null;
this.graph = new NodeGraph();
/** @type {Map<string, WorkspaceVariable>} */
@@ -240,6 +245,8 @@ export class BlueprintWorkspace {
this.paletteManager = null;
/** @type {WorkspaceContextMenuManager | null} */
this.contextMenuManager = null;
/** @type {WorkspaceNodeContextMenu | null} */
this.nodeContextMenu = null;
/** @type {WorkspaceDropManager | null} */
this.dropManager = null;
/** @type {WorkspaceMarqueeManager | null} */
@@ -252,6 +259,8 @@ export class BlueprintWorkspace {
this.navigationManager = null;
/** @type {WorkspaceDragManager | null} */
this.dragManager = null;
/** @type {WorkspaceWireCutManager | null} */
this.wireCutManager = null;
/** @type {HTMLTemplateElement} */
this.nodeTemplate = BlueprintWorkspace.#requireTemplate(
"nodeTemplate",
@@ -349,6 +358,7 @@ export class BlueprintWorkspace {
isTypeDropdownOpen: (context) => this.#isTypeDropdownOpen(context),
});
this.contextMenuManager = new WorkspaceContextMenuManager(this);
this.nodeContextMenu = new WorkspaceNodeContextMenu(this);
this.paletteManager = new WorkspacePaletteManager(this);
this.dropManager = new WorkspaceDropManager(this);
this.marqueeManager = new WorkspaceMarqueeManager(this);
@@ -356,6 +366,7 @@ export class BlueprintWorkspace {
renderConnections: () => this.#renderConnections(),
schedulePersist: () => this.persistenceManager.schedulePersist(),
});
this.wireCutManager = new WorkspaceWireCutManager(this);
this.nodeRenderer = new WorkspaceNodeRenderer(this, {
inlineEditorManager: this.inlineEditorManager,
applySelectionState: () => this.#applySelectionState(),
@@ -380,6 +391,7 @@ export class BlueprintWorkspace {
});
this.contextMenuManager.initialize();
this.nodeContextMenu.initialize();
this.paletteManager.initialize();
this.dropManager.initialize();
this.marqueeManager.ensureElement();
@@ -405,6 +417,32 @@ export class BlueprintWorkspace {
this.historyManager.initialize();
this.isInitialized = true;
this.#startPeriodicUpdate();
}
/**
* Starts a periodic update that refreshes the graph when idle.
*/
#startPeriodicUpdate() {
if (this.periodicUpdateTimer !== null) {
return;
}
this.periodicUpdateTimer = window.setInterval(() => {
if (!this.isDraggingNodes && this.workspaceDragDepth === 0) {
this.#renderConnections();
this.nodeRenderer?.refreshAllPinConnections();
}
}, 1000);
}
/**
* Stops the periodic update timer.
*/
#stopPeriodicUpdate() {
if (this.periodicUpdateTimer !== null) {
window.clearInterval(this.periodicUpdateTimer);
this.periodicUpdateTimer = null;
}
}
/**
@@ -726,7 +764,7 @@ export class BlueprintWorkspace {
event.preventDefault();
const direction = event.deltaY < 0 ? 1 : -1;
this.navigationManager.zoomAt(pointer, direction, {
this.navigationManager.pushSmoothZoom(pointer, direction, {
clientX: event.clientX,
clientY: event.clientY,
});
@@ -754,10 +792,25 @@ export class BlueprintWorkspace {
}
}
if (this.nodeContextMenu?.isVisible()) {
if (
!(rawTarget instanceof Node) ||
!this.nodeContextMenu.isTargetInside(rawTarget)
) {
this.nodeContextMenu.hide();
}
}
if (!(rawTarget instanceof Element)) {
return;
}
// Middle mouse starts a wire-cut gesture from anywhere in the workspace.
if (event.button === 1) {
this.wireCutManager?.beginCut(event);
return;
}
if (!this.#isWorkspaceBackgroundTarget(rawTarget)) {
return;
}
@@ -789,6 +842,7 @@ export class BlueprintWorkspace {
return;
}
event.preventDefault();
this.nodeContextMenu?.hide();
this.contextMenuManager.show(event.clientX, event.clientY);
});
@@ -921,7 +975,7 @@ export class BlueprintWorkspace {
this.clipboardManager?.clearPointerPosition();
});
this.connectionLayer.addEventListener("pointerdown", (event) => {
this.workspaceElement.addEventListener("pointerdown", (event) => {
this.#handleConnectionPointerDown(event);
});
}
@@ -1031,6 +1085,72 @@ export class BlueprintWorkspace {
}
}
/**
* Refreshes a node by replacing it with a new instance, preserving connections.
*
* @param {string} nodeId Target node identifier.
* @returns {boolean} Whether the node was successfully refreshed.
*/
refreshNode(nodeId) {
const node = this.graph.nodes.get(nodeId);
if (!node) {
return false;
}
const connections = this.graph.getConnectionsForNode(nodeId);
const position = { ...node.position };
const nodeType = node.type;
const properties = { ...node.properties };
this.historyManager.withSuspended(() => {
this.removeNode(nodeId);
const newNode = this.registry.createNode(nodeType, {
id: nodeId,
position,
});
newNode.properties = properties;
this.graph.addNode(newNode);
this.renderNode(newNode);
connections.forEach((connection) => {
const isSource = connection.from.nodeId === nodeId;
const isTarget = connection.to.nodeId === nodeId;
if (isSource) {
const fromPin = newNode.getPin(connection.from.pinId);
const toNode = this.graph.nodes.get(connection.to.nodeId);
const toPin = toNode?.getPin(connection.to.pinId);
if (fromPin && toNode && toPin) {
this.graph.connect(
{ nodeId: newNode.id, pinId: fromPin.id },
{ nodeId: toNode.id, pinId: toPin.id }
);
}
} else if (isTarget) {
const fromNode = this.graph.nodes.get(connection.from.nodeId);
const fromPin = fromNode?.getPin(connection.from.pinId);
const toPin = newNode.getPin(connection.to.pinId);
if (fromNode && fromPin && toPin) {
this.graph.connect(
{ nodeId: fromNode.id, pinId: fromPin.id },
{ nodeId: newNode.id, pinId: toPin.id }
);
}
}
});
this.#renderConnections();
this.nodeRenderer.refreshAllPinConnections();
});
return true;
}
/**
* Clears the current node selection.
*/
@@ -4739,15 +4859,24 @@ export class BlueprintWorkspace {
* @param {PointerEvent} event Pointer interaction payload.
*/
#handleConnectionPointerDown(event) {
if (!(event.target instanceof SVGPathElement)) {
return;
}
if (event.button !== 0 || !event.altKey) {
return;
}
const path = /** @type {SVGPathElement} */ (event.target);
// The nodeLayer div sits above the SVG in z-order, so event.target is usually a
// node element rather than an SVGPathElement. Fall back to a point-based lookup.
let path = event.target instanceof SVGPathElement ? /** @type {SVGPathElement} */ (event.target) : null;
if (!path) {
const hits = document.elementsFromPoint(event.clientX, event.clientY);
path = /** @type {SVGPathElement | null} */ (
hits.find((el) => el instanceof SVGPathElement && /** @type {SVGPathElement} */ (el).dataset.connectionId) ?? null
);
}
if (!path) {
return;
}
const connectionId = path.dataset.connectionId;
if (!connectionId) {
return;
@@ -5179,23 +5308,20 @@ export class BlueprintWorkspace {
const workspaceRect = this.workspaceElement.getBoundingClientRect();
const startRect = startHandle.getBoundingClientRect();
const zoom = this.zoomLevel || 1;
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
const anchor = {
x:
(startRect.left - workspaceRect.left + startRect.width / 2) /
zoom,
y:
(startRect.top - workspaceRect.top + startRect.height / 2) /
zoom,
x: (startRect.left - workspaceRect.left + startRect.width / 2) / zoom - offset.x,
y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y,
};
const pointer = {
x: (clientX - workspaceRect.left) / zoom,
y: (clientY - workspaceRect.top) / zoom,
x: (clientX - workspaceRect.left) / zoom - offset.x,
y: (clientY - workspaceRect.top) / zoom - offset.y,
};
const start = direction === "output" ? anchor : pointer;
const end = direction === "output" ? pointer : anchor;
const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5);
const controlOffset = Math.max(60, Math.abs(end.x - start.x) * 0.5);
const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
start.y
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
@@ -5445,6 +5571,7 @@ export class BlueprintWorkspace {
const targetHeight = Math.max(1, workspaceRect.height / zoom);
this.connectionLayer.style.width = `${targetWidth}px`;
this.connectionLayer.style.height = `${targetHeight}px`;
this.connectionLayer.setAttribute("overflow", "visible");
if (this.connectionLayer.hasAttribute("viewBox")) {
this.connectionLayer.removeAttribute("viewBox");
}
@@ -5472,7 +5599,7 @@ export class BlueprintWorkspace {
}
const { start, end } = geometry;
const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5);
const controlOffset = Math.max(60, Math.abs(end.x - start.x) * 0.5);
const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
start.y
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
@@ -5504,22 +5631,17 @@ export class BlueprintWorkspace {
const workspaceRect = this.workspaceElement.getBoundingClientRect();
const zoom = this.zoomLevel || 1;
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
const startRect = startHandle.getBoundingClientRect();
const endRect = endHandle.getBoundingClientRect();
return {
start: {
x:
(startRect.left - workspaceRect.left + startRect.width / 2) /
zoom,
y:
(startRect.top - workspaceRect.top + startRect.height / 2) /
zoom,
x: (startRect.left - workspaceRect.left + startRect.width / 2) / zoom - offset.x,
y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y,
},
end: {
x:
(endRect.left - workspaceRect.left + endRect.width / 2) / zoom,
y:
(endRect.top - workspaceRect.top + endRect.height / 2) / zoom,
x: (endRect.left - workspaceRect.left + endRect.width / 2) / zoom - offset.x,
y: (endRect.top - workspaceRect.top + endRect.height / 2) / zoom - offset.y,
},
};
}
@@ -5560,14 +5682,11 @@ export class BlueprintWorkspace {
const rect = handle.getBoundingClientRect();
const workspaceRect = this.workspaceElement.getBoundingClientRect();
const zoom = this.zoomLevel || 1;
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
return {
x:
(rect.left - workspaceRect.left + rect.width / 2) /
zoom,
y:
(rect.top - workspaceRect.top + rect.height / 2) /
zoom,
x: (rect.left - workspaceRect.left + rect.width / 2) / zoom - offset.x,
y: (rect.top - workspaceRect.top + rect.height / 2) / zoom - offset.y,
};
}
@@ -6356,10 +6475,12 @@ export class BlueprintWorkspace {
*/
applySerializedWorkspace(payload) {
this.isRestoring = true;
this.#stopPeriodicUpdate();
try {
this.persistenceManager.cancelScheduledPersist();
this.contextMenuManager.hide();
this.nodeContextMenu?.hide();
this.#closeTypeDropdown();
this.dragManager.removePaletteGhost();
this.dragManager.removeNodeGhost();
@@ -6480,7 +6601,6 @@ export class BlueprintWorkspace {
this.isPaletteVisible = paletteVisible;
this.#applyPaletteVisibility(this.isPaletteVisible);
this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
this.#updateProjectSettingsToggle();
this.#renderOverview();
this.#setInspectorView("default");
@@ -6489,6 +6609,7 @@ export class BlueprintWorkspace {
this.#refreshLuaOutput();
} finally {
this.isRestoring = false;
this.#startPeriodicUpdate();
}
}

View File

@@ -0,0 +1,383 @@
/**
* Manages embedded Pico-8 cart preview within the application.
*/
export class EmbeddedPreview {
/**
* @param {object} options Configuration options
* @param {() => string} options.getLuaCode Function to get generated Lua code
*/
constructor(options) {
this.getLuaCode = options.getLuaCode;
this.modal = null;
this.canvas = null;
this.isOpen = false;
this.isRunning = false;
}
/**
* Creates the preview modal if it doesn't exist
*/
#ensureModal() {
if (this.modal) {
return;
}
// Create modal structure
this.modal = document.createElement('div');
this.modal.className = 'preview-modal';
this.modal.setAttribute('role', 'dialog');
this.modal.setAttribute('aria-modal', 'true');
this.modal.setAttribute('aria-labelledby', 'previewModalTitle');
this.modal.hidden = true;
const backdrop = document.createElement('div');
backdrop.className = 'preview-modal__backdrop';
backdrop.addEventListener('click', () => this.close());
const panel = document.createElement('div');
panel.className = 'preview-modal__panel';
const header = document.createElement('div');
header.className = 'preview-modal__header';
// Header controls (left side)
const headerControls = document.createElement('div');
headerControls.className = 'preview-header-controls';
const refreshButton = document.createElement('button');
refreshButton.type = 'button';
refreshButton.className = 'preview-header-btn';
refreshButton.setAttribute('aria-label', 'Reload cart');
refreshButton.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
refreshButton.addEventListener('click', () => this.#runCart());
const stopButton = document.createElement('button');
stopButton.type = 'button';
stopButton.className = 'preview-header-btn preview-header-btn--stop';
stopButton.setAttribute('aria-label', 'Stop cart');
stopButton.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>';
stopButton.addEventListener('click', () => this.#stopCart());
const saveButton = document.createElement('button');
saveButton.type = 'button';
saveButton.className = 'preview-header-btn preview-header-btn--save';
saveButton.setAttribute('aria-label', 'Save .p8');
saveButton.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
saveButton.addEventListener('click', () => this.#downloadCart());
headerControls.appendChild(refreshButton);
headerControls.appendChild(stopButton);
headerControls.appendChild(saveButton);
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'preview-modal__close';
closeButton.setAttribute('aria-label', 'Close preview');
closeButton.textContent = '×';
closeButton.addEventListener('click', () => this.close());
header.appendChild(headerControls);
header.appendChild(closeButton);
const content = document.createElement('div');
content.className = 'preview-modal__content';
// Create iframe for PICO-8 player
this.iframe = document.createElement('iframe');
this.iframe.className = 'preview-iframe';
this.iframe.allow = 'gamepad';
this.iframe.style.display = 'none';
// Create the canvas for rendering placeholder
this.canvas = document.createElement('canvas');
this.canvas.width = 128;
this.canvas.height = 128;
this.canvas.className = 'preview-canvas';
content.appendChild(this.iframe);
content.appendChild(this.canvas);
// Refocus iframe when clicking in the modal to ensure input keeps working
panel.addEventListener('click', (e) => {
if (this.iframe.style.display !== 'none') {
setTimeout(() => {
this.iframe.focus();
if (this.iframe.contentWindow) {
this.iframe.contentWindow.focus();
}
}, 0);
}
});
panel.appendChild(header);
panel.appendChild(content);
this.modal.appendChild(backdrop);
this.modal.appendChild(panel);
document.body.appendChild(this.modal);
}
/**
* Runs the cart in the embedded preview
*/
async #runCart() {
try {
const luaCode = this.getLuaCode();
if (!luaCode || !luaCode.trim()) {
return;
}
this.isRunning = true;
// Generate cart content
const cartContent = this.#generateCartContent(luaCode);
// Load cart in iframe using PICO-8 web player
await this.#loadCartInPlayer(cartContent);
} catch (error) {
console.error('Error running cart:', error);
}
}
/**
* Loads a cart into the PICO-8 web player
*
* @param {string} cartData The .p8 cart file content as a string
*/
async #loadCartInPlayer(cartData) {
try {
const electronAPI = window.electronAPI;
if (!electronAPI?.writePlayerHtml) {
throw new Error('Player IPC not available');
}
const cartB64 = btoa(unescape(encodeURIComponent(cartData)));
// HTML references pico8_edu.js by relative path — both files live in public/.
const playerHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; display: flex; align-items: center; justify-content: center; width: 100vw; height: 100vh; overflow: hidden; }
#canvas { image-rendering: pixelated; image-rendering: crisp-edges; width: 512px !important; height: 512px !important; border: 8px solid #000; }
</style>
</head>
<body>
<canvas id="canvas" width="128" height="128"></canvas>
<script>
var pico8_state = [];
var pico8_buttons = [0,0,0,0,0,0,0,0];
var codo_command = 0;
var _cartname = ['untitled.p8'];
var codo_key_buffer = [];
var p8_keyboard_state = 0;
var pico8_mouse = [];
var pico8_gamepads = { count: 0 };
var pico8_gpio = new Array(128).fill(0);
var p8_dropped_cart = null;
var p8_dropped_cart_name = '';
var CART_B64 = '${cartB64}';
// Silence all XHR — PICO-8 makes BBS metadata calls that crash from file:// origin.
(function() {
function MockXHR() {
this.onload = null; this.onerror = null; this.onreadystatechange = null;
this.readyState = 0; this.status = 0; this.statusText = '';
this.response = null; this.responseText = ''; this.responseType = '';
}
MockXHR.UNSENT=0; MockXHR.OPENED=1; MockXHR.HEADERS_RECEIVED=2; MockXHR.LOADING=3; MockXHR.DONE=4;
MockXHR.prototype.open = function() { this.readyState = 1; };
MockXHR.prototype.send = function() {
var self = this;
setTimeout(function() {
self.readyState = 4; self.status = 200; self.statusText = 'OK';
self.response = self.responseType === 'arraybuffer' ? new ArrayBuffer(0) : '';
self.responseText = '';
if (typeof self.onload === 'function') self.onload({ target: self });
if (typeof self.onreadystatechange === 'function') self.onreadystatechange();
}, 0);
};
MockXHR.prototype.setRequestHeader = function() {};
MockXHR.prototype.getResponseHeader = function() { return null; };
MockXHR.prototype.getAllResponseHeaders = function() { return ''; };
MockXHR.prototype.abort = function() {};
MockXHR.prototype.addEventListener = function(e, fn) {
if (e==='load') this.onload=fn;
if (e==='error') this.onerror=fn;
if (e==='readystatechange') this.onreadystatechange=fn;
};
MockXHR.prototype.removeEventListener = function() {};
window.XMLHttpRequest = MockXHR;
})();
var Module = {
arguments: [],
canvas: document.getElementById('canvas'),
preRun: [function() {
if (typeof IDBFS !== 'undefined' && typeof MEMFS !== 'undefined') {
var orig = FS.mount.bind(FS);
FS.mount = function(type, opts, mp) { return orig(type===IDBFS?MEMFS:type, opts, mp); };
}
FS.syncfs = function(populate, callback) {
var cb = typeof callback==='function' ? callback : typeof populate==='function' ? populate : null;
if (cb) setTimeout(function(){ cb(null); }, 0);
};
}],
postRun: [function() {
setTimeout(function() {
p8_dropped_cart = 'data:application/octet-stream;base64,' + CART_B64;
p8_dropped_cart_name = 'picograph.p8';
codo_command = 9;
// After cart loads, inject "run" + Enter to execute it
setTimeout(function() {
// Push keystrokes: r u n Enter (lowercase ASCII)
codo_key_buffer.push(114, 117, 110, 13);
}, 300);
}, 200);
}],
print: function(t) { console.log('P8:', t); },
printErr: function(t) { console.error('P8:', t); },
setStatus: function() {}
};
function pico8_audio_context_suspended() { return false; }
</script>
<script src="./pico8_edu.js"></script>
</body>
</html>`;
const { filePath } = await electronAPI.writePlayerHtml(playerHtml);
const fileUrl = 'file:///' + filePath.replace(/\\/g, '/') + '?t=' + Date.now();
this.iframe.src = 'about:blank';
this.iframe.src = fileUrl;
this.iframe.style.display = 'block';
this.canvas.style.display = 'none';
// Focus iframe once loaded so it captures keyboard/gamepad input
this.iframe.onload = () => {
setTimeout(() => {
this.iframe.focus();
if (this.iframe.contentWindow) {
this.iframe.contentWindow.focus();
}
}, 500);
};
} catch (error) {
console.error('Failed to load player:', error);
}
}
/**
* Stops the running cart
*/
#stopCart() {
this.isRunning = false;
// Hide iframe and show canvas
if (this.iframe) {
this.iframe.src = 'about:blank';
this.iframe.style.display = 'none';
}
if (this.canvas) {
this.canvas.style.display = 'block';
const ctx = this.canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, 128, 128);
}
}
}
/**
* Downloads the current cart as a .p8 file
*/
#downloadCart() {
try {
const luaCode = this.getLuaCode();
const cartContent = this.#generateCartContent(luaCode);
const blob = new Blob([cartContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `picograph_${Date.now()}.p8`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading cart:', error);
}
}
/**
* Generates the .p8 cart file content
*
* @param {string} luaCode Generated Lua code
* @returns {string}
*/
#generateCartContent(luaCode) {
const hasUpdateCallback = /function\s+_update(?:60)?\b|_update(?:60)?\s*=/.test(luaCode);
const hasDrawCallback = /function\s+_draw\b|_draw\s*=/.test(luaCode);
const fallback = (hasUpdateCallback && hasDrawCallback) ? '' :
(!hasUpdateCallback ? '\nfunction _update()\nend\n' : '') +
(!hasDrawCallback ? '\nfunction _draw()\n cls()\n print("picograph ok",2,60,7)\nend\n' : '');
return `pico-8 cartridge // http://www.pico-8.com
version 41
__lua__
${luaCode}${fallback}
__gfx__
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
`;
}
/**
* Opens the preview modal
*/
open() {
this.#ensureModal();
this.modal.hidden = false;
this.isOpen = true;
// Auto-run the cart
this.#runCart();
}
/**
* Closes the preview modal
*/
close() {
this.#stopCart();
if (this.modal) {
this.modal.hidden = true;
this.isOpen = false;
}
}
/**
* Toggles the preview modal
*/
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
}

View File

@@ -0,0 +1,102 @@
import { spawn } from 'child_process';
import { writeFile } from 'fs/promises';
import { join } from 'path';
/**
* Manages cart preview functionality
*/
export class PreviewManager {
/**
* @param {object} options Configuration options
* @param {() => string} options.getLuaCode Function to get generated Lua code
* @param {string} options.fake08Path Path to fake-08 executable
*/
constructor(options) {
this.getLuaCode = options.getLuaCode;
this.fake08Path = options.fake08Path || null;
this.currentProcess = null;
}
/**
* Exports the current graph as a .p8 file
*
* @param {string} outputPath Path to save the .p8 file
* @returns {Promise<void>}
*/
async exportCart(outputPath) {
const luaCode = this.getLuaCode();
// Create basic .p8 cart format
const cartContent = `pico-8 cartridge // http://www.pico-8.com
version 41
__lua__
${luaCode}
__gfx__
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
`;
await writeFile(outputPath, cartContent, 'utf8');
}
/**
* Launches the preview with fake-08
*
* @param {string} cartPath Path to the .p8 file
* @returns {Promise<void>}
*/
async launchPreview(cartPath) {
if (!this.fake08Path) {
throw new Error('fake-08 path not configured');
}
// Kill existing preview if running
if (this.currentProcess) {
this.currentProcess.kill();
this.currentProcess = null;
}
return new Promise((resolve, reject) => {
this.currentProcess = spawn(this.fake08Path, [cartPath]);
this.currentProcess.on('error', (err) => {
console.error('Failed to launch preview:', err);
reject(err);
});
this.currentProcess.on('exit', (code) => {
console.log(`Preview exited with code ${code}`);
this.currentProcess = null;
resolve();
});
});
}
/**
* Exports and previews the current cart
*
* @param {string} [tempPath] Optional path for temporary cart file
* @returns {Promise<void>}
*/
async exportAndPreview(tempPath = null) {
const outputPath = tempPath || join(process.cwd(), 'temp_preview.p8');
await this.exportCart(outputPath);
if (this.fake08Path) {
await this.launchPreview(outputPath);
} else {
console.log(`Cart exported to: ${outputPath}`);
console.log('fake-08 not configured - cannot launch preview');
}
}
/**
* Stops any running preview
*/
stopPreview() {
if (this.currentProcess) {
this.currentProcess.kill();
this.currentProcess = null;
}
}
}

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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,
});
});
}
/**

View 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);
}
}

View File

@@ -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);

View 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;
}
}