Initial commit

moved from private version control
This commit is contained in:
2025-10-19 21:12:41 +11:00
parent 5e84773bbe
commit e64f3e881e
187 changed files with 25372 additions and 0 deletions

View File

@@ -0,0 +1,289 @@
/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
* @typedef {import('./WorkspaceHistoryManager.js').WorkspaceHistoryManager} WorkspaceHistoryManager
* @typedef {ReturnType<import('../../core/NodeGraph.js').NodeGraph['toJSON']>} GraphSnapshot
*/
const CLIPBOARD_OFFSET_STEP = 32;
/**
* Manages copy and paste operations for selected nodes.
*/
export class WorkspaceClipboardManager {
#workspace;
#history;
/** @type {{ nodes: Array<GraphSnapshot['nodes'][number]>, connections: Array<GraphSnapshot['connections'][number]>, pasteCount: number, bounds: { minX: number, maxX: number, minY: number, maxY: number } | null } | null} */
#payload;
/** @type {{ x: number, y: number } | null} */
#lastPointerPosition;
/**
* @param {BlueprintWorkspace} workspace Owning workspace instance.
* @param {WorkspaceHistoryManager} history History manager for undo integration.
*/
constructor(workspace, history) {
this.#workspace = workspace;
this.#history = history;
this.#payload = null;
/** @type {{ x: number, y: number } | null} */
this.#lastPointerPosition = null;
}
/**
* Clears the clipboard payload.
*/
reset() {
this.#payload = null;
this.#lastPointerPosition = null;
}
/**
* Copies the current node selection into the clipboard payload.
*
* @returns {boolean} Whether a payload was captured.
*/
copySelection() {
if (!this.#workspace.selectedNodeIds.size) {
return false;
}
/** @type {Array<GraphSnapshot['nodes'][number]>} */
const nodes = [];
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
let hasBounds = false;
this.#workspace.selectedNodeIds.forEach((id) => {
const node = this.#workspace.graph.nodes.get(id);
if (!node) {
return;
}
nodes.push(node.toJSON());
const pos = node.position;
if (!Number.isFinite(pos.x) || !Number.isFinite(pos.y)) {
return;
}
const element = this.#workspace.nodeElements.get(id) ?? null;
const size = this.#measureNodeSize(element);
hasBounds = true;
minX = Math.min(minX, pos.x);
minY = Math.min(minY, pos.y);
maxX = Math.max(maxX, pos.x + size.width);
maxY = Math.max(maxY, pos.y + size.height);
});
if (!nodes.length) {
return false;
}
const selectedIds = new Set(nodes.map((node) => node.id));
const connections = this.#workspace.graph
.getConnections()
.filter(
(connection) =>
selectedIds.has(connection.from.nodeId) &&
selectedIds.has(connection.to.nodeId)
)
.map((connection) => connection.toJSON());
const bounds = hasBounds ? { minX, maxX, minY, maxY } : null;
this.#payload = { nodes, connections, pasteCount: 0, bounds };
return true;
}
/**
* Captures the latest pointer position within the workspace.
*
* @param {{ x: number, y: number } | null} position Workspace-space pointer coordinates.
*/
updatePointerPosition(position) {
if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) {
this.#lastPointerPosition = null;
return;
}
this.#lastPointerPosition = { x: position.x, y: position.y };
}
/**
* Clears any stored pointer position hints.
*/
clearPointerPosition() {
this.#lastPointerPosition = null;
}
/**
* Cuts the current node selection: copies it to the clipboard and removes the originals.
*
* @returns {boolean} Whether any nodes were cut.
*/
cutSelection() {
const copied = this.copySelection();
if (!copied) {
return false;
}
const targets = Array.from(this.#workspace.selectedNodeIds);
if (!targets.length) {
return false;
}
this.#history.withSuspended(
() => {
targets.forEach((id) => this.#workspace.removeNode(id));
},
{ suppressAutoCommit: true }
);
this.#history.commitCheckpoint("cut");
return true;
}
/**
* Pastes the clipboard payload, centering at the pointer when available.
*/
paste() {
if (!this.#payload || !this.#payload.nodes.length) {
return;
}
const clipboard = this.#payload;
const bounds = clipboard.bounds ?? this.#calculatePayloadBounds(clipboard.nodes);
const anchor = this.#lastPointerPosition;
let delta;
if (anchor && bounds) {
const center = {
x: (bounds.minX + bounds.maxX) / 2,
y: (bounds.minY + bounds.maxY) / 2,
};
delta = {
x: anchor.x - center.x,
y: anchor.y - center.y,
};
} else {
const offsetMultiplier = CLIPBOARD_OFFSET_STEP * (clipboard.pasteCount + 1);
delta = { x: offsetMultiplier, y: offsetMultiplier };
}
/** @type {Array<string>} */
const newNodeIds = [];
const idMap = new Map();
this.#history.withSuspended(
() => {
clipboard.nodes.forEach((nodeData) => {
const definition = this.#workspace.registry.get(nodeData.type);
if (!definition) {
return;
}
const newId = this.#workspace.graph.createNodeId(nodeData.type);
const position = {
x: nodeData.position.x + delta.x,
y: nodeData.position.y + delta.y,
};
const node = this.#workspace.registry.createNode(nodeData.type, {
id: newId,
position,
});
node.title = nodeData.title;
node.properties = { ...nodeData.properties };
node.inputs = nodeData.inputs.map((pin) => ({ ...pin }));
node.outputs = nodeData.outputs.map((pin) => ({ ...pin }));
this.#workspace.graph.addNode(node);
this.#workspace.renderNode(node);
idMap.set(nodeData.id, newId);
newNodeIds.push(newId);
});
clipboard.connections.forEach((connection) => {
const fromId = idMap.get(connection.from.nodeId);
const toId = idMap.get(connection.to.nodeId);
if (!fromId || !toId) {
return;
}
this.#workspace.graph.connect(
{ nodeId: fromId, pinId: connection.from.pinId },
{ nodeId: toId, pinId: connection.to.pinId }
);
});
},
{ suppressAutoCommit: true }
);
if (newNodeIds.length) {
clipboard.pasteCount += 1;
this.#workspace.setSelectionState(newNodeIds, newNodeIds[0]);
this.#history.commitCheckpoint("paste");
}
}
/**
* Computes the bounding extents of nodes stored in the clipboard payload.
*
* @param {Array<GraphSnapshot['nodes'][number]>} nodes Clipboard node entries.
* @returns {{ minX: number, maxX: number, minY: number, maxY: number } | null}
*/
#calculatePayloadBounds(nodes) {
if (!nodes.length) {
return null;
}
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
let hasValid = false;
nodes.forEach((node) => {
const pos = node?.position;
if (!pos || !Number.isFinite(pos.x) || !Number.isFinite(pos.y)) {
return;
}
hasValid = true;
minX = Math.min(minX, pos.x);
minY = Math.min(minY, pos.y);
maxX = Math.max(maxX, pos.x);
maxY = Math.max(maxY, pos.y);
});
if (!hasValid) {
return null;
}
return { minX, maxX, minY, maxY };
}
/**
* Measures a node element's size in workspace units.
*
* @param {HTMLElement | null} element Node DOM element reference.
* @returns {{ width: number, height: number }}
*/
#measureNodeSize(element) {
if (!element) {
return { width: 1, height: 1 };
}
const zoom = this.#workspace.zoomLevel || 1;
const rect = element.getBoundingClientRect();
const width = rect.width / zoom;
const height = rect.height / zoom;
const fallbackWidth = element.offsetWidth || width || 1;
const fallbackHeight = element.offsetHeight || height || 1;
return {
width: Number.isFinite(width) && width > 0 ? width : Math.max(1, fallbackWidth),
height: Number.isFinite(height) && height > 0 ? height : Math.max(1, fallbackHeight),
};
}
}

View File

@@ -0,0 +1,620 @@
/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
* @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition
* @typedef {import('../../nodes/nodeTypes.js').NodeDefinition & {
* shortcut?: (
* { type: 'variable', action: 'get'|'set', variableId: string } |
* { type: 'custom_event_call', eventNodeId: string }
* )
* }} PaletteNodeDefinition
*/
const DEFAULT_PLACEHOLDER = "Search nodes";
/**
* Handles DOM management and interactions for the workspace context menu.
*/
export class WorkspaceContextMenuManager {
#workspace;
/** @type {HTMLDivElement | null} */
#container;
/** @type {HTMLInputElement | null} */
#searchInput;
/** @type {HTMLDivElement | null} */
#list;
/** @type {boolean} */
#isVisible;
/** @type {{ x: number, y: number }} */
#spawnPosition;
/** @type {Array<PaletteNodeDefinition>} */
#results;
/** @type {Array<PaletteNodeDefinition> | null} */
#customResults;
/** @type {string} */
#preSearchNormalized;
/** @type {'default'|'connection'|'variable'} */
#mode;
/** @type {number} */
#selectedIndex;
/** @type {string | null} */
#selectedDefinitionId;
/** @type {Array<HTMLButtonElement>} */
#itemElements;
/** @type {string} */
#lastNormalizedQuery;
/**
* @param {BlueprintWorkspace} workspace Owning workspace instance.
*/
constructor(workspace) {
this.#workspace = workspace;
this.#container = null;
this.#searchInput = null;
this.#list = null;
this.#isVisible = false;
this.#spawnPosition = { x: 0, y: 0 };
this.#results = [];
this.#customResults = null;
this.#preSearchNormalized = "";
this.#mode = "default";
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
this.#itemElements = [];
this.#lastNormalizedQuery = "";
}
/**
* Builds the context menu DOM if it does not already exist.
*/
initialize() {
if (this.#container) {
return;
}
const container = document.createElement("div");
container.className = "workspace-context-menu";
container.setAttribute("role", "dialog");
container.setAttribute("aria-label", "Add node");
const search = document.createElement("input");
search.type = "search";
search.className = "context-menu-search";
search.placeholder = DEFAULT_PLACEHOLDER;
search.setAttribute("aria-label", "Search nodes");
container.appendChild(search);
const list = document.createElement("div");
list.className = "context-menu-list";
list.setAttribute("role", "listbox");
container.appendChild(list);
this.#workspace.workspaceElement.appendChild(container);
search.addEventListener("input", () => {
this.#renderResults(search.value);
});
search.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.stopPropagation();
this.hide();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
this.#moveSelection(1);
this.#focusSelection();
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
this.#moveSelection(-1);
this.#focusSelection();
return;
}
if (event.key === "Enter") {
event.preventDefault();
this.#selectSelection();
}
});
document.addEventListener("pointerdown", (event) => {
if (!this.#isVisible) {
return;
}
if (!(event.target instanceof Node)) {
this.hide();
return;
}
if (!container.contains(event.target)) {
this.hide();
}
});
document.addEventListener("keydown", (event) => {
if (!this.#isVisible) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
this.hide();
}
});
this.#container = container;
this.#searchInput = search;
this.#list = list;
}
/**
* Determines whether the context menu is currently displayed.
*
* @returns {boolean}
*/
isVisible() {
return this.#isVisible;
}
/**
* Checks whether the provided target node resides inside the context menu.
*
* @param {Node | null} target Target node.
* @returns {boolean}
*/
isTargetInside(target) {
return Boolean(
this.#container && target && this.#container.contains(target)
);
}
/**
* Displays the context menu at the given viewport coordinates.
*
* @param {number} clientX Viewport X coordinate.
* @param {number} clientY Viewport Y coordinate.
* @param {{ definitions?: Array<PaletteNodeDefinition>, placeholder?: string, query?: string, mode?: 'default'|'connection'|'variable' }} [options]
*/
show(clientX, clientY, options = {}) {
this.initialize();
if (!this.#container || !this.#searchInput) {
return;
}
const workspaceRect =
this.#workspace.workspaceElement.getBoundingClientRect();
const relativeX = Math.max(0, clientX - workspaceRect.left);
const relativeY = Math.max(0, clientY - workspaceRect.top);
this.#spawnPosition = { x: relativeX, y: relativeY };
this.#customResults = options.definitions ?? null;
this.#preSearchNormalized = (options.query ?? "").trim().toLowerCase();
this.#mode = options.mode ?? "default";
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
if (this.#mode !== "connection") {
this.#workspace.pendingConnectionSpawn = null;
}
if (this.#mode !== "variable") {
this.#workspace.pendingVariableSpawn = null;
}
const placeholder = options.placeholder ?? DEFAULT_PLACEHOLDER;
if (this.#searchInput.placeholder !== placeholder) {
this.#searchInput.placeholder = placeholder;
}
this.#searchInput.value = options.query ?? "";
this.#renderResults(this.#searchInput.value);
this.#container.classList.add("is-visible");
this.#container.style.left = `${relativeX}px`;
this.#container.style.top = `${relativeY}px`;
this.#isVisible = true;
const menuRect = this.#container.getBoundingClientRect();
let adjustedLeft = relativeX;
let adjustedTop = relativeY;
if (menuRect.right > workspaceRect.right) {
adjustedLeft -= menuRect.right - workspaceRect.right;
}
if (menuRect.bottom > workspaceRect.bottom) {
adjustedTop -= menuRect.bottom - workspaceRect.bottom;
}
adjustedLeft = Math.max(0, adjustedLeft);
adjustedTop = Math.max(0, adjustedTop);
this.#container.style.left = `${adjustedLeft}px`;
this.#container.style.top = `${adjustedTop}px`;
requestAnimationFrame(() => {
this.#searchInput?.focus();
this.#searchInput?.select();
});
}
/**
* Conceals the context menu and clears its state.
*/
hide() {
if (!this.#container || !this.#isVisible) {
return;
}
this.#container.classList.remove("is-visible");
this.#isVisible = false;
this.#customResults = null;
this.#mode = "default";
this.#preSearchNormalized = "";
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
this.#itemElements = [];
this.#results = [];
this.#spawnPosition = { x: 0, y: 0 };
if (this.#searchInput) {
this.#searchInput.placeholder = DEFAULT_PLACEHOLDER;
this.#searchInput.value = "";
}
this.#workspace.pendingConnectionSpawn = null;
}
/**
* Renders context menu results using the supplied query.
*
* @param {string} query Search query.
*/
#renderResults(query) {
if (!this.#list) {
return;
}
let effectiveQuery = query ?? "";
const normalizedQuery = effectiveQuery.trim().toLowerCase();
const queryChanged = normalizedQuery !== this.#lastNormalizedQuery;
if (
this.#customResults &&
this.#preSearchNormalized &&
normalizedQuery === this.#preSearchNormalized
) {
effectiveQuery = "";
this.#preSearchNormalized = "";
}
const results = this.#computeDefinitions(effectiveQuery);
this.#results = results;
this.#list.innerHTML = "";
this.#list.scrollTop = 0;
this.#itemElements = [];
if (queryChanged) {
this.#selectedDefinitionId = null;
}
this.#lastNormalizedQuery = normalizedQuery;
if (!results.length) {
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
const empty = document.createElement("p");
empty.className = "context-menu-empty";
empty.textContent = "No matching nodes";
this.#list.appendChild(empty);
return;
}
const section = document.createElement("div");
section.className = "context-menu-section";
const body = document.createElement("div");
body.className = "context-menu-section-body";
section.appendChild(body);
results.forEach((definition) => {
const index = this.#itemElements.length;
const item = this.#createDefinitionButton(definition, () => {
this.#updateSelection(index, { scrollIntoView: false });
this.#selectSelection();
});
item.addEventListener("pointerenter", () => {
this.#updateSelection(index, { scrollIntoView: false });
});
item.addEventListener("pointerdown", () => {
this.#updateSelection(index, { scrollIntoView: false });
});
item.addEventListener("focus", () => {
this.#updateSelection(index, { scrollIntoView: false });
});
item.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
this.#moveSelection(1);
this.#focusSelection();
} else if (event.key === "ArrowUp") {
event.preventDefault();
this.#moveSelection(-1);
this.#focusSelection();
}
});
body.appendChild(item);
this.#itemElements.push(item);
});
this.#list.appendChild(section);
if (this.#selectedDefinitionId) {
const index = results.findIndex(
(definition) => definition.id === this.#selectedDefinitionId
);
if (index >= 0) {
this.#updateSelection(index, { scrollIntoView: false });
return;
}
}
this.#updateSelection(results.length ? 0 : -1, { scrollIntoView: false });
}
/**
* Computes palette definitions for the provided query, including shortcuts.
*
* @param {string} query Search query.
* @returns {Array<PaletteNodeDefinition>}
*/
#computeDefinitions(query) {
const hasQuery = query.trim().length > 0;
if (this.#customResults) {
if (!hasQuery) {
return this.#customResults.slice();
}
return this.#customResults.filter((definition) =>
this.#workspace.registry.matchesDefinition(definition, query)
);
}
const registryMatches = this.#workspace.registry.search(query);
const filteredBase = registryMatches.filter(
(definition) => definition.id !== "get_var" && definition.id !== "set_var"
);
const customEventShortcuts = this.#buildCustomEventShortcuts(query);
const variableShortcuts = this.#buildVariableShortcuts(query);
return filteredBase.concat(customEventShortcuts, variableShortcuts);
}
/**
* Moves the highlighted selection by the provided offset.
*
* @param {number} offset Offset delta.
*/
#moveSelection(offset) {
const total = this.#results.length;
if (!total || !Number.isFinite(offset) || offset === 0) {
return;
}
let index = this.#selectedIndex;
if (index < 0) {
index = offset > 0 ? 0 : total - 1;
} else {
index = (index + offset + total) % total;
}
this.#updateSelection(index);
}
/**
* Applies visual selection state for the supplied menu index.
*
* @param {number} index Target index.
* @param {{ scrollIntoView?: boolean }} [options]
*/
#updateSelection(index, options = {}) {
const { scrollIntoView = true } = options;
const previousIndex = this.#selectedIndex;
const items = this.#itemElements;
if (previousIndex >= 0 && items[previousIndex]) {
items[previousIndex].classList.remove("is-selected");
items[previousIndex].setAttribute("aria-selected", "false");
}
if (
typeof index !== "number" ||
index < 0 ||
index >= this.#results.length
) {
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
return;
}
this.#selectedIndex = index;
this.#selectedDefinitionId = this.#results[index]?.id ?? null;
const element = items[index];
if (element) {
element.classList.add("is-selected");
element.setAttribute("aria-selected", "true");
if (scrollIntoView) {
element.scrollIntoView({ block: "nearest" });
}
}
}
/**
* Attempts to focus the currently highlighted menu element.
*/
#focusSelection() {
const element = this.#itemElements[this.#selectedIndex];
if (!element || typeof element.focus !== "function") {
return;
}
try {
element.focus({ preventScroll: true });
} catch {
element.focus();
}
}
/**
* Spawns a node for the currently highlighted context menu result.
*/
#selectSelection() {
if (
this.#selectedIndex < 0 ||
this.#selectedIndex >= this.#results.length
) {
return;
}
const definition = this.#results[this.#selectedIndex];
const spawn = { ...this.#spawnPosition };
const node = this.#workspace.spawnNodeFromContextMenu(definition, spawn);
if (node) {
this.hide();
}
}
/**
* Builds buttons for context menu rows with an attached selection handler.
*
* @param {PaletteNodeDefinition} definition Palette definition entry.
* @param {() => void} onSelect Selection callback.
* @returns {HTMLButtonElement}
*/
#createDefinitionButton(definition, onSelect) {
const button = document.createElement("button");
button.type = "button";
button.className = "context-menu-item";
button.dataset.definitionId = definition.id;
button.setAttribute("role", "option");
button.setAttribute("aria-selected", "false");
const title = document.createElement("span");
title.className = "context-menu-item-name";
title.textContent = definition.title;
button.appendChild(title);
button.addEventListener("click", () => {
onSelect();
});
return button;
}
/**
* Generates variable shortcut entries matching the provided query.
*
* @param {string} query Search query.
* @returns {Array<PaletteNodeDefinition>}
*/
#buildVariableShortcuts(query) {
const normalizedQuery = (query ?? "").trim().toLowerCase();
const includeAll = normalizedQuery.length === 0;
const entries = [];
this.#workspace.variableOrder.forEach((variableId) => {
const variable = this.#workspace.variables.get(variableId);
if (!variable) {
return;
}
const displayName = (variable.name ?? "").trim() || variable.id;
const normalizedName = displayName.toLowerCase();
const tokens = [
normalizedName,
variableId.toLowerCase(),
`get ${normalizedName}`,
`set ${normalizedName}`,
];
if (
!includeAll &&
!tokens.some((token) => token.includes(normalizedQuery))
) {
return;
}
entries.push({
id: `variable_get_${variableId}`,
title: `Get ${displayName}`,
category: "Variables",
description: `Get ${displayName}`,
shortcut: { type: "variable", action: "get", variableId },
});
entries.push({
id: `variable_set_${variableId}`,
title: `Set ${displayName}`,
category: "Variables",
description: `Set ${displayName}`,
shortcut: { type: "variable", action: "set", variableId },
});
});
return entries;
}
/**
* Generates custom event shortcut entries matching the provided query.
*
* @param {string} query Search query.
* @returns {Array<PaletteNodeDefinition>}
*/
#buildCustomEventShortcuts(query) {
const normalizedQuery = (query ?? "").trim().toLowerCase();
const includeAll = normalizedQuery.length === 0;
const events = [...this.#workspace.graph.nodes.values()]
.filter((node) => node.type === "custom_event")
.sort((a, b) =>
this.#resolveCustomEventLabel(a).localeCompare(
this.#resolveCustomEventLabel(b)
)
);
const entries = [];
events.forEach((eventNode) => {
const label = this.#resolveCustomEventLabel(eventNode);
const normalizedName = label.toLowerCase();
const tokens = [
normalizedName,
eventNode.id.toLowerCase(),
`call ${normalizedName}`,
];
if (
!includeAll &&
!tokens.some((token) => token.includes(normalizedQuery))
) {
return;
}
entries.push({
id: `custom_event_call_${eventNode.id}`,
title: `Call ${label}`,
category: "Custom Events",
description: `Call ${label}`,
shortcut: { type: "custom_event_call", eventNodeId: eventNode.id },
});
});
return entries;
}
/**
* Resolves a display label for the provided custom event node.
*
* @param {import('../../core/BlueprintNode.js').BlueprintNode} node Custom event node.
* @returns {string}
*/
#resolveCustomEventLabel(node) {
const raw =
typeof node.properties.name === "string"
? node.properties.name.trim()
: "";
return raw || "CustomEvent";
}
}

View File

@@ -0,0 +1,652 @@
import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
/**
* Handles drag interactions, ghost previews, and drag-induced transforms.
*/
export class WorkspaceDragManager {
/**
* @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Owning workspace instance.
*/
constructor(workspace) {
this.workspace = workspace;
}
/**
* Ensures a ghost preview element exists for palette drags.
*
* @param {string | null} definitionId Dragged node definition identifier.
*/
ensurePaletteGhost(definitionId) {
const { workspace } = this;
const existing = workspace.paletteDragGhost;
if (existing && existing.element.isConnected) {
if (existing.definitionId === definitionId) {
return;
}
this.removePaletteGhost();
}
const element = document.createElement("div");
element.className = "workspace-drop-ghost";
element.setAttribute("role", "presentation");
const label = document.createElement("span");
label.className = "workspace-drop-ghost__label";
const definition = definitionId
? workspace.registry.get(definitionId)
: null;
label.textContent = definition?.title ?? "Node Preview";
element.appendChild(label);
workspace.nodeLayer.appendChild(element);
workspace.paletteDragGhost = {
element,
width: element.offsetWidth,
height: element.offsetHeight,
definitionId: definitionId ?? null,
};
}
/**
* Updates the palette ghost transform to match the supplied position.
*
* @param {{ x: number, y: number }} position Snapped workspace position.
*/
updatePaletteGhost(position) {
const { paletteDragGhost } = this.workspace;
if (!paletteDragGhost) {
return;
}
paletteDragGhost.element.style.transform =
WorkspaceGeometry.positionToTransform(position);
}
/**
* Removes the active palette drag ghost.
*/
removePaletteGhost() {
const { workspace } = this;
if (
workspace.paletteDragGhost?.element &&
workspace.paletteDragGhost.element.isConnected
) {
workspace.paletteDragGhost.element.remove();
}
workspace.paletteDragGhost = null;
}
/**
* Ensures a ghost preview exists for node drag operations.
*
* @param {string} nodeId Active node identifier.
*/
ensureNodeGhost(nodeId) {
const { workspace } = this;
const nodeElement = workspace.nodeElements.get(nodeId);
if (!nodeElement) {
return;
}
const existing = workspace.nodeDragGhost;
if (
existing &&
existing.nodeId === nodeId &&
existing.element.isConnected
) {
return;
}
this.removeNodeGhost();
const ghost = document.createElement("div");
ghost.className = "workspace-node-ghost";
const zoom = this.workspace.zoomLevel || 1;
const rect = nodeElement.getBoundingClientRect();
const unscaledWidth = rect.width || nodeElement.offsetWidth;
const unscaledHeight = rect.height || nodeElement.offsetHeight;
const width = Math.max(1, unscaledWidth / zoom);
const height = Math.max(1, unscaledHeight / zoom);
ghost.style.width = `${width}px`;
ghost.style.height = `${height}px`;
ghost.setAttribute("role", "presentation");
if (nodeElement.parentElement) {
nodeElement.parentElement.insertBefore(ghost, nodeElement);
} else {
workspace.nodeLayer.appendChild(ghost);
}
workspace.nodeDragGhost = {
element: ghost,
width,
height,
nodeId,
};
}
/**
* Updates the node drag ghost to the provided position.
*
* @param {{ x: number, y: number }} position Snapped workspace position.
*/
updateNodeGhost(position) {
const ghost = this.workspace.nodeDragGhost;
if (!ghost) {
return;
}
ghost.element.style.transform =
WorkspaceGeometry.positionToTransform(position);
}
/**
* Removes the node drag ghost preview if active.
*/
removeNodeGhost() {
const { workspace } = this;
if (
workspace.nodeDragGhost?.element &&
workspace.nodeDragGhost.element.isConnected
) {
workspace.nodeDragGhost.element.remove();
}
workspace.nodeDragGhost = null;
}
/**
* Adjusts active drag offsets when the workspace shifts.
*
* @param {{ x: number, y: number }} delta Workspace-space translation applied externally.
*/
applyActiveDragShift(delta) {
if (!delta || (!delta.x && !delta.y)) {
return;
}
const context = this.workspace.activeNodeDragContext;
if (!context) {
return;
}
if (context.lastPointerWorld) {
context.lastPointerWorld = {
x: context.lastPointerWorld.x + delta.x,
y: context.lastPointerWorld.y + delta.y,
};
}
if (!context.multiDrag) {
const ghost = this.workspace.nodeDragGhost;
const pointer = context.lastPointerWorld;
if (ghost?.nodeId && pointer) {
const anchor = context.anchors.get(ghost.nodeId);
if (anchor) {
this.updateNodeGhost({
x: pointer.x - anchor.ratioX * anchor.width,
y: pointer.y - anchor.ratioY * anchor.height,
});
}
}
}
}
/**
* Updates the cached pointer state used during drag operations.
*
* @param {{ clientX?: number, clientY?: number } | null | undefined} pointer Pointer-like payload.
*/
rebaseActiveDragPointer(pointer) {
if (!pointer) {
return;
}
const context = this.workspace.activeNodeDragContext;
if (!context) {
return;
}
const hasClientX = Number.isFinite(pointer.clientX);
const hasClientY = Number.isFinite(pointer.clientY);
if (!hasClientX && !hasClientY) {
return;
}
const pointerWorld = this.#clientToWorkspace(
hasClientX ? pointer.clientX : context.previousClientX,
hasClientY ? pointer.clientY : context.previousClientY
);
context.lastPointerWorld = pointerWorld;
if (hasClientX) {
context.previousClientX = pointer.clientX;
}
if (hasClientY) {
context.previousClientY = pointer.clientY;
}
if (!context.multiDrag) {
const ghost = this.workspace.nodeDragGhost;
if (ghost?.nodeId) {
const anchor = context.anchors.get(ghost.nodeId);
if (anchor) {
this.updateNodeGhost({
x: pointerWorld.x - anchor.ratioX * anchor.width,
y: pointerWorld.y - anchor.ratioY * anchor.height,
});
}
}
}
}
/**
* Applies a snapped position to a node with a subtle easing transition.
*
* @param {string} nodeId Node identifier.
* @param {{ x: number, y: number }} position Target snapped position.
*/
applyNodeSnapPosition(nodeId, position) {
const { workspace } = this;
const element = workspace.nodeElements.get(nodeId);
if (element) {
element.style.transition =
"transform 140ms cubic-bezier(0.22, 0.61, 0.36, 1)";
const handleTransitionEnd = () => {
element.style.transition = "";
workspace.flushConnectionRefresh();
if (workspace.nodeDragGhost?.nodeId === nodeId) {
this.removeNodeGhost();
}
};
element.addEventListener("transitionend", handleTransitionEnd, {
once: true,
});
window.setTimeout(() => {
element.removeEventListener("transitionend", handleTransitionEnd);
element.style.transition = "";
workspace.flushConnectionRefresh();
if (workspace.nodeDragGhost?.nodeId === nodeId) {
this.removeNodeGhost();
}
}, 200);
}
this.updateNodeDomPosition(nodeId, position);
workspace.graph.setNodePosition(nodeId, position);
workspace.scheduleConnectionRefresh();
}
/**
* Updates the DOM transform for a node element.
*
* @param {string} nodeId Node identifier.
* @param {{x:number,y:number}} position Coordinates.
*/
updateNodeDomPosition(nodeId, position) {
const { workspace } = this;
const element = workspace.nodeElements.get(nodeId);
if (!element) {
return;
}
const rotation = workspace.nodeDragRotation.get(nodeId) ?? 0;
element.style.transform = WorkspaceGeometry.positionToTransform(
position,
rotation
);
}
/**
* Initiates a node drag operation.
*
* @param {PointerEvent} event Pointer event.
* @param {string} nodeId Dragged node identifier.
*/
startNodeDrag(event, nodeId) {
const { workspace } = this;
const node = workspace.graph.nodes.get(nodeId);
if (!node) {
return;
}
const isMouse = event.pointerType === "mouse" || event.pointerType === "";
if (isMouse && event.button !== 0) {
return;
}
if (
!isMouse &&
event.pointerType !== "touch" &&
event.pointerType !== "pen"
) {
return;
}
event.stopPropagation();
event.preventDefault();
const selectedIds = workspace.selectedNodeIds.has(nodeId)
? [...workspace.selectedNodeIds]
: [nodeId];
const pointerWorld = this.#eventToWorkspacePoint(event);
const anchors = new Map();
selectedIds.forEach((id) => {
const target = workspace.graph.nodes.get(id);
if (!target) {
return;
}
const element = workspace.nodeElements.get(id) ?? null;
const { width, height } = this.#measureNodeSize(element);
const ratioX = width > 0 ? (pointerWorld.x - target.position.x) / width : 0;
const ratioY = height > 0 ? (pointerWorld.y - target.position.y) / height : 0;
anchors.set(id, {
ratioX,
ratioY,
width,
height,
});
});
if (!anchors.has(nodeId)) {
const element = workspace.nodeElements.get(nodeId) ?? null;
const { width, height } = this.#measureNodeSize(element);
const ratioX = width > 0 ? (pointerWorld.x - node.position.x) / width : 0;
const ratioY = height > 0 ? (pointerWorld.y - node.position.y) / height : 0;
anchors.set(nodeId, {
ratioX,
ratioY,
width,
height,
});
}
const multiDrag = anchors.size > 1;
workspace.draggingNode = {
nodeId,
pointerId: event.pointerId,
selection: new Set(anchors.keys()),
};
workspace.isDraggingNodes = true;
workspace.activeNodeDragContext = {
anchors,
multiDrag,
lastPointerWorld: pointerWorld,
previousClientX: event.clientX,
previousClientY: event.clientY,
};
if (multiDrag) {
this.removeNodeGhost();
} else {
this.ensureNodeGhost(nodeId);
const anchor = anchors.get(nodeId);
if (anchor) {
const initialPosition = {
x: pointerWorld.x - anchor.ratioX * anchor.width,
y: pointerWorld.y - anchor.ratioY * anchor.height,
};
this.updateNodeGhost(WorkspaceGeometry.snapPositionToGrid(workspace, initialPosition));
}
}
const draggedElement = workspace.nodeElements.get(nodeId);
if (draggedElement) {
draggedElement.style.transition = "";
}
const handlePointerMove = (moveEvent) => {
if (moveEvent.pointerId !== event.pointerId) {
return;
}
const context = workspace.activeNodeDragContext;
if (!context) {
return;
}
const pointer = this.#eventToWorkspacePoint(moveEvent);
context.lastPointerWorld = pointer;
const pendingPositions = new Map();
context.anchors.forEach((anchor, id) => {
const width = anchor.width || 1;
const height = anchor.height || 1;
const updated = {
x: pointer.x - anchor.ratioX * width,
y: pointer.y - anchor.ratioY * height,
};
this.updateNodeDomPosition(id, updated);
pendingPositions.set(id, updated);
if (!context.multiDrag && id === nodeId) {
this.updateNodeGhost(WorkspaceGeometry.snapPositionToGrid(workspace, updated));
}
});
pendingPositions.forEach((position, id) => {
workspace.graph.setNodePosition(id, position);
});
const screenDeltaX = Number.isFinite(moveEvent.movementX)
? moveEvent.movementX
: moveEvent.clientX - context.previousClientX;
const velocityX = Number.isFinite(moveEvent.movementX)
? moveEvent.movementX
: screenDeltaX;
pendingPositions.forEach((_, id) => {
this.nudgeNodeDragRotation(id, velocityX);
});
context.previousClientX = moveEvent.clientX;
context.previousClientY = moveEvent.clientY;
};
const finalizeSelection = (pointer) => {
const context = workspace.activeNodeDragContext;
if (!context) {
return;
}
const targetPointer = pointer ?? context.lastPointerWorld;
if (!targetPointer) {
return;
}
context.anchors.forEach((anchor, id) => {
const width = anchor.width || 1;
const height = anchor.height || 1;
const raw = {
x: targetPointer.x - anchor.ratioX * width,
y: targetPointer.y - anchor.ratioY * height,
};
const snapped = WorkspaceGeometry.snapPositionToGrid(workspace, raw);
this.applyNodeSnapPosition(id, snapped);
this.setNodeDragRotation(id, 0);
if (!context.multiDrag && id === nodeId) {
this.updateNodeGhost(snapped);
}
});
if (context.multiDrag) {
this.removeNodeGhost();
} else {
window.setTimeout(() => {
this.removeNodeGhost();
}, 160);
}
workspace.isDraggingNodes = false;
workspace.activeNodeDragContext = null;
workspace.flushConnectionRefresh();
};
const handlePointerFinish = (finishEvent) => {
if (finishEvent.pointerId !== event.pointerId) {
return;
}
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerFinish);
window.removeEventListener("pointercancel", handlePointerFinish);
const context = workspace.activeNodeDragContext;
let pointer = null;
if (context) {
const hasClient = Number.isFinite(finishEvent.clientX);
const hasClientY = Number.isFinite(finishEvent.clientY);
if (hasClient || hasClientY) {
pointer = this.#eventToWorkspacePoint(finishEvent);
}
}
finalizeSelection(pointer);
workspace.draggingNode = null;
workspace.activeNodeDragContext = null;
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerFinish);
window.addEventListener("pointercancel", handlePointerFinish);
}
/**
* Applies a momentum impulse into the current drag rotation state.
*
* @param {string} nodeId Node identifier.
* @param {number} horizontalVelocity Horizontal pointer velocity.
*/
nudgeNodeDragRotation(nodeId, horizontalVelocity) {
if (!Number.isFinite(horizontalVelocity)) {
return;
}
const { workspace } = this;
const existing = workspace.nodeDragRotation.get(nodeId) ?? 0;
const impulse = horizontalVelocity * 0.9;
const blended = existing * 0.75 + impulse;
const clamped = Math.max(-12, Math.min(12, blended));
this.setNodeDragRotation(nodeId, clamped);
}
/**
* Converts pointer event coordinates into workspace-space values without clamping.
*
* @param {PointerEvent} event Pointer event reference.
* @returns {{ x: number, y: number }}
*/
#eventToWorkspacePoint(event) {
return this.#clientToWorkspace(event.clientX, event.clientY);
}
/**
* Measures a node element and returns its workspace-space dimensions.
*
* @param {HTMLElement | null} element Node DOM element.
* @returns {{ width: number, height: number }}
*/
#measureNodeSize(element) {
const zoom = this.workspace.zoomLevel || 1;
if (!element) {
return { width: 1, height: 1 };
}
const rect = element.getBoundingClientRect();
const rawWidth = rect.width / zoom;
const rawHeight = rect.height / zoom;
const fallbackWidth = element.offsetWidth || rawWidth;
const fallbackHeight = element.offsetHeight || rawHeight;
return {
width: Math.max(1, Number.isFinite(rawWidth) ? rawWidth : fallbackWidth || 1),
height: Math.max(1, Number.isFinite(rawHeight) ? rawHeight : fallbackHeight || 1),
};
}
/**
* Converts client coordinates to workspace-space units.
*
* @param {number | undefined} clientX Viewport X coordinate.
* @param {number | undefined} clientY Viewport Y coordinate.
* @returns {{ x: number, y: number }}
*/
#clientToWorkspace(clientX, clientY) {
const rect = this.workspace.workspaceElement.getBoundingClientRect();
const zoom = this.workspace.zoomLevel || 1;
const safeX = Number.isFinite(clientX) ? clientX : rect.left;
const safeY = Number.isFinite(clientY) ? clientY : rect.top;
return {
x: (safeX - rect.left) / zoom,
y: (safeY - rect.top) / zoom,
};
}
/**
* Applies a temporary rotation used during drag interactions.
*
* @param {string} nodeId Node identifier.
* @param {number} angle Rotation in degrees.
*/
setNodeDragRotation(nodeId, angle) {
const { workspace } = this;
const sanitized = Number.isFinite(angle) ? angle : 0;
const clamped = Math.max(-12, Math.min(12, sanitized));
if (Math.abs(clamped) < 0.01) {
workspace.nodeDragRotation.delete(nodeId);
} else {
workspace.nodeDragRotation.set(nodeId, clamped);
}
const node = workspace.graph.nodes.get(nodeId);
if (node) {
this.updateNodeDomPosition(nodeId, node.position);
}
if (workspace.nodeDragRotation.size > 0) {
this.ensureNodeRotationDecay();
} else {
this.stopNodeRotationDecay();
}
}
/**
* Ensures the drag rotation damping loop is active.
*/
ensureNodeRotationDecay() {
const { workspace } = this;
if (workspace.nodeDragRotationDecayFrame !== null) {
return;
}
const step = () => {
let hasActive = false;
workspace.nodeDragRotation.forEach((angle, id) => {
const node = workspace.graph.nodes.get(id);
if (!node) {
workspace.nodeDragRotation.delete(id);
return;
}
const damped = angle * 0.85;
if (Math.abs(damped) < 0.05) {
workspace.nodeDragRotation.delete(id);
} else {
workspace.nodeDragRotation.set(id, damped);
hasActive = true;
}
this.updateNodeDomPosition(id, node.position);
});
if (hasActive) {
workspace.nodeDragRotationDecayFrame =
window.requestAnimationFrame(step);
} else {
workspace.nodeDragRotationDecayFrame = null;
}
};
workspace.nodeDragRotationDecayFrame = window.requestAnimationFrame(step);
}
/**
* Stops the drag rotation damping loop if one is queued.
*/
stopNodeRotationDecay() {
const { workspace } = this;
if (workspace.nodeDragRotationDecayFrame !== null) {
window.cancelAnimationFrame(workspace.nodeDragRotationDecayFrame);
workspace.nodeDragRotationDecayFrame = null;
}
}
}

View File

@@ -0,0 +1,326 @@
/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
* @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition
*/
import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
import {
PALETTE_DRAG_MIME,
VARIABLE_DRAG_MIME,
} from "../BlueprintWorkspaceConstants.js";
/**
* Manages drag-and-drop interactions for palette and variable entries.
*/
export class WorkspaceDropManager {
#workspace;
/**
* @param {BlueprintWorkspace} workspace Owning workspace instance.
*/
constructor(workspace) {
this.#workspace = workspace;
}
/**
* Hooks workspace-level drag events.
*/
initialize() {
const element = this.#workspace.workspaceElement;
element.addEventListener("dragenter", (event) => {
this.#handleDragEnter(event);
});
element.addEventListener("dragover", (event) => {
this.#handleDragOver(event);
});
element.addEventListener("dragleave", (event) => {
this.#handleDragLeave(event);
});
element.addEventListener("drop", (event) => {
this.#handleDrop(event);
});
}
/**
* Begins a variable drag gesture from the overview panel.
*
* @param {DragEvent} event Drag event payload.
* @param {string} variableId Variable identifier being dragged.
*/
handleVariableDragStart(event, variableId) {
if (!event.dataTransfer) {
return;
}
event.dataTransfer.setData(VARIABLE_DRAG_MIME, variableId);
event.dataTransfer.setData("text/plain", variableId);
event.dataTransfer.effectAllowed = "copy";
this.#workspace.workspaceDragDepth = 0;
const element = this.#workspace.workspaceElement;
element.classList.add("is-variable-dragging");
element.classList.remove("is-palette-dragging");
}
/**
* Clears workspace drag styling for variable gestures.
*/
handleVariableDragEnd() {
this.#workspace.workspaceDragDepth = 0;
const element = this.#workspace.workspaceElement;
element.classList.remove("is-variable-dragging");
element.classList.remove("is-drag-target");
this.#workspace.dragManager.removePaletteGhost();
}
/**
* Responds to dragenter events and prepares drop feedback.
*
* @param {DragEvent} event Drag enter event.
*/
#handleDragEnter(event) {
const isPalette = this.#isPaletteDrag(event);
const isVariable = this.#isVariableDrag(event);
if (!isPalette && !isVariable) {
return;
}
event.preventDefault();
this.#workspace.workspaceDragDepth += 1;
const element = this.#workspace.workspaceElement;
element.classList.add("is-drag-target");
if (isPalette) {
const definitionId = this.#resolvePaletteDefinitionId(event);
this.#workspace.dragManager.ensurePaletteGhost(definitionId ?? null);
const point = WorkspaceGeometry.snapPositionToGrid(
this.#workspace,
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
);
this.#workspace.dragManager.updatePaletteGhost(point);
} else {
this.#workspace.dragManager.removePaletteGhost();
}
}
/**
* Keeps drop feedback active while objects move across the workspace.
*
* @param {DragEvent} event Drag over event.
*/
#handleDragOver(event) {
const isPalette = this.#isPaletteDrag(event);
const isVariable = this.#isVariableDrag(event);
if (!isPalette && !isVariable) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
if (isPalette) {
const definitionId = this.#resolvePaletteDefinitionId(event);
this.#workspace.dragManager.ensurePaletteGhost(definitionId ?? null);
const point = WorkspaceGeometry.snapPositionToGrid(
this.#workspace,
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
);
this.#workspace.dragManager.updatePaletteGhost(point);
} else {
this.#workspace.dragManager.removePaletteGhost();
}
}
/**
* Clears drop feedback once the drag leaves workspace bounds.
*
* @param {DragEvent} event Drag leave event.
*/
#handleDragLeave(event) {
const isPalette = this.#isPaletteDrag(event);
const isVariable = this.#isVariableDrag(event);
if (!isPalette && !isVariable) {
return;
}
this.#workspace.workspaceDragDepth = Math.max(
0,
this.#workspace.workspaceDragDepth - 1
);
if (this.#workspace.workspaceDragDepth === 0) {
const element = this.#workspace.workspaceElement;
element.classList.remove("is-drag-target");
this.#workspace.dragManager.removePaletteGhost();
}
}
/**
* Handles finalized drops for palette nodes and variables.
*
* @param {DragEvent} event Drop event containing pointer details.
*/
#handleDrop(event) {
const isPalette = this.#isPaletteDrag(event);
const isVariable = this.#isVariableDrag(event);
if (!isPalette && !isVariable) {
return;
}
event.preventDefault();
const transfer = event.dataTransfer;
if (!transfer) {
return;
}
if (isVariable) {
this.handleVariableDragEnd();
this.#handleVariableDrop(event, transfer);
return;
}
const definitionId =
transfer.getData(PALETTE_DRAG_MIME) || transfer.getData("text/plain");
if (!definitionId) {
return;
}
this.#workspace.paletteManager.handleDragEnd();
const spawn = WorkspaceGeometry.snapPositionToGrid(
this.#workspace,
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
);
this.#workspace.addNode(definitionId, spawn);
}
/**
* Prompts for node selection when a variable is dropped onto the workspace.
*
* @param {DragEvent} event Drop event payload.
* @param {DataTransfer} transfer Drag data transfer object.
*/
#handleVariableDrop(event, transfer) {
const variableId =
transfer.getData(VARIABLE_DRAG_MIME) || transfer.getData("text/plain");
const variable = variableId
? this.#workspace.variables.get(variableId)
: undefined;
if (!variableId || !variable) {
return;
}
const definitions = this.#getVariableSpawnDefinitions(variableId);
if (!definitions.length) {
const spawn = WorkspaceGeometry.snapPositionToGrid(
this.#workspace,
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
);
const fallback = this.#workspace.addNode("get_var", spawn);
if (fallback) {
const nodeName = variable.name || variable.id;
this.#workspace.graph.setNodeProperty(fallback.id, "name", nodeName);
this.#workspace.graph.setNodeProperty(
fallback.id,
"variableId",
variable.id
);
}
return;
}
this.#workspace.pendingVariableSpawn = { variableId };
const placeholderName = variable.name || "variable";
this.#workspace.contextMenuManager.show(event.clientX, event.clientY, {
definitions,
mode: "variable",
placeholder: `Create node for ${placeholderName}`,
});
}
/**
* Resolves potential variable spawn definitions from the registry.
*
* @param {string} variableId Variable identifier.
* @returns {Array<NodeDefinition>}
*/
#getVariableSpawnDefinitions(_variableId) {
const definitions = [];
const getDefinition = this.#workspace.registry.get("get_var");
const setDefinition = this.#workspace.registry.get("set_var");
if (getDefinition) {
definitions.push(getDefinition);
}
if (setDefinition) {
definitions.push(setDefinition);
}
return definitions;
}
/**
* Determines whether the drag originates from the palette interface.
*
* @param {DragEvent} event Drag event to inspect.
* @returns {boolean}
*/
#isPaletteDrag(event) {
const transfer = event.dataTransfer;
if (!transfer) {
return false;
}
const types = Array.from(transfer.types ?? []);
if (types.includes(PALETTE_DRAG_MIME)) {
return true;
}
return this.#workspace.workspaceElement.classList.contains(
"is-palette-dragging"
);
}
/**
* Determines whether the drag originates from the variable overview.
*
* @param {DragEvent} event Drag event to inspect.
* @returns {boolean}
*/
#isVariableDrag(event) {
const transfer = event.dataTransfer;
if (!transfer) {
return false;
}
const types = Array.from(transfer.types ?? []);
if (types.includes(VARIABLE_DRAG_MIME)) {
return true;
}
return this.#workspace.workspaceElement.classList.contains(
"is-variable-dragging"
);
}
/**
* Resolves the palette definition identifier associated with the active drag.
*
* @param {DragEvent} event Drag event payload.
* @returns {string | null}
*/
#resolvePaletteDefinitionId(event) {
if (this.#workspace.activePaletteDefinitionId) {
return this.#workspace.activePaletteDefinitionId;
}
const transfer = event.dataTransfer;
if (!transfer) {
return null;
}
const identifier =
transfer.getData(PALETTE_DRAG_MIME) || transfer.getData("text/plain");
return identifier || null;
}
}

View File

@@ -0,0 +1,85 @@
import { DEFAULT_GRID_SIZE } from "../BlueprintWorkspaceConstants.js";
/**
* Provides workspace geometry helpers for coordinate conversion and transforms.
*/
export class WorkspaceGeometry {
/**
* Resolves the active grid size from workspace styles.
*
* @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance.
* @returns {number}
*/
static resolveGridSize(workspace) {
const { workspaceElement } = workspace;
if (!workspaceElement || !workspaceElement.isConnected) {
return DEFAULT_GRID_SIZE;
}
const computed = window.getComputedStyle(workspaceElement);
const raw = computed.getPropertyValue("--grid-size");
const parsed = Number.parseFloat(raw);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
return DEFAULT_GRID_SIZE;
}
/**
* Converts viewport coordinates into workspace-relative coordinates.
*
* @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance.
* @param {{ clientX: number, clientY: number }} event Pointer-like event payload.
* @returns {{ x: number, y: number }}
*/
static eventToWorkspacePoint(workspace, event) {
const rect = workspace.workspaceElement.getBoundingClientRect();
const rawX = event?.clientX ?? 0;
const rawY = event?.clientY ?? 0;
const x = rawX - rect.left;
const y = rawY - rect.top;
const zoom = workspace.zoomLevel || 1;
return {
x: Math.max(0, Math.min(rect.width, x)) / zoom,
y: Math.max(0, Math.min(rect.height, y)) / zoom,
};
}
/**
* Snaps a workspace position to the configured grid size.
*
* @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance.
* @param {{ x: number, y: number }} position Raw workspace position.
* @returns {{ x: number, y: number }}
*/
static snapPositionToGrid(workspace, position) {
const size = workspace.gridSize || DEFAULT_GRID_SIZE;
const offsetX = workspace.workspaceBackgroundOffset?.x ?? 0;
const offsetY = workspace.workspaceBackgroundOffset?.y ?? 0;
const snap = (value, offset) => {
const local = value - offset;
const snapped = Math.round(local / size) * size;
return snapped + offset;
};
return { x: snap(position.x, offsetX), y: snap(position.y, offsetY) };
}
/**
* Converts a position vector into a CSS transform.
*
* @param {{x:number,y:number}} position Position vector.
* @param {number} rotation Rotation in degrees around the Z axis.
* @returns {string}
*/
static positionToTransform(position, rotation = 0) {
const transforms = [`translate3d(${position.x}px, ${position.y}px, 0)`];
const rotationZ = Number.isFinite(rotation) ? rotation : 0;
if (rotationZ) {
const tilt = Math.max(-10, Math.min(10, rotationZ * 0.8));
transforms.push(`rotate3d(0, 1, 0, ${tilt}deg)`);
transforms.push(`rotate(${rotationZ}deg)`);
}
return transforms.join(" ");
}
}

View File

@@ -0,0 +1,310 @@
/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
* @typedef {ReturnType<import('../../core/NodeGraph.js').NodeGraph['toJSON']>} GraphSnapshot
*/
/**
* @typedef {Object} WorkspaceSnapshotSelection
* @property {Array<string>} nodes
* @property {string | null} primary
* @property {string | null} variable
*/
/**
* @typedef {Object} WorkspaceSnapshotView
* @property {{ x: number, y: number }} backgroundOffset
* @property {number} zoom
*/
/**
* @typedef {Object} SerializedWorkspace
* @property {number} version
* @property {{ use60Fps: boolean }} projectSettings
* @property {GraphSnapshot} graph
* @property {{ entries: Array<{ id: string, name: string, type: import('../BlueprintWorkspace.js').VariableType, defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue | null }>, counter: number }} variables
* @property {number} spawnIndex
* @property {{ paletteVisible: boolean }} [ui]
*/
/**
* @typedef {Object} WorkspaceSnapshot
* @property {SerializedWorkspace} workspace
* @property {WorkspaceSnapshotSelection} selection
* @property {WorkspaceSnapshotView} view
*/
const HISTORY_MAX_ENTRIES = 200;
const HISTORY_DEBOUNCE_MS = 200;
/**
* Coordinates undo/redo history for the blueprint workspace.
*/
export class WorkspaceHistoryManager {
#workspace;
#timerId;
#lastSerialized;
#pendingDirty;
#pendingReason;
#suspendDepth;
/**
* @param {BlueprintWorkspace} workspace Owning workspace instance.
*/
constructor(workspace) {
this.#workspace = workspace;
/** @type {Array<WorkspaceSnapshot>} */
this.undoStack = [];
/** @type {Array<WorkspaceSnapshot>} */
this.redoStack = [];
this.isReady = false;
this.#timerId = null;
this.#lastSerialized = null;
this.#pendingDirty = false;
this.#pendingReason = null;
this.#suspendDepth = 0;
}
/**
* Resets undo/redo buffers and records the current workspace state.
*/
initialize() {
this.#clearTimer();
this.undoStack = [];
this.redoStack = [];
this.#pendingDirty = false;
this.#pendingReason = null;
this.#suspendDepth = 0;
const snapshot = this.#captureSnapshot();
this.undoStack.push(snapshot);
this.#lastSerialized = JSON.stringify(snapshot);
this.isReady = true;
}
/**
* Clears undo/redo buffers without recording a snapshot. Used when the workspace is about to be rebuilt entirely.
*/
handleReset() {
this.isReady = false;
this.#clearTimer();
this.undoStack = [];
this.redoStack = [];
this.#pendingDirty = false;
this.#pendingReason = null;
this.#lastSerialized = null;
this.#suspendDepth = 0;
}
/**
* Records a mutation for undo/redo bookkeeping.
*
* @param {'nodeschanged'|'nodepropertychanged'|'connectionschanged'|'nodepositionchanged'} kind Mutation descriptor.
*/
registerMutation(kind) {
if (!this.isReady || this.#workspace.isRestoring) {
return;
}
if (kind === "nodepositionchanged") {
this.markDirty(kind, HISTORY_DEBOUNCE_MS);
return;
}
if (kind === "nodeschanged") {
this.markDirty(kind, 0);
return;
}
this.commitCheckpoint(kind);
}
/**
* Schedules a deferred history checkpoint to coalesce rapid mutations.
*
* @param {string} reason Descriptive label for the mutation.
* @param {number} [delay=HISTORY_DEBOUNCE_MS] Debounce duration.
*/
markDirty(reason, delay = HISTORY_DEBOUNCE_MS) {
if (!this.isReady || this.#workspace.isRestoring) {
return;
}
if (this.#suspendDepth > 0) {
this.#pendingDirty = true;
this.#pendingReason = reason;
return;
}
if (delay <= 0 || typeof window === "undefined") {
this.commitCheckpoint(reason);
return;
}
this.#clearTimer();
this.#pendingDirty = true;
this.#pendingReason = reason;
this.#timerId = window.setTimeout(() => {
this.#timerId = null;
const pendingReason = this.#pendingReason ?? reason;
this.#pendingDirty = false;
this.#pendingReason = null;
this.commitCheckpoint(pendingReason);
}, delay);
}
/**
* Captures and stores a new undo checkpoint immediately.
*
* @param {string} reason Descriptive label for the mutation.
*/
commitCheckpoint(reason) {
if (!this.isReady || this.#workspace.isRestoring) {
return;
}
if (this.#suspendDepth > 0) {
this.#pendingDirty = true;
this.#pendingReason = reason;
return;
}
this.#clearTimer();
const snapshot = this.#captureSnapshot();
const serialized = JSON.stringify(snapshot);
if (serialized === this.#lastSerialized) {
return;
}
this.undoStack.push(snapshot);
if (this.undoStack.length > HISTORY_MAX_ENTRIES + 1) {
this.undoStack.shift();
}
this.#lastSerialized = serialized;
this.redoStack = [];
this.#pendingDirty = false;
this.#pendingReason = null;
}
/**
* Executes a callback while temporarily suppressing automatic history commits.
*
* @template T
* @param {() => T} callback Callback executed while suspended.
* @param {{ suppressAutoCommit?: boolean }} [options] Behaviour overrides.
* @returns {T}
*/
withSuspended(callback, options = {}) {
const suppressAutoCommit = Boolean(options.suppressAutoCommit);
this.#suspendDepth += 1;
try {
return callback();
} finally {
this.#suspendDepth = Math.max(0, this.#suspendDepth - 1);
if (this.#suspendDepth === 0 && this.#pendingDirty) {
if (suppressAutoCommit) {
this.#pendingDirty = false;
this.#pendingReason = null;
} else {
const reason = this.#pendingReason ?? "batched";
this.#pendingDirty = false;
this.#pendingReason = null;
this.markDirty(reason, 0);
}
}
}
}
/**
* Reverts the workspace to the previous history entry, if available.
*/
undo() {
if (!this.isReady || this.undoStack.length <= 1) {
return;
}
this.#clearTimer();
const current = this.undoStack.pop();
if (!current) {
return;
}
this.redoStack.push(current);
const previous = this.undoStack[this.undoStack.length - 1];
if (!previous) {
return;
}
this.#restoreSnapshot(previous);
this.#lastSerialized = JSON.stringify(previous);
}
/**
* Re-applies the most recently undone history entry, if available.
*/
redo() {
if (!this.isReady || !this.redoStack.length) {
return;
}
this.#clearTimer();
const snapshot = this.redoStack.pop();
if (!snapshot) {
return;
}
this.undoStack.push(snapshot);
this.#restoreSnapshot(snapshot);
this.#lastSerialized = JSON.stringify(snapshot);
}
/**
* Cancels any pending history timers.
*/
#clearTimer() {
if (this.#timerId !== null && typeof window !== "undefined") {
window.clearTimeout(this.#timerId);
this.#timerId = null;
}
}
/**
* Captures a complete workspace snapshot, including selection and view state.
*
* @returns {WorkspaceSnapshot}
*/
#captureSnapshot() {
const workspacePayload = /** @type {SerializedWorkspace} */ (
this.#workspace.serializeWorkspace()
);
const selection = {
nodes: [...this.#workspace.selectedNodeIds],
primary: this.#workspace.selectedNodeId ?? null,
variable: this.#workspace.selectedVariableId ?? null,
};
const view = {
backgroundOffset: { ...this.#workspace.workspaceBackgroundOffset },
zoom: this.#workspace.zoomLevel,
};
return { workspace: workspacePayload, selection, view };
}
/**
* Restores the workspace to a previously captured snapshot.
*
* @param {WorkspaceSnapshot} snapshot Snapshot to restore.
*/
#restoreSnapshot(snapshot) {
this.#clearTimer();
const wasReady = this.isReady;
this.isReady = false;
this.#pendingDirty = false;
this.#pendingReason = null;
this.#workspace.applySerializedWorkspace(snapshot.workspace);
this.#workspace.applyHistoryView(snapshot.view);
this.#workspace.applyHistorySelection(snapshot.selection);
this.isReady = wasReady;
}
}

View File

@@ -0,0 +1,941 @@
/** @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode */
/** @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor */
/** @typedef {import('../../core/NodeGraph.js').NodeGraph} NodeGraph */
/** @typedef {import('../../nodes/NodeRegistry.js').NodeRegistry} NodeRegistry */
/** @typedef {import('../BlueprintWorkspace.js').VariableType} VariableType */
/**
* Manages inline literal and pin editors for workspace nodes.
*/
export class WorkspaceInlineEditorManager {
/**
* @param {{
* getGraph: () => NodeGraph,
* registry: NodeRegistry,
* formatVariableType?: (type: VariableType) => string,
* openLocalVariableTypeDropdown?: (
* nodeId: string,
* trigger: HTMLButtonElement,
* anchor: HTMLElement,
* currentType: VariableType,
* onSelect: (type: VariableType) => void
* ) => void,
* closeTypeDropdown?: () => void,
* isTypeDropdownOpen?: (context: { kind: 'local_variable', nodeId: string }) => boolean,
* }} options Inline editor dependencies.
*/
constructor(options) {
this.#getGraph = options.getGraph;
this.registry = options.registry;
this.#formatVariableType =
typeof options.formatVariableType === "function"
? (type) => options.formatVariableType(type)
: (type) => {
const value = typeof type === "string" ? type : "";
if (!value.length) {
return "";
}
return value.charAt(0).toUpperCase() + value.slice(1);
};
this.#openLocalTypeDropdown =
typeof options.openLocalVariableTypeDropdown === "function"
? options.openLocalVariableTypeDropdown
: null;
this.#closeTypeDropdown =
typeof options.closeTypeDropdown === "function"
? options.closeTypeDropdown
: () => {};
this.#isTypeDropdownOpen =
typeof options.isTypeDropdownOpen === "function"
? options.isTypeDropdownOpen
: () => false;
/**
* @type {Map<string, {
* literal?: { setValue: (value: unknown) => void },
* pinEditors: Map<string, { setValue: (value: unknown) => void, setConnected: (connected: boolean) => void }>
* }>}
*/
this.inlineEditors = new Map();
}
/**
* Creates inline literal editor controls for supported nodes.
*
* @param {BlueprintNode} node Target node instance.
* @param {HTMLElement} article Node root article element.
* @param {HTMLElement} inputContainer Container holding input pins.
* @returns {{ setValue: (value: unknown) => void } | null}
*/
setupLiteralEditor(node, article, inputContainer) {
if (node.type === "set_local_var" || node.type === "get_local_var") {
return this.#setupLocalVariableEditor(node, article, inputContainer);
}
const supported = new Set([
"number_literal",
"string_literal",
"boolean_literal",
]);
if (!supported.has(node.type)) {
return null;
}
const existing = article.querySelector(".node-body");
const body = existing ?? document.createElement("div");
body.className = "node-body";
if (!existing) {
article.insertBefore(body, inputContainer);
}
body.innerHTML = "";
const editor = document.createElement("div");
editor.className = "literal-inline-editor";
body.appendChild(editor);
const controlId = `${node.id}_inline_value`;
const label = document.createElement("label");
label.className = "literal-inline-label";
label.setAttribute("for", controlId);
label.textContent = "Value";
editor.appendChild(label);
const graph = this.#getGraph();
let control;
const commitNumber = () => {
const numeric = Number(control.value);
const value = Number.isFinite(numeric) ? numeric : 0;
graph.setNodeProperty(node.id, "value", value);
};
switch (node.type) {
case "number_literal": {
control = document.createElement("input");
control.type = "number";
control.step = "any";
control.className = "literal-inline-input";
control.addEventListener("change", commitNumber);
control.addEventListener("blur", commitNumber);
break;
}
case "string_literal": {
control = document.createElement("input");
control.type = "text";
control.className = "literal-inline-input";
control.spellcheck = false;
control.addEventListener("input", () => {
graph.setNodeProperty(node.id, "value", control.value);
});
break;
}
case "boolean_literal": {
control = document.createElement("input");
control.type = "checkbox";
control.className = "literal-inline-checkbox";
control.addEventListener("change", () => {
graph.setNodeProperty(node.id, "value", control.checked);
});
break;
}
default:
return null;
}
control.id = controlId;
editor.appendChild(control);
const setValue = (rawValue) => {
switch (node.type) {
case "number_literal": {
const numeric = Number(rawValue);
control.value = Number.isFinite(numeric) ? String(numeric) : "0";
break;
}
case "string_literal": {
control.value = rawValue == null ? "" : String(rawValue);
break;
}
case "boolean_literal": {
control.checked = this.#isTruthy(rawValue);
break;
}
default:
break;
}
};
setValue(node.properties.value);
const entry = this.#ensureEntry(node.id);
entry.literal = { setValue };
return entry.literal;
}
#setupLocalVariableEditor(node, article, inputContainer) {
const typeOptions = [
{ value: "number", label: "Number" },
{ value: "string", label: "String" },
{ value: "boolean", label: "Boolean" },
{ value: "table", label: "Table" },
];
const ensureBody = () => {
const existing = article.querySelector(".node-body");
if (existing) {
existing.innerHTML = "";
return existing;
}
const body = document.createElement("div");
body.className = "node-body";
article.insertBefore(body, inputContainer);
return body;
};
const body = ensureBody();
const editor = document.createElement("div");
editor.className = "local-variable-editor";
body.appendChild(editor);
const headerRow = document.createElement("div");
headerRow.className = "local-variable-row local-variable-row--header";
editor.appendChild(headerRow);
const typeAnchor = document.createElement("div");
typeAnchor.className = "local-variable-type";
headerRow.appendChild(typeAnchor);
const typeButton = document.createElement("button");
typeButton.type = "button";
typeButton.className = "variable-type-button local-variable-type-button";
typeButton.setAttribute("aria-haspopup", "menu");
typeButton.setAttribute("aria-expanded", "false");
typeAnchor.appendChild(typeButton);
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.className = "local-variable-name-input";
nameInput.placeholder = "localVar";
nameInput.setAttribute("spellcheck", "false");
nameInput.setAttribute("aria-label", "Local variable name");
headerRow.appendChild(nameInput);
const valueContainer = document.createElement("div");
valueContainer.className = "local-variable-inline-value";
const valueField = document.createElement("div");
valueField.className = "local-variable-inline-value-field";
valueContainer.appendChild(valueField);
const attachValueContainer = () => {
if (node.type !== "set_local_var") {
if (valueContainer.isConnected) {
valueContainer.remove();
}
return;
}
const valuePin = article.querySelector(
".node-inputs .pin[data-pin-id='value']"
);
if (!valuePin) {
return;
}
const parent = valuePin.parentElement;
if (!parent) {
return;
}
if (valueContainer.parentElement !== parent) {
valuePin.insertAdjacentElement("afterend", valueContainer);
return;
}
if (valuePin.nextElementSibling !== valueContainer) {
valuePin.insertAdjacentElement("afterend", valueContainer);
}
};
const graph = this.#getGraph();
const normalizeType = (value) => {
const candidate = typeof value === "string" ? value : "number";
return typeOptions.some((option) => option.value === candidate)
? candidate
: "number";
};
const formatType = (type) => {
return this.#formatVariableType(normalizeType(type));
};
const resolveValuePropertyKey = (type) => {
switch (type) {
case "string":
return "valueString";
case "boolean":
return "valueBoolean";
case "table":
return "valueTable";
case "number":
default:
return "valueNumber";
}
};
const resolveValueControlKind = (type) => {
switch (type) {
case "boolean":
return "boolean";
case "string":
return "string";
case "table":
return "table";
default:
return "number";
}
};
const defaultValueForType = (type) => {
switch (type) {
case "string":
return "";
case "boolean":
return false;
case "table":
return "{}";
case "number":
default:
return 0;
}
};
let activeType = normalizeType(node.properties.variableType);
const updateTypeButton = (type) => {
const normalized = normalizeType(type);
activeType = normalized;
typeButton.dataset.variableType = normalized;
const label = formatType(normalized);
typeButton.title = `Type: ${label}`;
typeButton.setAttribute(
"aria-label",
`Change local variable type (currently ${label})`
);
};
const renderValueControl = (currentNode, type) => {
if (currentNode.type !== "set_local_var") {
if (valueContainer.isConnected) {
valueContainer.remove();
}
valueField.innerHTML = "";
return;
}
const key = resolveValuePropertyKey(type);
const current = currentNode.properties?.[key];
const ensureValueControl = () => {
const desiredKind = resolveValueControlKind(type);
const existing = valueField.firstElementChild;
if (
existing instanceof HTMLElement &&
existing.dataset.localValueKind === desiredKind
) {
return /** @type {HTMLElement} */ (existing);
}
valueField.innerHTML = "";
let control;
if (desiredKind === "boolean") {
const select = document.createElement("select");
select.className = "local-variable-boolean";
select.setAttribute("aria-label", "Local variable value");
const trueOption = document.createElement("option");
trueOption.value = "true";
trueOption.textContent = "True";
const falseOption = document.createElement("option");
falseOption.value = "false";
falseOption.textContent = "False";
select.appendChild(trueOption);
select.appendChild(falseOption);
select.addEventListener("change", () => {
const next = select.value === "true";
graph.setNodeProperty(currentNode.id, key, next);
});
control = select;
} else if (desiredKind === "string") {
const input = document.createElement("input");
input.type = "text";
input.className = "local-variable-text";
input.setAttribute("aria-label", "Local variable value");
input.addEventListener("input", () => {
graph.setNodeProperty(currentNode.id, key, input.value);
});
control = input;
} else if (desiredKind === "table") {
const textarea = document.createElement("textarea");
textarea.className = "local-variable-textarea";
textarea.rows = 2;
textarea.setAttribute("aria-label", "Local variable value");
textarea.addEventListener("input", () => {
graph.setNodeProperty(currentNode.id, key, textarea.value);
});
control = textarea;
} else {
const numberInput = document.createElement("input");
numberInput.type = "number";
numberInput.step = "any";
numberInput.className = "local-variable-number";
numberInput.setAttribute("aria-label", "Local variable value");
const commit = () => {
const value = Number(numberInput.value);
const next = Number.isFinite(value) ? value : 0;
numberInput.value = String(next);
graph.setNodeProperty(currentNode.id, key, next);
};
numberInput.addEventListener("change", commit);
numberInput.addEventListener("blur", commit);
control = numberInput;
}
control.dataset.localValueKind = desiredKind;
valueField.appendChild(control);
return control;
};
const control = ensureValueControl();
if (control instanceof HTMLSelectElement) {
const nextValue = current ? "true" : "false";
if (control.value !== nextValue) {
control.value = nextValue;
}
} else if (control instanceof HTMLTextAreaElement) {
const nextValue = typeof current === "string" ? current : "{}";
if (control.value !== nextValue) {
control.value = nextValue;
}
} else if (control instanceof HTMLInputElement) {
if (control.type === "text") {
const nextValue = typeof current === "string" ? current : "";
if (control.value !== nextValue) {
control.value = nextValue;
}
} else {
const numeric = Number(current);
const nextValue = Number.isFinite(numeric) ? String(numeric) : "0";
if (control.value !== nextValue) {
control.value = nextValue;
}
}
}
attachValueContainer();
};
const applyTypeChange = (rawType) => {
const nextType = normalizeType(rawType);
const key = resolveValuePropertyKey(nextType);
const currentNode = graph.nodes.get(node.id) ?? node;
const existing = currentNode.properties?.[key];
const fallback = existing ?? defaultValueForType(nextType);
updateTypeButton(nextType);
graph.setNodeProperty(currentNode.id, "variableType", nextType);
graph.setNodeProperty(currentNode.id, key, fallback);
const refreshed = graph.nodes.get(node.id) ?? currentNode;
renderValueControl(refreshed, nextType);
};
const entry = this.#ensureEntry(node.id);
const setState = (currentNode) => {
const currentType = normalizeType(currentNode.properties.variableType);
updateTypeButton(currentType);
const currentName =
typeof currentNode.properties.name === "string"
? currentNode.properties.name
: "localVar";
if (nameInput.value !== currentName) {
nameInput.value = currentName;
}
renderValueControl(currentNode, currentType);
};
const openDropdown = () => {
if (!this.#openLocalTypeDropdown) {
return;
}
this.#openLocalTypeDropdown(
node.id,
typeButton,
typeAnchor,
activeType,
(selectedType) => {
applyTypeChange(selectedType);
}
);
};
typeButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (!this.#openLocalTypeDropdown) {
return;
}
const context = { kind: "local_variable", nodeId: node.id };
const isOpen = this.#isTypeDropdownOpen(context);
if (isOpen) {
this.#closeTypeDropdown();
return;
}
openDropdown();
});
typeButton.addEventListener("pointerdown", (event) => {
event.stopPropagation();
});
typeButton.addEventListener("keydown", (event) => {
const isToggle =
event.key === "Enter" || event.key === " " || event.key === "Spacebar";
if (isToggle || event.key === "ArrowDown") {
event.preventDefault();
openDropdown();
} else if (event.key === "Escape") {
event.preventDefault();
this.#closeTypeDropdown();
}
});
nameInput.addEventListener("blur", () => {
graph.setNodeProperty(node.id, "name", nameInput.value);
});
nameInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
graph.setNodeProperty(node.id, "name", nameInput.value);
nameInput.blur();
}
});
entry.literal = { setState };
setState(node);
return entry.literal;
}
/**
* Creates inline pin editors when supported.
*
* @param {BlueprintNode} node Current node instance.
* @param {PinDescriptor} pin Pin descriptor.
* @param {HTMLElement} container Pin container element.
*/
setupPinEditor(node, pin, container) {
if (pin.direction !== "input") {
return;
}
const isIfCondition = node.type === "if" && pin.id === "condition";
const isExtcmdCommand =
node.type === "system_extcmd" && pin.id === "command";
const isNamedButtonSelector =
node.type === "input_button_constants" && pin.id === "button";
const isNamedPlayerSelector =
node.type === "input_button_constants" && pin.id === "player";
if (
!isIfCondition &&
!isExtcmdCommand &&
!isNamedButtonSelector &&
!isNamedPlayerSelector
) {
container.classList.remove("has-inline-dropdown");
return;
}
const entry = this.#ensureEntry(node.id);
const propertyKey = this.#pinPropertyKey(pin.id);
const graph = this.#getGraph();
if (!(propertyKey in node.properties)) {
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
const definition = this.registry.get(node.type);
const targetKey = isExtcmdCommand
? "command"
: isNamedButtonSelector
? "button"
: "player";
const schema = definition?.properties?.find(
(prop) => prop.key === targetKey
);
const options = Array.isArray(schema?.options) ? schema.options : [];
const defaultOption =
typeof node.properties[targetKey] === "string"
? node.properties[targetKey]
: String(schema?.defaultValue ?? options[0]?.value ?? "");
if (typeof node.properties[targetKey] !== "string") {
node.properties[targetKey] = defaultOption;
}
if (isExtcmdCommand) {
node.properties[propertyKey] = defaultOption;
} else {
const numericDefault = Number.parseInt(defaultOption, 10);
node.properties[propertyKey] = Number.isFinite(numericDefault)
? numericDefault
: 0;
}
} else {
node.properties[propertyKey] = this.#defaultValueForPin(pin);
}
}
container.querySelectorAll(".pin-inline-control").forEach((element) => {
element.remove();
});
let wrapper;
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
wrapper = document.createElement("div");
wrapper.className = "pin-inline-control pin-inline-dropdown";
container.appendChild(wrapper);
} else {
wrapper = document.createElement("span");
wrapper.className = "pin-inline-control";
container.classList.remove("has-inline-dropdown");
const label = container.querySelector(".pin-label");
if (label) {
container.insertBefore(wrapper, label);
} else {
container.appendChild(wrapper);
}
}
if (isIfCondition && pin.kind === "boolean") {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "pin-inline-checkbox";
wrapper.appendChild(checkbox);
const setValue = (value) => {
checkbox.checked = this.#isTruthy(value);
};
const setConnected = (connected) => {
wrapper.classList.toggle("is-hidden", connected);
};
checkbox.addEventListener("change", () => {
graph.setNodeProperty(node.id, propertyKey, checkbox.checked);
});
entry.pinEditors.set(pin.id, { setValue, setConnected });
setValue(node.properties[propertyKey]);
setConnected(this.#hasConnection(node.id, pin.id));
return;
}
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
const definition = this.registry.get(node.type);
const targetKey = isExtcmdCommand
? "command"
: isNamedButtonSelector
? "button"
: "player";
const schema = definition?.properties?.find(
(prop) => prop.key === targetKey
);
const options = Array.isArray(schema?.options) ? schema.options : [];
const select = document.createElement("select");
select.className = "pin-inline-select";
const ariaLabel = isExtcmdCommand
? "Command"
: isNamedButtonSelector
? "Button"
: "Player";
select.setAttribute("aria-label", ariaLabel);
const values = options.map((option) => String(option.value));
options.forEach((option) => {
const element = document.createElement("option");
element.value = String(option.value);
element.textContent = option.label ?? String(option.value);
select.appendChild(element);
});
const normalizeValue = (value) => {
const candidate =
typeof value === "string" ? value : String(value ?? "");
if (values.length === 0) {
return candidate;
}
if (values.includes(candidate)) {
return candidate;
}
const schemaDefault =
schema?.defaultValue != null ? String(schema.defaultValue) : null;
if (schemaDefault && values.includes(schemaDefault)) {
return schemaDefault;
}
return values[0];
};
const setValue = (value) => {
const normalized = normalizeValue(
(isNamedButtonSelector || isNamedPlayerSelector) &&
typeof node.properties[targetKey] === "string"
? node.properties[targetKey]
: value
);
select.value = normalized;
};
const setConnected = (connected) => {
wrapper.classList.toggle("is-hidden", connected);
container.classList.toggle("has-inline-dropdown", !connected);
};
select.addEventListener("change", () => {
const nextValue = select.value;
if (isExtcmdCommand) {
graph.setNodeProperty(node.id, propertyKey, nextValue);
graph.setNodeProperty(node.id, "command", nextValue);
} else {
const normalized = normalizeValue(nextValue);
const numeric = Number.parseInt(normalized, 10);
graph.setNodeProperty(
node.id,
propertyKey,
Number.isFinite(numeric) ? numeric : 0
);
graph.setNodeProperty(node.id, targetKey, normalized);
}
});
wrapper.appendChild(select);
entry.pinEditors.set(pin.id, { setValue, setConnected });
setValue(node.properties[propertyKey]);
setConnected(this.#hasConnection(node.id, pin.id));
}
}
/**
* Removes inline editors for pins no longer present on a node.
*
* @param {BlueprintNode} node Target node reference.
*/
prunePinEditors(node) {
const entry = this.inlineEditors.get(node.id);
if (!entry?.pinEditors) {
return;
}
const activePins = new Set(node.inputs.map((pin) => pin.id));
[...entry.pinEditors.keys()].forEach((pinId) => {
if (!activePins.has(pinId)) {
entry.pinEditors.delete(pinId);
}
});
}
/**
* Synchronizes inline editor controls with the graph state for the supplied node.
*
* @param {string} nodeId Node identifier.
*/
syncNode(nodeId) {
const entry = this.inlineEditors.get(nodeId);
if (!entry) {
return;
}
const graph = this.#getGraph();
const node = graph.nodes.get(nodeId);
if (!node) {
return;
}
if (entry.literal) {
if (typeof entry.literal.setState === "function") {
entry.literal.setState(node);
} else if (typeof entry.literal.setValue === "function") {
entry.literal.setValue(node.properties.value);
}
}
entry.pinEditors.forEach((pinEditor, pinId) => {
const pin = node.getPin(pinId);
const propertyKey = this.#pinPropertyKey(pinId);
let value;
if (node.type === "system_extcmd" && pinId === "command") {
const commandValue =
typeof node.properties.command === "string"
? node.properties.command
: null;
if (
commandValue !== null &&
node.properties[propertyKey] !== commandValue
) {
node.properties[propertyKey] = commandValue;
}
} else if (node.type === "input_button_constants") {
if (pinId === "button") {
const rawButton =
typeof node.properties.button === "string"
? node.properties.button
: null;
if (rawButton !== null) {
const numericButton = Number.parseInt(rawButton, 10);
const safeButton = Number.isFinite(numericButton)
? numericButton
: 0;
if (node.properties[propertyKey] !== safeButton) {
node.properties[propertyKey] = safeButton;
}
}
} else if (pinId === "player") {
const rawPlayer =
typeof node.properties.player === "string"
? node.properties.player
: null;
if (rawPlayer !== null) {
const numericPlayer = Number.parseInt(rawPlayer, 10);
const safePlayer = Number.isFinite(numericPlayer)
? numericPlayer
: 0;
if (node.properties[propertyKey] !== safePlayer) {
node.properties[propertyKey] = safePlayer;
}
}
}
}
if (propertyKey in node.properties) {
value = node.properties[propertyKey];
} else if (pin) {
value = this.#defaultValueForPin(pin);
} else {
value = null;
}
pinEditor.setValue(value);
pinEditor.setConnected(this.#hasConnection(nodeId, pinId));
});
}
/** Synchronizes inline editors for every tracked node. */
syncAll() {
this.inlineEditors.forEach((_, nodeId) => this.syncNode(nodeId));
}
/**
* Removes inline editor tracking for a node.
*
* @param {string} nodeId Node identifier.
*/
removeNode(nodeId) {
this.inlineEditors.delete(nodeId);
}
/** Clears all inline editor state. */
clear() {
this.inlineEditors.clear();
}
/**
* Ensures inline editor entry exists for a node.
*
* @param {string} nodeId Node identifier.
* @returns {{ literal?: { setValue: (value: unknown) => void }, pinEditors: Map<string, { setValue: (value: unknown) => void, setConnected: (connected: boolean) => void }> }}
*/
#ensureEntry(nodeId) {
let entry = this.inlineEditors.get(nodeId);
if (!entry) {
entry = { pinEditors: new Map() };
this.inlineEditors.set(nodeId, entry);
} else if (!entry.pinEditors) {
entry.pinEditors = new Map();
}
return entry;
}
/**
* Generates the property key used for storing inline pin overrides.
*
* @param {string} pinId Pin identifier.
* @returns {string}
*/
#pinPropertyKey(pinId) {
return `pin:${pinId}`;
}
/**
* Determines whether a pin has any connections.
*
* @param {string} nodeId Node identifier.
* @param {string} pinId Pin identifier.
* @returns {boolean}
*/
#hasConnection(nodeId, pinId) {
const graph = this.#getGraph();
return graph.getConnectionsForNode(nodeId, pinId).some((connection) => {
const isTarget =
connection.to.nodeId === nodeId && connection.to.pinId === pinId;
const isSource =
connection.from.nodeId === nodeId && connection.from.pinId === pinId;
return isTarget || isSource;
});
}
/**
* Provides a reasonable default value for the supplied pin.
*
* @param {PinDescriptor} pin Pin descriptor.
* @returns {unknown}
*/
#defaultValueForPin(pin) {
if (!pin) {
return null;
}
if (pin.defaultValue !== undefined) {
return pin.defaultValue;
}
switch (pin.kind) {
case "boolean":
return false;
case "number":
return 0;
case "string":
return "";
default:
return null;
}
}
/**
* Normalizes truthy values for inline boolean controls.
*
* @param {unknown} value Candidate value.
* @returns {boolean}
*/
#isTruthy(value) {
if (typeof value === "string") {
return value.toLowerCase() !== "false";
}
return Boolean(value);
}
/** @type {() => NodeGraph} */
#getGraph;
/** @type {(type: VariableType) => string} */
#formatVariableType;
/** @type {(nodeId: string, trigger: HTMLButtonElement, anchor: HTMLElement, currentType: VariableType, onSelect: (type: VariableType) => void) => void} */
#openLocalTypeDropdown;
/** @type {() => void} */
#closeTypeDropdown;
/** @type {(context: { kind: 'local_variable', nodeId: string }) => boolean} */
#isTypeDropdownOpen;
}

View File

@@ -0,0 +1,228 @@
/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
*/
/**
* Coordinates marquee-based selection gestures within the workspace.
*/
export class WorkspaceMarqueeManager {
/** @type {BlueprintWorkspace} */
#workspace;
/** @type {HTMLDivElement | null} */
#element;
/** @type {{ pointerId: number, origin: { x: number, y: number }, current: { x: number, y: number }, hasDragged: boolean, moveHandler: (event: PointerEvent) => void, upHandler: (event: PointerEvent) => void } | null} */
#state;
/**
* @param {BlueprintWorkspace} workspace Owning workspace instance.
*/
constructor(workspace) {
this.#workspace = workspace;
this.#element = null;
this.#state = null;
}
/**
* Ensures the marquee overlay element exists within the workspace DOM.
*/
ensureElement() {
if (this.#element) {
return;
}
const marquee = document.createElement("div");
marquee.className = "workspace-marquee";
marquee.style.display = "none";
marquee.setAttribute("role", "presentation");
this.#workspace.workspaceElement.appendChild(marquee);
this.#element = marquee;
}
/**
* Indicates whether the provided element is the marquee overlay.
*
* @param {Element | null} target Potential marquee element.
* @returns {boolean}
*/
isMarqueeElement(target) {
return Boolean(target && this.#element && target === this.#element);
}
/**
* Initiates a marquee selection gesture at the supplied pointer event.
*
* @param {PointerEvent} event Pointer event initiating the marquee.
*/
beginSelection(event) {
this.ensureElement();
if (!this.#element) {
return;
}
const bounds = this.#workspace.workspaceElement.getBoundingClientRect();
const origin = {
x: Math.max(0, event.clientX - bounds.left),
y: Math.max(0, event.clientY - bounds.top),
};
this.#element.style.display = "block";
this.#element.style.left = `${origin.x}px`;
this.#element.style.top = `${origin.y}px`;
this.#element.style.width = "0px";
this.#element.style.height = "0px";
const handlePointerMove = (moveEvent) => {
if (!this.#state || moveEvent.pointerId !== this.#state.pointerId) {
return;
}
this.#updateSelection(moveEvent);
};
const handlePointerUp = (upEvent) => {
if (!this.#state || upEvent.pointerId !== this.#state.pointerId) {
return;
}
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
this.#finalizeSelection();
};
this.#state = {
pointerId: event.pointerId,
origin,
current: { ...origin },
hasDragged: false,
moveHandler: handlePointerMove,
upHandler: handlePointerUp,
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
event.preventDefault();
}
/**
* Clears any ongoing marquee gesture and hides the overlay.
*/
clear() {
if (this.#state) {
window.removeEventListener("pointermove", this.#state.moveHandler);
window.removeEventListener("pointerup", this.#state.upHandler);
window.removeEventListener("pointercancel", this.#state.upHandler);
this.#state = null;
}
if (this.#element) {
this.#element.style.display = "none";
this.#element.style.width = "0px";
this.#element.style.height = "0px";
}
}
/**
* Updates the marquee overlay to reflect the latest pointer location.
*
* @param {PointerEvent} event Active pointer event.
*/
#updateSelection(event) {
if (!this.#state || !this.#element) {
return;
}
const bounds = this.#workspace.workspaceElement.getBoundingClientRect();
const clampedX = Math.min(
Math.max(0, event.clientX - bounds.left),
bounds.width
);
const clampedY = Math.min(
Math.max(0, event.clientY - bounds.top),
bounds.height
);
this.#state.current = { x: clampedX, y: clampedY };
const { origin, current } = this.#state;
const left = Math.min(origin.x, current.x);
const top = Math.min(origin.y, current.y);
const width = Math.abs(current.x - origin.x);
const height = Math.abs(current.y - origin.y);
if (!this.#state.hasDragged && (width > 2 || height > 2)) {
this.#state.hasDragged = true;
}
this.#element.style.left = `${left}px`;
this.#element.style.top = `${top}px`;
this.#element.style.width = `${width}px`;
this.#element.style.height = `${height}px`;
}
/**
* Finishes the marquee gesture and updates the workspace selection.
*/
#finalizeSelection() {
if (!this.#state || !this.#element) {
return;
}
const { origin, current, hasDragged } = this.#state;
this.clear();
if (!hasDragged) {
return;
}
const bounds = this.#workspace.workspaceElement.getBoundingClientRect();
const selectionBounds = {
left: Math.min(origin.x, current.x) + bounds.left,
top: Math.min(origin.y, current.y) + bounds.top,
right: Math.max(origin.x, current.x) + bounds.left,
bottom: Math.max(origin.y, current.y) + bounds.top,
};
this.#selectNodesWithinRect(selectionBounds);
}
/**
* Selects nodes that intersect the provided screen-space bounds.
*
* @param {{ left: number, top: number, right: number, bottom: number }} bounds Selection bounds.
*/
#selectNodesWithinRect(bounds) {
/** @type {Array<string>} */
const selected = [];
this.#workspace.nodeElements.forEach((element, nodeId) => {
const rect = element.getBoundingClientRect();
if (this.#rectanglesOverlap(bounds, rect)) {
selected.push(nodeId);
}
});
if (selected.length) {
const primary = selected[selected.length - 1];
this.#workspace.setSelectionState(selected, primary);
} else {
this.#workspace.clearSelection();
}
}
/**
* Determines whether two rectangles overlap (inclusive of borders).
*
* @param {{ left: number, top: number, right: number, bottom: number }} a First rectangle.
* @param {{ left: number, top: number, right: number, bottom: number }} b Second rectangle.
* @returns {boolean}
*/
#rectanglesOverlap(a, b) {
return !(
b.left > a.right ||
b.right < a.left ||
b.top > a.bottom ||
b.bottom < a.top
);
}
}

View File

@@ -0,0 +1,597 @@
import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
/**
* Coordinates workspace navigation behaviour including pan, zoom, and coordinate transforms.
*/
export class WorkspaceNavigationManager {
#workspace;
#renderConnections;
#markViewDirty;
#schedulePersist;
#backgroundOffset;
#zoomLevel;
#zoomConfig;
#panState;
#lastPanTimestamp;
/**
* @param {import("../BlueprintWorkspace.js").BlueprintWorkspace} workspace Owning workspace instance.
* @param {{ renderConnections?: () => void, markViewDirty?: (reason: string, delay?: number) => void, schedulePersist?: () => void }} [callbacks]
* Hook callbacks invoked during navigation changes.
*/
constructor(workspace, callbacks = {}) {
this.#workspace = workspace;
this.#renderConnections =
typeof callbacks.renderConnections === "function"
? callbacks.renderConnections
: () => {};
this.#markViewDirty =
typeof callbacks.markViewDirty === "function"
? callbacks.markViewDirty
: () => {};
this.#schedulePersist =
typeof callbacks.schedulePersist === "function"
? callbacks.schedulePersist
: () => {};
this.#backgroundOffset = { x: 0, y: 0 };
this.#zoomLevel = 1;
this.#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };
this.#panState = null;
this.#lastPanTimestamp = 0;
}
/**
* @returns {number} Active zoom multiplier.
*/
getZoomLevel() {
return this.#zoomLevel;
}
/**
* Adjusts the zoom level within configured bounds.
*
* @param {number} nextZoom Requested zoom multiplier.
* @param {{ silent?: boolean }} [options] Behaviour overrides.
* @returns {boolean} Whether the zoom value changed.
*/
setZoomLevel(nextZoom, options = {}) {
if (!Number.isFinite(nextZoom)) {
return false;
}
const { silent = false } = options;
const clamped = Math.max(
this.#zoomConfig.min,
Math.min(this.#zoomConfig.max, nextZoom)
);
if (Math.abs(clamped - this.#zoomLevel) < 0.0001) {
return false;
}
this.#zoomLevel = clamped;
this.updateZoomDisplay();
if (!silent) {
this.#markViewDirty("view", 200);
this.#schedulePersist();
}
return true;
}
/**
* @returns {{ min: number, max: number, step: number }} Current zoom configuration.
*/
getZoomConfig() {
return { ...this.#zoomConfig };
}
/**
* Updates workspace transforms to match the stored zoom level.
*/
updateZoomDisplay() {
const zoom = this.#zoomLevel || 1;
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
if (nodeLayer) {
nodeLayer.style.transformOrigin = "0 0";
nodeLayer.style.transform = `scale(${zoom})`;
}
if (connectionLayer) {
connectionLayer.style.transformOrigin = "0 0";
connectionLayer.style.transform = `scale(${zoom})`;
}
workspaceElement.style.setProperty("--workspace-zoom", `${zoom}`);
this.#applyBackgroundOffset();
this.#renderConnections();
}
/**
* Converts a screen-space point to workspace coordinates.
*
* @param {{ x: number, y: number }} point Screen-space point relative to the workspace element.
* @returns {{ x: number, y: number }}
*/
screenPointToWorld(point) {
const zoom = this.#zoomLevel || 1;
return {
x: point.x / zoom,
y: point.y / zoom,
};
}
/**
* Converts a screen-space delta to workspace-space respecting the active zoom.
*
* @param {{ x: number, y: number }} delta Screen-space delta.
* @returns {{ x: number, y: number }}
*/
screenDeltaToWorld(delta) {
const zoom = this.#zoomLevel || 1;
return {
x: delta.x / zoom,
y: delta.y / zoom,
};
}
/**
* Applies zoom centred at a screen-space location.
*
* @param {{ x: number, y: number }} screenPoint Screen coordinates inside the workspace.
* @param {number} direction Positive to zoom in, negative to zoom out.
* @param {{ clientX?: number, clientY?: number }} [pointer] Optional pointer coordinates relative to the viewport.
*/
zoomAt(screenPoint, direction, pointer = {}) {
if (!direction) {
return;
}
const previousZoom = this.#zoomLevel || 1;
const worldPoint = this.screenPointToWorld(screenPoint);
const nextZoom = previousZoom + direction * this.#zoomConfig.step;
const changed = this.setZoomLevel(nextZoom);
if (!changed) {
return;
}
const currentZoom = this.#zoomLevel || 1;
if (Math.abs(currentZoom - previousZoom) < 0.0001) {
return;
}
const scale = previousZoom / currentZoom;
const shift = {
x: worldPoint.x * (scale - 1),
y: worldPoint.y * (scale - 1),
};
if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) {
this.translateWorkspace(shift);
const { dragManager } = this.#workspace;
if (dragManager?.rebaseActiveDragPointer) {
dragManager.rebaseActiveDragPointer(pointer);
}
this.#rebasePanGestureAfterZoom(pointer);
this.#renderConnections();
}
}
/**
* Updates the persisted background offset and re-renders the tiled background.
*
* @param {{ x: number, y: number }} offset Workspace-space offset.
*/
setBackgroundOffset(offset) {
const next = offset ?? { x: 0, y: 0 };
this.#backgroundOffset = {
x: Number.isFinite(next.x) ? next.x : 0,
y: Number.isFinite(next.y) ? next.y : 0,
};
this.#applyBackgroundOffset();
}
/**
* @returns {{ x: number, y: number }} Current background offset reference.
*/
getBackgroundOffset() {
return this.#backgroundOffset;
}
/**
* Applies a workspace translation to all nodes and the background.
*
* @param {{ x: number, y: number }} delta Translation amount in workspace units.
*/
translateWorkspace(delta) {
if (!delta || (delta.x === 0 && delta.y === 0)) {
return;
}
this.setBackgroundOffset({
x: this.#backgroundOffset.x + delta.x,
y: this.#backgroundOffset.y + delta.y,
});
const { graph, dragManager } = this.#workspace;
const panState = this.#panState;
const panDelta = panState ? panState.lastDelta : null;
graph.nodes.forEach((node) => {
const origin = panState?.nodeOrigins.get(node.id) ?? node.position;
const baseDelta = panDelta ?? { x: 0, y: 0 };
const next = {
x: origin.x + baseDelta.x + delta.x,
y: origin.y + baseDelta.y + delta.y,
};
graph.setNodePosition(node.id, next);
if (dragManager?.updateNodeDomPosition) {
dragManager.updateNodeDomPosition(node.id, next);
}
});
if (dragManager?.applyActiveDragShift) {
dragManager.applyActiveDragShift(delta);
}
this.#syncPanStateAfterShift(delta);
}
/**
* Recentres the viewport on the supplied node if available.
*
* @param {string} nodeId Target node identifier.
*/
focusNode(nodeId) {
const element = this.#workspace.nodeElements.get(nodeId);
if (!element) {
return;
}
const workspaceRect = this.#workspace.workspaceElement.getBoundingClientRect();
const nodeRect = element.getBoundingClientRect();
if (!workspaceRect.width || !workspaceRect.height) {
this.#workspace.selectNode(nodeId);
return;
}
const workspaceCenterX = workspaceRect.left + workspaceRect.width / 2;
const workspaceCenterY = workspaceRect.top + workspaceRect.height / 2;
const nodeCenterX = nodeRect.left + nodeRect.width / 2;
const nodeCenterY = nodeRect.top + nodeRect.height / 2;
const deltaXScreen = workspaceCenterX - nodeCenterX;
const deltaYScreen = workspaceCenterY - nodeCenterY;
if (Math.abs(deltaXScreen) < 1 && Math.abs(deltaYScreen) < 1) {
this.#workspace.selectNode(nodeId);
return;
}
const delta = this.screenDeltaToWorld({
x: deltaXScreen,
y: deltaYScreen,
});
this.translateWorkspace(delta);
this.#workspace.selectNode(nodeId);
}
/**
* Frames the supplied node identifiers within the viewport.
*
* @param {Array<string>} nodeIds Node identifiers to include when framing.
*/
frameNodes(nodeIds) {
const { workspaceElement, nodeElements, graph } = this.#workspace;
const rect = workspaceElement.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
/** @type {Array<{ id: string, x: number, y: number, width: number, height: number }>} */
const targets = [];
const collect = (id) => {
const node = graph.nodes.get(id);
const element = nodeElements.get(id);
if (!node || !element || !element.isConnected) {
return;
}
const width = Math.max(1, element.offsetWidth);
const height = Math.max(1, element.offsetHeight);
targets.push({
id,
x: node.position.x,
y: node.position.y,
width,
height,
});
};
if (Array.isArray(nodeIds) && nodeIds.length) {
nodeIds.forEach((id) => collect(id));
} else {
nodeElements.forEach((_, id) => collect(id));
}
if (!targets.length) {
return;
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
targets.forEach((entry) => {
minX = Math.min(minX, entry.x);
minY = Math.min(minY, entry.y);
maxX = Math.max(maxX, entry.x + entry.width);
maxY = Math.max(maxY, entry.y + entry.height);
});
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
return;
}
const boundsWidth = Math.max(1, maxX - minX);
const boundsHeight = Math.max(1, maxY - minY);
const viewportPadding = 200;
const availableWidth = Math.max(120, rect.width - viewportPadding);
const availableHeight = Math.max(120, rect.height - viewportPadding);
let targetZoom = Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight);
if (!Number.isFinite(targetZoom) || targetZoom <= 0) {
targetZoom = this.#zoomLevel || 1;
}
targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom));
this.setZoomLevel(targetZoom);
const contentCenter = {
x: minX + boundsWidth / 2,
y: minY + boundsHeight / 2,
};
const viewportCenter = this.screenPointToWorld({
x: rect.width / 2,
y: rect.height / 2,
});
const delta = {
x: viewportCenter.x - contentCenter.x,
y: viewportCenter.y - contentCenter.y,
};
if (Math.abs(delta.x) > 0.0001 || Math.abs(delta.y) > 0.0001) {
this.translateWorkspace(delta);
this.#renderConnections();
}
this.#markViewDirty("view", 200);
this.#schedulePersist();
}
/**
* Begins a pan gesture driven by the supplied pointer event.
*
* @param {PointerEvent} event Pointer event initiating the pan.
*/
beginPan(event) {
if (this.#panState) {
return;
}
const { dragManager, contextMenuManager, graph, nodeElements } =
this.#workspace;
dragManager?.removeNodeGhost();
dragManager?.removePaletteGhost();
const nodeOrigins = new Map();
graph.nodes.forEach((node) => {
nodeOrigins.set(node.id, { x: node.position.x, y: node.position.y });
});
const state = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
lastDelta: { x: 0, y: 0 },
hasMoved: false,
nodeOrigins,
backgroundOrigin: { ...this.#backgroundOffset },
};
const handlePointerMove = (moveEvent) => {
if (!this.#panState || moveEvent.pointerId !== this.#panState.pointerId) {
return;
}
const deltaXScreen = moveEvent.clientX - this.#panState.startX;
const deltaYScreen = moveEvent.clientY - this.#panState.startY;
const worldDelta = this.screenDeltaToWorld({
x: deltaXScreen,
y: deltaYScreen,
});
this.#panState.lastDelta = worldDelta;
if (!this.#panState.hasMoved) {
const distance = Math.hypot(deltaXScreen, deltaYScreen);
if (distance < 3) {
return;
}
moveEvent.preventDefault();
this.#panState.hasMoved = true;
contextMenuManager?.hide();
}
if (!this.#panState.hasMoved) {
return;
}
const backgroundOffset = {
x: this.#panState.backgroundOrigin.x + worldDelta.x,
y: this.#panState.backgroundOrigin.y + worldDelta.y,
};
this.setBackgroundOffset(backgroundOffset);
this.#panState.nodeOrigins.forEach((origin, nodeId) => {
const element = nodeElements.get(nodeId);
if (!element) {
return;
}
const next = {
x: origin.x + worldDelta.x,
y: origin.y + worldDelta.y,
};
element.style.transform = WorkspaceGeometry.positionToTransform(next);
});
this.#renderConnections();
};
const handlePointerUp = (upEvent) => {
if (!this.#panState || upEvent.pointerId !== this.#panState.pointerId) {
return;
}
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
const finalState = this.#panState;
this.#panState = null;
if (!finalState.hasMoved) {
return;
}
this.#lastPanTimestamp = this.#timestamp();
const delta = finalState.lastDelta;
this.setBackgroundOffset({
x: finalState.backgroundOrigin.x + delta.x,
y: finalState.backgroundOrigin.y + delta.y,
});
finalState.nodeOrigins.forEach((origin, nodeId) => {
graph.setNodePosition(nodeId, {
x: origin.x + delta.x,
y: origin.y + delta.y,
});
});
};
this.#panState = state;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}
/**
* Applies persisted view state values.
*
* @param {{ backgroundOffset?: { x: number, y: number }, zoom?: number } | null | undefined} view View state payload.
*/
applyViewState(view) {
const offset = view?.backgroundOffset ?? { x: 0, y: 0 };
this.setBackgroundOffset(offset);
if (typeof view?.zoom === "number" && Number.isFinite(view.zoom)) {
this.setZoomLevel(view.zoom, { silent: true });
} else {
this.updateZoomDisplay();
}
}
/**
* Determines whether a pan gesture concluded recently.
*
* @param {number} thresholdMs Time window in milliseconds.
* @returns {boolean}
*/
isPanRecent(thresholdMs) {
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) {
return false;
}
return this.#timestamp() - this.#lastPanTimestamp < thresholdMs;
}
/**
* @returns {number} Timestamp of the most recent completed pan gesture.
*/
getLastPanTimestamp() {
return this.#lastPanTimestamp;
}
/**
* Applies the current background offset to the workspace element styles.
*/
#applyBackgroundOffset() {
const zoom = this.#zoomLevel || 1;
const scaledX = this.#backgroundOffset.x * zoom;
const scaledY = this.#backgroundOffset.y * zoom;
const position = `${scaledX}px ${scaledY}px`;
this.#workspace.workspaceElement.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
}
/**
* Aligns any active pan state with a programmatic workspace translation.
*
* @param {{ x: number, y: number }} shift Workspace-space delta applied externally.
*/
#syncPanStateAfterShift(shift) {
if (!this.#panState) {
return;
}
if (!shift || (shift.x === 0 && shift.y === 0)) {
return;
}
this.#panState.backgroundOrigin = {
x: this.#panState.backgroundOrigin.x + shift.x,
y: this.#panState.backgroundOrigin.y + shift.y,
};
this.#panState.nodeOrigins.forEach((origin, nodeId, map) => {
map.set(nodeId, {
x: origin.x + shift.x,
y: origin.y + shift.y,
});
});
}
/**
* Rebases the active pan gesture to keep world deltas stable after zoom changes.
*
* @param {{ clientX?: number, clientY?: number }} [pointer] Pointer coordinates supplied by the zoom trigger.
*/
#rebasePanGestureAfterZoom(pointer) {
if (!this.#panState) {
return;
}
const zoom = this.#zoomLevel || 1;
const { clientX, clientY } = pointer ?? {};
if (Number.isFinite(clientX)) {
this.#panState.startX = clientX - this.#panState.lastDelta.x * zoom;
}
if (Number.isFinite(clientY)) {
this.#panState.startY = clientY - this.#panState.lastDelta.y * zoom;
}
}
/**
* @returns {number} High-resolution timestamp when available.
*/
#timestamp() {
if (
typeof performance !== "undefined" &&
typeof performance.now === "function"
) {
return performance.now();
}
return Date.now();
}
}

View File

@@ -0,0 +1,420 @@
/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
* @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode
* @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor
* @typedef {import('./WorkspaceInlineEditorManager.js').WorkspaceInlineEditorManager} WorkspaceInlineEditorManager
*/
import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
/**
* @typedef {"input"|"output"} PinDirection
*/
/**
* Coordinates node DOM rendering, pin creation, and connection class updates.
*/
export class WorkspaceNodeRenderer {
#workspace;
#inlineEditors;
#callbacks;
/**
* @param {BlueprintWorkspace} workspace Owning workspace instance.
* @param {{
* inlineEditorManager: WorkspaceInlineEditorManager,
* applySelectionState: () => void,
* selectNode: (nodeId: string) => void,
* startNodeDrag: (event: PointerEvent, nodeId: string) => void,
* refreshVariableNode: (nodeId: string) => boolean,
* refreshLocalVariableNode: (nodeId: string) => boolean,
* refreshCustomEventNode: (nodeId: string) => boolean,
* refreshCustomEventCallNode: (nodeId: string) => boolean,
* renderConnections: () => void,
* shouldHighlightPin: (nodeId: string, pinId: string, direction: PinDirection) => boolean,
* beginConnection: (event: PointerEvent, nodeId: string, pinId: string, direction: PinDirection) => void,
* finalizeConnection: (nodeId: string, pinId: string, direction: PinDirection) => void,
* getPinElement: (nodeId: string, pinId: string, direction: PinDirection) => HTMLElement | null,
* }} options Manager dependencies.
*/
constructor(workspace, options) {
this.#workspace = workspace;
this.#inlineEditors = options.inlineEditorManager;
this.#callbacks = {
applySelectionState: options.applySelectionState,
selectNode: options.selectNode,
startNodeDrag: options.startNodeDrag,
refreshVariableNode: options.refreshVariableNode,
refreshLocalVariableNode: options.refreshLocalVariableNode,
refreshCustomEventNode: options.refreshCustomEventNode,
refreshCustomEventCallNode: options.refreshCustomEventCallNode,
renderConnections: options.renderConnections,
shouldHighlightPin: options.shouldHighlightPin,
beginConnection: options.beginConnection,
finalizeConnection: options.finalizeConnection,
getPinElement: options.getPinElement,
};
}
/**
* Renders a node article, wires pin events, and updates inline editors.
*
* @param {BlueprintNode} node Target node.
*/
renderNode(node) {
const fragment = this.#workspace.nodeTemplate.content.cloneNode(true);
const article = /** @type {HTMLElement} */ (
fragment.querySelector(".blueprint-node")
);
const title = /** @type {HTMLElement} */ (
article.querySelector(".node-title")
);
const inputContainer = /** @type {HTMLElement} */ (
article.querySelector(".node-inputs")
);
const outputContainer = /** @type {HTMLElement} */ (
article.querySelector(".node-outputs")
);
title.textContent = node.title;
article.dataset.nodeId = node.id;
article.dataset.nodeType = node.type;
article.style.transform = WorkspaceGeometry.positionToTransform(
node.position
);
this.#inlineEditors.setupLiteralEditor(node, article, inputContainer);
inputContainer.innerHTML = "";
node.inputs.forEach((pin) => {
const pinElement = this.#createPinElement(node, pin);
pinElement.dataset.direction = "input";
inputContainer.appendChild(pinElement);
this.#inlineEditors.setupPinEditor(node, pin, pinElement);
});
outputContainer.innerHTML = "";
node.outputs.forEach((pin) => {
const pinElement = this.#createPinElement(node, pin);
pinElement.dataset.direction = "output";
outputContainer.appendChild(pinElement);
this.#inlineEditors.setupPinEditor(node, pin, pinElement);
});
article.addEventListener("pointerdown", (event) => {
const target = /** @type {EventTarget | null} */ (event.target);
if (!(target instanceof HTMLElement)) {
return;
}
if (target.closest(".pin-handle")) {
return;
}
const selectedIds = this.#workspace.selectedNodeIds;
if (selectedIds.has(node.id)) {
if (this.#workspace.selectedNodeId !== node.id) {
this.#workspace.selectedNodeId = node.id;
this.#callbacks.applySelectionState();
}
} else {
this.#callbacks.selectNode(node.id);
}
const isMouse =
event.pointerType === "mouse" || event.pointerType === "";
if (isMouse && event.button !== 0) {
return;
}
if (
!isMouse &&
event.pointerType !== "touch" &&
event.pointerType !== "pen"
) {
return;
}
if (target.closest(".pin")) {
return;
}
if (this.#isInteractiveElement(target)) {
return;
}
this.#callbacks.startNodeDrag(event, node.id);
});
article.addEventListener("pointerenter", () => {
article.classList.add("is-hovered");
});
article.addEventListener("pointerleave", () => {
article.classList.remove("is-hovered");
});
this.#workspace.nodeLayer.appendChild(article);
this.#workspace.nodeElements.set(node.id, article);
this.#inlineEditors.syncNode(node.id);
let connectionsMutated = false;
connectionsMutated =
this.#callbacks.refreshVariableNode(node.id) || connectionsMutated;
if (this.#callbacks.refreshLocalVariableNode) {
connectionsMutated =
this.#callbacks.refreshLocalVariableNode(node.id) ||
connectionsMutated;
}
if (node.type === "custom_event") {
connectionsMutated =
this.#callbacks.refreshCustomEventNode(node.id) || connectionsMutated;
} else if (node.type === "call_custom_event") {
connectionsMutated =
this.#callbacks.refreshCustomEventCallNode(node.id) ||
connectionsMutated;
}
if (connectionsMutated) {
this.#callbacks.renderConnections();
}
this.updatePinConnectionsForNode(node.id);
}
/**
* Rebuilds DOM pin containers for a node when its descriptors change.
*
* @param {BlueprintNode} node Target node.
* @param {{ inputs?: boolean, outputs?: boolean }} [options] Pin refresh options.
*/
rebuildNodePins(node, options = {}) {
const { inputs = true, outputs = true } = options;
const article = this.#workspace.nodeElements.get(node.id);
if (!article) {
return;
}
if (inputs) {
const inputContainer = article.querySelector(".node-inputs");
if (inputContainer instanceof HTMLElement) {
inputContainer.innerHTML = "";
node.inputs.forEach((pin) => {
const pinElement = this.#createPinElement(node, pin);
pinElement.dataset.direction = "input";
inputContainer.appendChild(pinElement);
this.#inlineEditors.setupPinEditor(node, pin, pinElement);
});
}
this.#inlineEditors.prunePinEditors(node);
}
if (outputs) {
const outputContainer = article.querySelector(".node-outputs");
if (outputContainer instanceof HTMLElement) {
outputContainer.innerHTML = "";
node.outputs.forEach((pin) => {
const pinElement = this.#createPinElement(node, pin);
pinElement.dataset.direction = "output";
outputContainer.appendChild(pinElement);
this.#inlineEditors.setupPinEditor(node, pin, pinElement);
});
}
}
this.updatePinConnectionsForNode(node.id);
}
/**
* Applies connection state classes to every pin associated with a node.
*
* @param {string} nodeId Node identifier.
*/
updatePinConnectionsForNode(nodeId) {
const node = this.#workspace.graph.nodes.get(nodeId);
if (!node) {
return;
}
node.inputs.forEach((pin) => {
this.#applyPinConnectionState(nodeId, pin.id, "input");
});
node.outputs.forEach((pin) => {
this.#applyPinConnectionState(nodeId, pin.id, "output");
});
}
/**
* Refreshes connection state classes for every pin in the workspace.
*/
refreshAllPinConnections() {
this.#workspace.graph.nodes.forEach((node) => {
this.updatePinConnectionsForNode(node.id);
});
}
/**
* Creates and wires a pin element for the provided descriptor.
*
* @param {BlueprintNode} node Parent node reference.
* @param {PinDescriptor} pin Pin descriptor.
* @returns {HTMLElement}
*/
#createPinElement(node, pin) {
const fragment = this.#workspace.pinTemplate.content.cloneNode(true);
const container = /** @type {HTMLElement} */ (fragment.firstElementChild);
const label = /** @type {HTMLElement} */ (
container.querySelector(".pin-label")
);
const handle = /** @type {HTMLElement} */ (
container.querySelector(".pin-handle")
);
container.dataset.pinId = pin.id;
container.dataset.nodeId = node.id;
container.dataset.type = pin.kind;
const isStandardExec =
pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
if (isStandardExec) {
label.textContent = "";
label.classList.add("is-hidden");
} else {
label.textContent = pin.name;
}
const shouldIgnoreForConnection = (target) => {
if (!(target instanceof HTMLElement)) {
return false;
}
return Boolean(target.closest(".pin-inline-control"));
};
const handlePointerDown = (event) => {
if (event.button !== 0) {
return;
}
if (shouldIgnoreForConnection(event.target)) {
return;
}
if (event.altKey) {
event.preventDefault();
event.stopPropagation();
this.#workspace.graph.removeConnectionsForPin({
nodeId: node.id,
pinId: pin.id,
});
return;
}
event.stopPropagation();
event.preventDefault();
this.#callbacks.beginConnection(event, node.id, pin.id, pin.direction);
};
const finalizeConnection = (event) => {
if (shouldIgnoreForConnection(event.target)) {
return;
}
if (!this.#workspace.pendingConnection) {
return;
}
event.stopPropagation();
this.#callbacks.finalizeConnection(node.id, pin.id, pin.direction);
};
handle.addEventListener("pointerdown", handlePointerDown);
handle.addEventListener("pointerup", finalizeConnection);
container.addEventListener("pointerdown", handlePointerDown);
container.addEventListener("pointerup", finalizeConnection);
container.addEventListener("pointerenter", () => {
container.classList.add("is-hovered");
if (
this.#callbacks.shouldHighlightPin(node.id, pin.id, pin.direction)
) {
container.classList.add("is-drop-hover");
}
});
container.addEventListener("pointerleave", () => {
container.classList.remove("is-hovered");
container.classList.remove("is-drop-hover");
});
const handleContextMenu = (event) => {
if (shouldIgnoreForConnection(event.target)) {
return;
}
event.preventDefault();
this.#workspace.graph.removeConnectionsForPin({
nodeId: node.id,
pinId: pin.id,
});
};
handle.addEventListener("contextmenu", handleContextMenu);
container.addEventListener("contextmenu", handleContextMenu);
this.#applyPinConnectionState(node.id, pin.id, pin.direction);
return container;
}
/**
* Applies connection state classes to the targeted pin element.
*
* @param {string} nodeId Node identifier.
* @param {string} pinId Pin identifier.
* @param {PinDirection} direction Pin direction.
*/
#applyPinConnectionState(nodeId, pinId, direction) {
const element = this.#callbacks.getPinElement(nodeId, pinId, direction);
if (!element) {
return;
}
const connected = this.#hasConnection(nodeId, pinId);
element.classList.toggle("is-disconnected", !connected);
element.classList.toggle("is-connected", connected);
}
/**
* Determines whether a pin currently participates in any connection.
*
* @param {string} nodeId Node identifier.
* @param {string} pinId Pin identifier.
* @returns {boolean}
*/
#hasConnection(nodeId, pinId) {
return this.#workspace.graph
.getConnectionsForNode(nodeId, pinId)
.some((connection) => {
const isTarget =
connection.to.nodeId === nodeId && connection.to.pinId === pinId;
const isSource =
connection.from.nodeId === nodeId &&
connection.from.pinId === pinId;
return isTarget || isSource;
});
}
/**
* Determines whether a node event target should block drag initiation.
*
* @param {HTMLElement} element Candidate event target.
* @returns {boolean}
*/
#isInteractiveElement(element) {
if (element.closest(".pin-inline-control")) {
return true;
}
if (element.closest(".literal-inline-editor")) {
return true;
}
if (element.closest('[contenteditable="true"]')) {
return true;
}
if (element.closest("input, textarea, select, button")) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,295 @@
/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
* @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition
* @typedef {import('../BlueprintWorkspace.js').PaletteNodeDefinition} PaletteNodeDefinition
*/
import { PALETTE_DRAG_MIME } from "../BlueprintWorkspaceConstants.js";
/**
* Coordinates palette rendering, search handling, and palette drag state.
*/
export class WorkspacePaletteManager {
#workspace;
/** @type {Set<string>} */
#collapsedCategories;
/**
* @param {BlueprintWorkspace} workspace Owning workspace instance.
*/
constructor(workspace) {
this.#workspace = workspace;
this.#collapsedCategories = new Set();
}
/**
* Wires palette UI listeners and renders initial content.
*/
initialize() {
const search = this.#workspace.paletteSearch;
search.addEventListener("input", () => {
this.render(search.value);
});
this.render();
}
/**
* Recreates palette DOM structure using the provided search filter.
*
* @param {string} [query] Optional search query.
*/
render(query) {
const searchValue = query ?? "";
const baseDefinitions = this.#workspace.registry.search(searchValue);
const filteredBase = baseDefinitions.filter(
(definition) => definition.id !== "get_var" && definition.id !== "set_var"
);
const variableShortcuts =
this.#buildVariableShortcutDefinitions(searchValue);
const definitions = filteredBase.concat(variableShortcuts);
const list = this.#workspace.paletteList;
list.innerHTML = "";
if (!definitions.length) {
const empty = document.createElement("p");
empty.className = "palette-empty";
empty.textContent = "No matching nodes";
list.appendChild(empty);
return;
}
const grouped = new Map();
definitions.forEach((definition) => {
if (!grouped.has(definition.category)) {
grouped.set(definition.category, []);
}
grouped.get(definition.category).push(definition);
});
const isSearchActive = Boolean(searchValue && searchValue.trim().length);
const categories = [...grouped.keys()].sort((a, b) => a.localeCompare(b));
categories.forEach((category) => {
const entries = grouped
.get(category)
.slice()
.sort((a, b) => a.title.localeCompare(b.title));
const section = document.createElement("section");
section.className = "palette-section";
section.dataset.category = category;
const header = document.createElement("button");
header.type = "button";
header.className = "palette-section-header";
header.textContent = category;
const slug = category.toLowerCase().replace(/[^a-z0-9]+/g, "_");
const headerId = `palette_section_${slug}`;
const bodyId = `${headerId}_body`;
header.id = headerId;
header.setAttribute("aria-controls", bodyId);
const collapsed =
!isSearchActive && this.#collapsedCategories.has(category);
header.setAttribute("aria-expanded", String(!collapsed));
header.addEventListener("click", () => {
if (this.#collapsedCategories.has(category)) {
this.#collapsedCategories.delete(category);
} else {
this.#collapsedCategories.add(category);
}
this.render(this.#workspace.paletteSearch.value);
requestAnimationFrame(() => {
const refreshedSection = [
...this.#workspace.paletteList.querySelectorAll(".palette-section"),
].find((element) => element.dataset.category === category);
const refreshedHeader = refreshedSection?.querySelector(
".palette-section-header"
);
if (refreshedHeader instanceof HTMLButtonElement) {
refreshedHeader.focus();
}
});
});
section.appendChild(header);
const body = document.createElement("div");
body.className = "palette-section-body";
body.setAttribute("role", "group");
body.id = bodyId;
body.setAttribute("aria-labelledby", headerId);
body.dataset.category = category;
if (collapsed) {
body.hidden = true;
section.classList.add("is-collapsed");
}
entries.forEach((definition) => {
const isVariableShortcut = Boolean(
definition.shortcut?.type === "variable"
);
const item = this.#createDefinitionButton(definition, {
className: "palette-item",
showCategory: false,
draggable: !isVariableShortcut,
onSelect: () => {
this.#workspace.createNodeFromDefinition(definition);
},
onDragStart: isVariableShortcut
? undefined
: (event) => {
this.#handlePaletteDragStart(event, definition.id);
},
onDragEnd: isVariableShortcut
? undefined
: () => {
this.#handlePaletteDragEnd();
},
});
body.appendChild(item);
});
section.appendChild(body);
list.appendChild(section);
});
}
/**
* Allows external callers to cancel palette drag state.
*/
handleDragEnd() {
this.#handlePaletteDragEnd();
}
/**
* Builds a button node for palette entries with attached drag/click handlers.
*
* @param {PaletteNodeDefinition} definition Target definition.
* @param {{ className?: string, showCategory?: boolean, draggable?: boolean, onSelect: () => void, onDragStart?: (event: DragEvent) => void, onDragEnd?: (event: DragEvent) => void }} options Button configuration.
* @returns {HTMLButtonElement}
*/
#createDefinitionButton(definition, options) {
const button = document.createElement("button");
button.type = "button";
button.className = options?.className ?? "palette-item";
button.dataset.definitionId = definition.id;
const showCategory = options?.showCategory ?? true;
if (showCategory) {
const category = document.createElement("span");
category.className = "palette-item-category";
category.textContent = definition.category;
button.appendChild(category);
}
const title = document.createElement("span");
title.className = "palette-item-name";
title.textContent = definition.title;
button.appendChild(title);
if (definition.description) {
button.title = definition.description;
}
button.addEventListener("click", () => {
options.onSelect();
});
if (options?.draggable) {
button.draggable = true;
button.addEventListener("dragstart", (event) => {
options.onDragStart?.(event);
});
button.addEventListener("dragend", (event) => {
options.onDragEnd?.(event);
});
}
return button;
}
/**
* Prepares drag metadata when beginning a palette drag interaction.
*
* @param {DragEvent} event Drag event payload.
* @param {string} definitionId Node definition identifier being dragged.
*/
#handlePaletteDragStart(event, definitionId) {
if (!event.dataTransfer) {
return;
}
event.dataTransfer.setData(PALETTE_DRAG_MIME, definitionId);
event.dataTransfer.setData("text/plain", definitionId);
event.dataTransfer.effectAllowed = "copy";
this.#workspace.workspaceDragDepth = 0;
this.#workspace.workspaceElement.classList.add("is-palette-dragging");
this.#workspace.activePaletteDefinitionId = definitionId;
}
/**
* Cleans up workspace styling once a palette drag interaction ends.
*/
#handlePaletteDragEnd() {
this.#workspace.workspaceDragDepth = 0;
this.#workspace.workspaceElement.classList.remove("is-palette-dragging");
this.#workspace.workspaceElement.classList.remove("is-drag-target");
this.#workspace.activePaletteDefinitionId = null;
this.#workspace.dragManager.removePaletteGhost();
}
/**
* Builds shortcut node definitions for existing workspace variables.
*
* @param {string} [query] Optional search filter.
* @returns {Array<PaletteNodeDefinition>}
*/
#buildVariableShortcutDefinitions(query) {
const normalizedQuery = (query ?? "").trim().toLowerCase();
const includeAll = normalizedQuery.length === 0;
const entries = [];
this.#workspace.variableOrder.forEach((variableId) => {
const variable = this.#workspace.variables.get(variableId);
if (!variable) {
return;
}
const displayName = (variable.name ?? "").trim() || variable.id;
const normalizedName = displayName.toLowerCase();
const tokens = [
normalizedName,
variableId.toLowerCase(),
`get ${normalizedName}`,
`set ${normalizedName}`,
];
if (
!includeAll &&
!tokens.some((token) => token.includes(normalizedQuery))
) {
return;
}
entries.push({
id: `variable_get_${variableId}`,
title: `Get ${displayName}`,
category: "Variables",
description: `Get ${displayName}`,
shortcut: { type: "variable", action: "get", variableId },
});
entries.push({
id: `variable_set_${variableId}`,
title: `Set ${displayName}`,
category: "Variables",
description: `Set ${displayName}`,
shortcut: { type: "variable", action: "set", variableId },
});
});
return entries;
}
}

View File

@@ -0,0 +1,213 @@
/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
* @typedef {import('./WorkspaceHistoryManager.js').SerializedWorkspace} SerializedWorkspace
*/
import { WORKSPACE_STORAGE_KEY } from "../BlueprintWorkspaceConstants.js";
/**
* Handles saving and restoring workspace state using localStorage.
*/
export class WorkspacePersistenceManager {
#workspace;
#timerId;
#storageAvailable;
/**
* @param {BlueprintWorkspace} workspace Owning workspace instance.
*/
constructor(workspace) {
this.#workspace = workspace;
this.#timerId = null;
this.#storageAvailable = this.#detectLocalStorage();
}
/**
* Indicates whether localStorage is available for persistence.
*
* @returns {boolean}
*/
get isStorageAvailable() {
return this.#storageAvailable;
}
/**
* Clears any pending persist timer.
*/
cancelScheduledPersist() {
if (this.#timerId !== null && typeof window !== "undefined") {
window.clearTimeout(this.#timerId);
this.#timerId = null;
}
}
/**
* Persists workspace state after a small debounce to reduce write frequency.
*/
schedulePersist() {
if (
!this.#storageAvailable ||
this.#workspace.isRestoring ||
typeof window === "undefined"
) {
return;
}
this.cancelScheduledPersist();
this.#timerId = window.setTimeout(() => {
this.#timerId = null;
this.persistImmediately();
}, 200);
}
/**
* Serializes current workspace state and writes it to localStorage immediately.
*/
persistImmediately() {
if (!this.#storageAvailable || typeof window === "undefined") {
return;
}
try {
const payload = this.serializeWorkspace();
window.localStorage.setItem(
WORKSPACE_STORAGE_KEY,
JSON.stringify(payload)
);
} catch (error) {
console.error("Failed to persist workspace state", error);
}
}
/**
* Creates a serializable snapshot of the workspace.
*
* @returns {SerializedWorkspace}
*/
serializeWorkspace() {
const variableEntries = this.#workspace.variableOrder
.map((variableId) => this.#workspace.variables.get(variableId))
.filter((entry) => Boolean(entry))
.map((entry) => {
const variable =
/** @type {{ id: string, name: string, type: 'any'|'number'|'string'|'boolean'|'table', defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue }} */ (
entry
);
return {
id: variable.id,
name: variable.name,
type: variable.type,
defaultValue:
variable.defaultValue === undefined
? null
: this.#cloneVariableDefault(variable.defaultValue),
};
});
return {
version: 4,
projectSettings: { ...this.#workspace.projectSettings },
graph: this.#workspace.graph.toJSON(),
variables: {
entries: variableEntries,
counter: this.#workspace.variableCounter,
},
spawnIndex: this.#workspace.spawnIndex,
ui: {
paletteVisible: Boolean(this.#workspace.isPaletteVisible),
},
};
}
/**
* Attempts to load workspace state from localStorage.
*
* @returns {boolean} Whether a valid payload was applied.
*/
restoreFromStorage() {
if (!this.#storageAvailable || typeof window === "undefined") {
return false;
}
let raw = null;
try {
raw = window.localStorage.getItem(WORKSPACE_STORAGE_KEY);
} catch (error) {
console.error("Failed to access stored workspace state", error);
return false;
}
if (!raw) {
return false;
}
try {
const payload = JSON.parse(raw);
const hasGraph =
payload &&
typeof payload === "object" &&
payload.graph &&
typeof payload.graph === "object" &&
Array.isArray(payload.graph.nodes) &&
Array.isArray(payload.graph.connections);
if (!hasGraph) {
return false;
}
this.#workspace.applySerializedWorkspace(payload);
return true;
} catch (error) {
console.error("Failed to restore workspace state", error);
return false;
}
}
/**
* Detects whether localStorage is available in the execution environment.
*
* @returns {boolean}
*/
#detectLocalStorage() {
try {
if (typeof window === "undefined" || !("localStorage" in window)) {
return false;
}
const probeKey = "__picograph_probe__";
window.localStorage.setItem(probeKey, "1");
window.localStorage.removeItem(probeKey);
return true;
} catch (error) {
return false;
}
}
/**
* Produces a JSON-safe clone of a variable default value.
*
* @param {import('../BlueprintWorkspace.js').VariableDefaultValue} value Default value to clone.
* @returns {import('../BlueprintWorkspace.js').VariableDefaultValue}
*/
#cloneVariableDefault(value) {
if (Array.isArray(value)) {
return value.map((entry) => ({
id: typeof entry?.id === "string" ? entry.id : "",
key: typeof entry?.key === "string" ? entry.key : "",
value: typeof entry?.value === "string" ? entry.value : "",
}));
}
if (value === undefined || value === null) {
return null;
}
if (typeof value === "object") {
try {
return JSON.parse(JSON.stringify(value));
} catch (_error) {
return null;
}
}
return value;
}
}

View File

@@ -0,0 +1,108 @@
/**
* Renders the event summary list.
*
* @param {object} options Rendering options.
* @param {HTMLUListElement | null} options.element Target list element.
* @param {import('../../nodes/NodeRegistry.js').NodeRegistry} options.registry Node registry instance.
* @param {import('../../core/NodeGraph.js').NodeGraph} options.graph Active node graph.
* @param {(nodeId: string) => void} options.focusNode Callback to focus a node.
* @param {(message: string) => void} options.renderEmpty Callback to render empty state.
* @param {(node: import('../../core/BlueprintNode.js').BlueprintNode, entryTypes: Set<string>) => boolean} options.isEventNode Predicate to determine if node represents an event.
*/
export function renderEventList({
element,
registry,
graph,
focusNode,
renderEmpty,
isEventNode,
}) {
if (!element) {
return;
}
element.innerHTML = "";
const entryTypes = new Set(registry.getEntryNodeTypes());
const events = [...graph.nodes.values()].filter((node) =>
isEventNode(node, entryTypes)
);
const builtInEvents = [];
const customEvents = [];
events.forEach((node) => {
if (node.type === "custom_event") {
customEvents.push(node);
} else {
builtInEvents.push(node);
}
});
if (!builtInEvents.length && !customEvents.length) {
renderEmpty("No events yet");
return;
}
const appendHeading = (title) => {
const headingItem = document.createElement("li");
headingItem.className = "overview-subheading";
headingItem.textContent = title;
element.appendChild(headingItem);
};
const appendEventItem = (node, displayName) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.className = "overview-item";
button.dataset.nodeId = node.id;
button.addEventListener("click", () => {
focusNode(node.id);
});
const label = document.createElement("span");
label.className = "overview-item-label";
label.textContent = displayName;
button.appendChild(label);
const meta = document.createElement("span");
meta.className = "overview-item-meta";
meta.textContent = node.id;
button.appendChild(meta);
item.appendChild(button);
element.appendChild(item);
};
if (builtInEvents.length) {
appendHeading("Events");
builtInEvents
.slice()
.sort((a, b) => a.title.localeCompare(b.title))
.forEach((node) => appendEventItem(node, node.title));
}
if (customEvents.length) {
appendHeading("Custom Events");
customEvents
.slice()
.sort((a, b) => {
const nameA =
(typeof a.properties.name === "string"
? a.properties.name.trim()
: "") || "CustomEvent";
const nameB =
(typeof b.properties.name === "string"
? b.properties.name.trim()
: "") || "CustomEvent";
return nameA.localeCompare(nameB);
})
.forEach((node) => {
const displayName =
(typeof node.properties.name === "string"
? node.properties.name.trim()
: "") || "CustomEvent";
appendEventItem(node, displayName);
});
}
}

View File

@@ -0,0 +1,442 @@
import { VARIABLE_SORT_MIME } from "../BlueprintWorkspaceConstants.js";
/**
* Renders the global variable summary list.
*
* @param {object} options Rendering options.
* @param {HTMLUListElement | null} options.element Target list element.
* @param {Array<string>} options.variableOrder Display order of variable identifiers.
* @param {Map<string, import('../BlueprintWorkspace.js').WorkspaceVariable>} options.variables Registered variables.
* @param {import('../../core/NodeGraph.js').NodeGraph} options.graph Active node graph.
* @param {() => void} options.cancelVariableRename Callback to cancel an in-progress rename.
* @param {() => void} options.closeVariableTypeDropdown Callback to close the variable type dropdown.
* @param {(message: string) => void} options.renderEmpty Renders an empty-state message.
* @param {(name: string) => string} options.normalizeVariableName Normalizes variable names for comparisons.
* @param {(type: import('../BlueprintWorkspace.js').VariableType) => string} options.formatVariableType Creates a human-readable type label.
* @param {(variableId: string) => void} options.selectVariable Activates a variable selection.
* @param {(event: DragEvent, variableId: string) => void} options.handleVariableDragStart Prepares workspace drag metadata for a variable.
* @param {() => void} options.handleVariableDragEnd Clears workspace drag feedback for variables.
* @param {(variableId: string, targetIndex: number) => void} options.reorderVariable Reorders a variable in the list.
* @param {(variableId: string, button: HTMLButtonElement, anchor: HTMLElement) => void} options.openVariableTypeDropdown Opens the variable type dropdown.
* @param {(variableId: string) => void} options.deleteVariable Removes the selected variable.
* @param {(variableId: string, label: HTMLElement) => void} options.startVariableRename Begins inline rename mode.
* @param {() => void} options.highlightVariableSelection Refreshes selection highlight state.
* @param {(variableId: string) => boolean} options.isVariableDropdownOpen Determines whether the dropdown is already open for a variable.
*/
export function renderVariableList({
element,
variableOrder,
variables,
graph,
cancelVariableRename,
closeVariableTypeDropdown,
renderEmpty,
normalizeVariableName,
formatVariableType,
selectVariable,
handleVariableDragStart,
handleVariableDragEnd,
reorderVariable,
openVariableTypeDropdown,
deleteVariable,
startVariableRename,
highlightVariableSelection,
isVariableDropdownOpen,
}) {
if (!element) {
return;
}
cancelVariableRename();
closeVariableTypeDropdown();
element.innerHTML = "";
if (!variableOrder.length) {
renderEmpty("No variables yet");
return;
}
const SORT_MIME = VARIABLE_SORT_MIME;
let draggedVariableId = null;
/**
* Determines whether a drag event carries the variable sort payload.
*
* @param {DragEvent} event Drag event payload.
* @returns {boolean}
*/
const hasSortPayload = (event) => {
if (!event.dataTransfer) {
return false;
}
const types = event.dataTransfer.types;
if (!types) {
return false;
}
return Array.from(types).includes(SORT_MIME);
};
const clearDropIndicators = () => {
const rows = element.querySelectorAll(
".overview-item--variable.is-drop-before, .overview-item--variable.is-drop-after"
);
rows.forEach((node) => {
node.classList.remove("is-drop-before", "is-drop-after");
});
};
const clearDragState = () => {
clearDropIndicators();
const rows = element.querySelectorAll(
".overview-item--variable.is-dragging"
);
rows.forEach((node) => {
node.classList.remove("is-dragging");
});
draggedVariableId = null;
};
const setDropIndicator = (row, position) => {
clearDropIndicators();
if (!row) {
return;
}
if (position === "before") {
row.classList.add("is-drop-before");
} else if (position === "after") {
row.classList.add("is-drop-after");
}
};
element.addEventListener("dragover", (event) => {
if (!draggedVariableId || !hasSortPayload(event)) {
return;
}
const targetRow =
event.target instanceof HTMLElement
? event.target.closest(".overview-item--variable")
: null;
if (targetRow) {
return;
}
event.preventDefault();
const lastRow = element.querySelector(
".overview-item--variable:last-of-type"
);
if (!lastRow) {
return;
}
setDropIndicator(lastRow, "after");
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
});
element.addEventListener("drop", (event) => {
if (!draggedVariableId || !hasSortPayload(event)) {
return;
}
const targetRow =
event.target instanceof HTMLElement
? event.target.closest(".overview-item--variable")
: null;
if (targetRow) {
return;
}
event.preventDefault();
const sourceId =
(event.dataTransfer && event.dataTransfer.getData(SORT_MIME)) ||
draggedVariableId;
const sourceIndex = variableOrder.indexOf(sourceId);
if (sourceIndex === -1) {
if (typeof handleVariableDragEnd === "function") {
handleVariableDragEnd();
}
clearDragState();
return;
}
let targetIndex = variableOrder.length;
if (targetIndex > sourceIndex) {
targetIndex -= 1;
}
if (targetIndex !== sourceIndex) {
reorderVariable(sourceId, targetIndex);
}
if (typeof handleVariableDragEnd === "function") {
handleVariableDragEnd();
}
clearDragState();
});
element.addEventListener("dragleave", (event) => {
if (!draggedVariableId) {
return;
}
const related = event.relatedTarget;
if (related instanceof HTMLElement && element.contains(related)) {
return;
}
clearDropIndicators();
});
/** @type {Map<string, { sets: number, gets: number }>} */
const usageById = new Map();
/** @type {Map<string, { sets: number, gets: number }>} */
const usageByName = new Map();
graph.nodes.forEach((node) => {
if (node.type !== "set_var" && node.type !== "get_var") {
return;
}
const nodeVariableId =
typeof node.properties.variableId === "string" &&
node.properties.variableId
? node.properties.variableId
: "";
const raw =
typeof node.properties.name === "string" ? node.properties.name : "";
const normalized = normalizeVariableName(raw) || "__blank__";
const usageMap =
nodeVariableId && variables.has(nodeVariableId) ? usageById : usageByName;
const key =
nodeVariableId && variables.has(nodeVariableId)
? nodeVariableId
: normalized;
const usage = usageMap.get(key) ?? { sets: 0, gets: 0 };
if (node.type === "set_var") {
usage.sets += 1;
} else {
usage.gets += 1;
}
usageMap.set(key, usage);
});
variableOrder.forEach((variableId) => {
const variable = variables.get(variableId);
if (!variable) {
return;
}
const item = document.createElement("li");
const row = document.createElement("div");
row.className = "overview-item overview-item--variable";
row.tabIndex = 0;
row.setAttribute("role", "button");
row.dataset.variableId = variable.id;
row.dataset.variableType = variable.type;
row.draggable = true;
row.setAttribute("aria-pressed", "false");
const activate = () => {
selectVariable(variable.id);
};
row.addEventListener("click", () => {
activate();
});
row.addEventListener("dragstart", (event) => {
activate();
draggedVariableId = variable.id;
row.classList.add("is-dragging");
if (typeof handleVariableDragStart === "function") {
handleVariableDragStart(event, variable.id);
}
if (event.dataTransfer) {
event.dataTransfer.setData(SORT_MIME, variable.id);
event.dataTransfer.effectAllowed = "copyMove";
}
});
row.addEventListener("dragend", () => {
if (typeof handleVariableDragEnd === "function") {
handleVariableDragEnd();
}
clearDragState();
});
row.addEventListener("dragover", (event) => {
if (!draggedVariableId || !hasSortPayload(event)) {
return;
}
event.preventDefault();
const rect = row.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const position = event.clientY <= midpoint ? "before" : "after";
setDropIndicator(row, position);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
});
row.addEventListener("dragleave", (event) => {
if (!draggedVariableId) {
return;
}
const related = event.relatedTarget;
if (related instanceof HTMLElement && row.contains(related)) {
return;
}
clearDropIndicators();
});
row.addEventListener("drop", (event) => {
if (!draggedVariableId || !hasSortPayload(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
const sourceId =
(event.dataTransfer && event.dataTransfer.getData(SORT_MIME)) ||
draggedVariableId;
const sourceIndex = variableOrder.indexOf(sourceId);
const targetId = row.dataset.variableId || "";
const targetIndexRaw = variableOrder.indexOf(targetId);
if (sourceIndex === -1 || targetIndexRaw === -1) {
if (typeof handleVariableDragEnd === "function") {
handleVariableDragEnd();
}
clearDragState();
return;
}
const rect = row.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const dropPosition = event.clientY <= midpoint ? "before" : "after";
let targetIndex = targetIndexRaw;
if (dropPosition === "after") {
targetIndex += 1;
}
if (targetIndex > variableOrder.length) {
targetIndex = variableOrder.length;
}
if (targetIndex > sourceIndex) {
targetIndex -= 1;
}
if (targetIndex !== sourceIndex) {
reorderVariable(sourceId, targetIndex);
}
if (typeof handleVariableDragEnd === "function") {
handleVariableDragEnd();
}
clearDragState();
});
const layout = document.createElement("div");
layout.className = "variable-overview-row";
row.appendChild(layout);
const typeButton = document.createElement("button");
typeButton.type = "button";
typeButton.className = "variable-type-button";
typeButton.dataset.variableType = variable.type;
typeButton.setAttribute("aria-haspopup", "menu");
typeButton.setAttribute("aria-expanded", "false");
typeButton.title = `Type: ${formatVariableType(variable.type)}`;
typeButton.setAttribute(
"aria-label",
`Change type for ${
variable.name || variable.id
} (currently ${formatVariableType(variable.type)})`
);
typeButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
activate();
const isOpen = isVariableDropdownOpen(variable.id);
if (isOpen) {
closeVariableTypeDropdown();
} else {
openVariableTypeDropdown(variable.id, typeButton, row);
}
});
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();
activate();
openVariableTypeDropdown(variable.id, typeButton, row);
} else if (event.key === "Escape") {
event.preventDefault();
closeVariableTypeDropdown();
}
});
layout.appendChild(typeButton);
const label = document.createElement("span");
label.className = "overview-item-label variable-name-display";
label.tabIndex = 0;
label.textContent = variable.name || "Unnamed";
label.title = variable.name || "Unnamed";
label.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
activate();
startVariableRename(variable.id, label);
});
label.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
startVariableRename(variable.id, label);
}
});
layout.appendChild(label);
const meta = document.createElement("span");
meta.className = "overview-item-meta";
const fallbackKey = normalizeVariableName(variable.name) || "__blank__";
const usage = usageById.get(variable.id) ?? usageByName.get(fallbackKey);
const summary = [formatVariableType(variable.type)];
if (usage) {
if (usage.sets) {
summary.push(`${usage.sets} set${usage.sets === 1 ? "" : "s"}`);
}
if (usage.gets) {
summary.push(`${usage.gets} get${usage.gets === 1 ? "" : "s"}`);
}
}
meta.textContent = summary.join(" • ");
layout.appendChild(meta);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "overview-item-action overview-item-action--remove";
const removeLabel = variable.name || variable.id;
removeButton.setAttribute(
"aria-label",
`Delete variable ${removeLabel}`
);
removeButton.title = `Delete ${removeLabel}`;
removeButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
deleteVariable(variable.id);
});
removeButton.addEventListener("pointerdown", (event) => {
event.stopPropagation();
});
layout.appendChild(removeButton);
row.addEventListener("keydown", (event) => {
const isSpace =
event.key === " " || event.key === "Space" || event.key === "Spacebar";
if (event.key === "Enter" || isSpace) {
event.preventDefault();
activate();
} else if (event.key === "F2") {
event.preventDefault();
startVariableRename(variable.id, label);
}
});
item.appendChild(row);
element.appendChild(item);
});
highlightVariableSelection();
}