mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 02:36:04 +10:00
6684 lines
203 KiB
JavaScript
6684 lines
203 KiB
JavaScript
import { NodeGraph } from "../core/NodeGraph.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";
|
|
|
|
/**
|
|
* @typedef {'any'|'number'|'string'|'boolean'|'table'|'color'} VariableType
|
|
*/
|
|
|
|
/** @type {Array<{ value: VariableType, label: string }>} */
|
|
const VARIABLE_TYPE_DEFINITIONS = [
|
|
{ value: "any", label: "Any" },
|
|
{ value: "number", label: "Number" },
|
|
{ value: "string", label: "String" },
|
|
{ value: "boolean", label: "Boolean" },
|
|
{ value: "table", label: "Table" },
|
|
{ value: "color", label: "Color" },
|
|
];
|
|
|
|
const SUPPORTED_VARIABLE_TYPES = new Set(
|
|
VARIABLE_TYPE_DEFINITIONS.map((entry) => entry.value)
|
|
);
|
|
|
|
/** @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} 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.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;
|
|
|
|
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 });
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* 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.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) {
|
|
switch (type) {
|
|
case "number":
|
|
return 0;
|
|
case "string":
|
|
return "";
|
|
case "table":
|
|
return [];
|
|
case "boolean":
|
|
return false;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 "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 === "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.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;
|
|
}
|
|
|
|
const rect = workspaceElement.getBoundingClientRect();
|
|
const screenPoint = {
|
|
x: Math.max(0, Math.min(rect.width, event.clientX - rect.left)),
|
|
y: Math.max(0, Math.min(rect.height, event.clientY - rect.top)),
|
|
};
|
|
const worldPoint = navigator.screenPointToWorld(screenPoint);
|
|
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 = VARIABLE_TYPE_DEFINITIONS.find(
|
|
(definition) => definition.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) {
|
|
if (typeof value === "string") {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (
|
|
SUPPORTED_VARIABLE_TYPES.has(
|
|
/** @type {VariableType} */ (normalized)
|
|
)
|
|
) {
|
|
return /** @type {VariableType} */ (normalized);
|
|
}
|
|
}
|
|
return "any";
|
|
}
|
|
|
|
/**
|
|
* Returns the available variable type dropdown entries.
|
|
*
|
|
* @returns {Array<{ value: VariableType, label: string }>}
|
|
*/
|
|
#getVariableTypeOptions() {
|
|
return VARIABLE_TYPE_DEFINITIONS.map((entry) => ({ ...entry }));
|
|
}
|
|
|
|
/**
|
|
* 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.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.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 Workspace-relative spawn coordinates.
|
|
* @returns {import('../core/BlueprintNode.js').BlueprintNode | null}
|
|
*/
|
|
spawnNodeFromContextMenu(definition, spawnPosition) {
|
|
const clamped = {
|
|
x: Math.max(0, spawnPosition.x),
|
|
y: Math.max(0, spawnPosition.y),
|
|
};
|
|
const worldSpawn = this.navigationManager.screenPointToWorld(clamped);
|
|
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;
|
|
this.connectionLayer.appendChild(path);
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* 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 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.type = connection.kind;
|
|
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);
|
|
});
|
|
|
|
this.connectionElements.forEach((path, id) => {
|
|
if (!activeIds.has(id)) {
|
|
path.remove();
|
|
this.connectionElements.delete(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
if (inputKind === "any" || outputKind === "any") {
|
|
return true;
|
|
}
|
|
return 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;
|
|
|
|
const typeButton = document.createElement("button");
|
|
typeButton.type = "button";
|
|
typeButton.className =
|
|
"variable-type-button custom-event-parameter-type-button";
|
|
typeButton.dataset.variableType = 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;
|
|
typeButton.dataset.variableType = 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.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
|
|
: "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;
|
|
}
|
|
}
|