mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 02:36:04 +10:00
Initial commit
moved from private version control
This commit is contained in:
289
scripts/ui/workspace/WorkspaceClipboardManager.js
Normal file
289
scripts/ui/workspace/WorkspaceClipboardManager.js
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
620
scripts/ui/workspace/WorkspaceContextMenuManager.js
Normal file
620
scripts/ui/workspace/WorkspaceContextMenuManager.js
Normal 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";
|
||||
}
|
||||
}
|
||||
652
scripts/ui/workspace/WorkspaceDragManager.js
Normal file
652
scripts/ui/workspace/WorkspaceDragManager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
326
scripts/ui/workspace/WorkspaceDropManager.js
Normal file
326
scripts/ui/workspace/WorkspaceDropManager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
85
scripts/ui/workspace/WorkspaceGeometry.js
Normal file
85
scripts/ui/workspace/WorkspaceGeometry.js
Normal 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(" ");
|
||||
}
|
||||
}
|
||||
310
scripts/ui/workspace/WorkspaceHistoryManager.js
Normal file
310
scripts/ui/workspace/WorkspaceHistoryManager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
941
scripts/ui/workspace/WorkspaceInlineEditorManager.js
Normal file
941
scripts/ui/workspace/WorkspaceInlineEditorManager.js
Normal 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;
|
||||
|
||||
}
|
||||
228
scripts/ui/workspace/WorkspaceMarqueeManager.js
Normal file
228
scripts/ui/workspace/WorkspaceMarqueeManager.js
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
597
scripts/ui/workspace/WorkspaceNavigationManager.js
Normal file
597
scripts/ui/workspace/WorkspaceNavigationManager.js
Normal 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();
|
||||
}
|
||||
}
|
||||
420
scripts/ui/workspace/WorkspaceNodeRenderer.js
Normal file
420
scripts/ui/workspace/WorkspaceNodeRenderer.js
Normal 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;
|
||||
}
|
||||
}
|
||||
295
scripts/ui/workspace/WorkspacePaletteManager.js
Normal file
295
scripts/ui/workspace/WorkspacePaletteManager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
213
scripts/ui/workspace/WorkspacePersistenceManager.js
Normal file
213
scripts/ui/workspace/WorkspacePersistenceManager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
108
scripts/ui/workspace/renderEventList.js
Normal file
108
scripts/ui/workspace/renderEventList.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
442
scripts/ui/workspace/renderVariableList.js
Normal file
442
scripts/ui/workspace/renderVariableList.js
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user