mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 02:36:04 +10:00
- Created test_cart.p8 with basic drawing functionality and a print statement. - Created test_unix.p8 with a simple initialization function.
925 lines
28 KiB
JavaScript
925 lines
28 KiB
JavaScript
import { createColorSwatchPicker } from '../ColorSwatchPicker.js';
|
|
import { getTypeColor } from '../../types/TypeRegistry.js';
|
|
|
|
/** @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",
|
|
"color_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();
|
|
|
|
if (node.type === "color_literal") {
|
|
const picker = createColorSwatchPicker({
|
|
id: controlId,
|
|
value: node.properties.value ?? 7,
|
|
onChange: (index) => {
|
|
graph.setNodeProperty(node.id, "value", index);
|
|
},
|
|
});
|
|
editor.appendChild(picker.element);
|
|
|
|
const setValue = (rawValue) => {
|
|
const n = Number(rawValue);
|
|
picker.setValue(Number.isFinite(n) ? n : 7);
|
|
};
|
|
|
|
const entry = this.#ensureEntry(node.id);
|
|
entry.literal = { setValue };
|
|
return entry.literal;
|
|
}
|
|
|
|
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" },
|
|
{ value: "color", label: "Color" },
|
|
];
|
|
|
|
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 "color":
|
|
return "valueColor";
|
|
case "number":
|
|
default:
|
|
return "valueNumber";
|
|
}
|
|
};
|
|
|
|
const resolveValueControlKind = (type) => {
|
|
switch (type) {
|
|
case "boolean":
|
|
return "boolean";
|
|
case "string":
|
|
return "string";
|
|
case "table":
|
|
return "table";
|
|
case "color":
|
|
return "color";
|
|
default:
|
|
return "number";
|
|
}
|
|
};
|
|
|
|
const defaultValueForType = (type) => {
|
|
switch (type) {
|
|
case "string":
|
|
return "";
|
|
case "boolean":
|
|
return false;
|
|
case "table":
|
|
return "{}";
|
|
case "color":
|
|
return 7;
|
|
case "number":
|
|
default:
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
let activeType = normalizeType(node.properties.variableType);
|
|
|
|
const updateTypeButton = (type) => {
|
|
const normalized = normalizeType(type);
|
|
activeType = normalized;
|
|
typeButton.dataset.variableType = normalized;
|
|
typeButton.style.setProperty("--overview-variable-color", getTypeColor(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 if (desiredKind === "color") {
|
|
const rawColor = currentNode.properties?.[key];
|
|
const n = Number(rawColor);
|
|
const initialIndex = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7;
|
|
const picker = createColorSwatchPicker({
|
|
value: initialIndex,
|
|
onChange: (index) => {
|
|
graph.setNodeProperty(currentNode.id, key, index);
|
|
},
|
|
});
|
|
picker.element.dataset.localValueKind = "color";
|
|
/** @type {any} */ (picker.element)._setColorValue = picker.setValue;
|
|
valueField.appendChild(picker.element);
|
|
return picker.element;
|
|
} 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 (/** @type {any} */ (control)._setColorValue) {
|
|
const n = Number(current);
|
|
const index = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7;
|
|
/** @type {any} */ (control)._setColorValue(index);
|
|
} 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 isColorPin = pin.kind === "color";
|
|
const pinOptions =
|
|
Array.isArray(pin.options) && pin.options.length > 0 ? pin.options : null;
|
|
if (!isIfCondition && !pinOptions && !isColorPin) {
|
|
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 (pinOptions) {
|
|
const defaultStr =
|
|
pin.defaultValue != null
|
|
? String(pin.defaultValue)
|
|
: String(pinOptions[0]?.value ?? "");
|
|
node.properties[propertyKey] = defaultStr;
|
|
if (!(pin.id in node.properties)) {
|
|
node.properties[pin.id] = defaultStr;
|
|
}
|
|
} else {
|
|
node.properties[propertyKey] = this.#defaultValueForPin(pin);
|
|
}
|
|
}
|
|
|
|
container.querySelectorAll(".pin-inline-control").forEach((element) => {
|
|
element.remove();
|
|
});
|
|
|
|
let wrapper;
|
|
if (pinOptions) {
|
|
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 (pinOptions) {
|
|
const optionValues = pinOptions.map((o) => String(o.value));
|
|
|
|
const select = document.createElement("select");
|
|
select.className = "pin-inline-select";
|
|
select.setAttribute("aria-label", pin.name || pin.id);
|
|
|
|
pinOptions.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 = value == null ? "" : String(value);
|
|
return optionValues.includes(candidate)
|
|
? candidate
|
|
: (optionValues[0] ?? "");
|
|
};
|
|
|
|
const setValue = (value) => {
|
|
select.value = normalizeValue(value);
|
|
};
|
|
|
|
const setConnected = (connected) => {
|
|
wrapper.classList.toggle("is-hidden", connected);
|
|
container.classList.toggle("has-inline-dropdown", !connected);
|
|
};
|
|
|
|
select.addEventListener("change", () => {
|
|
const nextValue = select.value;
|
|
graph.setNodeProperty(node.id, propertyKey, nextValue);
|
|
graph.setNodeProperty(node.id, pin.id, nextValue);
|
|
});
|
|
|
|
wrapper.appendChild(select);
|
|
|
|
entry.pinEditors.set(pin.id, { setValue, setConnected });
|
|
setValue(node.properties[propertyKey]);
|
|
setConnected(this.#hasConnection(node.id, pin.id));
|
|
return;
|
|
}
|
|
|
|
if (isColorPin) {
|
|
const rawDefault = node.properties[propertyKey];
|
|
const n = Number(rawDefault);
|
|
const initialIndex = Number.isFinite(n)
|
|
? Math.max(0, Math.min(15, Math.round(n)))
|
|
: 7;
|
|
|
|
const picker = createColorSwatchPicker({
|
|
value: initialIndex,
|
|
onChange: (index) => {
|
|
graph.setNodeProperty(node.id, propertyKey, index);
|
|
},
|
|
});
|
|
wrapper.appendChild(picker.element);
|
|
|
|
const setValue = (value) => {
|
|
const num = Number(value);
|
|
picker.setValue(Number.isFinite(num) ? num : 7);
|
|
};
|
|
|
|
const setConnected = (connected) => {
|
|
wrapper.classList.toggle("is-hidden", connected);
|
|
};
|
|
|
|
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 (Array.isArray(pin?.options) && pin.options.length > 0) {
|
|
const canonical = node.properties[pinId];
|
|
if (canonical != null) {
|
|
const strValue = String(canonical);
|
|
if (node.properties[propertyKey] !== strValue) {
|
|
node.properties[propertyKey] = strValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 "";
|
|
case "color":
|
|
return 7;
|
|
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;
|
|
|
|
}
|