import { NodeGraph } from "../core/NodeGraph.js"; import { createColorSwatchPicker } from "./ColorSwatchPicker.js"; import { WorkspaceGeometry } from "./workspace/WorkspaceGeometry.js"; import { WorkspaceDragManager } from "./workspace/WorkspaceDragManager.js"; import { WorkspaceHistoryManager } from "./workspace/WorkspaceHistoryManager.js"; import { WorkspaceClipboardManager } from "./workspace/WorkspaceClipboardManager.js"; 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 { WORKSPACE_SHORTCUTS, } from "./WorkspaceSpawnShortcuts.js"; import { normalizeVariableType, getVariableTypeOptions, defaultVariableValue as getDefaultVariableValue, getTypeColor, areTypesCompatible, } from "../types/TypeRegistry.js"; /** * @typedef {import('../types/TypeRegistry.js').VariableType} VariableType */ /** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcut} WorkspaceShortcut */ /** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcutModifier} WorkspaceShortcutModifier */ /** * @typedef {{ kind: 'variable', variableId: string } | { kind: 'custom_event_parameter', nodeId: string, parameterId: string } | { kind: 'local_variable', nodeId: string }} TypeDropdownContext */ /** * @typedef {{ id: string, key: string, value: string }} TableDefaultEntry */ /** * @typedef {string | number | boolean | null | Array} VariableDefaultValue */ /** * @typedef {{ id: string, name: string, type: VariableType, defaultValue: VariableDefaultValue }} WorkspaceVariable */ /** * @typedef {{ id: string, name: string, type: VariableType, optional: boolean }} CustomEventParameter */ /** * @typedef {'default'|'project'} InspectorView */ /** * @typedef {{ key: string, definitionId: string, button: number, modifiers: Set }} NormalizedSpawnShortcut */ /** * @typedef {{ key: string, command: string, modifiers: Set }} NormalizedCommandShortcut */ /** * @typedef {Object} WorkspaceOptions * @property {HTMLElement} workspaceElement Root workspace container. * @property {HTMLElement} nodeLayer DOM node layer container. * @property {SVGElement} connectionLayer SVG connection layer. * @property {HTMLElement} paletteList Palette list element. * @property {HTMLInputElement} paletteSearch Palette search input element. * @property {HTMLElement} paletteElement Palette container element. * @property {HTMLElement} inspectorContent Inspector root element. * @property {HTMLButtonElement} duplicateNodeButton Duplicate action button. * @property {HTMLButtonElement} deleteNodeButton Delete action button. * @property {LuaGenerator} generator Lua generator responsible for code output. * @property {NodeRegistry} registry Registry containing available node definitions. * @property {HTMLUListElement} eventList Event summary list element. * @property {HTMLUListElement} variableList Variable summary list element. * @property {HTMLButtonElement} addVariableButton Global variable creation button. * @property {HTMLButtonElement} saveButton Save action button. * @property {HTMLButtonElement} projectSettingsButton Project settings toggle. * @property {HTMLButtonElement} paletteToggleButton Palette toggle control. * @property {HTMLElement} appBodyElement Layout container hosting the workspace panes. * @property {HTMLButtonElement} frameSelectionButton Frame selection control. */ /** * @typedef {{ type: 'variable', action: 'get'|'set', variableId: string }} VariablePaletteShortcut */ /** * @typedef {{ type: 'custom_event_call', eventNodeId: string }} CustomEventCallPaletteShortcut */ /** * @typedef {import('../nodes/nodeTypes.js').NodeDefinition & { * shortcut?: VariablePaletteShortcut | CustomEventCallPaletteShortcut * }} PaletteNodeDefinition */ /** @typedef {import('./workspace/WorkspaceHistoryManager.js').SerializedWorkspace} SerializedWorkspace */ /** @typedef {import('./workspace/WorkspaceHistoryManager.js').WorkspaceSnapshot} WorkspaceSnapshot */ const AUTO_CONNECT_DISTANCE = 300; const DEFAULT_SEQUENCE_BRANCHES = 3; /** * Manages blueprint workspace interactions and DOM rendering. */ export class BlueprintWorkspace { /** * @param {WorkspaceOptions} options Workspace setup options. */ constructor(options) { this.workspaceElement = options.workspaceElement; if ( this.workspaceElement instanceof HTMLElement && !this.workspaceElement.hasAttribute("tabindex") ) { this.workspaceElement.setAttribute("tabindex", "-1"); } this.nodeLayer = options.nodeLayer; this.connectionLayer = options.connectionLayer; this.paletteList = options.paletteList; this.paletteSearch = options.paletteSearch; /** @type {HTMLElement} */ this.paletteElement = options.paletteElement; this.inspectorContent = options.inspectorContent; this.duplicateNodeButton = options.duplicateNodeButton; this.deleteNodeButton = options.deleteNodeButton; this.generator = options.generator; this.registry = options.registry; /** @type {HTMLUListElement} */ this.eventListElement = options.eventList; /** @type {HTMLUListElement} */ this.variableListElement = options.variableList; this.addVariableButton = options.addVariableButton; /** @type {HTMLButtonElement} */ this.saveButton = options.saveButton; /** @type {HTMLButtonElement} */ this.projectSettingsButton = options.projectSettingsButton; /** @type {HTMLButtonElement} */ this.frameSelectionButton = options.frameSelectionButton; /** @type {HTMLButtonElement} */ this.paletteToggleButton = options.paletteToggleButton; /** @type {HTMLElement} */ this.appBodyElement = options.appBodyElement; /** @type {Set} */ this.activeShortcutKeys = new Set(); /** @type {Map<'background', Array>} */ this.spawnShortcutLookup = this.#normalizeSpawnShortcuts( WORKSPACE_SHORTCUTS ); /** @type {Array} */ this.commandShortcuts = this.#normalizeCommandShortcuts( WORKSPACE_SHORTCUTS ); /** @type {number} */ this.gridSize = WorkspaceGeometry.resolveGridSize(this); /** @type {{ element: HTMLElement, width: number, height: number, definitionId: string | null } | null} */ this.paletteDragGhost = null; /** @type {{ element: HTMLElement, width: number, height: number, nodeId: string | null } | null} */ this.nodeDragGhost = null; /** @type {string | null} */ this.activePaletteDefinitionId = null; /** @type {Map} */ this.nodeDragRotation = new Map(); /** @type {number | null} */ this.nodeDragRotationDecayFrame = null; /** @type {number | null} */ this.connectionRefreshFrame = null; /** @type {number | null} */ this.periodicUpdateTimer = null; /** @type {number | null} */ this.saveFeedbackTimer = null; /** @type {boolean} */ this.hasPendingSave = false; this.graph = new NodeGraph(); /** @type {Map} */ this.variables = new Map(); /** @type {Array} */ this.variableOrder = []; this.variableCounter = 0; /** @type {Map} */ this.nodeElements = new Map(); /** @type {Map} */ this.connectionElements = new Map(); /** @type {{ container: HTMLElement | null, trigger: HTMLButtonElement | null, context: TypeDropdownContext | null, onSelect: ((type: VariableType) => void) | null }} */ this.typeDropdown = { container: null, trigger: null, context: null, onSelect: null, }; /** @type {Set} */ this.highlightedPins = new Set(); this.pendingConnection = null; /** * @type {{ startRef: { nodeId: string, pinId: string }, direction: 'input'|'output', kind: string } | null} */ this.pendingConnectionSpawn = null; /** @type {{ variableId: string } | null} */ this.pendingVariableSpawn = null; /** @type {Set} */ this.selectedNodeIds = new Set(); /** @type {string | undefined} */ this.selectedNodeId = undefined; /** @type {string | null} */ this.selectedVariableId = null; /** @type {{ variableId: string, input: HTMLInputElement, label: HTMLElement, container: HTMLElement, wasDraggable: boolean } | null} */ this.activeVariableRename = null; /** @type {{ nodeId: string, property: string } | null} */ this.pendingInspectorFocus = null; /** @type {{ variableId: string, entryId: string, target: 'key' | 'value' } | null} */ this.pendingVariableInspectorFocus = null; /** @type {{ use60Fps: boolean }} */ this.projectSettings = { use60Fps: false }; /** @type {boolean} */ this.isPaletteVisible = true; /** @type {InspectorView} */ this.inspectorView = "default"; /** @type {boolean} */ this.isDraggingNodes = false; /** @type {boolean} */ this.isRestoring = false; /** @type {number} */ this.workspaceDragDepth = 0; /** @type {number} */ this.spawnIndex = 0; /** @type {string} */ this.luaOutput = ""; /** @type {WorkspaceHistoryManager | null} */ this.historyManager = null; /** @type {WorkspaceClipboardManager | null} */ this.clipboardManager = null; /** @type {WorkspacePersistenceManager | null} */ this.persistenceManager = null; /** @type {WorkspacePaletteManager | null} */ this.paletteManager = null; /** @type {WorkspaceContextMenuManager | null} */ this.contextMenuManager = null; /** @type {WorkspaceNodeContextMenu | null} */ this.nodeContextMenu = null; /** @type {WorkspaceDropManager | null} */ this.dropManager = null; /** @type {WorkspaceMarqueeManager | null} */ this.marqueeManager = null; /** @type {WorkspaceInlineEditorManager | null} */ this.inlineEditorManager = null; /** @type {WorkspaceNodeRenderer | null} */ this.nodeRenderer = null; /** @type {WorkspaceNavigationManager | null} */ this.navigationManager = null; /** @type {WorkspaceDragManager | null} */ this.dragManager = null; /** @type {WorkspaceWireCutManager | null} */ this.wireCutManager = null; /** @type {HTMLTemplateElement} */ this.nodeTemplate = BlueprintWorkspace.#requireTemplate( "nodeTemplate", HTMLTemplateElement ); /** @type {HTMLTemplateElement} */ this.pinTemplate = BlueprintWorkspace.#requireTemplate( "pinTemplate", HTMLTemplateElement ); /** @type {boolean} */ this.isInitialized = false; Object.defineProperty(this, "zoomLevel", { configurable: true, enumerable: false, get: () => this.navigationManager?.getZoomLevel() ?? 1, set: (value) => { if (this.navigationManager) { this.navigationManager.setZoomLevel(value, { silent: true }); this.navigationManager.updateZoomDisplay(); } }, }); Object.defineProperty(this, "workspaceBackgroundOffset", { configurable: true, enumerable: false, get: () => this.navigationManager?.getBackgroundOffset?.() ?? { x: 0, y: 0 }, set: (value) => { if (this.navigationManager) { this.navigationManager.setBackgroundOffset(value ?? { x: 0, y: 0 }); } }, }); } /** * Ensures a DOM template with the requested identifier exists. * * @template T * @param {string} id Element identifier. * @param {new (...args: Array) => T} ctor Expected constructor. * @returns {T} */ static #requireTemplate(id, ctor) { const element = document.getElementById(id); if (!(element instanceof ctor)) { throw new Error(`Missing required template: ${id}`); } return element; } /** * Initializes manager instances, event bindings, and persisted state. */ initialize() { if (this.isInitialized) { return; } this.dragManager = new WorkspaceDragManager(this); this.historyManager = new WorkspaceHistoryManager(this); this.persistenceManager = new WorkspacePersistenceManager(this); this.clipboardManager = new WorkspaceClipboardManager( this, this.historyManager ); this.inlineEditorManager = new WorkspaceInlineEditorManager({ getGraph: () => this.graph, registry: this.registry, formatVariableType: (type) => this.#formatVariableType(type), openLocalVariableTypeDropdown: ( nodeId, trigger, anchor, currentType, onSelect ) => { const typeOptions = this.#getVariableTypeOptions().filter( (entry) => entry.value !== "any" ); this.#openTypeDropdown({ context: { kind: "local_variable", nodeId }, trigger, anchor, currentType, onSelect, placement: "top", options: typeOptions, }); }, closeTypeDropdown: () => this.#closeTypeDropdown(), 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); this.navigationManager = new WorkspaceNavigationManager(this, { renderConnections: () => this.#renderConnections(), schedulePersist: () => this.persistenceManager.schedulePersist(), }); this.wireCutManager = new WorkspaceWireCutManager(this); this.nodeRenderer = new WorkspaceNodeRenderer(this, { inlineEditorManager: this.inlineEditorManager, applySelectionState: () => this.#applySelectionState(), selectNode: (nodeId) => this.selectNode(nodeId), startNodeDrag: (event, nodeId) => this.dragManager?.startNodeDrag(event, nodeId), refreshVariableNode: (nodeId) => this.#refreshVariableNode(nodeId), refreshLocalVariableNode: (nodeId) => this.#refreshLocalVariableNode(nodeId), refreshCustomEventNode: (nodeId) => this.#refreshCustomEventNode(nodeId), refreshCustomEventCallNode: (nodeId) => this.#refreshCustomEventCallNode(nodeId), renderConnections: () => this.#renderConnections(), shouldHighlightPin: (nodeId, pinId, direction) => this.#shouldHighlightHover(nodeId, pinId, direction), beginConnection: (event, nodeId, pinId, direction) => this.#beginConnection(event, nodeId, pinId, direction), finalizeConnection: (nodeId, pinId, direction) => this.#finalizeConnection(nodeId, pinId, direction), getPinElement: (nodeId, pinId, direction) => this.#getPinElement(nodeId, pinId, direction), }); this.contextMenuManager.initialize(); this.nodeContextMenu.initialize(); this.paletteManager.initialize(); this.dropManager.initialize(); this.marqueeManager.ensureElement(); this.navigationManager.updateZoomDisplay(); this.navigationManager.setBackgroundOffset({ x: 0, y: 0 }); this.#resetSaveButtonAppearance(); if (typeof this.generator.setProjectSettings === "function") { this.generator.setProjectSettings({ ...this.projectSettings }); } this.#bindGraphEvents(); this.#bindUiEvents(); this.#applyPaletteVisibility(this.isPaletteVisible); const restored = this.persistenceManager.restoreFromStorage(); if (!restored) { this.#renderOverview(); this.#renderInspectorState(); this.#refreshLuaOutput(); this.navigationManager.updateZoomDisplay(); } 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; } } /** * Clears the active save feedback timer. */ #clearSaveFeedbackTimer() { if (this.saveFeedbackTimer !== null && typeof window !== "undefined") { window.clearTimeout(this.saveFeedbackTimer); this.saveFeedbackTimer = null; } } /** * Restores the save button to its current base state. */ #resetSaveButtonAppearance() { const button = this.saveButton; if (!button) { return; } button.classList.remove("is-busy", "is-success", "is-error", "is-pending"); button.disabled = !this.persistenceManager?.isStorageAvailable; button.setAttribute( "title", this.persistenceManager?.isStorageAvailable ? this.hasPendingSave ? "Unsaved changes" : "Workspace saved" : "Saving is unavailable" ); if (this.hasPendingSave) { button.classList.add("is-pending"); } } /** * Applies transient visual state to the save button. * * @param {{ status: 'idle'|'pending'|'saving'|'saved'|'error', source: 'manual'|'auto' }} state */ #updateSaveButtonAppearance(state) { const button = this.saveButton; if (!button) { return; } this.#clearSaveFeedbackTimer(); this.#resetSaveButtonAppearance(); if (!this.persistenceManager?.isStorageAvailable) { return; } if (state.status === "pending") { button.classList.add("is-pending"); button.setAttribute("title", "Unsaved changes"); return; } if (state.status === "saving") { button.classList.remove("is-pending"); button.classList.add("is-busy"); button.disabled = true; button.setAttribute("title", "Saving workspace"); return; } if (state.status === "saved") { button.classList.remove("is-pending"); button.classList.add("is-success"); button.setAttribute( "title", state.source === "auto" ? "Workspace autosaved" : "Workspace saved" ); this.saveFeedbackTimer = window.setTimeout(() => { this.#resetSaveButtonAppearance(); this.saveFeedbackTimer = null; }, 1400); return; } if (state.status === "error") { button.classList.remove("is-pending"); button.classList.add("is-error"); button.setAttribute("title", "Workspace save failed"); this.saveFeedbackTimer = window.setTimeout(() => { this.#resetSaveButtonAppearance(); this.saveFeedbackTimer = null; }, 1800); return; } this.#resetSaveButtonAppearance(); } /** * Reverts the most recent checkpoint. */ undo() { this.historyManager.undo(); } /** * Re-applies the most recently undone checkpoint. */ redo() { this.historyManager.redo(); } /** * Produces Lua output using the current generator and graph state. */ exportLua() { return this.#refreshLuaOutput(); } /** * Persists the current workspace state immediately. * * @returns {boolean} */ saveWorkspace() { if (!this.persistenceManager?.isStorageAvailable) { return false; } this.persistenceManager.persistImmediately("manual"); return true; } /** * Reflects persistence lifecycle changes in the save button state. * * @param {{ status: 'idle'|'pending'|'saving'|'saved'|'error', source: 'manual'|'auto' }} state */ handlePersistenceStateChange(state) { if (!state) { return; } if (state.status === "pending") { this.hasPendingSave = true; } else if (state.status === "saved") { this.hasPendingSave = false; } else if (state.status === "error") { this.hasPendingSave = true; } this.#updateSaveButtonAppearance(state); } /** * Subscribes to graph lifecycle changes. */ #bindGraphEvents() { this.graph.addEventListener("nodeschanged", (event) => { const customEvent = /** @type {CustomEvent} */ (event); const detail = customEvent.detail; if (detail?.type === "add" && detail.node) { if (detail.node.type === "custom_event") { const currentName = typeof detail.node.properties.name === "string" ? detail.node.properties.name : ""; const uniqueName = this.#generateUniqueCustomEventName( currentName || "CustomEvent", detail.node.id ); if (uniqueName !== currentName) { this.graph.setNodeProperty(detail.node.id, "name", uniqueName); } else { this.#refreshCustomEventNode(detail.node.id); } if ( !this.pendingInspectorFocus || this.pendingInspectorFocus.nodeId !== detail.node.id ) { this.pendingInspectorFocus = { nodeId: detail.node.id, property: "name", }; } if ( this.selectedNodeId && this.graph.nodes.get(this.selectedNodeId)?.type === "call_custom_event" ) { this.#refreshInspectorIfActive(this.selectedNodeId, "eventId"); } } else if (detail.node.type === "call_custom_event") { this.#refreshCustomEventCallNode(detail.node.id); if ( !this.pendingInspectorFocus || this.pendingInspectorFocus.nodeId !== detail.node.id ) { this.pendingInspectorFocus = { nodeId: detail.node.id, property: "eventId", }; } } else if (detail.node.type === "sequence") { this.#refreshSequenceNode(detail.node.id); } } else if (detail?.type === "remove" && detail.nodeId) { this.#handleCustomEventRemoval(detail.nodeId); } this.#renderConnections(); this.#refreshLuaOutput(); this.#renderOverview(); this.nodeRenderer.refreshAllPinConnections(); this.historyManager.registerMutation("nodeschanged"); this.persistenceManager.schedulePersist(); }); this.graph.addEventListener("nodepositionchanged", () => { if (this.isDraggingNodes) { this.#scheduleConnectionRefresh(); } else { this.#flushConnectionRefresh(); } this.#refreshLuaOutput(); this.historyManager.registerMutation("nodepositionchanged"); this.persistenceManager.schedulePersist(); }); this.graph.addEventListener("nodepropertychanged", (event) => { const customEvent = /** @type {CustomEvent} */ (event); const detail = customEvent.detail ?? {}; const nodeId = detail.nodeId; const key = detail.key; const value = detail.value; let connectionsMutated = false; const nodesToRefreshConnections = new Set(); if (nodeId) { this.inlineEditorManager.syncNode(nodeId); const node = this.graph.nodes.get(nodeId); if (node?.type === "custom_event") { if (key === "name") { const input = typeof value === "string" ? value : ""; const trimmed = input.trim(); if (trimmed !== input) { this.graph.setNodeProperty(nodeId, "name", trimmed); return; } const ensured = trimmed || "CustomEvent"; const uniqueName = this.#generateUniqueCustomEventName( ensured, nodeId ); if (uniqueName !== ensured) { this.graph.setNodeProperty(nodeId, "name", uniqueName); return; } const mutated = this.#refreshCustomEventNode(nodeId); connectionsMutated = mutated || connectionsMutated; this.#refreshInspectorIfActive(nodeId, "name"); } else if (key === "parameters") { const mutated = this.#refreshCustomEventNode(nodeId); connectionsMutated = mutated || connectionsMutated; this.#refreshInspectorIfActive(nodeId); } else if (key === "parameterCounter") { // Counter adjustments are handled indirectly by parameter updates. } else { const mutated = this.#refreshCustomEventNode(nodeId); connectionsMutated = mutated || connectionsMutated; } } else if (node?.type === "call_custom_event") { if (key === "eventId") { const eventId = typeof value === "string" ? value : ""; const eventNode = eventId ? this.graph.nodes.get(eventId) : null; if (eventId && (!eventNode || eventNode.type !== "custom_event")) { if (eventId !== "") { this.graph.setNodeProperty(nodeId, "eventId", ""); } return; } const mutated = this.#refreshCustomEventCallNode(nodeId); connectionsMutated = mutated || connectionsMutated; this.#refreshInspectorIfActive(nodeId, "eventId"); } else if (key === "arguments") { const mutated = this.#refreshCustomEventCallNode(nodeId); connectionsMutated = mutated || connectionsMutated; this.#refreshInspectorIfActive(nodeId, "arguments"); } else { const mutated = this.#refreshCustomEventCallNode(nodeId); connectionsMutated = mutated || connectionsMutated; } } else if (node?.type === "sequence") { const mutated = this.#refreshSequenceNode(nodeId); if (mutated) { connectionsMutated = true; nodesToRefreshConnections.add(nodeId); } if (key === "branches") { this.#refreshInspectorIfActive(nodeId); } } else { const variableMutated = this.#refreshVariableNode(nodeId); if (variableMutated) { connectionsMutated = true; nodesToRefreshConnections.add(nodeId); } const localMutated = this.#refreshLocalVariableNode(nodeId); if (localMutated) { connectionsMutated = true; nodesToRefreshConnections.add(nodeId); } } } this.#refreshLuaOutput(); this.#renderOverview(); if (connectionsMutated) { this.#renderConnections(); nodesToRefreshConnections.forEach((id) => this.nodeRenderer.updatePinConnectionsForNode(id) ); } this.historyManager.registerMutation("nodepropertychanged"); this.persistenceManager.schedulePersist(); }); this.graph.addEventListener("connectionschanged", (event) => { const customEvent = /** @type {CustomEvent} */ (event); const detail = customEvent.detail; /** @type {Set} */ const nodesToRefresh = new Set(); const addNode = (nodeId) => { if (typeof nodeId === "string" && nodeId.length) { nodesToRefresh.add(nodeId); } }; if (detail?.connection) { addNode(detail.connection.from.nodeId); addNode(detail.connection.to.nodeId); } if (detail?.ref) { addNode(detail.ref.nodeId); } if (Array.isArray(detail?.nodes)) { detail.nodes.forEach(addNode); } if (detail?.nodeId) { addNode(detail.nodeId); } if (nodesToRefresh.size) { nodesToRefresh.forEach((nodeId) => { if (this.graph.nodes.has(nodeId)) { this.inlineEditorManager.syncNode(nodeId); } }); } else { this.inlineEditorManager.syncAll(); this.graph.nodes.forEach((node) => nodesToRefresh.add(node.id)); } this.#renderConnections(); nodesToRefresh.forEach((nodeId) => this.nodeRenderer.updatePinConnectionsForNode(nodeId) ); this.#refreshLuaOutput(); this.historyManager.registerMutation("connectionschanged"); this.persistenceManager.schedulePersist(); }); } /** * Registers DOM event listeners for interactivity. */ #bindUiEvents() { if (this.saveButton) { this.saveButton.addEventListener("click", () => { this.saveWorkspace(); }); } if (this.paletteToggleButton) { this.paletteToggleButton.addEventListener("click", () => { this.#togglePaletteVisibility(); }); } if (this.frameSelectionButton) { this.frameSelectionButton.addEventListener("click", () => { this.#frameSelection(); }); } if (this.projectSettingsButton) { this.projectSettingsButton.addEventListener("click", () => { const wasProjectView = this.inspectorView === "project"; if (wasProjectView) { this.#setInspectorView("default"); this.clearSelection(); this.#renderInspectorState(); return; } this.clearSelection(); this.#setInspectorView("project"); this.#renderInspectorState(); }); } if (this.addVariableButton) { this.addVariableButton.addEventListener("click", () => { this.#createGlobalVariable(); }); } this.workspaceElement.addEventListener( "wheel", (event) => { if (event.defaultPrevented) { return; } const rawTarget = /** @type {EventTarget | null} */ (event.target); if ( rawTarget instanceof Node && this.contextMenuManager?.isTargetInside(rawTarget) ) { return; } if (!Number.isFinite(event.deltaY) || event.deltaY === 0) { return; } const rect = this.workspaceElement.getBoundingClientRect(); const pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top, }; if (pointer.x < 0 || pointer.y < 0 || pointer.x > rect.width || pointer.y > rect.height) { return; } event.preventDefault(); const direction = event.deltaY < 0 ? 1 : -1; this.navigationManager.pushSmoothZoom(pointer, direction, { clientX: event.clientX, clientY: event.clientY, }); }, { passive: false } ); this.workspaceElement.addEventListener("pointerdown", (event) => { const rawTarget = /** @type {EventTarget | null} */ (event.target); if (rawTarget instanceof Element) { const interactive = rawTarget.closest( "input, textarea, select, button, [contenteditable='true']" ); if (!interactive) { this.#focusWorkspaceSurface({ force: true }); } } if (this.contextMenuManager.isVisible()) { if ( !(rawTarget instanceof Node) || !this.contextMenuManager.isTargetInside(rawTarget) ) { this.contextMenuManager.hide(); } } 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; } if (event.button === 0) { const shortcut = this.#matchBackgroundSpawnShortcut(event); if (shortcut) { event.preventDefault(); event.stopPropagation(); const spawned = this.#spawnShortcutNodeAtPointer(shortcut, event); if (spawned) { this.#focusWorkspaceSurface({ force: true }); } return; } this.clearSelection(); this.marqueeManager?.beginSelection(event); } else if (event.button === 2) { this.navigationManager?.beginPan(event); } }); this.workspaceElement.addEventListener("contextmenu", (event) => { if (event.defaultPrevented) { return; } if (this.navigationManager.isPanRecent(200)) { event.preventDefault(); return; } event.preventDefault(); this.nodeContextMenu?.hide(); this.contextMenuManager.show(event.clientX, event.clientY); }); this.duplicateNodeButton.addEventListener("click", () => { if (this.selectedNodeIds.size !== 1 || !this.selectedNodeId) { return; } const node = this.graph.nodes.get(this.selectedNodeId); if (!node) { return; } const duplicated = this.registry.createNode(node.type, { id: this.graph.createNodeId(node.type), position: { x: node.position.x + 40, y: node.position.y + 40 }, }); duplicated.properties = { ...node.properties }; this.graph.addNode(duplicated); this.nodeRenderer.renderNode(duplicated); this.selectNode(duplicated.id); }); this.deleteNodeButton.addEventListener("click", () => { if (!this.selectedNodeIds.size) { return; } const targets = [...this.selectedNodeIds]; targets.forEach((id) => { this.removeNode(id); }); }); document.addEventListener("keydown", (event) => { const key = event.key; const normalizedKey = typeof key === "string" ? key.toLowerCase() : ""; const isModifier = event.ctrlKey || event.metaKey; if (normalizedKey && normalizedKey.length === 1) { this.activeShortcutKeys.add(normalizedKey); } const target = /** @type {EventTarget | null} */ (event.target); if (target instanceof HTMLElement) { const tagName = target.tagName; if ( target.isContentEditable || tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" ) { return; } } if (isModifier) { const handled = this.#dispatchCommandShortcut(event, normalizedKey); if (handled) { return; } } if (key !== "Delete" && normalizedKey !== "q") { return; } if (key === "Delete") { const handled = this.#dispatchCommandShortcut(event, "delete"); if (!handled) { if (!this.selectedNodeIds.size) { return; } event.preventDefault(); const ids = [...this.selectedNodeIds]; ids.forEach((id) => this.removeNode(id)); } return; } if (normalizedKey === "q") { const connected = this.#tryAutoConnectSelectedNode(); if (connected) { event.preventDefault(); } } }); document.addEventListener("keyup", (event) => { const key = event.key; const normalizedKey = typeof key === "string" ? key.toLowerCase() : ""; if (normalizedKey && normalizedKey.length === 1) { this.activeShortcutKeys.delete(normalizedKey); } }); window.addEventListener("blur", () => { this.activeShortcutKeys.clear(); }); document.addEventListener("pointerdown", (event) => { const state = this.typeDropdown; if (!state.container || !state.trigger) { return; } const target = /** @type {EventTarget | null} */ (event.target); if (!(target instanceof Node)) { this.#closeTypeDropdown(); return; } if (state.container.contains(target) || state.trigger.contains(target)) { return; } this.#closeTypeDropdown(); }); const updateClipboardPointer = (event) => { if (!this.clipboardManager) { return; } const point = WorkspaceGeometry.eventToWorkspacePoint(this, event); this.clipboardManager.updatePointerPosition(point); }; this.workspaceElement.addEventListener("pointermove", updateClipboardPointer); this.workspaceElement.addEventListener("pointerdown", updateClipboardPointer); this.workspaceElement.addEventListener("pointerenter", updateClipboardPointer); this.workspaceElement.addEventListener("pointerleave", () => { this.clipboardManager?.clearPointerPosition(); }); this.workspaceElement.addEventListener("pointerdown", (event) => { this.#handleConnectionPointerDown(event); }); } /** * Creates and renders a node of the requested definition. * * @param {string} definitionId Node definition identifier. * @param {{ x: number, y: number }} [position] Optional workspace position. * @returns {import('../core/BlueprintNode.js').BlueprintNode | null} */ addNode(definitionId, position) { const definition = this.registry.get(definitionId); if (!definition) { return null; } if (definition.unique) { const existing = [...this.graph.nodes.values()].find( (candidate) => candidate.type === definitionId ); if (existing) { this.setSelectionState([existing.id], existing.id); const navigator = this.navigationManager; if (navigator) { window.requestAnimationFrame(() => { navigator.frameNodes([existing.id]); }); } return existing; } } const hasPosition = position && Number.isFinite(position.x) && Number.isFinite(position.y); const spawnIndex = this.spawnIndex; const spawnPosition = hasPosition ? WorkspaceGeometry.snapPositionToGrid(this, position) : this.#computeSpawnPosition(definitionId, spawnIndex); let node; try { node = this.registry.createNode(definitionId, { id: this.graph.createNodeId(definitionId), position: spawnPosition, }); } catch { return null; } this.spawnIndex = spawnIndex + 1; this.graph.addNode(node); this.renderNode(node); if (node.type === "sequence") { this.#refreshSequenceNode(node.id); } this.setSelectionState([node.id], node.id); return node; } /** * Renders a node within the workspace node layer. * * @param {import('../core/BlueprintNode.js').BlueprintNode | null | undefined} node Node to render. */ renderNode(node) { if (!node) { return; } this.nodeRenderer.renderNode(node); } /** * Removes a specific node from the workspace. * * @param {string} nodeId Target node identifier. */ removeNode(nodeId) { if (this.nodeDragGhost?.nodeId === nodeId) { this.dragManager.removeNodeGhost(); } const element = this.nodeElements.get(nodeId); if (element) { element.remove(); this.nodeElements.delete(nodeId); } this.inlineEditorManager.removeNode(nodeId); if ( this.pendingConnection && ((this.pendingConnection.source && this.pendingConnection.source.nodeId === nodeId) || (this.pendingConnection.target && this.pendingConnection.target.nodeId === nodeId)) ) { this.#cancelPendingConnection(); } this.graph.removeNode(nodeId); if (this.selectedNodeIds.delete(nodeId)) { if (this.selectedNodeId === nodeId) { const iterator = this.selectedNodeIds.values().next(); this.selectedNodeId = iterator.done ? undefined : iterator.value; } this.#applySelectionState(); } } /** * 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. */ clearSelection() { this.#cancelVariableRename({ commit: true }); const hadVariable = Boolean(this.selectedVariableId); if (hadVariable) { this.selectedVariableId = null; } if (!this.selectedNodeIds.size) { if (hadVariable) { this.#highlightVariableSelection(); this.#renderInspectorState(); } return; } this.#setSelection([], undefined); } /** * Selects a node, updates UI affordances, and renders inspector controls. * * @param {string} nodeId Node identifier. */ selectNode(nodeId) { if (!nodeId) { this.clearSelection(); return; } if (this.selectedNodeIds.size === 1 && this.selectedNodeId === nodeId) { return; } const element = this.nodeElements.get(nodeId); if (!element) { this.clearSelection(); return; } this.#setSelection([nodeId], nodeId); } /** * Applies the supplied selection state and optional primary node designation. * * @param {Array} nodeIds Node identifiers to select. * @param {string | undefined} primaryId Preferred primary selection identifier. */ setSelectionState(nodeIds, primaryId) { this.#setSelection(Array.isArray(nodeIds) ? nodeIds : [], primaryId); } /** * Frames the active selection or all nodes when nothing is selected. */ #frameSelection() { if (!this.navigationManager) { return; } const selected = [...this.selectedNodeIds]; if (selected.length) { this.navigationManager.frameNodes(selected); return; } const allIds = []; this.graph.nodes.forEach((node) => { allIds.push(node.id); }); if (!allIds.length) { return; } this.navigationManager.frameNodes(allIds); } /** * Toggles the palette visibility state. */ #togglePaletteVisibility() { this.#setPaletteVisibility(!this.isPaletteVisible); } /** * Updates internal palette visibility tracking and applies side effects. * * @param {boolean} visible Target palette visibility flag. */ #setPaletteVisibility(visible) { const next = Boolean(visible); if (this.isPaletteVisible === next) { this.#applyPaletteVisibility(next); return; } const shouldFocus = !this.isPaletteVisible && next; this.isPaletteVisible = next; this.#applyPaletteVisibility(next, { focusSearch: shouldFocus }); this.persistenceManager.schedulePersist(); } /** * Applies palette visibility-related DOM updates. * * @param {boolean} visible Whether the palette should be visible. * @param {{ focusSearch?: boolean }} [options] Behaviour overrides. */ #applyPaletteVisibility(visible, options = {}) { const palette = this.paletteElement; const button = this.paletteToggleButton; const appBody = this.appBodyElement; const { focusSearch = false } = options; if (appBody) { appBody.classList.toggle("app-body--palette-hidden", !visible); } if (palette) { palette.setAttribute("aria-hidden", visible ? "false" : "true"); } if (button) { button.classList.toggle("is-active", visible); button.setAttribute("aria-pressed", visible ? "true" : "false"); const label = visible ? "Hide palette" : "Show palette"; button.setAttribute("aria-label", label); button.title = label; } if (focusSearch && this.paletteSearch) { window.requestAnimationFrame(() => { if (this.isPaletteVisible && this.paletteSearch) { this.paletteSearch.focus(); this.paletteSearch.select(); } }); } } /** * Focuses the workspace surface when appropriate. * * @param {{ force?: boolean }} [options] Behaviour overrides. */ #focusWorkspaceSurface(options = {}) { const element = this.workspaceElement; if (!(element instanceof HTMLElement) || typeof element.focus !== "function") { return; } const { force = false } = options; const activeElement = /** @type {Element | null} */ (document.activeElement); if (!force && activeElement && activeElement !== document.body) { return; } try { element.focus({ preventScroll: true }); } catch (_error) { element.focus(); } } /** * Selects a global variable and opens the inspector for editing. * * @param {string} variableId Variable identifier. */ #selectVariable(variableId) { this.#cancelVariableRename({ commit: true }); this.#setInspectorView("default"); if (!this.variables.has(variableId)) { return; } if (this.selectedVariableId === variableId && !this.selectedNodeIds.size) { this.#renderInspectorState(); return; } this.selectedVariableId = variableId; this.#setSelection([], undefined); this.#highlightVariableSelection(); this.#renderInspectorState(); } /** * Synchronizes inspector content and overview highlighting for variable selection. */ #renderInspectorState() { if (this.inspectorView === "project") { this.selectedVariableId = null; this.inspectorContent.innerHTML = ""; this.#highlightVariableSelection(); this.#renderProjectSettingsInspector(); return; } if (this.selectedNodeIds.size) { if (this.selectedVariableId) { this.selectedVariableId = null; this.#highlightVariableSelection(); } return; } if (!this.selectedVariableId) { this.inspectorContent.innerHTML = ""; const empty = document.createElement("p"); empty.className = "inspector-empty"; empty.textContent = "Select a node or variable to edit."; this.inspectorContent.appendChild(empty); this.#highlightVariableSelection(); return; } const variable = this.variables.get(this.selectedVariableId); if (!variable) { this.selectedVariableId = null; this.#highlightVariableSelection(); this.inspectorContent.innerHTML = ""; const empty = document.createElement("p"); empty.className = "inspector-empty"; empty.textContent = "Select a node or variable to edit."; this.inspectorContent.appendChild(empty); return; } this.#highlightVariableSelection(); this.#renderVariableInspector(variable); } /** * Adds viewport-aware positioning and lifecycle management to an inspector tooltip element. * * @param {HTMLElement} tooltipElement Tooltip root element containing trigger and content. */ #enhanceInspectorTooltip(tooltipElement) { const trigger = tooltipElement.querySelector(".inspector-tooltip__trigger"); const content = tooltipElement.querySelector(".inspector-tooltip__content"); if ( !(trigger instanceof HTMLElement) || !(content instanceof HTMLElement) ) { return; } const margin = 12; const offset = 12; const arrowPadding = 14; let rafId = null; const clearRaf = () => { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }; const schedulePosition = () => { if (rafId !== null) { return; } rafId = window.requestAnimationFrame(position); }; const handleScroll = () => { if (!tooltipElement.classList.contains("is-active")) { close(); return; } schedulePosition(); }; const handleResize = () => { if (!tooltipElement.classList.contains("is-active")) { return; } schedulePosition(); }; const handleDocumentPointerDown = (event) => { const target = event.target; if (target instanceof Node && tooltipElement.contains(target)) { return; } close(); }; const close = () => { if (!tooltipElement.classList.contains("is-active")) { clearRaf(); trigger.setAttribute("aria-expanded", "false"); return; } tooltipElement.classList.remove("is-active"); tooltipElement.dataset.placement = "bottom"; trigger.setAttribute("aria-expanded", "false"); clearRaf(); window.removeEventListener("resize", handleResize); window.removeEventListener("scroll", handleScroll, true); document.removeEventListener( "pointerdown", handleDocumentPointerDown, true ); }; const position = () => { rafId = null; if ( !tooltipElement.isConnected || !trigger.isConnected || !content.isConnected ) { close(); return; } const previousTransition = content.style.transition; const previousTransform = content.style.transform; content.style.transition = "none"; content.style.transform = "translate3d(0, 0, 0)"; const contentRect = content.getBoundingClientRect(); content.style.transition = previousTransition; content.style.transform = previousTransform; const triggerRect = trigger.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const availableTop = triggerRect.top - margin; const availableBottom = viewportHeight - triggerRect.bottom - margin; let placement = "bottom"; if ( availableBottom < contentRect.height + offset && availableTop > availableBottom ) { placement = "top"; } const minLeft = margin; let maxLeft = viewportWidth - margin - contentRect.width; if (maxLeft < minLeft) { maxLeft = minLeft; } let left = triggerRect.left + triggerRect.width / 2 - contentRect.width / 2; left = Math.min(Math.max(left, minLeft), maxLeft); const minTop = margin; let maxTop = viewportHeight - margin - contentRect.height; if (maxTop < minTop) { maxTop = minTop; } let top = placement === "top" ? triggerRect.top - offset - contentRect.height : triggerRect.bottom + offset; top = Math.min(Math.max(top, minTop), maxTop); const triggerCenter = triggerRect.left + triggerRect.width / 2; let arrowOffset = triggerCenter - left; const arrowMin = arrowPadding; const arrowMax = Math.max(arrowPadding, contentRect.width - arrowPadding); arrowOffset = Math.min(Math.max(arrowOffset, arrowMin), arrowMax); content.style.setProperty("--tooltip-left", `${Math.round(left)}px`); content.style.setProperty("--tooltip-top", `${Math.round(top)}px`); content.style.setProperty( "--tooltip-arrow-offset", `${Math.round(arrowOffset)}px` ); tooltipElement.dataset.placement = placement; }; const open = () => { if (tooltipElement.classList.contains("is-active")) { schedulePosition(); return; } tooltipElement.classList.add("is-active"); trigger.setAttribute("aria-expanded", "true"); schedulePosition(); window.addEventListener("resize", handleResize); window.addEventListener("scroll", handleScroll, true); document.addEventListener("pointerdown", handleDocumentPointerDown, true); }; const handlePointerLeave = () => { close(); }; const handleFocusOut = (event) => { const focusEvent = /** @type {FocusEvent} */ (event); const next = focusEvent.relatedTarget; if (!(next instanceof Node) || !tooltipElement.contains(next)) { close(); } }; const handleKeyDown = (event) => { if (!(event instanceof KeyboardEvent)) { return; } if (event.key === "Escape") { close(); if (typeof trigger.focus === "function") { trigger.focus(); } return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); if (tooltipElement.classList.contains("is-active")) { close(); } else { open(); } } }; trigger.setAttribute("aria-haspopup", "true"); trigger.setAttribute("aria-expanded", "false"); trigger.addEventListener("focus", open); tooltipElement.addEventListener("pointerenter", open); tooltipElement.addEventListener("pointerleave", handlePointerLeave); tooltipElement.addEventListener("focusout", handleFocusOut); tooltipElement.addEventListener("keydown", handleKeyDown); } /** * Renders the project settings inspector view. */ #renderProjectSettingsInspector() { const heading = document.createElement("h3"); heading.className = "inspector-section-title"; heading.textContent = "Project Settings"; this.inspectorContent.appendChild(heading); const fpsField = document.createElement("div"); fpsField.className = "inspector-field inspector-field--checkbox"; const fpsLabel = document.createElement("label"); const fieldId = "projectSetting_fps60"; fpsLabel.setAttribute("for", fieldId); const tooltip = document.createElement("span"); tooltip.className = "inspector-tooltip"; const labelText = document.createElement("span"); labelText.className = "inspector-tooltip__trigger"; labelText.textContent = "Run at 60 FPS (_UPDATE60)"; labelText.setAttribute("tabindex", "0"); labelText.setAttribute("role", "button"); tooltip.appendChild(labelText); const tooltipContent = document.createElement("div"); tooltipContent.className = "inspector-tooltip__content"; tooltipContent.setAttribute("role", "tooltip"); const tooltipId = `${fieldId}_tooltip`; tooltipContent.id = tooltipId; labelText.setAttribute("aria-describedby", tooltipId); const tooltipIntro = document.createElement("p"); tooltipIntro.append("When "); const tooltipUpdate60 = document.createElement("code"); tooltipUpdate60.textContent = "_UPDATE60()"; tooltipIntro.append(tooltipUpdate60, " is defined instead of "); const tooltipUpdate = document.createElement("code"); tooltipUpdate.textContent = "_UPDATE()"; tooltipIntro.append(tooltipUpdate, ", PICO-8 will run in 60fps mode:"); tooltipContent.appendChild(tooltipIntro); const tooltipList = document.createElement("ul"); tooltipList.className = "inspector-tooltip__list"; const listItemA = document.createElement("li"); listItemA.append("both "); const listCodeA = document.createElement("code"); listCodeA.textContent = "_UPDATE60()"; listItemA.append(listCodeA, " and "); const listCodeB = document.createElement("code"); listCodeB.textContent = "_DRAW()"; listItemA.append(listCodeB, " are called at 60fps"); tooltipList.appendChild(listItemA); const listItemB = document.createElement("li"); listItemB.textContent = "half the PICO-8 CPU is available per frame before dropping down to 30fps"; tooltipList.appendChild(listItemB); tooltipContent.appendChild(tooltipList); const tooltipNote = document.createElement("p"); tooltipNote.className = "inspector-tooltip__note"; tooltipNote.append( "Note that not all host machines are capable of running at 60fps. Older machines, and / or web versions might also request PICO-8 to run at 30 fps (or 15 fps), even when the PICO-8 CPU is not over capacity. In this case, multiple " ); const noteCode = document.createElement("code"); noteCode.textContent = "_UPDATE60"; tooltipNote.append(noteCode, " calls are made for every "); const noteDrawCode = document.createElement("code"); noteDrawCode.textContent = "_DRAW"; tooltipNote.append(noteDrawCode, " call in the same way."); tooltipContent.appendChild(tooltipNote); tooltip.appendChild(tooltipContent); this.#enhanceInspectorTooltip(tooltip); fpsLabel.appendChild(tooltip); fpsField.appendChild(fpsLabel); const fpsToggle = document.createElement("input"); fpsToggle.type = "checkbox"; fpsToggle.id = fieldId; fpsToggle.checked = Boolean(this.projectSettings.use60Fps); fpsToggle.addEventListener("change", () => { this.projectSettings.use60Fps = fpsToggle.checked; this.#handleProjectSettingsChanged(); }); fpsField.appendChild(fpsToggle); this.inspectorContent.appendChild(fpsField); } /** * Updates the active inspector view flag. * * @param {InspectorView} view Target inspector view. */ #setInspectorView(view) { if (this.inspectorView === view) { return; } this.inspectorView = view; this.#updateProjectSettingsToggle(); } /** * Synchronizes the project settings toggle button appearance/state. */ #updateProjectSettingsToggle() { if (!this.projectSettingsButton) { return; } const isActive = this.inspectorView === "project"; this.projectSettingsButton.classList.toggle("is-active", isActive); this.projectSettingsButton.setAttribute( "aria-pressed", isActive ? "true" : "false" ); } /** * Handles updates to project-level configuration values. */ #handleProjectSettingsChanged() { if (typeof this.generator.setProjectSettings === "function") { this.generator.setProjectSettings({ ...this.projectSettings }); } this.#refreshLuaOutput(); this.persistenceManager.schedulePersist(); } /** * Updates the stored default value for a variable and refreshes the inspector. * * @param {string} variableId Variable identifier. * @param {unknown} nextValue Candidate default value. */ #setVariableDefault(variableId, nextValue) { const variable = this.variables.get(variableId); if (!variable) { return; } const normalized = this.#normalizeVariableDefault( variable.type, nextValue ); if (this.#areVariableDefaultsEqual(variable.defaultValue, normalized)) { return; } variable.defaultValue = normalized; this.selectedVariableId = variableId; this.#renderInspectorState(); this.#refreshLuaOutput(); this.persistenceManager.schedulePersist(); } /** * Provides the initial default value for the supplied variable type. * * @param {VariableType} type Variable type descriptor. * @returns {VariableDefaultValue} */ #defaultVariableValue(type) { return /** @type {VariableDefaultValue} */ (getDefaultVariableValue(type)); } /** * Normalizes an arbitrary value so it is valid for the requested variable type. * * @param {VariableType} type Variable type descriptor. * @param {unknown} value Candidate default value. * @returns {VariableDefaultValue} */ #normalizeVariableDefault(type, value) { const numericPattern = /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/; switch (type) { case "number": { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "boolean") { return value ? 1 : 0; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) { return 0; } if (numericPattern.test(trimmed)) { return Number.parseFloat(trimmed); } } return 0; } case "color": { const n = typeof value === "number" ? value : Number(value); const safe = Number.isFinite(n) ? Math.round(n) : 7; return Math.max(0, Math.min(15, safe)); } case "boolean": { if (typeof value === "boolean") { return value; } if (typeof value === "number") { return value !== 0; } if (typeof value === "string") { const normalized = value.trim().toLowerCase(); if (!normalized) { return false; } if (["true", "1", "yes", "y"].includes(normalized)) { return true; } if (["false", "0", "no", "n"].includes(normalized)) { return false; } } return Boolean(value); } case "string": { if (value == null) { return ""; } if (Array.isArray(value)) { return value .map((entry) => typeof entry?.value === "string" ? entry.value : "" ) .filter((entry) => entry.length) .join(", "); } return String(value); } case "table": { const entries = []; if (Array.isArray(value)) { value.forEach((entry) => { const normalized = this.#normalizeTableDefaultEntry(entry); if (normalized) { entries.push(normalized); } }); } else if ( value && typeof value === "object" && Array.isArray(/** @type {{ entries?: Array }} */ (value).entries) ) { const payload = /** @type {{ entries: Array }} */ (value); payload.entries.forEach((entry) => { const normalized = this.#normalizeTableDefaultEntry(entry); if (normalized) { entries.push(normalized); } }); } else if (value != null && value !== "") { const normalized = this.#normalizeTableDefaultEntry({ value }); if (normalized) { entries.push(normalized); } } return entries; } default: { if (value == null) { return null; } if (Array.isArray(value)) { return value.length ? value[0]?.value ?? null : null; } if (typeof value === "boolean" || typeof value === "number") { return value; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) { return null; } const lowered = trimmed.toLowerCase(); if (lowered === "true") { return true; } if (lowered === "false") { return false; } if (numericPattern.test(trimmed)) { return Number.parseFloat(trimmed); } return trimmed; } return String(value); } } } /** * Normalizes a raw table default entry descriptor. * * @param {unknown} entry Candidate entry value. * @returns {TableDefaultEntry | null} */ #normalizeTableDefaultEntry(entry) { if (entry == null) { return null; } if (typeof entry === "string") { return { id: this.#allocateTableDefaultEntryId(), key: "", value: entry.trim(), }; } if (typeof entry === "number" || typeof entry === "boolean") { return { id: this.#allocateTableDefaultEntryId(), key: "", value: String(entry), }; } if (typeof entry === "object" && !Array.isArray(entry)) { const record = /** @type {{ id?: unknown, key?: unknown, value?: unknown }} */ ( entry ); const id = typeof record.id === "string" && record.id.trim().length ? record.id.trim() : this.#allocateTableDefaultEntryId(); const key = typeof record.key === "string" ? record.key.trim() : ""; let rawValue = record.value; if (rawValue == null && typeof record.entry === "string") { rawValue = record.entry; } const value = rawValue == null ? "" : String(rawValue).trim(); return { id, key, value }; } return null; } /** * Allocates a unique identifier for a table default entry. * * @returns {string} */ #allocateTableDefaultEntryId() { if ( typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ) { return `tbl_${crypto.randomUUID()}`; } return `tbl_${Math.random().toString(36).slice(2, 10)}`; } /** * Produces a string representation of a variable default for inspector inputs. * * @param {WorkspaceVariable} variable Variable descriptor. * @returns {string} */ #formatVariableDefaultForDisplay(variable) { if (variable.type === "number") { return typeof variable.defaultValue === "number" ? String(variable.defaultValue) : "0"; } if (variable.defaultValue == null) { return ""; } if (Array.isArray(variable.defaultValue)) { return variable.defaultValue .map((entry) => (typeof entry?.value === "string" ? entry.value : "")) .filter((text) => text.length) .join(", "); } return String(variable.defaultValue); } /** * Determines whether two variable defaults are equivalent. * * @param {VariableDefaultValue} a Existing value. * @param {VariableDefaultValue} b Candidate value. * @returns {boolean} */ #areVariableDefaultsEqual(a, b) { if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) { return false; } for (let index = 0; index < a.length; index += 1) { const left = a[index]; const right = b[index]; if (!right) { return false; } if (left.id !== right.id) { return false; } if (left.key !== right.key) { return false; } if (left.value !== right.value) { return false; } } return true; } return Object.is(a, b); } /** * Collects variable metadata for Lua generation. * * @returns {Array<{ id: string, name: string, type: VariableType, defaultValue: VariableDefaultValue }>} */ #getGeneratorVariableMetadata() { return this.variableOrder .map((variableId) => this.variables.get(variableId)) .filter((entry) => Boolean(entry)) .map((entry) => ({ id: entry.id, name: entry.name, type: entry.type, defaultValue: entry.defaultValue, })); } /** * Highlights the active variable in the overview sidebar. */ #highlightVariableSelection() { if (!this.variableListElement) { return; } const rows = this.variableListElement.querySelectorAll(".overview-item"); rows.forEach((element) => { if (!(element instanceof HTMLElement)) { return; } const variableId = element.dataset.variableId ?? ""; const isSelected = Boolean( this.selectedVariableId && variableId === this.selectedVariableId ); element.classList.toggle("is-selected", isSelected); if (isSelected) { element.setAttribute("aria-current", "true"); element.setAttribute("aria-pressed", "true"); } else { element.removeAttribute("aria-current"); element.setAttribute("aria-pressed", "false"); } }); } /** * Renders inspector controls for the supplied variable entry. * * @param {WorkspaceVariable} variable Variable to display in the inspector. */ #renderVariableInspector(variable) { this.inspectorContent.innerHTML = ""; const nameField = document.createElement("div"); nameField.className = "inspector-field"; const nameLabel = document.createElement("label"); const nameId = `${variable.id}_name`; nameLabel.setAttribute("for", nameId); nameLabel.textContent = "Variable Name"; nameField.appendChild(nameLabel); const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.id = nameId; nameInput.value = variable.name; nameInput.placeholder = "Variable"; nameInput.addEventListener("change", () => { this.#setVariableName(variable.id, nameInput.value); }); nameField.appendChild(nameInput); this.inspectorContent.appendChild(nameField); const typeField = document.createElement("div"); typeField.className = "inspector-field"; const typeLabel = document.createElement("label"); const typeId = `${variable.id}_type`; typeLabel.setAttribute("for", typeId); typeLabel.textContent = "Variable Type"; typeField.appendChild(typeLabel); const typeSelect = document.createElement("select"); typeSelect.id = typeId; this.#getVariableTypeOptions().forEach((option) => { const opt = document.createElement("option"); opt.value = option.value; opt.textContent = option.label; typeSelect.appendChild(opt); }); typeSelect.value = variable.type; typeSelect.addEventListener("change", () => { const next = this.#normalizeVariableType(typeSelect.value); this.#setVariableType(variable.id, next); typeSelect.value = next; }); typeField.appendChild(typeSelect); this.inspectorContent.appendChild(typeField); const defaultField = document.createElement("div"); defaultField.className = "inspector-field"; const defaultLabel = document.createElement("label"); const defaultId = `${variable.id}_default`; defaultLabel.setAttribute("for", defaultId); defaultLabel.textContent = "Default Value"; defaultField.appendChild(defaultLabel); if (variable.type === "boolean") { const defaultToggle = document.createElement("input"); defaultToggle.type = "checkbox"; defaultToggle.id = defaultId; defaultToggle.checked = Boolean(variable.defaultValue); defaultToggle.addEventListener("change", () => { this.#setVariableDefault(variable.id, defaultToggle.checked); }); defaultField.appendChild(defaultToggle); } else if (variable.type === "color") { const rawDefault = variable.defaultValue; const n = typeof rawDefault === "number" ? rawDefault : Number(rawDefault); const initialIndex = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7; const picker = createColorSwatchPicker({ id: defaultId, value: initialIndex, onChange: (index) => { this.#setVariableDefault(variable.id, index); }, }); defaultField.appendChild(picker.element); } else if (variable.type === "number") { const defaultInput = document.createElement("input"); defaultInput.type = "number"; defaultInput.id = defaultId; defaultInput.value = this.#formatVariableDefaultForDisplay(variable); defaultInput.addEventListener("change", () => { this.#setVariableDefault(variable.id, defaultInput.value); }); defaultField.appendChild(defaultInput); } else if (variable.type === "table") { const editor = this.#buildTableDefaultEditor(variable); defaultField.appendChild(editor); } else { const defaultInput = document.createElement("input"); defaultInput.type = "text"; defaultInput.id = defaultId; defaultInput.value = this.#formatVariableDefaultForDisplay(variable); defaultInput.placeholder = "No default"; defaultInput.addEventListener("change", () => { this.#setVariableDefault(variable.id, defaultInput.value); }); defaultField.appendChild(defaultInput); } this.inspectorContent.appendChild(defaultField); } /** * Builds the editable UI for table variable defaults. * * @param {WorkspaceVariable} variable Variable descriptor. * @returns {HTMLElement} */ #buildTableDefaultEditor(variable) { const editor = document.createElement("div"); editor.className = "table-default-editor"; const toolbar = document.createElement("div"); toolbar.className = "table-default-toolbar"; const addButton = document.createElement("button"); addButton.type = "button"; addButton.className = "table-default-add"; addButton.textContent = "+"; addButton.setAttribute("aria-label", "Add entry"); addButton.title = "Add entry"; addButton.addEventListener("click", () => { this.#addTableDefaultEntry(variable.id); }); toolbar.appendChild(addButton); editor.appendChild(toolbar); const list = document.createElement("div"); list.className = "table-default-list"; const entries = Array.isArray(variable.defaultValue) ? variable.defaultValue : []; if (!entries.length) { const empty = document.createElement("p"); empty.className = "table-default-empty"; empty.textContent = "No entries yet"; list.appendChild(empty); } entries.forEach((entry) => { const row = document.createElement("div"); row.className = "table-default-row"; row.dataset.entryId = entry.id; const keyInput = document.createElement("input"); keyInput.type = "text"; keyInput.placeholder = "Key (optional)"; keyInput.value = entry.key; keyInput.dataset.field = "key"; keyInput.addEventListener("change", () => { this.#updateTableDefaultEntry(variable.id, entry.id, { key: keyInput.value, }); }); row.appendChild(keyInput); const valueInput = document.createElement("input"); valueInput.type = "text"; valueInput.placeholder = "Value"; valueInput.value = entry.value; valueInput.dataset.field = "value"; valueInput.addEventListener("change", () => { this.#updateTableDefaultEntry(variable.id, entry.id, { value: valueInput.value, }); }); row.appendChild(valueInput); const removeButton = document.createElement("button"); removeButton.type = "button"; removeButton.className = "overview-item-action overview-item-action--remove"; removeButton.title = `Delete ${entry.key || entry.value || "entry"}`; removeButton.setAttribute( "aria-label", `Delete ${entry.key || entry.value || "entry"}` ); removeButton.addEventListener("click", () => { this.#removeTableDefaultEntry(variable.id, entry.id); }); removeButton.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); row.appendChild(removeButton); list.appendChild(row); }); editor.appendChild(list); const focusRequest = this.pendingVariableInspectorFocus; this.pendingVariableInspectorFocus = null; if (focusRequest && focusRequest.variableId === variable.id) { requestAnimationFrame(() => { const selector = `.table-default-row[data-entry-id="${focusRequest.entryId}"] input[data-field="${focusRequest.target}"]`; const target = editor.querySelector(selector); if (target instanceof HTMLInputElement) { target.focus(); if (typeof target.select === "function") { target.select(); } } }); } return editor; } /** * Appends a new table default entry for the supplied variable. * * @param {string} variableId Target variable identifier. */ #addTableDefaultEntry(variableId) { const variable = this.variables.get(variableId); if (!variable || variable.type !== "table") { return; } const existing = Array.isArray(variable.defaultValue) ? variable.defaultValue.slice() : []; const entry = { id: this.#allocateTableDefaultEntryId(), key: "", value: "", }; existing.push(entry); this.pendingVariableInspectorFocus = { variableId, entryId: entry.id, target: "value", }; this.#setVariableDefault(variableId, existing); } /** * Updates a table default entry for the supplied variable. * * @param {string} variableId Target variable identifier. * @param {string} entryId Entry identifier to update. * @param {{ key?: string, value?: string }} updates Field overrides. */ #updateTableDefaultEntry(variableId, entryId, updates) { const variable = this.variables.get(variableId); if (!variable || variable.type !== "table") { return; } const current = Array.isArray(variable.defaultValue) ? variable.defaultValue : []; const next = current.map((entry) => { if (entry.id !== entryId) { return entry; } const nextKey = updates.key !== undefined ? String(updates.key) : entry.key; const nextValue = updates.value !== undefined ? String(updates.value) : entry.value; return { ...entry, key: nextKey, value: nextValue }; }); this.#setVariableDefault(variableId, next); } /** * Removes a table default entry from the supplied variable. * * @param {string} variableId Target variable identifier. * @param {string} entryId Entry identifier slated for removal. */ #removeTableDefaultEntry(variableId, entryId) { const variable = this.variables.get(variableId); if (!variable || variable.type !== "table") { return; } const current = Array.isArray(variable.defaultValue) ? variable.defaultValue : []; const next = current.filter((entry) => entry.id !== entryId); if (next.length === current.length) { return; } this.pendingVariableInspectorFocus = null; this.#setVariableDefault(variableId, next); } /** * Updates the stored name for a variable and refreshes the overview. * * @param {string} variableId Variable identifier. * @param {string} nextName Requested variable name. */ #setVariableName(variableId, nextName) { const variable = this.variables.get(variableId); if (!variable) { return; } const previousName = variable.name; const trimmed = nextName.trim(); if (variable.name === trimmed) { return; } variable.name = trimmed; this.selectedVariableId = variableId; const previousNormalized = previousName.trim(); this.graph.nodes.forEach((node) => { if (node.type !== "set_var" && node.type !== "get_var") { return; } const nodeVariableId = typeof node.properties.variableId === "string" ? node.properties.variableId : ""; if (nodeVariableId === variableId) { this.graph.setNodeProperty(node.id, "name", trimmed); return; } const nodeName = typeof node.properties.name === "string" ? node.properties.name.trim() : ""; if ( !nodeVariableId && previousNormalized && nodeName === previousNormalized ) { this.graph.setNodeProperty(node.id, "name", trimmed); this.graph.setNodeProperty(node.id, "variableId", variableId); } }); this.#renderOverview(); this.#highlightVariableSelection(); this.#renderInspectorState(); this.#refreshLuaOutput(); this.persistenceManager.schedulePersist(); } /** * Updates the stored type for a variable and refreshes dependent UI. * * @param {string} variableId Variable identifier. * @param {VariableType} nextType Next variable type. */ #setVariableType(variableId, nextType) { const variable = this.variables.get(variableId); const normalizedType = this.#normalizeVariableType(nextType); if (!variable || variable.type === normalizedType) { return; } variable.type = normalizedType; variable.defaultValue = this.#normalizeVariableDefault( normalizedType, variable.defaultValue ); this.selectedVariableId = variableId; this.#renderOverview(); this.#highlightVariableSelection(); this.#renderInspectorState(); const normalizedName = this.#normalizeVariableName(variable.name); let connectionsMutated = false; this.graph.nodes.forEach((node) => { if (!this.#nodeReferencesVariable(node, variableId, normalizedName)) { return; } const mutated = this.#refreshVariableNode(node.id); connectionsMutated = connectionsMutated || mutated; }); if (connectionsMutated) { this.#renderConnections(); } this.#refreshLuaOutput(); this.persistenceManager.schedulePersist(); } /** * Removes a global variable from the workspace and clears dependent references. * * @param {string} variableId Variable identifier slated for removal. */ #deleteVariable(variableId) { const variable = this.variables.get(variableId); if (!variable) { return; } this.#closeTypeDropdown(); this.#cancelVariableRename({ commit: false }); this.variables.delete(variableId); const orderIndex = this.variableOrder.indexOf(variableId); if (orderIndex !== -1) { this.variableOrder.splice(orderIndex, 1); } const fallbackName = variable.name || variable.id; const normalizedName = this.#normalizeVariableName(variable.name); this.graph.nodes.forEach((node) => { if (node.type !== "set_var" && node.type !== "get_var") { return; } const nodeVariableId = typeof node.properties.variableId === "string" ? node.properties.variableId : ""; if (nodeVariableId === variableId) { this.graph.setNodeProperty(node.id, "variableId", ""); this.graph.setNodeProperty(node.id, "name", fallbackName); return; } if (!nodeVariableId && normalizedName) { const nodeName = typeof node.properties.name === "string" ? node.properties.name.trim() : ""; if (nodeName && nodeName === normalizedName) { this.graph.setNodeProperty(node.id, "name", nodeName); } } }); if (this.selectedVariableId === variableId) { this.selectedVariableId = null; } this.#renderOverview(); this.#renderInspectorState(); this.#refreshLuaOutput(); this.persistenceManager.schedulePersist(); } /** * Reorders variables within the overview panel based on drag gestures. * * @param {string} variableId Variable identifier being repositioned. * @param {number} targetIndex Desired zero-based insertion index. */ #reorderVariable(variableId, targetIndex) { const currentIndex = this.variableOrder.indexOf(variableId); if (currentIndex === -1) { return; } if (this.variableOrder.length <= 1) { return; } const normalizedIndex = Number.isFinite(targetIndex) ? Math.max(0, Math.floor(targetIndex)) : 0; if (normalizedIndex === currentIndex) { return; } const [entry] = this.variableOrder.splice(currentIndex, 1); const insertionIndex = Math.max( 0, Math.min(normalizedIndex, this.variableOrder.length) ); this.variableOrder.splice(insertionIndex, 0, entry); this.#renderVariableList(); this.#highlightVariableSelection(); this.persistenceManager.schedulePersist(); } /** * Presents a dropdown menu for updating a variable type. * * @param {string} variableId Target variable identifier. * @param {HTMLButtonElement} trigger Trigger button opening the menu. * @param {HTMLElement} anchor Element used to anchor the dropdown position. */ #openVariableTypeDropdown(variableId, trigger, anchor) { const variable = this.variables.get(variableId); if (!variable) { return; } this.#openTypeDropdown({ context: { kind: "variable", variableId }, trigger, anchor, currentType: variable.type, onSelect: (nextType) => { if (nextType !== variable.type) { this.#setVariableType(variable.id, nextType); } }, }); } /** * Presents the shared type dropdown with the supplied configuration. * * @param {{ * context: TypeDropdownContext, * trigger: HTMLButtonElement, * anchor: HTMLElement, * currentType: VariableType, * onSelect: (type: VariableType) => void, * options?: Array<{ value: VariableType, label: string }>, * placement?: 'bottom' | 'top', * }} options Dropdown configuration. */ #openTypeDropdown({ context, trigger, anchor, currentType, onSelect, options, placement = "bottom", }) { this.#closeTypeDropdown(); const dropdown = document.createElement("div"); dropdown.className = "variable-type-dropdown"; dropdown.setAttribute("role", "menu"); dropdown.tabIndex = -1; /** @type {Array} */ const buttons = []; const entries = Array.isArray(options) && options.length ? options : this.#getVariableTypeOptions(); entries.forEach((entry) => { const option = document.createElement("button"); option.type = "button"; option.className = "variable-type-option"; option.dataset.variableType = entry.value; option.style.setProperty("--overview-variable-color", getTypeColor(entry.value)); option.textContent = entry.label; option.setAttribute("role", "menuitemradio"); option.setAttribute( "aria-checked", entry.value === currentType ? "true" : "false" ); option.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); option.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (entry.value !== currentType) { onSelect(entry.value); } this.#closeTypeDropdown(); }); option.addEventListener("keydown", (event) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const index = buttons.indexOf(option); if (index === -1) { return; } const delta = event.key === "ArrowDown" ? 1 : -1; const nextIndex = (index + delta + buttons.length) % buttons.length; const next = buttons[nextIndex]; next.focus(); } else if (event.key === "Escape") { event.preventDefault(); this.#closeTypeDropdown({ restoreFocus: true }); } }); buttons.push(option); dropdown.appendChild(option); }); dropdown.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); if (placement === "top") { dropdown.classList.add("is-above"); } anchor.appendChild(dropdown); trigger.setAttribute("aria-expanded", "true"); this.typeDropdown = { container: dropdown, trigger, context, onSelect }; requestAnimationFrame(() => { const target = dropdown.querySelector('button[aria-checked="true"]'); if (target instanceof HTMLButtonElement) { target.focus(); } else if (buttons[0]) { buttons[0].focus(); } }); } /** * Presents the type dropdown for a custom event parameter row. * * @param {string} nodeId Custom event node identifier. * @param {string} parameterId Parameter identifier. * @param {HTMLButtonElement} trigger Trigger button opening the menu. * @param {HTMLElement} anchor Element used to anchor the dropdown position. * @param {(type: VariableType) => void} [afterSelect] Callback fired after a selection is applied. */ #openCustomEventParameterTypeDropdown( nodeId, parameterId, trigger, anchor, afterSelect ) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "custom_event") { return; } const { parameters } = this.#sanitizeCustomEventParameters(node); const parameter = parameters.find((entry) => entry.id === parameterId); if (!parameter) { return; } this.#openTypeDropdown({ context: { kind: "custom_event_parameter", nodeId, parameterId }, trigger, anchor, currentType: parameter.type, onSelect: (nextType) => { this.#setCustomEventParameterType(nodeId, parameterId, nextType); afterSelect?.(nextType); }, }); } /** * Closes the active type dropdown if present. * * @param {{ restoreFocus?: boolean }} [options] Behaviour overrides. */ #closeTypeDropdown(options = {}) { const state = this.typeDropdown; const container = state.container; const trigger = state.trigger; if (trigger) { trigger.setAttribute("aria-expanded", "false"); } if (container && container.isConnected) { container.remove(); } if (options.restoreFocus && trigger) { trigger.focus(); } this.typeDropdown = { container: null, trigger: null, context: null, onSelect: null, }; } /** * Begins inline rename mode for a variable list entry. * * @param {string} variableId Target variable identifier. * @param {HTMLElement} labelElement Label element that was clicked. */ #startVariableRename(variableId, labelElement) { this.#closeTypeDropdown(); const variable = this.variables.get(variableId); if (!variable) { return; } const row = labelElement.closest(".overview-item--variable"); if (!(row instanceof HTMLElement)) { return; } const parent = labelElement.parentElement; if (!(parent instanceof HTMLElement)) { return; } this.#cancelVariableRename({ commit: true }); const wasDraggable = Boolean(row.draggable); row.draggable = false; const input = document.createElement("input"); input.type = "text"; input.className = "overview-item-rename"; input.value = variable.name; input.setAttribute( "aria-label", `Rename variable ${variable.name || variable.id}` ); input.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); input.addEventListener("click", (event) => { event.stopPropagation(); }); const finish = (commit) => { if ( !this.activeVariableRename || this.activeVariableRename.input !== input ) { return; } const value = input.value; this.#cancelVariableRename({ commit, value }); }; input.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); finish(true); } else if (event.key === "Escape") { event.preventDefault(); finish(false); } }); input.addEventListener("blur", () => { finish(true); }); labelElement.hidden = true; parent.insertBefore(input, labelElement); this.activeVariableRename = { variableId, input, label: labelElement, container: row, wasDraggable, }; requestAnimationFrame(() => { input.focus(); input.select(); }); } /** * Cancels the active inline variable rename, optionally committing the value. * * @param {{ commit?: boolean, value?: string }} [options] Behaviour overrides. */ #cancelVariableRename(options = {}) { if (!this.activeVariableRename) { return; } const { input, label, variableId, container, wasDraggable } = this.activeVariableRename; const value = typeof options.value === "string" ? options.value : input.value; if (input.isConnected) { input.remove(); } label.hidden = false; if (container.isConnected) { container.draggable = wasDraggable; } this.activeVariableRename = null; if (options.commit) { this.#setVariableName(variableId, value); } } /** * Applies the provided selection set and designates a primary node. * * @param {Array} nodeIds Collection of node identifiers to select. * @param {string | undefined} primaryId Preferred primary selection identifier. */ #setSelection(nodeIds, primaryId) { this.#cancelVariableRename({ commit: true }); this.#setInspectorView("default"); this.contextMenuManager?.hide(); if (nodeIds.length) { this.selectedVariableId = null; } this.selectedNodeIds.clear(); nodeIds.forEach((id) => { if (this.graph.nodes.has(id)) { this.selectedNodeIds.add(id); } }); if (primaryId && this.selectedNodeIds.has(primaryId)) { this.selectedNodeId = primaryId; } else { const iterator = this.selectedNodeIds.values().next(); this.selectedNodeId = iterator.done ? undefined : iterator.value; } this.#applySelectionState(); } /** * Synchronizes DOM affordances and inspector content with the active selection. */ #applySelectionState() { this.nodeElements.forEach((element, id) => { const isSelected = this.selectedNodeIds.has(id); const isPrimary = isSelected && this.selectedNodeId === id; element.classList.toggle("is-selected", isSelected); element.classList.toggle("is-selection-primary", isPrimary); }); this.#raiseSelectedNodes(); const selectionCount = this.selectedNodeIds.size; this.duplicateNodeButton.disabled = selectionCount !== 1; this.deleteNodeButton.disabled = selectionCount === 0; if ( selectionCount === 1 && this.selectedNodeId && this.inspectorView === "default" ) { this.#renderInspector(this.selectedNodeId); } else { this.inspectorContent.innerHTML = ""; if (selectionCount > 1) { const message = document.createElement("p"); message.className = "inspector-empty"; message.textContent = "Multiple nodes selected."; this.inspectorContent.appendChild(message); } } this.#renderInspectorState(); } /** * Raises selected nodes above unselected nodes to ensure they render on top. * * @returns {void} */ #raiseSelectedNodes() { if (!(this.nodeLayer instanceof HTMLElement)) { return; } if (!this.selectedNodeIds.size) { return; } const primaryId = this.selectedNodeId; this.selectedNodeIds.forEach((id) => { if (id === primaryId) { return; } const element = this.nodeElements.get(id); if ( element && element.parentElement === this.nodeLayer && element.isConnected ) { this.nodeLayer.appendChild(element); } }); if (primaryId && this.selectedNodeIds.has(primaryId)) { const primaryElement = this.nodeElements.get(primaryId); if ( primaryElement && primaryElement.parentElement === this.nodeLayer && primaryElement.isConnected ) { this.nodeLayer.appendChild(primaryElement); } } } /** * Determines whether the supplied target represents empty workspace space. * * @param {Element} target Candidate event target. * @returns {boolean} */ #isWorkspaceBackgroundTarget(target) { if ( target === this.workspaceElement || target === this.nodeLayer || target === this.connectionLayer || this.marqueeManager?.isMarqueeElement(target) ) { return true; } if (target.closest(".blueprint-node")) { return false; } if (this.contextMenuManager?.isTargetInside(target)) { return false; } return this.workspaceElement.contains(target); } /** * Attempts to match a configured background spawn shortcut for the current pointer event. * * @param {PointerEvent} event Pointer event payload. * @returns {NormalizedSpawnShortcut | null} */ #matchBackgroundSpawnShortcut(event) { const shortcuts = this.spawnShortcutLookup.get("background"); if (!shortcuts || !shortcuts.length) { return null; } for (const shortcut of shortcuts) { if (event.button !== shortcut.button) { continue; } if (!this.activeShortcutKeys.has(shortcut.key)) { continue; } if (!this.#validateShortcutModifiers(shortcut.modifiers, event)) { continue; } return shortcut; } return null; } /** * Spawns a node tied to a shortcut at the pointer location. * * @param {NormalizedSpawnShortcut} shortcut Normalized shortcut configuration. * @param {PointerEvent} event Pointer event payload. * @returns {import('../core/BlueprintNode.js').BlueprintNode | null} */ #spawnShortcutNodeAtPointer(shortcut, event) { const workspaceElement = this.workspaceElement; if (!(workspaceElement instanceof HTMLElement)) { return null; } const navigator = this.navigationManager; if (!navigator) { return null; } // Use dragManager.clientToWorkspace for proper coordinate conversion const worldPoint = this.dragManager.clientToWorkspace(event.clientX, event.clientY); return this.addNode(shortcut.definitionId, worldPoint); } /** * Normalizes configured spawn shortcuts into an indexed map keyed by context. * * @param {Array} shortcuts Raw workspace spawn shortcut configuration entries. * @returns {Map<'background', Array>} */ #normalizeSpawnShortcuts(shortcuts) { /** @type {Map<'background', Array>} */ const lookup = new Map(); const allowedModifiers = new Set([ "shift", "ctrl", "alt", "meta", ]); shortcuts.forEach((entry) => { if (!entry || typeof entry !== "object") { return; } const rawKey = typeof entry.key === "string" ? entry.key.trim().toLowerCase() : ""; if (!rawKey) { return; } const context = entry.context || "background"; if (context !== "background") { return; } const definitionId = typeof entry.definitionId === "string" ? entry.definitionId.trim() : ""; if (!definitionId) { return; } const button = typeof entry.button === "number" && Number.isFinite(entry.button) ? entry.button : 0; /** @type {Set} */ const modifiers = new Set(); if (Array.isArray(entry.modifiers)) { entry.modifiers.forEach((modifier) => { if (typeof modifier !== "string") { return; } const normalizedModifier = modifier.trim().toLowerCase(); if (allowedModifiers.has(normalizedModifier)) { modifiers.add( /** @type {WorkspaceShortcutModifier} */ (normalizedModifier) ); } }); } const shortcut = { key: rawKey, definitionId, button, modifiers, }; const bucket = lookup.get("background"); if (bucket) { bucket.push(shortcut); } else { lookup.set("background", [shortcut]); } }); return lookup; } /** * Checks whether the supplied pointer event satisfies the expected modifier state. * * @param {Set} required Required modifier keys. * @param {PointerEvent} event Pointer event payload. * @returns {boolean} */ #validateShortcutModifiers(required, event) { return this.#validateKeyboardModifiers(required, event); } /** * Attempts to execute a command shortcut bound to the provided key event. * * @param {KeyboardEvent} event Keyboard event payload. * @param {string} normalizedKey Lowercase key identifier from the event. * @returns {boolean} */ #dispatchCommandShortcut(event, normalizedKey) { if (!this.commandShortcuts.length) { return false; } const handlers = { copy: () => this.clipboardManager.copySelection(), cut: () => this.clipboardManager.cutSelection(), paste: () => { this.clipboardManager.paste(); return true; }, undo: () => { this.undo(); return true; }, redo: () => { this.redo(); return true; }, delete: () => { if (!this.selectedNodeIds.size) { return false; } const ids = [...this.selectedNodeIds]; ids.forEach((id) => this.removeNode(id)); return true; }, }; for (const shortcut of this.commandShortcuts) { if (shortcut.key !== normalizedKey) { continue; } if (!this.#validateKeyboardModifiers(shortcut.modifiers, event)) { continue; } const handler = handlers[shortcut.command]; if (!handler) { continue; } const handled = handler(); if (handled) { event.preventDefault(); return true; } } return false; } /** * Normalizes command shortcuts from configuration. * * @param {Array} shortcuts Raw shortcut configuration entries. * @returns {Array} */ #normalizeCommandShortcuts(shortcuts) { /** @type {Array} */ const result = []; const allowedCommands = new Set([ "copy", "cut", "paste", "undo", "redo", "delete", ]); shortcuts.forEach((entry) => { if (!entry || typeof entry !== "object") { return; } if (entry.action !== "command") { return; } const rawKey = typeof entry.key === "string" ? entry.key.trim().toLowerCase() : ""; if (!rawKey) { return; } const context = entry.context || "workspace"; if (context !== "workspace") { return; } const command = typeof entry.command === "string" ? entry.command.trim().toLowerCase() : ""; if (!command || !allowedCommands.has(command)) { return; } /** @type {Set} */ const modifiers = new Set(); if (Array.isArray(entry.modifiers)) { entry.modifiers.forEach((modifier) => { if (typeof modifier !== "string") { return; } const normalizedModifier = modifier.trim().toLowerCase(); switch (normalizedModifier) { case "shift": case "ctrl": case "alt": case "meta": modifiers.add( /** @type {WorkspaceShortcutModifier} */ (normalizedModifier) ); break; default: break; } }); } result.push({ key: rawKey, command, modifiers, }); }); return result; } /** * Validates modifier keys for keyboard-driven shortcuts. * * @param {Set} required Required modifier keys. * @param {KeyboardEvent | PointerEvent} event Native event payload. * @returns {boolean} */ #validateKeyboardModifiers(required, event) { const modifierState = { shift: Boolean(event.shiftKey), ctrl: Boolean(event.ctrlKey), alt: Boolean(event.altKey), meta: Boolean(event.metaKey), }; /** @type {Array} */ const modifierKeys = ["shift", "ctrl", "alt", "meta"]; for (const modifier of modifierKeys) { const expected = required.has(modifier); const actual = modifierState[modifier]; if (expected !== actual) { return false; } } return true; } /** * Renders the overview sidebar lists for events and variables. */ #renderOverview() { this.#renderEventList(); this.#renderVariableList(); if (this.paletteManager) { this.paletteManager.render(this.paletteSearch.value); } } /** * Creates a new global variable node and focuses it. */ #createGlobalVariable() { const variable = this.#registerVariable(); this.selectedVariableId = variable.id; this.#renderOverview(); this.#highlightVariableSelection(); this.#renderInspectorState(); this.#refreshLuaOutput(); if (this.variableListElement) { const target = this.variableListElement.querySelector( `button.overview-item[data-variable-id="${variable.id}"]` ); if (target instanceof HTMLButtonElement) { target.focus(); } } } /** * Registers a new global variable entry. * * @param {{ name?: string, type?: VariableType }} [options] Optional variable overrides. * @returns {WorkspaceVariable} */ #registerVariable(options = {}) { const providedName = typeof options.name === "string" ? options.name.trim() : ""; const name = providedName || this.#suggestVariableName(); const type = this.#normalizeVariableType(options.type ?? "any"); this.variableCounter += 1; const id = `var_${this.variableCounter}`; const defaultValue = this.#defaultVariableValue(type); const entry = { id, name, type, defaultValue }; this.variables.set(id, entry); this.variableOrder.push(id); this.persistenceManager.schedulePersist(); return entry; } /** * Suggests a unique variable name. * * @returns {string} */ #suggestVariableName() { const base = "var"; const existing = new Set(); this.variableOrder.forEach((variableId) => { const variable = this.variables.get(variableId); if (!variable) { return; } const raw = variable.name.trim(); if (raw) { existing.add(raw.toLowerCase()); } }); this.graph.nodes.forEach((node) => { if (node.type === "set_var" || node.type === "get_var") { const raw = typeof node.properties.name === "string" ? node.properties.name.trim() : ""; if (raw) { existing.add(raw.toLowerCase()); } } }); if (!existing.has(base)) { return base; } let index = 1; while (existing.has(`${base}${index}`)) { index += 1; } return `${base}${index}`; } /** * Normalizes a variable name for lookups. * * @param {string} name Raw variable name. * @returns {string} */ #normalizeVariableName(name) { return name.trim().toLowerCase(); } /** * Converts a variable type to a human-readable label. * * @param {VariableType} type Variable type identifier. * @returns {string} */ #formatVariableType(type) { const entry = getVariableTypeOptions().find((opt) => opt.value === type); return entry ? entry.label : "Any"; } /** * Normalizes a raw variable type value to a supported type identifier. * * @param {unknown} value Candidate type value. * @returns {VariableType} */ #normalizeVariableType(value) { return /** @type {VariableType} */ (normalizeVariableType(value)); } /** * Returns the available variable type dropdown entries. * * @returns {Array<{ value: VariableType, label: string }>} */ #getVariableTypeOptions() { return getVariableTypeOptions(); } /** * Determines whether the shared type dropdown is open for the supplied context. * * @param {TypeDropdownContext} context Target dropdown context. * @returns {boolean} */ #isTypeDropdownOpen(context) { const state = this.typeDropdown; if (!state.context) { return false; } if (state.context.kind !== context.kind) { return false; } if (context.kind === "variable") { return ( state.context.kind === "variable" && state.context.variableId === context.variableId ); } if (context.kind === "local_variable") { return ( state.context.kind === "local_variable" && state.context.nodeId === context.nodeId ); } return ( state.context.kind === "custom_event_parameter" && state.context.nodeId === context.nodeId && state.context.parameterId === context.parameterId ); } /** * Locates a workspace variable by name using case-insensitive matching. * * @param {string} name Candidate variable name. * @returns {WorkspaceVariable | null} */ #findVariableByName(name) { const normalized = this.#normalizeVariableName(name); if (!normalized) { return null; } for (const variableId of this.variableOrder) { const variable = this.variables.get(variableId); if (!variable) { continue; } if (this.#normalizeVariableName(variable.name) === normalized) { return variable; } } return null; } /** * Determines whether the supplied node references the provided workspace variable. * * @param {BlueprintNode} node Node to inspect. * @param {string} variableId Variable identifier. * @param {string} normalizedName Normalized variable name used for fallback matching. * @returns {boolean} */ #nodeReferencesVariable(node, variableId, normalizedName) { if (node.type !== "set_var" && node.type !== "get_var") { return false; } const nodeVariableId = typeof node.properties.variableId === "string" ? node.properties.variableId : ""; if (nodeVariableId && nodeVariableId === variableId) { return true; } if (!normalizedName) { return false; } const nodeName = typeof node.properties.name === "string" ? node.properties.name : ""; if (!nodeName) { return false; } return this.#normalizeVariableName(nodeName) === normalizedName; } /** * Derives binding metadata for a variable node, including display name and type. * * @param {BlueprintNode} node Variable node reference. * @returns {{ displayName: string, type: VariableType, variableId: string | null }} */ #resolveVariableBinding(node) { const rawName = typeof node.properties.name === "string" ? node.properties.name.trim() : ""; const storedVariableId = typeof node.properties.variableId === "string" && node.properties.variableId ? node.properties.variableId : ""; let variable = storedVariableId ? this.variables.get(storedVariableId) ?? null : null; if (!variable && rawName) { variable = this.#findVariableByName(rawName); } const displayName = variable?.name?.trim() || rawName || "Variable"; const type = variable?.type ?? "any"; const resolvedVariableId = variable?.id ?? (storedVariableId || null); return { displayName, type, variableId: resolvedVariableId }; } /** * Updates a variable node's DOM representation to match the active binding. * * @param {string} nodeId Node identifier to refresh. * @returns {boolean} Whether any connection kinds were mutated. */ #refreshVariableNode(nodeId) { const node = this.graph.nodes.get(nodeId); if (!node || (node.type !== "set_var" && node.type !== "get_var")) { return false; } const article = this.nodeElements.get(nodeId); if (!article) { return false; } const binding = this.#resolveVariableBinding(node); this.#applyVariableNodeTitle(article, node, binding); return this.#applyVariableNodePins(node, binding.type); } /** * Updates a local variable node's DOM representation to match its properties. * * @param {string} nodeId Node identifier to refresh. * @returns {boolean} Whether any connection kinds were mutated. */ #refreshLocalVariableNode(nodeId) { const node = this.graph.nodes.get(nodeId); if ( !node || (node.type !== "set_local_var" && node.type !== "get_local_var") ) { return false; } const article = this.nodeElements.get(nodeId); if (!article) { return false; } const rawName = typeof node.properties.name === "string" ? node.properties.name.trim() : ""; const displayName = rawName || "localVar"; const normalizedType = this.#normalizeVariableType( node.properties.variableType ); const type = normalizedType === "any" ? "number" : normalizedType; this.#applyVariableNodeTitle(article, node, { displayName, type }); return this.#applyVariableNodePins(node, type); } /** * Applies variable-specific title styling to the supplied node header. * * @param {HTMLElement} article Node root element. * @param {BlueprintNode} node Node instance. * @param {{ displayName: string, type: VariableType }} binding Binding metadata. */ #applyVariableNodeTitle(article, node, binding) { const header = article.querySelector(".node-header"); const title = article.querySelector(".node-title"); if (!(header instanceof HTMLElement) || !(title instanceof HTMLElement)) { return; } const isSetter = node.type === "set_var" || node.type === "set_local_var"; const role = isSetter ? "set" : "get"; const isLocal = node.type === "set_local_var" || node.type === "get_local_var"; article.classList.add("blueprint-node--variable"); article.dataset.variableType = binding.type; article.style.setProperty("--overview-variable-color", getTypeColor(binding.type)); article.dataset.variableRole = role; article.dataset.variableScope = isLocal ? "local" : "global"; header.classList.add("node-header--variable"); header.setAttribute("data-variable-role", role); header.setAttribute("data-variable-type", binding.type); header.style.setProperty("--overview-variable-color", getTypeColor(binding.type)); header.setAttribute("data-variable-scope", isLocal ? "local" : "global"); const prefixBase = role === "set" ? "Set" : "Get"; const prefix = isLocal ? `${prefixBase} Local` : prefixBase; const titleText = `${prefix} ${binding.displayName}`; header.title = titleText; title.classList.add("node-title--variable"); title.textContent = ""; const label = document.createElement("span"); label.className = "node-title-label"; label.textContent = titleText; title.appendChild(label); node.title = titleText; } /** * Synchronizes a variable node's data pin kinds with the supplied type. * * @param {BlueprintNode} node Node instance. * @param {VariableType} type Target variable type. * @returns {boolean} Whether any connection kinds were mutated. */ #applyVariableNodePins(node, type) { const targetKind = type ?? "any"; /** @type {Array<{ pinId: string, direction: 'input'|'output', kind: VariableType }>} */ const changedPins = []; const updatePin = (pin, direction) => { if (!pin) { return; } if (pin.kind !== targetKind) { pin.kind = targetKind; changedPins.push({ pinId: pin.id, direction, kind: targetKind }); } const element = this.#getPinElement(node.id, pin.id, direction); if (element) { element.dataset.type = targetKind; } }; if (node.type === "set_var" || node.type === "set_local_var") { const valuePin = node.inputs.find((pin) => pin.id === "value"); updatePin(valuePin, "input"); } else if (node.type === "get_var" || node.type === "get_local_var") { const valuePin = node.outputs.find((pin) => pin.id === "value"); updatePin(valuePin, "output"); } if (!changedPins.length) { return false; } const connections = this.graph.getConnections(); let connectionsMutated = false; const toRemove = new Set(); changedPins.forEach((update) => { connections.forEach((connection) => { const isSource = connection.from.nodeId === node.id && connection.from.pinId === update.pinId; const isTarget = connection.to.nodeId === node.id && connection.to.pinId === update.pinId; if (!isSource && !isTarget) { return; } const otherNodeId = isSource ? connection.to.nodeId : connection.from.nodeId; const otherPinId = isSource ? connection.to.pinId : connection.from.pinId; const otherNode = this.graph.nodes.get(otherNodeId); const otherPin = otherNode?.getPin(otherPinId); if (!otherNode || !otherPin) { return; } const outputKind = isSource ? update.kind : otherPin.kind; const inputKind = isSource ? otherPin.kind : update.kind; if (!this.#arePinKindsCompatible(outputKind, inputKind)) { toRemove.add(connection.id); return; } if (connection.kind !== update.kind) { connection.kind = update.kind; connectionsMutated = true; } }); }); if (toRemove.size) { toRemove.forEach((connectionId) => this.graph.removeConnection(connectionId) ); connectionsMutated = true; } return connectionsMutated; } /** * Synchronizes sequence node outputs with configured branches. * * @param {string} nodeId Target node identifier. * @returns {boolean} Whether any connections were mutated. */ #refreshSequenceNode(nodeId) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "sequence") { return false; } const { branches } = this.#ensureSequenceBranches(node); const result = this.#applySequenceOutputs(node, branches); if (this.nodeRenderer) { this.nodeRenderer.rebuildNodePins(node, { inputs: false, outputs: true }); this.nodeRenderer.updatePinConnectionsForNode(node.id); } if (this.navigationManager) { this.navigationManager.updateZoomDisplay(); } else { this.scheduleConnectionRefresh(); } return result.connectionsMutated; } /** * Ensures sequence branch metadata is present and unique. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference. * @returns {{ branches: Array<{ id: string }>, mutated: boolean }} */ #ensureSequenceBranches(node) { if (!Array.isArray(node.properties.branches)) { node.properties.branches = []; } if (!Number.isFinite(node.properties.branchCounter)) { node.properties.branchCounter = 0; } const existing = Array.isArray(node.properties.branches) ? /** @type {Array<{ id?: string }>} */ (node.properties.branches) : []; const sanitized = []; const usedIds = new Set(); let mutated = !Array.isArray(node.properties.branches); existing.forEach((entry) => { let id = typeof entry?.id === "string" ? entry.id.trim() : ""; if (!id || usedIds.has(id)) { id = this.#allocateSequenceBranchId(node); mutated = true; } usedIds.add(id); sanitized.push({ id }); }); if (!sanitized.length) { node.outputs .filter((pin) => pin.direction === "output") .forEach((pin) => { const pinId = typeof pin.id === "string" ? pin.id : ""; if (!pinId || usedIds.has(pinId)) { const generated = this.#allocateSequenceBranchId(node); sanitized.push({ id: generated }); usedIds.add(generated); mutated = true; } else { sanitized.push({ id: pinId }); usedIds.add(pinId); } }); if (!sanitized.length) { const count = Math.max(DEFAULT_SEQUENCE_BRANCHES, 1); for (let index = 0; index < count; index += 1) { const generated = this.#allocateSequenceBranchId(node); sanitized.push({ id: generated }); usedIds.add(generated); } mutated = true; } } let maxCounter = Number.isFinite(node.properties.branchCounter) ? Number(node.properties.branchCounter) : 0; sanitized.forEach((entry) => { const match = /_(\d+)$/.exec(entry.id); if (match) { const numeric = Number.parseInt(match[1], 10); if (Number.isFinite(numeric)) { maxCounter = Math.max(maxCounter, numeric); } } }); node.properties.branchCounter = maxCounter; node.properties.branches = sanitized.map((entry) => ({ id: entry.id })); return { branches: node.properties.branches.map((entry) => ({ id: entry.id })), mutated, }; } /** * Allocates a unique branch identifier for a sequence node. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference. * @returns {string} */ #allocateSequenceBranchId(node) { if (!Number.isFinite(node.properties.branchCounter)) { node.properties.branchCounter = 0; } node.properties.branchCounter += 1; return `branch_${String(node.properties.branchCounter).padStart(2, "0")}`; } /** * Derives a human-friendly label for a sequence branch index. * * @param {number} index Branch index starting at zero. * @returns {string} */ #formatSequenceBranchLabel(index) { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; let value = Math.max(0, Math.floor(index)); let label = ""; do { label = alphabet[value % 26] + label; value = Math.floor(value / 26) - 1; } while (value >= 0); return label; } /** * Applies sequence branch descriptors to the node outputs. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference. * @param {Array<{ id: string }>} branches Normalized branch descriptors. * @returns {{ pinsChanged: boolean, connectionsMutated: boolean }} */ #applySequenceOutputs(node, branches) { const existing = new Map(node.outputs.map((pin) => [pin.id, pin])); let pinsChanged = false; let connectionsMutated = false; const desired = []; branches.forEach((branch, index) => { const pinId = branch.id; const label = this.#formatSequenceBranchLabel(index); let descriptor = existing.get(pinId); if (!descriptor) { descriptor = { id: pinId, name: label, direction: "output", kind: "exec", }; pinsChanged = true; } else { if (descriptor.name !== label) { descriptor.name = label; pinsChanged = true; } if (descriptor.direction !== "output") { descriptor.direction = "output"; pinsChanged = true; } if (descriptor.kind !== "exec") { descriptor.kind = "exec"; pinsChanged = true; } } desired.push(descriptor); }); node.outputs.forEach((pin) => { const keep = branches.some((branch) => branch.id === pin.id); if (!keep) { this.graph.removeConnectionsForPin({ nodeId: node.id, pinId: pin.id, }); connectionsMutated = true; } }); if (pinsChanged || node.outputs.length !== desired.length) { node.outputs = desired; } return { pinsChanged, connectionsMutated }; } /** Adds an execution branch to a sequence node. */ #addSequenceBranch(nodeId) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "sequence") { return; } const { branches } = this.#ensureSequenceBranches(node); const nextBranches = branches.map((entry) => ({ id: entry.id })); const branchId = this.#allocateSequenceBranchId(node); nextBranches.push({ id: branchId }); this.graph.setNodeProperty(nodeId, "branches", nextBranches); } /** Removes an execution branch from a sequence node when possible. */ #removeSequenceBranch(nodeId, branchId) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "sequence") { return; } const { branches } = this.#ensureSequenceBranches(node); if (branches.length <= 1) { return; } const nextBranches = branches .filter((entry) => entry.id !== branchId) .map((entry) => ({ id: entry.id })); if (nextBranches.length === branches.length) { return; } this.graph.setNodeProperty(nodeId, "branches", nextBranches); } /** * Enumerates custom event nodes within the active graph. * * @returns {Array} */ #getCustomEventNodes() { return [...this.graph.nodes.values()].filter( (node) => node.type === "custom_event" ); } /** * Generates a unique custom event name based on the provided base string. * * @param {string} baseName Desired base name. * @param {string} [excludeNodeId] Optional node identifier to exclude from duplicate checks. * @returns {string} */ #generateUniqueCustomEventName(baseName, excludeNodeId) { const trimmedBase = baseName.trim(); const base = trimmedBase || "CustomEvent"; const normalizedBase = base.toLowerCase(); const existing = new Set(); this.graph.nodes.forEach((candidate) => { if (candidate.type !== "custom_event") { return; } if (excludeNodeId && candidate.id === excludeNodeId) { return; } const rawName = typeof candidate.properties.name === "string" ? candidate.properties.name.trim() : ""; if (rawName) { existing.add(rawName.toLowerCase()); } }); if (!existing.has(normalizedBase)) { return base; } let suffix = 1; while (existing.has(`${base}${suffix}`.toLowerCase())) { suffix += 1; } return `${base}${suffix}`; } /** * Derives the user-facing label for a custom event node. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Custom event node reference. * @returns {string} */ #resolveCustomEventLabel(node) { const raw = typeof node.properties.name === "string" ? node.properties.name.trim() : ""; return raw || "CustomEvent"; } /** * Produces a stable output pin identifier for the supplied parameter. * * @param {string} parameterId Custom event parameter identifier. * @returns {string} */ #customEventOutputPinId(parameterId) { return `param_${parameterId}`; } /** * Produces a stable input pin identifier for call nodes invoking the supplied parameter. * * @param {string} parameterId Custom event parameter identifier. * @returns {string} */ #callCustomEventInputPinId(parameterId) { return `arg_${parameterId}`; } /** * Allocates a unique custom event parameter identifier for the provided node. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Target custom event node. * @returns {string} */ #allocateCustomEventParameterId(node) { let counter = Number.isFinite(node.properties.parameterCounter) ? Number(node.properties.parameterCounter) : 0; counter += 1; node.properties.parameterCounter = counter; return `param_${String(counter).padStart(2, "0")}`; } /** * Ensures the custom event parameter collection is valid and normalized. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Target custom event node. * @returns {{ parameters: Array, mutated: boolean }} */ #sanitizeCustomEventParameters(node) { const existing = Array.isArray(node.properties.parameters) ? /** @type {Array} */ (node.properties.parameters) : []; const normalizeOptional = (value) => { if (typeof value === "boolean") { return value; } if (typeof value === "string") { const trimmed = value.trim().toLowerCase(); if (trimmed === "true") { return true; } if (!trimmed || trimmed === "false") { return false; } } return Boolean(value); }; const sanitized = []; const usedIds = new Set(); const usedNames = new Set(); let mutated = !Array.isArray(node.properties.parameters); let maxCounter = Number.isFinite(node.properties.parameterCounter) ? Number(node.properties.parameterCounter) : 0; existing.forEach((entry, index) => { const rawName = typeof entry?.name === "string" ? entry.name.trim() : ""; const baseName = rawName || `Param${index + 1}`; if (!rawName) { mutated = true; } let candidateName = baseName; let suffix = 2; while (usedNames.has(candidateName.toLowerCase())) { candidateName = `${baseName}${suffix}`; suffix += 1; mutated = true; } usedNames.add(candidateName.toLowerCase()); const rawType = typeof entry?.type === "string" ? entry.type : ""; const type = this.#normalizeVariableType(rawType); if (type !== rawType) { mutated = true; } const optional = normalizeOptional(entry?.optional); let identifier = typeof entry?.id === "string" ? entry.id.trim() : ""; if (!identifier || usedIds.has(identifier)) { identifier = this.#allocateCustomEventParameterId(node); mutated = true; } usedIds.add(identifier); const numericMatch = /_(\d+)$/.exec(identifier); if (numericMatch) { const numeric = Number.parseInt(numericMatch[1], 10); if (Number.isFinite(numeric)) { maxCounter = Math.max(maxCounter, numeric); } } sanitized.push({ id: identifier, name: candidateName, type, optional }); }); if (sanitized.length !== existing.length) { mutated = true; } else if (!mutated) { for (let index = 0; index < sanitized.length; index += 1) { const current = existing[index]; const next = sanitized[index]; if ( !current || current.id !== next.id || current.name !== next.name || current.type !== next.type || normalizeOptional(current?.optional) !== next.optional ) { mutated = true; break; } } } if (mutated) { node.properties.parameters = sanitized.map((parameter) => ({ ...parameter, })); } if ( !Number.isFinite(node.properties.parameterCounter) || node.properties.parameterCounter < maxCounter ) { node.properties.parameterCounter = maxCounter; mutated = true; } const stored = /** @type {Array} */ ( Array.isArray(node.properties.parameters) ? node.properties.parameters : [] ); return { parameters: stored.map((parameter) => ({ ...parameter })), mutated, }; } /** * Aligns the node output pins with the supplied custom event parameters. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Custom event node reference. * @param {Array} parameters Normalized parameter list. * @returns {{ pinsChanged: boolean, connectionsMutated: boolean }} */ #applyCustomEventNodePins(node, parameters) { const existingOutputs = new Map(node.outputs.map((pin) => [pin.id, pin])); let pinsChanged = false; let connectionsMutated = false; const desiredIds = new Set(["exec_out"]); const desiredOutputs = []; const execPin = existingOutputs.get("exec_out") ?? { id: "exec_out", name: "Exec", direction: "output", kind: "exec", }; execPin.name = "Exec"; execPin.direction = "output"; execPin.kind = "exec"; desiredOutputs.push(execPin); parameters.forEach((parameter) => { const pinId = this.#customEventOutputPinId(parameter.id); desiredIds.add(pinId); let descriptor = existingOutputs.get(pinId); if (!descriptor) { descriptor = { id: pinId, name: parameter.name, direction: "output", kind: parameter.type, }; pinsChanged = true; } else { if (descriptor.name !== parameter.name) { descriptor.name = parameter.name; pinsChanged = true; } if (descriptor.kind !== parameter.type) { descriptor.kind = parameter.type; pinsChanged = true; } descriptor.direction = "output"; } desiredOutputs.push(descriptor); }); node.outputs .filter((pin) => !desiredIds.has(pin.id)) .forEach((pin) => { this.graph.removeConnectionsForPin({ nodeId: node.id, pinId: pin.id }); connectionsMutated = true; pinsChanged = true; }); if (pinsChanged || node.outputs.length !== desiredOutputs.length) { node.outputs = desiredOutputs; } parameters.forEach((parameter) => { const pinId = this.#customEventOutputPinId(parameter.id); if (this.#ensureConnectionsReflectPinKinds(node.id, pinId)) { connectionsMutated = true; } }); return { pinsChanged, connectionsMutated }; } /** * Aligns a call node's input pins with its target custom event parameters. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Call custom event node. * @param {Array} parameters Target parameter list. * @returns {{ pinsChanged: boolean, connectionsMutated: boolean }} */ #applyCallCustomEventNodePins(node, parameters) { const existingInputs = new Map(node.inputs.map((pin) => [pin.id, pin])); let pinsChanged = false; let connectionsMutated = false; const desiredIds = new Set(["exec_in"]); const desiredInputs = []; const execPin = existingInputs.get("exec_in") ?? { id: "exec_in", name: "Exec", direction: "input", kind: "exec", }; execPin.name = "Exec"; execPin.direction = "input"; execPin.kind = "exec"; desiredInputs.push(execPin); parameters.forEach((parameter) => { const pinId = this.#callCustomEventInputPinId(parameter.id); desiredIds.add(pinId); let descriptor = existingInputs.get(pinId); if (!descriptor) { descriptor = { id: pinId, name: parameter.name, direction: "input", kind: parameter.type, }; pinsChanged = true; } else { if (descriptor.name !== parameter.name) { descriptor.name = parameter.name; pinsChanged = true; } if (descriptor.kind !== parameter.type) { descriptor.kind = parameter.type; pinsChanged = true; } descriptor.direction = "input"; } desiredInputs.push(descriptor); }); node.inputs .filter((pin) => !desiredIds.has(pin.id)) .forEach((pin) => { this.graph.removeConnectionsForPin({ nodeId: node.id, pinId: pin.id }); connectionsMutated = true; pinsChanged = true; }); if (pinsChanged || node.inputs.length !== desiredInputs.length) { node.inputs = desiredInputs; } parameters.forEach((parameter) => { const pinId = this.#callCustomEventInputPinId(parameter.id); if (this.#ensureConnectionsReflectPinKinds(node.id, pinId)) { connectionsMutated = true; } }); return { pinsChanged, connectionsMutated }; } /** * Guarantees connection compatibility after a pin kind update. * * @param {string} nodeId Node identifier. * @param {string} pinId Pin identifier to reconcile. * @returns {boolean} Whether any connections were mutated. */ #ensureConnectionsReflectPinKinds(nodeId, pinId) { const node = this.graph.nodes.get(nodeId); const pin = node?.getPin(pinId); if (!node || !pin) { return false; } const connections = this.graph.getConnectionsForNode(nodeId, pinId); let mutated = false; connections.forEach((connection) => { const fromNode = this.graph.nodes.get(connection.from.nodeId); const toNode = this.graph.nodes.get(connection.to.nodeId); const fromPin = fromNode?.getPin(connection.from.pinId); const toPin = toNode?.getPin(connection.to.pinId); if (!fromNode || !toNode || !fromPin || !toPin) { this.graph.removeConnection(connection.id); mutated = true; return; } if (!this.#arePinKindsCompatible(fromPin.kind, toPin.kind)) { this.graph.removeConnection(connection.id); mutated = true; return; } const expectedKind = toPin.kind === "any" ? fromPin.kind : toPin.kind; if (connection.kind !== expectedKind) { connection.kind = expectedKind; mutated = true; } }); return mutated; } /** * Ensures the call node arguments map contains entries for each parameter. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Call node reference. * @param {Array} parameters Parameter metadata. */ #syncCallCustomEventArguments(node, parameters) { const current = node.properties.arguments; const record = current && typeof current === "object" && !Array.isArray(current) ? /** @type {Record} */ (current) : {}; const validIds = new Set(parameters.map((parameter) => parameter.id)); Object.keys(record).forEach((key) => { if (!validIds.has(key)) { delete record[key]; } }); parameters.forEach((parameter) => { if (!(parameter.id in record)) { record[parameter.id] = null; } }); node.properties.arguments = record; } /** * Appends a new parameter to the provided custom event node. * * @param {string} nodeId Target custom event node identifier. * @param {{ name?: string, type?: VariableType }} [options] Optional overrides. * @returns {CustomEventParameter | null} */ #addCustomEventParameter(nodeId, options = {}) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "custom_event") { return null; } const { parameters } = this.#sanitizeCustomEventParameters(node); const identifier = this.#allocateCustomEventParameterId(node); const baseName = typeof options.name === "string" ? options.name.trim() : ""; const parameter = { id: identifier, name: baseName || `Param${parameters.length + 1}`, type: this.#normalizeVariableType(options.type ?? "any"), optional: Boolean(options.optional), }; const nextParameters = parameters.concat(parameter); this.graph.setNodeProperty(nodeId, "parameters", nextParameters); return parameter; } /** * Removes a parameter from a custom event node if present. * * @param {string} nodeId Target custom event node identifier. * @param {string} parameterId Parameter identifier to remove. * @returns {boolean} Whether a parameter was removed. */ #removeCustomEventParameter(nodeId, parameterId) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "custom_event") { return false; } const { parameters } = this.#sanitizeCustomEventParameters(node); const nextParameters = parameters.filter( (parameter) => parameter.id !== parameterId ); if (nextParameters.length === parameters.length) { return false; } this.graph.setNodeProperty(nodeId, "parameters", nextParameters); return true; } /** * Updates the display name for a custom event parameter. * * @param {string} nodeId Target custom event node identifier. * @param {string} parameterId Parameter identifier to rename. * @param {string} name Desired display name. */ #renameCustomEventParameter(nodeId, parameterId, name) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "custom_event") { return; } const trimmed = typeof name === "string" ? name.trim() : ""; const { parameters } = this.#sanitizeCustomEventParameters(node); const nextParameters = parameters.map((parameter) => parameter.id === parameterId ? { ...parameter, name: trimmed || parameter.name } : parameter ); this.graph.setNodeProperty(nodeId, "parameters", nextParameters); } /** * Adjusts the type for a custom event parameter. * * @param {string} nodeId Target custom event node identifier. * @param {string} parameterId Parameter identifier to modify. * @param {VariableType} nextType Desired parameter type. */ #setCustomEventParameterType(nodeId, parameterId, nextType) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "custom_event") { return; } const resolvedType = this.#normalizeVariableType(nextType); const { parameters } = this.#sanitizeCustomEventParameters(node); const nextParameters = parameters.map((parameter) => parameter.id === parameterId ? { ...parameter, type: resolvedType } : parameter ); this.graph.setNodeProperty(nodeId, "parameters", nextParameters); } /** * Toggles whether a custom event parameter is optional. * * @param {string} nodeId Target custom event node identifier. * @param {string} parameterId Parameter identifier to update. * @param {boolean} isOptional Desired optional state. */ #setCustomEventParameterOptional(nodeId, parameterId, isOptional) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "custom_event") { return; } const flag = Boolean(isOptional); const { parameters } = this.#sanitizeCustomEventParameters(node); const nextParameters = parameters.map((parameter) => parameter.id === parameterId ? { ...parameter, optional: flag } : parameter ); this.graph.setNodeProperty(nodeId, "parameters", nextParameters); } /** * Guarantees the custom event structure matches its parameter list without touching call nodes. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Custom event node reference. * @returns {{ parameters: Array, connectionsMutated: boolean }} */ #ensureCustomEventStructure(node) { const { parameters } = this.#sanitizeCustomEventParameters(node); const pinResult = this.#applyCustomEventNodePins(node, parameters); if (pinResult.pinsChanged) { this.nodeRenderer.rebuildNodePins(node, { inputs: false, outputs: true }); } return { parameters, connectionsMutated: pinResult.connectionsMutated, }; } /** * Refreshes the DOM representation of a custom event node and synchronizes call references. * * @param {string} nodeId Target node identifier. * @returns {boolean} Whether any connections were mutated. */ #refreshCustomEventNode(nodeId) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "custom_event") { return false; } const { parameters, connectionsMutated: eventConnectionsMutated } = this.#ensureCustomEventStructure(node); const label = this.#resolveCustomEventLabel(node); const titleText = `Custom Event: ${label}`; node.title = titleText; const article = this.nodeElements.get(nodeId); if (article) { article.classList.add("blueprint-node--custom-event"); article.dataset.customEventName = label; article.dataset.customEventParamCount = String(parameters.length); const titleElement = article.querySelector(".node-title"); if (titleElement instanceof HTMLElement) { titleElement.textContent = titleText; } } const callConnectionsMutated = this.#syncCallNodesForEvent( nodeId, parameters ); return eventConnectionsMutated || callConnectionsMutated; } /** * Synchronizes all call nodes targeting the specified custom event. * * @param {string} eventNodeId Custom event identifier. * @param {Array} parameters Normalized parameter definitions. * @returns {boolean} Whether any connections were mutated. */ #syncCallNodesForEvent(eventNodeId, parameters) { const eventNode = this.graph.nodes.get(eventNodeId); let connectionsMutated = false; this.graph.nodes.forEach((candidate) => { if (candidate.type !== "call_custom_event") { return; } const targetId = typeof candidate.properties.eventId === "string" ? candidate.properties.eventId : ""; if (targetId !== eventNodeId) { return; } const mutated = this.#refreshCustomEventCallNode(candidate.id, { eventNode: eventNode && eventNode.type === "custom_event" ? eventNode : null, parameters, }); connectionsMutated = mutated || connectionsMutated; }); return connectionsMutated; } /** * Refreshes the display metadata and pins for a call custom event node. * * @param {string} nodeId Target node identifier. * @param {{ eventNode?: import('../core/BlueprintNode.js').BlueprintNode | null, parameters?: Array }} [options] * @returns {boolean} Whether any connections were mutated. */ #refreshCustomEventCallNode(nodeId, options = {}) { const node = this.graph.nodes.get(nodeId); if (!node || node.type !== "call_custom_event") { return false; } const eventId = typeof node.properties.eventId === "string" ? node.properties.eventId : ""; let eventNode = options.eventNode ?? null; if (!eventNode && eventId) { const fetched = this.graph.nodes.get(eventId); if (fetched && fetched.type === "custom_event") { eventNode = fetched; } } let parameters = Array.isArray(options.parameters) ? options.parameters.map((parameter) => ({ ...parameter })) : []; if (eventNode && eventNode.type === "custom_event" && !options.parameters) { const structure = this.#ensureCustomEventStructure(eventNode); parameters = structure.parameters.map((parameter) => ({ ...parameter })); } const pinResult = this.#applyCallCustomEventNodePins(node, parameters); if (pinResult.pinsChanged) { this.nodeRenderer.rebuildNodePins(node, { inputs: true, outputs: false }); } this.#syncCallCustomEventArguments(node, parameters); let label = ""; if (eventNode && eventNode.type === "custom_event") { label = this.#resolveCustomEventLabel(eventNode); } const titleText = label ? `Call ${label}` : "Call Custom Event"; node.title = titleText; const article = this.nodeElements.get(nodeId); if (article) { article.classList.add("blueprint-node--custom-event-call"); if (label && eventId) { article.dataset.targetEventId = eventId; article.dataset.targetEventName = label; article.dataset.targetEventParamCount = String(parameters.length); } else { delete article.dataset.targetEventId; delete article.dataset.targetEventName; delete article.dataset.targetEventParamCount; } const titleElement = article.querySelector(".node-title"); if (titleElement instanceof HTMLElement) { titleElement.textContent = titleText; } } return pinResult.connectionsMutated; } /** * Re-renders the inspector panel when the supplied node is the active selection. * * @param {string} nodeId Node identifier to check. */ #refreshInspectorIfActive(nodeId, propertyKey) { if ( this.selectedNodeIds.size === 1 && this.selectedNodeId === nodeId && this.inspectorView === "default" ) { if (propertyKey) { this.pendingInspectorFocus = { nodeId, property: propertyKey }; } this.#renderInspector(nodeId); } } /** * Updates every call node referencing the specified custom event. * * @param {string} eventNodeId Custom event node identifier. */ #refreshCallNodesForEvent(eventNodeId) { const eventNode = this.graph.nodes.get(eventNodeId); const structure = eventNode && eventNode.type === "custom_event" ? this.#ensureCustomEventStructure(eventNode) : { parameters: [], connectionsMutated: false }; this.#syncCallNodesForEvent(eventNodeId, structure.parameters); } /** * Clears references to a custom event that has been removed from the graph. * * @param {string} eventNodeId Identifier for the removed event node. */ #handleCustomEventRemoval(eventNodeId) { this.graph.nodes.forEach((node) => { if (node.type !== "call_custom_event") { return; } const targetId = typeof node.properties.eventId === "string" ? node.properties.eventId : ""; if (targetId !== eventNodeId) { return; } this.graph.setNodeProperty(node.id, "eventId", ""); }); } /** * Renders the event list summarizing entry nodes. */ #renderEventList() { renderEventList({ element: this.eventListElement, registry: this.registry, graph: this.graph, focusNode: (nodeId) => { this.navigationManager.focusNode(nodeId); }, renderEmpty: (message) => { if (this.eventListElement) { this.#renderOverviewEmpty(this.eventListElement, message); } }, isEventNode: (node, entryTypes) => this.#isEventNode(node, entryTypes), }); } /** * Renders the variable list summarizing set/get nodes. */ #renderVariableList() { renderVariableList({ element: this.variableListElement, variableOrder: this.variableOrder, variables: this.variables, graph: this.graph, cancelVariableRename: () => this.#cancelVariableRename(), closeVariableTypeDropdown: () => this.#closeTypeDropdown(), renderEmpty: (message) => { if (this.variableListElement) { this.#renderOverviewEmpty(this.variableListElement, message); } }, normalizeVariableName: (name) => this.#normalizeVariableName(name), formatVariableType: (type) => this.#formatVariableType(type), selectVariable: (variableId) => this.#selectVariable(variableId), handleVariableDragStart: (event, variableId) => this.dropManager.handleVariableDragStart(event, variableId), handleVariableDragEnd: () => this.dropManager.handleVariableDragEnd(), reorderVariable: (variableId, targetIndex) => this.#reorderVariable(variableId, targetIndex), openVariableTypeDropdown: (variableId, button, anchor) => this.#openVariableTypeDropdown(variableId, button, anchor), deleteVariable: (variableId) => this.#deleteVariable(variableId), startVariableRename: (variableId, label) => this.#startVariableRename(variableId, label), highlightVariableSelection: () => this.#highlightVariableSelection(), isVariableDropdownOpen: (variableId) => this.#isTypeDropdownOpen({ kind: "variable", variableId }), }); } /** * Appends an empty-state message to the provided overview list. * * @param {HTMLUListElement} list Target list element. * @param {string} message Empty-state message. */ #renderOverviewEmpty(list, message) { const item = document.createElement("li"); const placeholder = document.createElement("p"); placeholder.className = "overview-empty"; placeholder.textContent = message; item.appendChild(placeholder); list.appendChild(item); } /** * Determines whether a node qualifies as an event for the overview list. * * @param {BlueprintNode} node Node to inspect. * @param {Set} entryTypes Node types designated as entry points. * @returns {boolean} */ #isEventNode(node, entryTypes) { const definition = this.registry.get(node.type); if (!definition) { return false; } if (entryTypes.has(node.type) && node.type !== "call_custom_event") { return true; } if (node.type === "custom_event") { return true; } return false; } /** * Prepares drag metadata for variable overview interactions. * * @param {DragEvent} event Drag event payload. * @param {string} variableId Variable identifier being dragged. */ /** * Handles pointer interactions with rendered connection paths, enabling quick removal. * * @param {PointerEvent} event Pointer interaction payload. */ #handleConnectionPointerDown(event) { if (event.button !== 0 || !event.altKey) { return; } // 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; } event.preventDefault(); event.stopPropagation(); this.graph.removeConnection(connectionId); } /** * Creates a node instance from a registry definition or shortcut entry. * * @param {PaletteNodeDefinition} definition Definition describing the node to spawn. * @param {{ position?: { x: number, y: number } }} [options] Spawn options. * @returns {import('../core/BlueprintNode.js').BlueprintNode | null} */ createNodeFromDefinition(definition, options = {}) { if (!definition) { return null; } const shortcut = definition.shortcut; if (shortcut?.type === "variable") { const targetId = shortcut.action === "set" ? "set_var" : "get_var"; const node = this.addNode(targetId, options.position); if (!node) { return null; } const variable = this.variables.get(shortcut.variableId); const rawName = variable?.name ?? ""; const label = rawName.trim() || variable?.id || shortcut.variableId; this.graph.setNodeProperty(node.id, "variableId", shortcut.variableId); this.graph.setNodeProperty(node.id, "name", label); this.#refreshVariableNode(node.id); return node; } let node = null; if (shortcut?.type === "custom_event_call") { node = this.addNode("call_custom_event", options.position); if (!node) { return null; } this.graph.setNodeProperty(node.id, "eventId", shortcut.eventNodeId); } else { node = this.addNode(definition.id, options.position); if (!node) { return null; } } if (node.type === "custom_event") { const currentName = typeof node.properties.name === "string" ? node.properties.name : ""; const uniqueName = this.#generateUniqueCustomEventName( currentName || "CustomEvent", node.id ); if (uniqueName !== currentName) { this.graph.setNodeProperty(node.id, "name", uniqueName); } else { this.#refreshCustomEventNode(node.id); } this.pendingInspectorFocus = { nodeId: node.id, property: "name" }; } else if (node.type === "call_custom_event") { if (!shortcut || shortcut.type !== "custom_event_call") { this.pendingInspectorFocus = { nodeId: node.id, property: "eventId" }; } this.#refreshCustomEventCallNode(node.id); } return node; } /** * Spawns a node at the requested context menu position. * * @param {PaletteNodeDefinition} definition Node definition to instantiate. * @param {{ x: number, y: number }} spawnPosition Screen-relative spawn coordinates (relative to workspace element). * @returns {import('../core/BlueprintNode.js').BlueprintNode | null} */ spawnNodeFromContextMenu(definition, spawnPosition) { const clamped = { x: Math.max(0, spawnPosition.x), y: Math.max(0, spawnPosition.y), }; // Convert screen-relative position to world coordinates const zoom = this.navigationManager?.getZoomLevel() ?? this.zoomLevel ?? 1; const clampedZoom = Math.max(0.01, zoom); const offset = this.navigationManager?.getEffectiveOffset() ?? { x: 0, y: 0 }; const worldSpawn = { x: clamped.x / clampedZoom - offset.x, y: clamped.y / clampedZoom - offset.y, }; const spawn = WorkspaceGeometry.snapPositionToGrid(this, worldSpawn); const pendingConnectionSnapshot = this.pendingConnectionSpawn ? { startRef: { nodeId: this.pendingConnectionSpawn.startRef.nodeId, pinId: this.pendingConnectionSpawn.startRef.pinId, }, direction: this.pendingConnectionSpawn.direction, kind: this.pendingConnectionSpawn.kind, } : null; this.pendingConnectionSpawn = null; const node = this.createNodeFromDefinition(definition, { position: spawn }); if ( node && this.pendingVariableSpawn && (definition.id === "set_var" || definition.id === "get_var") ) { const variable = this.variables.get(this.pendingVariableSpawn.variableId); if (variable) { const nodeName = variable.name || variable.id; this.graph.setNodeProperty(node.id, "name", nodeName); this.graph.setNodeProperty(node.id, "variableId", variable.id); } this.pendingVariableSpawn = null; } if (node && pendingConnectionSnapshot) { this.#connectSpawnedNodeToPending(node, pendingConnectionSnapshot); } return node; } /** * Computes a spawn position based on the workspace size and spawn index. * * @param {string} definitionId Node definition identifier. * @param {number} index Spawn index. * @returns {{x:number,y:number}} */ #computeSpawnPosition(definitionId, index) { const bounds = this.workspaceElement.getBoundingClientRect(); const zoom = this.zoomLevel || 1; const width = bounds.width / zoom; const height = bounds.height / zoom; const offset = (index % 5) * 30; const baseX = width * 0.3; const baseY = height * 0.3; const jitterX = (index * 37) % 120; const jitterY = (index * 53) % 120; const categoryOffset = this.registry.get(definitionId)?.category.length ?? 0; return WorkspaceGeometry.snapPositionToGrid(this, { x: Math.max(40, baseX + offset + jitterX + categoryOffset), y: Math.max(40, baseY + offset + jitterY), }); } /** * Initiates a node drag operation. * * @param {PointerEvent} event Pointer event. * @param {string} nodeId Dragged node identifier. */ #startDraggingNode(event, nodeId) { if (!this.dragManager) { return; } this.dragManager.startNodeDrag(event, nodeId); } /** * Attempts to connect the selected node to the nearest compatible pin. * * @returns {boolean} */ #tryAutoConnectSelectedNode() { if (this.selectedNodeIds.size !== 1 || !this.selectedNodeId) { return false; } const node = this.graph.nodes.get(this.selectedNodeId); if (!node) { return false; } let connected = false; const pins = [ ...node.outputs.filter((pin) => this.#isAutoConnectStartPinAvailable(node.id, pin) ), ...node.inputs.filter((pin) => this.#isAutoConnectStartPinAvailable(node.id, pin) ), ]; for (const pin of pins) { const candidates = this.#findAutoConnectTargets(node, pin); for (const candidate of candidates) { if (candidate.pruneExistingConnections) { this.graph.removeConnectionsForPin({ nodeId: candidate.nodeId, pinId: candidate.pinId, }); } const success = pin.direction === "output" ? this.graph.connect( { nodeId: node.id, pinId: pin.id }, { nodeId: candidate.nodeId, pinId: candidate.pinId } ) : this.graph.connect( { nodeId: candidate.nodeId, pinId: candidate.pinId }, { nodeId: node.id, pinId: pin.id } ); if (success) { connected = true; break; } } } return connected; } /** * Evaluates if a pin is eligible to initiate an auto-connect attempt. * * @param {string} nodeId Node identifier for the pin owner. * @param {import('../core/BlueprintNode.js').PinDescriptor} pin Pin descriptor to inspect. * @returns {boolean} */ #isAutoConnectStartPinAvailable(nodeId, pin) { const connections = this.graph.getConnectionsForNode(nodeId, pin.id); return connections.length === 0; } /** * Gathers compatible target pins ordered by proximity to the starting pin. * * @param {import('../core/BlueprintNode.js').BlueprintNode} startNode Node containing the starting pin. * @param {import('../core/BlueprintNode.js').PinDescriptor} startPin Starting pin descriptor. * @returns {Array<{ nodeId: string, pinId: string, distance: number }>} */ #findAutoConnectTargets(startNode, startPin) { const startCenter = this.#getPinHandleCenter( startNode.id, startPin.id, startPin.direction ); if (!startCenter) { return []; } const expectedDirection = startPin.direction === "output" ? "input" : "output"; /** @type {Array<{ nodeId: string, distance: number, pins: Array<{ pinId: string, pruneExistingConnections: boolean }> }> } */ const nodes = []; this.graph.nodes.forEach((candidateNode) => { if (candidateNode.id === startNode.id) { return; } const pins = expectedDirection === "input" ? candidateNode.inputs : candidateNode.outputs; /** @type {Array<{ pinId: string, pruneExistingConnections: boolean }>} */ const compatiblePins = []; let nodeDistance = Infinity; pins.forEach((candidatePin) => { if ( !this.#canPinsConnect( startNode, startPin, startPin.direction, candidateNode, candidatePin ) ) { return; } const candidateConnections = this.graph.getConnectionsForNode( candidateNode.id, candidatePin.id ); let pruneExistingConnections = false; if (startPin.direction === "output") { if (candidateConnections.length > 0) { if (candidatePin.kind === "exec") { pruneExistingConnections = true; } else { return; } } } else if ( candidatePin.kind !== "exec" && candidateConnections.length > 0 ) { return; } const candidateCenter = this.#getPinHandleCenter( candidateNode.id, candidatePin.id, candidatePin.direction ); if (!candidateCenter) { return; } const distance = Math.hypot( candidateCenter.x - startCenter.x, candidateCenter.y - startCenter.y ); if (distance > AUTO_CONNECT_DISTANCE) { return; } if (distance < nodeDistance) { nodeDistance = distance; } compatiblePins.push({ pinId: candidatePin.id, pruneExistingConnections, }); }); if (compatiblePins.length) { nodes.push({ nodeId: candidateNode.id, distance: nodeDistance, pins: compatiblePins, }); } }); /** @type {Array<{ nodeId: string, pinId: string, distance: number, pruneExistingConnections: boolean }> } */ nodes.sort((a, b) => a.distance - b.distance); /** @type {Array<{ nodeId: string, pinId: string, distance: number }> } */ const ordered = []; nodes.forEach((entry) => { entry.pins.forEach((pin) => { ordered.push({ pruneExistingConnections: pin.pruneExistingConnections, nodeId: entry.nodeId, pinId: pin.pinId, distance: entry.distance, }); }); }); return ordered; } /** * Begins a connection gesture from the provided pin. * * @param {PointerEvent} event Pointer event reference. * @param {string} nodeId Node identifier. * @param {string} pinId Pin identifier. * @param {'input'|'output'} direction Pin direction. */ #beginConnection(event, nodeId, pinId, direction) { const node = this.graph.nodes.get(nodeId); if (!node) { return; } const pin = node.getPin(pinId); if (!pin) { return; } event.stopPropagation(); this.pendingConnectionSpawn = null; const path = this.#createConnectionPath(pin.kind); this.pendingConnection = { direction, source: direction === "output" ? { nodeId, pinId } : undefined, target: direction === "input" ? { nodeId, pinId } : undefined, path, }; this.#highlightPinsForPending(nodeId, pinId, direction); const handlePointerMove = (moveEvent) => { this.#updatePendingConnectionPath( moveEvent.clientX, moveEvent.clientY, nodeId, pinId, direction ); }; const handlePointerUp = (upEvent) => { if (upEvent.pointerId !== event.pointerId) { return; } window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); const openedMenu = this.#tryOpenPendingConnectionMenu(upEvent); if (!openedMenu) { this.#cancelPendingConnection(); } }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); } /** * Updates the temporary connection path during a drag gesture. * * @param {number} clientX Pointer X coordinate. * @param {number} clientY Pointer Y coordinate. * @param {string} nodeId Node identifier. * @param {string} pinId Pin identifier. * @param {'input'|'output'} direction Pin direction. */ #updatePendingConnectionPath(clientX, clientY, nodeId, pinId, direction) { if (!this.pendingConnection) { return; } const startHandle = this.#getPinHandle(nodeId, pinId, direction); if (!startHandle) { return; } 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 - offset.x, y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y, }; const pointer = { 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, 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}`; this.pendingConnection.path.dataset.active = "true"; this.pendingConnection.path.setAttribute("d", d); } /** * Finalizes a pending connection if the gesture ends on a compatible pin. * * @param {string} nodeId Node identifier for the target pin. * @param {string} pinId Pin identifier for the target pin. * @param {'input'|'output'} direction Pin direction. */ #finalizeConnection(nodeId, pinId, direction) { if (!this.pendingConnection) { return; } const { source, target } = this.pendingConnection; if (direction === "input" && source) { this.graph.connect(source, { nodeId, pinId }); } else if (direction === "output" && target) { this.graph.connect({ nodeId, pinId }, target); } this.#cancelPendingConnection(); } /** * Cancels the current pending connection gesture. */ #cancelPendingConnection() { if (this.pendingConnection) { this.pendingConnection.path.remove(); this.pendingConnection = null; } this.#clearPinHighlights(); } /** * Opens the context menu using compatible node definitions when a connection drag ends without a target. * * @param {PointerEvent} upEvent Pointer event containing the release coordinates. * @returns {boolean} Whether a menu was shown. */ #tryOpenPendingConnectionMenu(upEvent) { if (!this.pendingConnection) { return false; } const startRef = this.pendingConnection.source ?? this.pendingConnection.target; if (!startRef) { return false; } const startNode = this.graph.nodes.get(startRef.nodeId); if (!startNode) { return false; } const startPin = startNode.getPin(startRef.pinId); if (!startPin) { return false; } const compatibleDefinitions = this.#getCompatibleDefinitionsForPending( startPin, this.pendingConnection.direction ); if (!compatibleDefinitions.length) { return false; } this.pendingConnectionSpawn = { startRef: { nodeId: startRef.nodeId, pinId: startRef.pinId }, direction: this.pendingConnection.direction, kind: startPin.kind, }; const kindLabel = typeof startPin.kind === "string" ? startPin.kind : ""; const friendlyKind = kindLabel ? kindLabel.charAt(0).toUpperCase() + kindLabel.slice(1) : ""; const placeholder = friendlyKind ? `Compatible ${friendlyKind} nodes` : "Search nodes"; this.#cancelPendingConnection(); this.contextMenuManager.show(upEvent.clientX, upEvent.clientY, { definitions: compatibleDefinitions, query: kindLabel, mode: "connection", placeholder, }); return true; } /** * Identifies node definitions that can connect to the active pending connection. * * @param {import('../core/BlueprintNode.js').PinDescriptor} startPin Starting pin descriptor. * @param {'input'|'output'} startDirection Starting pin direction. * @returns {Array} */ #getCompatibleDefinitionsForPending(startPin, startDirection) { const expectedDirection = startDirection === "output" ? "input" : "output"; return this.registry.list().filter((definition) => { const pins = expectedDirection === "input" ? definition.inputs : definition.outputs; return pins.some((candidatePin) => { if (startDirection === "output") { return this.#arePinKindsCompatible(startPin.kind, candidatePin.kind); } return this.#arePinKindsCompatible(candidatePin.kind, startPin.kind); }); }); } /** * Connects a newly spawned node to the pin that initiated the spawn request. * * @param {BlueprintNode} node Newly created node. * @param {{ startRef: { nodeId: string, pinId: string }, direction: 'input'|'output', kind: string }} spawnInfo Connection spawn metadata. */ #connectSpawnedNodeToPending(node, spawnInfo) { if (!spawnInfo) { return; } const { startRef, direction } = spawnInfo; const startNode = this.graph.nodes.get(startRef.nodeId); if (!startNode) { return; } const startPin = startNode.getPin(startRef.pinId); if (!startPin) { return; } const candidatePins = direction === "output" ? node.inputs : node.outputs; const compatiblePin = candidatePins.find((pin) => { if (direction === "output") { return this.#arePinKindsCompatible(startPin.kind, pin.kind); } return this.#arePinKindsCompatible(pin.kind, startPin.kind); }); if (!compatiblePin) { return; } if (direction === "output") { this.graph.connect( { nodeId: startRef.nodeId, pinId: startRef.pinId }, { nodeId: node.id, pinId: compatiblePin.id } ); } else { this.graph.connect( { nodeId: node.id, pinId: compatiblePin.id }, { nodeId: startRef.nodeId, pinId: startRef.pinId } ); } } /** * Creates a connection path element styled for the given kind. * * @param {string} kind Pin kind. * @returns {SVGPathElement} */ #createConnectionPath(kind) { const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.classList.add("connection-path"); path.dataset.type = kind; path.style.stroke = getTypeColor(kind); this.connectionLayer.appendChild(path); return path; } /** * Returns the SVG defs element on the connection layer, creating it if absent. * * @returns {SVGDefsElement} */ #getOrCreateSvgDefs() { let defs = this.connectionLayer.querySelector("defs"); if (!defs) { defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); this.connectionLayer.prepend(defs); } return /** @type {SVGDefsElement} */ (defs); } /** * Creates or updates a linearGradient element for a cross-type wire. * * @param {SVGDefsElement} defs Defs container. * @param {string} connectionId Connection identifier. * @param {string} fromKind Source pin kind. * @param {string} toKind Target pin kind. * @param {{x:number,y:number}} start Gradient start point. * @param {{x:number,y:number}} end Gradient end point. * @returns {string} The gradient element id. */ #upsertConnectionGradient(defs, connectionId, fromKind, toKind, start, end) { const gradId = `cgrad-${connectionId}`; let grad = defs.querySelector(`#${CSS.escape(gradId)}`); if (!grad) { grad = document.createElementNS( "http://www.w3.org/2000/svg", "linearGradient" ); grad.id = gradId; grad.setAttribute("gradientUnits", "userSpaceOnUse"); const stop1 = document.createElementNS( "http://www.w3.org/2000/svg", "stop" ); stop1.setAttribute("offset", "0%"); const stop2 = document.createElementNS( "http://www.w3.org/2000/svg", "stop" ); stop2.setAttribute("offset", "100%"); grad.appendChild(stop1); grad.appendChild(stop2); defs.appendChild(grad); } grad.setAttribute("x1", String(start.x)); grad.setAttribute("y1", String(start.y)); grad.setAttribute("x2", String(end.x)); grad.setAttribute("y2", String(end.y)); const style = getComputedStyle(this.workspaceElement); const stops = grad.querySelectorAll("stop"); stops[0].setAttribute( "stop-color", this.#getPinKindColor(style, fromKind) ); stops[1].setAttribute("stop-color", this.#getPinKindColor(style, toKind)); return gradId; } /** * Resolves the color for a given pin kind. * * @param {CSSStyleDeclaration} _style Computed style (unused, kept for API compatibility). * @param {string} kind Pin kind identifier. * @returns {string} */ #getPinKindColor(_style, kind) { return getTypeColor(kind); } /** * Requests a connection re-render on the next animation frame. */ scheduleConnectionRefresh() { this.#scheduleConnectionRefresh(); } /** * Cancels any pending connection refresh. */ cancelConnectionRefresh() { this.#cancelConnectionRefresh(); } /** * Forces a connection refresh immediately. */ flushConnectionRefresh() { this.#flushConnectionRefresh(); } /** * Requests a connection re-render on the next animation frame. */ #scheduleConnectionRefresh() { if (this.connectionRefreshFrame !== null) { return; } this.connectionRefreshFrame = window.requestAnimationFrame(() => { this.connectionRefreshFrame = null; this.#renderConnections(); }); } /** * Cancels a pending connection refresh request. */ #cancelConnectionRefresh() { if (this.connectionRefreshFrame !== null) { window.cancelAnimationFrame(this.connectionRefreshFrame); this.connectionRefreshFrame = null; } } /** * Executes any pending connection refresh immediately. */ #flushConnectionRefresh() { if (this.connectionRefreshFrame !== null) { window.cancelAnimationFrame(this.connectionRefreshFrame); this.connectionRefreshFrame = null; } this.#renderConnections(); } /** * Re-renders all connection paths to match their origin nodes. */ #renderConnections() { const zoom = this.zoomLevel || 1; const workspaceRect = this.workspaceElement.getBoundingClientRect(); if (this.connectionLayer instanceof SVGElement) { const targetWidth = Math.max(1, workspaceRect.width / zoom); 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"); } if (this.connectionLayer.hasAttribute("preserveAspectRatio")) { this.connectionLayer.removeAttribute("preserveAspectRatio"); } } const defs = this.#getOrCreateSvgDefs(); const activeIds = new Set(); this.graph.getConnections().forEach((connection) => { activeIds.add(connection.id); let path = this.connectionElements.get(connection.id); if (!path) { path = this.#createConnectionPath(connection.kind); this.connectionElements.set(connection.id, path); } path.dataset.connectionId = connection.id; const geometry = this.#computeConnectionGeometry( connection.from, connection.to ); if (!geometry) { return; } const { start, end } = geometry; 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}`; path.dataset.active = "false"; path.setAttribute("d", d); const fromPin = this.graph.nodes .get(connection.from.nodeId) ?.getPin(connection.from.pinId); const toPin = this.graph.nodes .get(connection.to.nodeId) ?.getPin(connection.to.pinId); const fromKind = fromPin?.kind; const toKind = toPin?.kind; if ( fromKind && toKind && fromKind !== toKind && fromKind !== "any" && toKind !== "any" ) { path.dataset.type = "mixed"; const gradId = this.#upsertConnectionGradient( defs, connection.id, fromKind, toKind, start, end ); path.style.stroke = ""; path.setAttribute("stroke", `url(#${gradId})`); } else { path.dataset.type = connection.kind; path.removeAttribute("stroke"); path.style.stroke = getTypeColor(connection.kind); defs.querySelector(`#cgrad-${connection.id}`)?.remove(); } }); this.connectionElements.forEach((path, id) => { if (!activeIds.has(id)) { path.remove(); this.connectionElements.delete(id); defs.querySelector(`#cgrad-${id}`)?.remove(); } }); } /** * Computes the screen-space geometry for a connection between two pins. * * @param {{nodeId:string,pinId:string}} from Origin pin reference. * @param {{nodeId:string,pinId:string}} to Target pin reference. * @returns {{start:{x:number,y:number},end:{x:number,y:number}} | undefined} */ #computeConnectionGeometry(from, to) { const startHandle = this.#getPinHandle(from.nodeId, from.pinId, "output"); const endHandle = this.#getPinHandle(to.nodeId, to.pinId, "input"); if (!startHandle || !endHandle) { return undefined; } 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 - offset.x, y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y, }, end: { x: (endRect.left - workspaceRect.left + endRect.width / 2) / zoom - offset.x, y: (endRect.top - workspaceRect.top + endRect.height / 2) / zoom - offset.y, }, }; } /** * Resolves a pin handle element given node and pin identifiers. * * @param {string} nodeId Node identifier. * @param {string} pinId Pin identifier. * @param {'input'|'output'} direction Pin direction. * @returns {HTMLElement | null} */ #getPinHandle(nodeId, pinId, direction) { const element = this.nodeElements.get(nodeId); if (!element) { return null; } return element.querySelector( `.pin[data-pin-id="${pinId}"][data-direction="${direction}"] .pin-handle` ); } /** * Resolves the workspace-relative center point for a pin handle. * * @param {string} nodeId Node identifier. * @param {string} pinId Pin identifier. * @param {'input'|'output'} direction Pin direction. * @returns {{x:number,y:number} | null} */ #getPinHandleCenter(nodeId, pinId, direction) { const handle = this.#getPinHandle(nodeId, pinId, direction); if (!handle) { return null; } 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 - offset.x, y: (rect.top - workspaceRect.top + rect.height / 2) / zoom - offset.y, }; } /** * Retrieves the pin container element for a given node and pin. * * @param {string} nodeId Node identifier. * @param {string} pinId Pin identifier. * @param {'input'|'output'} direction Pin direction. * @returns {HTMLElement | null} */ #getPinElement(nodeId, pinId, direction) { const element = this.nodeElements.get(nodeId); if (!element) { return null; } return element.querySelector( `.pin[data-pin-id="${pinId}"][data-direction="${direction}"]` ); } /** * Highlights valid drop targets for the active pending connection. * * @param {string} nodeId Starting node identifier. * @param {string} pinId Starting pin identifier. * @param {'input'|'output'} direction Pin direction used to initiate the drag. */ #highlightPinsForPending(nodeId, pinId, direction) { this.#clearPinHighlights(); const startNode = this.graph.nodes.get(nodeId); if (!startNode) { return; } const startPin = startNode.getPin(pinId); if (!startPin) { return; } const startElement = this.#getPinElement(nodeId, pinId, direction); if (startElement) { startElement.classList.add("is-drag-source"); this.highlightedPins.add(startElement); } const expectedDirection = direction === "output" ? "input" : "output"; this.graph.nodes.forEach((candidateNode) => { if (candidateNode.id === nodeId) { return; } const pins = expectedDirection === "input" ? candidateNode.inputs : candidateNode.outputs; pins.forEach((candidatePin) => { if ( this.#canPinsConnect( startNode, startPin, direction, candidateNode, candidatePin ) ) { const element = this.#getPinElement( candidateNode.id, candidatePin.id, candidatePin.direction ); if (element) { element.classList.add("is-drop-target"); this.highlightedPins.add(element); } } }); }); } /** * Determines whether a pin should display hover styling as a drop target. * * @param {string} nodeId Node identifier. * @param {string} pinId Pin identifier. * @param {'input'|'output'} direction Pin direction. * @returns {boolean} */ #shouldHighlightHover(nodeId, pinId, direction) { return this.#isPinCompatibleWithPending(nodeId, pinId, direction); } /** * Checks if a pin is compatible with the current pending connection. * * @param {string} nodeId Candidate node identifier. * @param {string} pinId Candidate pin identifier. * @param {'input'|'output'} direction Candidate pin direction. * @returns {boolean} */ #isPinCompatibleWithPending(nodeId, pinId, direction) { if (!this.pendingConnection) { return false; } const startRef = this.pendingConnection.source ?? this.pendingConnection.target; if (!startRef) { return false; } if (startRef.nodeId === nodeId && startRef.pinId === pinId) { return false; } const startNode = this.graph.nodes.get(startRef.nodeId); const candidateNode = this.graph.nodes.get(nodeId); if (!startNode || !candidateNode) { return false; } const startPin = startNode.getPin(startRef.pinId); const candidatePin = candidateNode.getPin(pinId); if (!startPin || !candidatePin) { return false; } return this.#canPinsConnect( startNode, startPin, this.pendingConnection.direction, candidateNode, candidatePin ); } /** * Removes highlight classes from every tracked pin element. */ #clearPinHighlights() { this.highlightedPins.forEach((element) => { element.classList.remove( "is-drag-source", "is-drop-target", "is-drop-hover" ); }); this.highlightedPins.clear(); } /** * Determines if two pins can connect based on direction and kind. * * @param {BlueprintNode} startNode Starting node. * @param {import('../core/BlueprintNode.js').PinDescriptor} startPin Starting pin descriptor. * @param {'input'|'output'} startDirection Direction of the starting pin. * @param {BlueprintNode} candidateNode Candidate node. * @param {import('../core/BlueprintNode.js').PinDescriptor} candidatePin Candidate pin descriptor. * @returns {boolean} */ #canPinsConnect( startNode, startPin, startDirection, candidateNode, candidatePin ) { if (startNode.id === candidateNode.id) { return false; } const expectedDirection = startDirection === "output" ? "input" : "output"; if (candidatePin.direction !== expectedDirection) { return false; } let outputKind; let inputKind; if (startDirection === "output") { outputKind = startPin.kind; inputKind = candidatePin.kind; } else { outputKind = candidatePin.kind; inputKind = startPin.kind; } return this.#arePinKindsCompatible(outputKind, inputKind); } /** * Determines if the output and input kinds are compatible under blueprint rules. * * @param {string} outputKind Output pin kind. * @param {string} inputKind Input pin kind. * @returns {boolean} */ #arePinKindsCompatible(outputKind, inputKind) { return areTypesCompatible(outputKind, inputKind); } /** * Renders inspector controls for the selected node. * * @param {string} nodeId Node identifier. */ #renderInspector(nodeId) { const node = this.graph.nodes.get(nodeId); if (!node) { this.inspectorContent.innerHTML = ""; return; } const definition = this.registry.get(node.type); if (!definition) { return; } this.inspectorContent.innerHTML = ""; if (node.type === "custom_event") { this.#renderCustomEventInspector(node); return; } if (node.type === "sequence") { this.#renderSequenceInspector(node); return; } if (node.type === "call_custom_event") { this.#renderCallCustomEventInspector(node); return; } if (!definition.properties.length) { const empty = document.createElement("p"); empty.textContent = "No editable properties."; empty.className = "inspector-empty"; this.inspectorContent.appendChild(empty); return; } definition.properties.forEach((schema) => { const field = document.createElement("div"); field.className = "inspector-field"; const label = document.createElement("label"); label.textContent = schema.label; label.setAttribute("for", `${nodeId}_${schema.key}`); field.appendChild(label); let control; switch (schema.type) { case "string": control = document.createElement("input"); control.type = "text"; control.value = String( node.properties[schema.key] ?? schema.defaultValue ?? "" ); if (schema.placeholder) { control.placeholder = schema.placeholder; } break; case "number": control = document.createElement("input"); control.type = "number"; control.value = String( node.properties[schema.key] ?? schema.defaultValue ?? 0 ); break; case "boolean": control = document.createElement("input"); control.type = "checkbox"; control.checked = Boolean(node.properties[schema.key]); break; case "enum": control = document.createElement("select"); (schema.options ?? []).forEach((option) => { const opt = document.createElement("option"); opt.value = option.value; opt.textContent = option.label; control.appendChild(opt); }); control.value = String( node.properties[schema.key] ?? schema.defaultValue ?? "" ); break; case "multiline": control = document.createElement("textarea"); control.rows = 3; control.value = String( node.properties[schema.key] ?? schema.defaultValue ?? "" ); break; default: control = document.createElement("input"); control.type = "text"; control.value = String(node.properties[schema.key] ?? ""); break; } control.id = `${nodeId}_${schema.key}`; field.appendChild(control); this.inspectorContent.appendChild(field); control.addEventListener("change", () => { let value = control.value; if (schema.type === "number") { value = Number(control.value); } else if (schema.type === "boolean") { value = control.checked; } this.graph.setNodeProperty(nodeId, schema.key, value); definition.onPropertiesChanged?.(node.properties); if (schema.type === "enum") { window.requestAnimationFrame(() => { if (document.activeElement === control && typeof control.blur === "function") { control.blur(); } this.#focusWorkspaceSurface({ force: true }); }); } }); if (schema.type === "enum") { control.addEventListener("blur", () => { this.#focusWorkspaceSurface(); }); } const shouldFocus = this.pendingInspectorFocus && this.pendingInspectorFocus.nodeId === nodeId && this.pendingInspectorFocus.property === schema.key; if (shouldFocus && typeof control.focus === "function") { requestAnimationFrame(() => { control.focus(); if ("select" in control && typeof control.select === "function") { control.select(); } }); this.pendingInspectorFocus = null; } }); } /** * Renders inspector controls for sequence nodes. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Sequence node reference. */ #renderSequenceInspector(node) { this.inspectorContent.innerHTML = ""; const { branches } = this.#ensureSequenceBranches(node); const header = document.createElement("div"); header.className = "sequence-branch-header"; const title = document.createElement("div"); title.className = "inspector-section-title"; title.textContent = "Execution Outputs"; header.appendChild(title); const addButton = document.createElement("button"); addButton.type = "button"; addButton.className = "sequence-branch-add"; addButton.textContent = "Add Output"; addButton.addEventListener("click", () => { this.#addSequenceBranch(node.id); }); header.appendChild(addButton); this.inspectorContent.appendChild(header); const list = document.createElement("div"); list.className = "sequence-branch-list"; if (!branches.length) { const empty = document.createElement("p"); empty.className = "inspector-empty"; empty.textContent = "No outputs configured."; list.appendChild(empty); } else { branches.forEach((branch, index) => { const row = document.createElement("div"); row.className = "sequence-branch-row"; row.dataset.branchId = branch.id; const label = document.createElement("span"); label.className = "sequence-branch-label"; label.textContent = this.#formatSequenceBranchLabel(index); row.appendChild(label); const removeButton = document.createElement("button"); removeButton.type = "button"; removeButton.className = "overview-item-action overview-item-action--remove sequence-branch-remove"; removeButton.title = `Delete output ${label.textContent}`; removeButton.setAttribute( "aria-label", `Delete output ${label.textContent}` ); removeButton.disabled = branches.length <= 1; removeButton.addEventListener("click", () => { this.#removeSequenceBranch(node.id, branch.id); }); removeButton.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); row.appendChild(removeButton); list.appendChild(row); }); } this.inspectorContent.appendChild(list); } /** * Renders inspector controls for a custom event node. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Node instance to inspect. */ #renderCustomEventInspector(node) { this.inspectorContent.innerHTML = ""; const pendingFocus = this.pendingInspectorFocus && this.pendingInspectorFocus.nodeId === node.id ? this.pendingInspectorFocus.property : null; const nameField = document.createElement("div"); nameField.className = "inspector-field"; const nameLabel = document.createElement("label"); nameLabel.setAttribute("for", `${node.id}_eventName`); nameLabel.textContent = "Event Name"; nameField.appendChild(nameLabel); const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.id = `${node.id}_eventName`; nameInput.placeholder = "Enter event name"; nameInput.value = typeof node.properties.name === "string" ? node.properties.name : ""; nameInput.addEventListener("change", () => { this.graph.setNodeProperty(node.id, "name", nameInput.value); }); nameField.appendChild(nameInput); this.inspectorContent.appendChild(nameField); if (pendingFocus === "name" && typeof nameInput.focus === "function") { requestAnimationFrame(() => { nameInput.focus(); if (typeof nameInput.select === "function") { nameInput.select(); } }); this.pendingInspectorFocus = null; } const header = document.createElement("div"); header.className = "custom-event-parameter-header"; const title = document.createElement("div"); title.className = "inspector-section-title"; title.textContent = "Parameters"; header.appendChild(title); const addButton = document.createElement("button"); addButton.type = "button"; addButton.className = "custom-event-parameter-add"; addButton.textContent = "Add Parameter"; addButton.addEventListener("click", () => { const created = this.#addCustomEventParameter(node.id); if (created) { this.pendingInspectorFocus = { nodeId: node.id, property: `parameter:${created.id}`, }; } }); header.appendChild(addButton); this.inspectorContent.appendChild(header); const list = document.createElement("div"); list.className = "custom-event-parameter-list"; const { parameters } = this.#sanitizeCustomEventParameters(node); if (!parameters.length) { const empty = document.createElement("p"); empty.className = "inspector-empty"; empty.textContent = "No parameters defined."; list.appendChild(empty); } parameters.forEach((parameter) => { const row = document.createElement("div"); row.className = "custom-event-parameter-row"; row.dataset.parameterId = parameter.id; row.dataset.parameterType = parameter.type; row.style.setProperty("--overview-variable-color", getTypeColor(parameter.type)); const typeButton = document.createElement("button"); typeButton.type = "button"; typeButton.className = "variable-type-button custom-event-parameter-type-button"; typeButton.dataset.variableType = parameter.type; typeButton.style.setProperty("--overview-variable-color", getTypeColor(parameter.type)); typeButton.setAttribute("aria-haspopup", "menu"); typeButton.setAttribute("aria-expanded", "false"); row.appendChild(typeButton); const nameInputElement = document.createElement("input"); nameInputElement.type = "text"; nameInputElement.value = parameter.name; nameInputElement.placeholder = "Parameter name"; row.appendChild(nameInputElement); const refreshTypeLabel = (rawType) => { const normalized = this.#normalizeVariableType(rawType); const display = this.#formatVariableType(normalized); const referenceName = nameInputElement.value.trim() || parameter.id; row.dataset.parameterType = normalized; row.style.setProperty("--overview-variable-color", getTypeColor(normalized)); typeButton.dataset.variableType = normalized; typeButton.style.setProperty("--overview-variable-color", getTypeColor(normalized)); typeButton.title = `Type: ${display}`; typeButton.setAttribute( "aria-label", `Change type for ${referenceName} (currently ${display})` ); }; refreshTypeLabel(parameter.type); const dropdownContext = { kind: "custom_event_parameter", nodeId: node.id, parameterId: parameter.id, }; const openDropdown = () => { this.#openCustomEventParameterTypeDropdown( node.id, parameter.id, typeButton, row, (nextType) => { refreshTypeLabel(nextType); } ); }; typeButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); const isOpen = this.#isTypeDropdownOpen(dropdownContext); if (isOpen) { this.#closeTypeDropdown(); } else { openDropdown(); } }); typeButton.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); typeButton.addEventListener("keydown", (event) => { const isToggleKey = event.key === "Enter" || event.key === " " || event.key === "Spacebar"; if (isToggleKey || event.key === "ArrowDown") { event.preventDefault(); openDropdown(); } else if (event.key === "Escape") { event.preventDefault(); this.#closeTypeDropdown(); } }); const optionalLabel = document.createElement("label"); optionalLabel.className = "custom-event-parameter-optional"; optionalLabel.title = "Optional"; optionalLabel.setAttribute( "aria-label", `Toggle optional state for ${parameter.name || parameter.id}` ); const optionalCheckbox = document.createElement("input"); optionalCheckbox.type = "checkbox"; optionalCheckbox.checked = Boolean(parameter.optional); optionalCheckbox.setAttribute( "aria-label", `Optional parameter ${parameter.name || parameter.id}` ); optionalCheckbox.title = "Optional"; optionalCheckbox.addEventListener("change", () => { this.#setCustomEventParameterOptional( node.id, parameter.id, optionalCheckbox.checked ); }); optionalLabel.appendChild(optionalCheckbox); row.appendChild(optionalLabel); const removeButton = document.createElement("button"); removeButton.type = "button"; removeButton.className = "overview-item-action overview-item-action--remove custom-event-parameter-action custom-event-parameter-action--remove"; removeButton.setAttribute( "aria-label", `Delete parameter ${parameter.name || parameter.id}` ); removeButton.title = `Delete ${parameter.name || parameter.id}`; removeButton.addEventListener("click", () => { this.#closeTypeDropdown(); this.#removeCustomEventParameter(node.id, parameter.id); }); removeButton.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); row.appendChild(removeButton); nameInputElement.addEventListener("change", () => { this.#renameCustomEventParameter( node.id, parameter.id, nameInputElement.value ); refreshTypeLabel(row.dataset.parameterType ?? parameter.type); const trimmed = nameInputElement.value.trim(); const labelText = trimmed || parameter.id; removeButton.setAttribute( "aria-label", `Delete parameter ${labelText}` ); removeButton.title = `Delete ${labelText}`; optionalLabel.setAttribute( "aria-label", `Toggle optional state for ${labelText}` ); optionalCheckbox.setAttribute( "aria-label", `Optional parameter ${labelText}` ); }); if ( pendingFocus === `parameter:${parameter.id}` && typeof nameInputElement.focus === "function" ) { requestAnimationFrame(() => { nameInputElement.focus(); if (typeof nameInputElement.select === "function") { nameInputElement.select(); } }); this.pendingInspectorFocus = null; } list.appendChild(row); }); this.inspectorContent.appendChild(list); } /** * Renders inspector controls for a call custom event node. * * @param {import('../core/BlueprintNode.js').BlueprintNode} node Node instance to inspect. */ #renderCallCustomEventInspector(node) { const field = document.createElement("div"); field.className = "inspector-field"; const controlId = `${node.id}_eventId`; const label = document.createElement("label"); label.setAttribute("for", controlId); label.textContent = "Target Event"; field.appendChild(label); const select = document.createElement("select"); select.id = controlId; const events = this.#getCustomEventNodes() .map((eventNode) => ({ id: eventNode.id, label: this.#resolveCustomEventLabel(eventNode), })) .sort((a, b) => a.label.localeCompare(b.label)); const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.textContent = events.length ? "Select event" : "No custom events available"; select.appendChild(placeholder); events.forEach((entry) => { const option = document.createElement("option"); option.value = entry.id; option.textContent = entry.label; select.appendChild(option); }); const storedEventId = typeof node.properties.eventId === "string" ? node.properties.eventId : ""; if (storedEventId && events.some((entry) => entry.id === storedEventId)) { select.value = storedEventId; } else { select.value = ""; } select.disabled = events.length === 0; select.addEventListener("change", () => { const next = select.value; const current = typeof node.properties.eventId === "string" ? node.properties.eventId : ""; if (next === current) { return; } this.graph.setNodeProperty(node.id, "eventId", next); }); field.appendChild(select); this.inspectorContent.appendChild(field); if (!events.length) { const helper = document.createElement("p"); helper.className = "inspector-empty"; helper.textContent = "Add a Custom Event node to call it here."; this.inspectorContent.appendChild(helper); } const shouldFocus = this.pendingInspectorFocus && this.pendingInspectorFocus.nodeId === node.id && this.pendingInspectorFocus.property === "eventId"; if (shouldFocus) { requestAnimationFrame(() => { select.focus(); }); this.pendingInspectorFocus = null; } } /** * Regenerates and caches the Lua output for the current graph state. * * @returns {string} */ #refreshLuaOutput() { try { this.luaOutput = this.generator.generate(this.graph, { variables: this.#getGeneratorVariableMetadata(), }); } catch (error) { console.error("Failed to generate Lua", error); this.luaOutput = "-- generation error"; } return this.luaOutput; } /** * Creates a serializable snapshot of the current workspace. * * @returns {SerializedWorkspace} */ serializeWorkspace() { return this.persistenceManager.serializeWorkspace(); } /** * Applies a persisted workspace payload to the live workspace. * * @param {SerializedWorkspace} payload Persisted workspace data. */ 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(); this.nodeElements.forEach((element) => element.remove()); this.connectionElements.forEach((path) => path.remove()); this.nodeElements.clear(); this.connectionElements.clear(); this.connectionLayer.querySelector("defs")?.remove(); this.inlineEditorManager.clear(); this.nodeDragRotation.clear(); this.dragManager.stopNodeRotationDecay(); this.marqueeManager?.clear(); this.#cancelConnectionRefresh(); this.isDraggingNodes = false; this.highlightedPins.clear(); this.pendingConnection = null; this.pendingConnectionSpawn = null; this.pendingVariableSpawn = null; this.selectedNodeIds.clear(); this.selectedNodeId = undefined; this.selectedVariableId = null; this.activeVariableRename = null; this.variables.clear(); this.variableOrder = []; this.variableCounter = 0; const variablesPayload = payload && typeof payload.variables === "object" ? payload.variables : null; if (variablesPayload && Array.isArray(variablesPayload.entries)) { variablesPayload.entries.forEach((entry) => { if (!entry || typeof entry !== "object") { return; } const id = typeof entry.id === "string" ? entry.id : ""; if (!id) { return; } const name = typeof entry.name === "string" && entry.name.trim() ? entry.name : id; const type = entry.type === "number" || entry.type === "string" || entry.type === "boolean" || entry.type === "any" || entry.type === "table" || entry.type === "color" ? entry.type : "any"; const defaultValue = this.#normalizeVariableDefault( type, entry.defaultValue ); this.variables.set(id, { id, name, type, defaultValue }); this.variableOrder.push(id); }); const storedCounter = typeof variablesPayload.counter === "number" && Number.isFinite(variablesPayload.counter) ? variablesPayload.counter : this.variableOrder.length; this.variableCounter = Math.max( storedCounter, this.variableOrder.length ); } const graphPayload = payload && typeof payload.graph === "object" ? payload.graph : { nodes: [], connections: [] }; this.graph = NodeGraph.fromJSON(graphPayload); this.#bindGraphEvents(); this.spawnIndex = typeof payload?.spawnIndex === "number" && Number.isFinite(payload.spawnIndex) ? payload.spawnIndex : this.graph.nodes.size; this.#rebuildNodeCounters(); this.graph.nodes.forEach((node) => { this.nodeRenderer.renderNode(node); if (node.type === "sequence") { this.#refreshSequenceNode(node.id); } else if (node.type === "custom_event") { this.#refreshCustomEventNode(node.id); } else if (node.type === "call_custom_event") { this.#refreshCustomEventCallNode(node.id); } }); this.#renderConnections(); const settings = payload && typeof payload.projectSettings === "object" ? payload.projectSettings : null; if (settings) { this.projectSettings = { use60Fps: Boolean(settings.use60Fps), }; } if (typeof this.generator.setProjectSettings === "function") { this.generator.setProjectSettings({ ...this.projectSettings }); } const uiSettings = payload && typeof payload.ui === "object" ? payload.ui : null; const paletteVisible = typeof uiSettings?.paletteVisible === "boolean" ? uiSettings.paletteVisible : true; this.isPaletteVisible = paletteVisible; this.#applyPaletteVisibility(this.isPaletteVisible); this.#updateProjectSettingsToggle(); this.#renderOverview(); this.#setInspectorView("default"); this.#applySelectionState(); this.nodeRenderer.refreshAllPinConnections(); this.#refreshLuaOutput(); } finally { this.isRestoring = false; this.#startPeriodicUpdate(); } } /** * Restores camera/view-related state from a history snapshot. * * @param {{ backgroundOffset: { x: number, y: number }, zoom: number }} view Snapshot view payload. */ applyHistoryView(view) { if (!view) { return; } const normalizedZoom = Number.isFinite(view.zoom) ? view.zoom : this.zoomLevel; const normalizedOffset = { x: Number.isFinite(view.backgroundOffset?.x) ? view.backgroundOffset.x : this.workspaceBackgroundOffset?.x ?? 0, y: Number.isFinite(view.backgroundOffset?.y) ? view.backgroundOffset.y : this.workspaceBackgroundOffset?.y ?? 0, }; if (this.navigationManager) { this.navigationManager.setZoomLevel(normalizedZoom, { silent: false }); this.navigationManager.setBackgroundOffset(normalizedOffset); this.navigationManager.updateZoomDisplay(); } else { this.zoomLevel = normalizedZoom; this.workspaceBackgroundOffset = normalizedOffset; } } /** * Restores selection state from a history snapshot. * * @param {{ nodes: Array, primary: string | null, variable: string | null }} selection Snapshot selection payload. */ applyHistorySelection(selection) { if (!selection) { return; } const nodes = Array.isArray(selection.nodes) ? selection.nodes : []; const primary = typeof selection.primary === "string" ? selection.primary : undefined; const variable = typeof selection.variable === "string" ? selection.variable : null; if (variable) { const exists = this.variables.has(variable); this.selectedVariableId = exists ? variable : null; } else { this.selectedVariableId = null; } this.setSelectionState(nodes, primary); } /** * Rebuilds node identifier counters from the current graph state. */ #rebuildNodeCounters() { const counters = new Map(); this.graph.nodes.forEach((node) => { const match = /_(\d+)$/.exec(node.id); const numeric = match ? Number.parseInt(match[1], 10) : 0; const current = counters.get(node.type) ?? 0; counters.set(node.type, Math.max(current, numeric)); }); this.graph.nodeCounters = counters; } }