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:
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;
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user