Files
picoGraph/scripts/ui/BlueprintWorkspace.js
Max Litruv Boonzaayer f917cf6ad5 Add initial Pico-8 cartridges for testing
- Created test_cart.p8 with basic drawing functionality and a print statement.
- Created test_unix.p8 with a simple initialization function.
2026-03-21 18:38:43 +11:00

6938 lines
211 KiB
JavaScript

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<TableDefaultEntry>} 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<WorkspaceShortcutModifier> }} NormalizedSpawnShortcut
*/
/**
* @typedef {{ key: string, command: string, modifiers: Set<WorkspaceShortcutModifier> }} 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<string>} */
this.activeShortcutKeys = new Set();
/** @type {Map<'background', Array<NormalizedSpawnShortcut>>} */
this.spawnShortcutLookup = this.#normalizeSpawnShortcuts(
WORKSPACE_SHORTCUTS
);
/** @type {Array<NormalizedCommandShortcut>} */
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<string, number>} */
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<string, WorkspaceVariable>} */
this.variables = new Map();
/** @type {Array<string>} */
this.variableOrder = [];
this.variableCounter = 0;
/** @type {Map<string, HTMLElement>} */
this.nodeElements = new Map();
/** @type {Map<string, SVGPathElement>} */
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<HTMLElement>} */
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<string>} */
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<any>) => 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<string>} */
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<string>} 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<unknown> }} */ (value).entries)
) {
const payload = /** @type {{ entries: Array<unknown> }} */ (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<HTMLButtonElement>} */
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<string>} 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<WorkspaceSpawnShortcut>} shortcuts Raw workspace spawn shortcut configuration entries.
* @returns {Map<'background', Array<NormalizedSpawnShortcut>>}
*/
#normalizeSpawnShortcuts(shortcuts) {
/** @type {Map<'background', Array<NormalizedSpawnShortcut>>} */
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<WorkspaceShortcutModifier>} */
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<WorkspaceShortcutModifier>} 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<WorkspaceShortcut>} shortcuts Raw shortcut configuration entries.
* @returns {Array<NormalizedCommandShortcut>}
*/
#normalizeCommandShortcuts(shortcuts) {
/** @type {Array<NormalizedCommandShortcut>} */
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<WorkspaceShortcutModifier>} */
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<WorkspaceShortcutModifier>} 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<WorkspaceShortcutModifier>} */
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<import('../core/BlueprintNode.js').BlueprintNode>}
*/
#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<CustomEventParameter>, mutated: boolean }}
*/
#sanitizeCustomEventParameters(node) {
const existing = Array.isArray(node.properties.parameters)
? /** @type {Array<CustomEventParameter>} */ (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<CustomEventParameter>} */ (
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<CustomEventParameter>} 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<CustomEventParameter>} 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<CustomEventParameter>} parameters Parameter metadata.
*/
#syncCallCustomEventArguments(node, parameters) {
const current = node.properties.arguments;
const record =
current && typeof current === "object" && !Array.isArray(current)
? /** @type {Record<string, unknown>} */ (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<CustomEventParameter>, 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<CustomEventParameter>} 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<CustomEventParameter> }} [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<string>} 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<NodeDefinition>}
*/
#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<string>, 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;
}
}