mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 02:36:04 +10:00
Add initial Pico-8 cartridges for testing
- Created test_cart.p8 with basic drawing functionality and a print statement. - Created test_unix.p8 with a simple initialization function.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { NodeGraph } from "../core/NodeGraph.js";
|
||||
import { createColorSwatchPicker } from "./ColorSwatchPicker.js";
|
||||
import { WorkspaceGeometry } from "./workspace/WorkspaceGeometry.js";
|
||||
import { WorkspaceDragManager } from "./workspace/WorkspaceDragManager.js";
|
||||
import { WorkspaceHistoryManager } from "./workspace/WorkspaceHistoryManager.js";
|
||||
@@ -18,25 +19,18 @@ import { renderVariableList } from "./workspace/renderVariableList.js";
|
||||
import {
|
||||
WORKSPACE_SHORTCUTS,
|
||||
} from "./WorkspaceSpawnShortcuts.js";
|
||||
import {
|
||||
normalizeVariableType,
|
||||
getVariableTypeOptions,
|
||||
defaultVariableValue as getDefaultVariableValue,
|
||||
getTypeColor,
|
||||
areTypesCompatible,
|
||||
} from "../types/TypeRegistry.js";
|
||||
|
||||
/**
|
||||
* @typedef {'any'|'number'|'string'|'boolean'|'table'|'color'} VariableType
|
||||
* @typedef {import('../types/TypeRegistry.js').VariableType} VariableType
|
||||
*/
|
||||
|
||||
/** @type {Array<{ value: VariableType, label: string }>} */
|
||||
const VARIABLE_TYPE_DEFINITIONS = [
|
||||
{ value: "any", label: "Any" },
|
||||
{ value: "number", label: "Number" },
|
||||
{ value: "string", label: "String" },
|
||||
{ value: "boolean", label: "Boolean" },
|
||||
{ value: "table", label: "Table" },
|
||||
{ value: "color", label: "Color" },
|
||||
];
|
||||
|
||||
const SUPPORTED_VARIABLE_TYPES = new Set(
|
||||
VARIABLE_TYPE_DEFINITIONS.map((entry) => entry.value)
|
||||
);
|
||||
|
||||
/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcut} WorkspaceShortcut */
|
||||
/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcutModifier} WorkspaceShortcutModifier */
|
||||
|
||||
@@ -88,6 +82,7 @@ const SUPPORTED_VARIABLE_TYPES = new Set(
|
||||
* @property {HTMLUListElement} eventList Event summary list element.
|
||||
* @property {HTMLUListElement} variableList Variable summary list element.
|
||||
* @property {HTMLButtonElement} addVariableButton Global variable creation button.
|
||||
* @property {HTMLButtonElement} saveButton Save action button.
|
||||
* @property {HTMLButtonElement} projectSettingsButton Project settings toggle.
|
||||
* @property {HTMLButtonElement} paletteToggleButton Palette toggle control.
|
||||
* @property {HTMLElement} appBodyElement Layout container hosting the workspace panes.
|
||||
@@ -146,6 +141,8 @@ export class BlueprintWorkspace {
|
||||
this.variableListElement = options.variableList;
|
||||
this.addVariableButton = options.addVariableButton;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this.saveButton = options.saveButton;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this.projectSettingsButton = options.projectSettingsButton;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this.frameSelectionButton = options.frameSelectionButton;
|
||||
@@ -180,6 +177,10 @@ export class BlueprintWorkspace {
|
||||
this.connectionRefreshFrame = null;
|
||||
/** @type {number | null} */
|
||||
this.periodicUpdateTimer = null;
|
||||
/** @type {number | null} */
|
||||
this.saveFeedbackTimer = null;
|
||||
/** @type {boolean} */
|
||||
this.hasPendingSave = false;
|
||||
|
||||
this.graph = new NodeGraph();
|
||||
/** @type {Map<string, WorkspaceVariable>} */
|
||||
@@ -398,6 +399,7 @@ export class BlueprintWorkspace {
|
||||
|
||||
this.navigationManager.updateZoomDisplay();
|
||||
this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
|
||||
this.#resetSaveButtonAppearance();
|
||||
|
||||
if (typeof this.generator.setProjectSettings === "function") {
|
||||
this.generator.setProjectSettings({ ...this.projectSettings });
|
||||
@@ -445,6 +447,101 @@ export class BlueprintWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active save feedback timer.
|
||||
*/
|
||||
#clearSaveFeedbackTimer() {
|
||||
if (this.saveFeedbackTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(this.saveFeedbackTimer);
|
||||
this.saveFeedbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the save button to its current base state.
|
||||
*/
|
||||
#resetSaveButtonAppearance() {
|
||||
const button = this.saveButton;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.remove("is-busy", "is-success", "is-error", "is-pending");
|
||||
button.disabled = !this.persistenceManager?.isStorageAvailable;
|
||||
button.setAttribute(
|
||||
"title",
|
||||
this.persistenceManager?.isStorageAvailable
|
||||
? this.hasPendingSave
|
||||
? "Unsaved changes"
|
||||
: "Workspace saved"
|
||||
: "Saving is unavailable"
|
||||
);
|
||||
|
||||
if (this.hasPendingSave) {
|
||||
button.classList.add("is-pending");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies transient visual state to the save button.
|
||||
*
|
||||
* @param {{ status: 'idle'|'pending'|'saving'|'saved'|'error', source: 'manual'|'auto' }} state
|
||||
*/
|
||||
#updateSaveButtonAppearance(state) {
|
||||
const button = this.saveButton;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#clearSaveFeedbackTimer();
|
||||
this.#resetSaveButtonAppearance();
|
||||
|
||||
if (!this.persistenceManager?.isStorageAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "pending") {
|
||||
button.classList.add("is-pending");
|
||||
button.setAttribute("title", "Unsaved changes");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "saving") {
|
||||
button.classList.remove("is-pending");
|
||||
button.classList.add("is-busy");
|
||||
button.disabled = true;
|
||||
button.setAttribute("title", "Saving workspace");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "saved") {
|
||||
button.classList.remove("is-pending");
|
||||
button.classList.add("is-success");
|
||||
button.setAttribute(
|
||||
"title",
|
||||
state.source === "auto" ? "Workspace autosaved" : "Workspace saved"
|
||||
);
|
||||
this.saveFeedbackTimer = window.setTimeout(() => {
|
||||
this.#resetSaveButtonAppearance();
|
||||
this.saveFeedbackTimer = null;
|
||||
}, 1400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
button.classList.remove("is-pending");
|
||||
button.classList.add("is-error");
|
||||
button.setAttribute("title", "Workspace save failed");
|
||||
this.saveFeedbackTimer = window.setTimeout(() => {
|
||||
this.#resetSaveButtonAppearance();
|
||||
this.saveFeedbackTimer = null;
|
||||
}, 1800);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#resetSaveButtonAppearance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts the most recent checkpoint.
|
||||
*/
|
||||
@@ -466,6 +563,41 @@ export class BlueprintWorkspace {
|
||||
return this.#refreshLuaOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the current workspace state immediately.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
saveWorkspace() {
|
||||
if (!this.persistenceManager?.isStorageAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.persistenceManager.persistImmediately("manual");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects persistence lifecycle changes in the save button state.
|
||||
*
|
||||
* @param {{ status: 'idle'|'pending'|'saving'|'saved'|'error', source: 'manual'|'auto' }} state
|
||||
*/
|
||||
handlePersistenceStateChange(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "pending") {
|
||||
this.hasPendingSave = true;
|
||||
} else if (state.status === "saved") {
|
||||
this.hasPendingSave = false;
|
||||
} else if (state.status === "error") {
|
||||
this.hasPendingSave = true;
|
||||
}
|
||||
|
||||
this.#updateSaveButtonAppearance(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to graph lifecycle changes.
|
||||
*/
|
||||
@@ -699,6 +831,12 @@ export class BlueprintWorkspace {
|
||||
* Registers DOM event listeners for interactivity.
|
||||
*/
|
||||
#bindUiEvents() {
|
||||
if (this.saveButton) {
|
||||
this.saveButton.addEventListener("click", () => {
|
||||
this.saveWorkspace();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.paletteToggleButton) {
|
||||
this.paletteToggleButton.addEventListener("click", () => {
|
||||
this.#togglePaletteVisibility();
|
||||
@@ -1755,18 +1893,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {VariableDefaultValue}
|
||||
*/
|
||||
#defaultVariableValue(type) {
|
||||
switch (type) {
|
||||
case "number":
|
||||
return 0;
|
||||
case "string":
|
||||
return "";
|
||||
case "table":
|
||||
return [];
|
||||
case "boolean":
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return /** @type {VariableDefaultValue} */ (getDefaultVariableValue(type));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1799,6 +1926,11 @@ export class BlueprintWorkspace {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case "color": {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
const safe = Number.isFinite(n) ? Math.round(n) : 7;
|
||||
return Math.max(0, Math.min(15, safe));
|
||||
}
|
||||
case "boolean": {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
@@ -2133,6 +2265,18 @@ export class BlueprintWorkspace {
|
||||
this.#setVariableDefault(variable.id, defaultToggle.checked);
|
||||
});
|
||||
defaultField.appendChild(defaultToggle);
|
||||
} else if (variable.type === "color") {
|
||||
const rawDefault = variable.defaultValue;
|
||||
const n = typeof rawDefault === "number" ? rawDefault : Number(rawDefault);
|
||||
const initialIndex = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7;
|
||||
const picker = createColorSwatchPicker({
|
||||
id: defaultId,
|
||||
value: initialIndex,
|
||||
onChange: (index) => {
|
||||
this.#setVariableDefault(variable.id, index);
|
||||
},
|
||||
});
|
||||
defaultField.appendChild(picker.element);
|
||||
} else if (variable.type === "number") {
|
||||
const defaultInput = document.createElement("input");
|
||||
defaultInput.type = "number";
|
||||
@@ -2607,6 +2751,7 @@ export class BlueprintWorkspace {
|
||||
option.type = "button";
|
||||
option.className = "variable-type-option";
|
||||
option.dataset.variableType = entry.value;
|
||||
option.style.setProperty("--overview-variable-color", getTypeColor(entry.value));
|
||||
option.textContent = entry.label;
|
||||
option.setAttribute("role", "menuitemradio");
|
||||
option.setAttribute(
|
||||
@@ -3028,12 +3173,8 @@ export class BlueprintWorkspace {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = workspaceElement.getBoundingClientRect();
|
||||
const screenPoint = {
|
||||
x: Math.max(0, Math.min(rect.width, event.clientX - rect.left)),
|
||||
y: Math.max(0, Math.min(rect.height, event.clientY - rect.top)),
|
||||
};
|
||||
const worldPoint = navigator.screenPointToWorld(screenPoint);
|
||||
// Use dragManager.clientToWorkspace for proper coordinate conversion
|
||||
const worldPoint = this.dragManager.clientToWorkspace(event.clientX, event.clientY);
|
||||
return this.addNode(shortcut.definitionId, worldPoint);
|
||||
}
|
||||
|
||||
@@ -3406,9 +3547,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {string}
|
||||
*/
|
||||
#formatVariableType(type) {
|
||||
const entry = VARIABLE_TYPE_DEFINITIONS.find(
|
||||
(definition) => definition.value === type
|
||||
);
|
||||
const entry = getVariableTypeOptions().find((opt) => opt.value === type);
|
||||
return entry ? entry.label : "Any";
|
||||
}
|
||||
|
||||
@@ -3419,17 +3558,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {VariableType}
|
||||
*/
|
||||
#normalizeVariableType(value) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
SUPPORTED_VARIABLE_TYPES.has(
|
||||
/** @type {VariableType} */ (normalized)
|
||||
)
|
||||
) {
|
||||
return /** @type {VariableType} */ (normalized);
|
||||
}
|
||||
}
|
||||
return "any";
|
||||
return /** @type {VariableType} */ (normalizeVariableType(value));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3438,7 +3567,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {Array<{ value: VariableType, label: string }>}
|
||||
*/
|
||||
#getVariableTypeOptions() {
|
||||
return VARIABLE_TYPE_DEFINITIONS.map((entry) => ({ ...entry }));
|
||||
return getVariableTypeOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3641,12 +3770,14 @@ export class BlueprintWorkspace {
|
||||
node.type === "set_local_var" || node.type === "get_local_var";
|
||||
article.classList.add("blueprint-node--variable");
|
||||
article.dataset.variableType = binding.type;
|
||||
article.style.setProperty("--overview-variable-color", getTypeColor(binding.type));
|
||||
article.dataset.variableRole = role;
|
||||
article.dataset.variableScope = isLocal ? "local" : "global";
|
||||
|
||||
header.classList.add("node-header--variable");
|
||||
header.setAttribute("data-variable-role", role);
|
||||
header.setAttribute("data-variable-type", binding.type);
|
||||
header.style.setProperty("--overview-variable-color", getTypeColor(binding.type));
|
||||
header.setAttribute("data-variable-scope", isLocal ? "local" : "global");
|
||||
const prefixBase = role === "set" ? "Set" : "Get";
|
||||
const prefix = isLocal ? `${prefixBase} Local` : prefixBase;
|
||||
@@ -4957,7 +5088,7 @@ export class BlueprintWorkspace {
|
||||
* Spawns a node at the requested context menu position.
|
||||
*
|
||||
* @param {PaletteNodeDefinition} definition Node definition to instantiate.
|
||||
* @param {{ x: number, y: number }} spawnPosition Workspace-relative spawn coordinates.
|
||||
* @param {{ x: number, y: number }} spawnPosition Screen-relative spawn coordinates (relative to workspace element).
|
||||
* @returns {import('../core/BlueprintNode.js').BlueprintNode | null}
|
||||
*/
|
||||
spawnNodeFromContextMenu(definition, spawnPosition) {
|
||||
@@ -4965,7 +5096,14 @@ export class BlueprintWorkspace {
|
||||
x: Math.max(0, spawnPosition.x),
|
||||
y: Math.max(0, spawnPosition.y),
|
||||
};
|
||||
const worldSpawn = this.navigationManager.screenPointToWorld(clamped);
|
||||
// Convert screen-relative position to world coordinates
|
||||
const zoom = this.navigationManager?.getZoomLevel() ?? this.zoomLevel ?? 1;
|
||||
const clampedZoom = Math.max(0.01, zoom);
|
||||
const offset = this.navigationManager?.getEffectiveOffset() ?? { x: 0, y: 0 };
|
||||
const worldSpawn = {
|
||||
x: clamped.x / clampedZoom - offset.x,
|
||||
y: clamped.y / clampedZoom - offset.y,
|
||||
};
|
||||
const spawn = WorkspaceGeometry.snapPositionToGrid(this, worldSpawn);
|
||||
const pendingConnectionSnapshot = this.pendingConnectionSpawn
|
||||
? {
|
||||
@@ -5500,10 +5638,88 @@ export class BlueprintWorkspace {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.classList.add("connection-path");
|
||||
path.dataset.type = kind;
|
||||
path.style.stroke = getTypeColor(kind);
|
||||
this.connectionLayer.appendChild(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SVG defs element on the connection layer, creating it if absent.
|
||||
*
|
||||
* @returns {SVGDefsElement}
|
||||
*/
|
||||
#getOrCreateSvgDefs() {
|
||||
let defs = this.connectionLayer.querySelector("defs");
|
||||
if (!defs) {
|
||||
defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
||||
this.connectionLayer.prepend(defs);
|
||||
}
|
||||
return /** @type {SVGDefsElement} */ (defs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a linearGradient element for a cross-type wire.
|
||||
*
|
||||
* @param {SVGDefsElement} defs Defs container.
|
||||
* @param {string} connectionId Connection identifier.
|
||||
* @param {string} fromKind Source pin kind.
|
||||
* @param {string} toKind Target pin kind.
|
||||
* @param {{x:number,y:number}} start Gradient start point.
|
||||
* @param {{x:number,y:number}} end Gradient end point.
|
||||
* @returns {string} The gradient element id.
|
||||
*/
|
||||
#upsertConnectionGradient(defs, connectionId, fromKind, toKind, start, end) {
|
||||
const gradId = `cgrad-${connectionId}`;
|
||||
let grad = defs.querySelector(`#${CSS.escape(gradId)}`);
|
||||
if (!grad) {
|
||||
grad = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"linearGradient"
|
||||
);
|
||||
grad.id = gradId;
|
||||
grad.setAttribute("gradientUnits", "userSpaceOnUse");
|
||||
const stop1 = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"stop"
|
||||
);
|
||||
stop1.setAttribute("offset", "0%");
|
||||
const stop2 = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"stop"
|
||||
);
|
||||
stop2.setAttribute("offset", "100%");
|
||||
grad.appendChild(stop1);
|
||||
grad.appendChild(stop2);
|
||||
defs.appendChild(grad);
|
||||
}
|
||||
|
||||
grad.setAttribute("x1", String(start.x));
|
||||
grad.setAttribute("y1", String(start.y));
|
||||
grad.setAttribute("x2", String(end.x));
|
||||
grad.setAttribute("y2", String(end.y));
|
||||
|
||||
const style = getComputedStyle(this.workspaceElement);
|
||||
const stops = grad.querySelectorAll("stop");
|
||||
stops[0].setAttribute(
|
||||
"stop-color",
|
||||
this.#getPinKindColor(style, fromKind)
|
||||
);
|
||||
stops[1].setAttribute("stop-color", this.#getPinKindColor(style, toKind));
|
||||
|
||||
return gradId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the color for a given pin kind.
|
||||
*
|
||||
* @param {CSSStyleDeclaration} _style Computed style (unused, kept for API compatibility).
|
||||
* @param {string} kind Pin kind identifier.
|
||||
* @returns {string}
|
||||
*/
|
||||
#getPinKindColor(_style, kind) {
|
||||
return getTypeColor(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a connection re-render on the next animation frame.
|
||||
*/
|
||||
@@ -5579,6 +5795,7 @@ export class BlueprintWorkspace {
|
||||
this.connectionLayer.removeAttribute("preserveAspectRatio");
|
||||
}
|
||||
}
|
||||
const defs = this.#getOrCreateSvgDefs();
|
||||
const activeIds = new Set();
|
||||
this.graph.getConnections().forEach((connection) => {
|
||||
activeIds.add(connection.id);
|
||||
@@ -5588,7 +5805,6 @@ export class BlueprintWorkspace {
|
||||
this.connectionElements.set(connection.id, path);
|
||||
}
|
||||
|
||||
path.dataset.type = connection.kind;
|
||||
path.dataset.connectionId = connection.id;
|
||||
const geometry = this.#computeConnectionGeometry(
|
||||
connection.from,
|
||||
@@ -5605,12 +5821,47 @@ export class BlueprintWorkspace {
|
||||
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
|
||||
path.dataset.active = "false";
|
||||
path.setAttribute("d", d);
|
||||
|
||||
const fromPin = this.graph.nodes
|
||||
.get(connection.from.nodeId)
|
||||
?.getPin(connection.from.pinId);
|
||||
const toPin = this.graph.nodes
|
||||
.get(connection.to.nodeId)
|
||||
?.getPin(connection.to.pinId);
|
||||
const fromKind = fromPin?.kind;
|
||||
const toKind = toPin?.kind;
|
||||
|
||||
if (
|
||||
fromKind &&
|
||||
toKind &&
|
||||
fromKind !== toKind &&
|
||||
fromKind !== "any" &&
|
||||
toKind !== "any"
|
||||
) {
|
||||
path.dataset.type = "mixed";
|
||||
const gradId = this.#upsertConnectionGradient(
|
||||
defs,
|
||||
connection.id,
|
||||
fromKind,
|
||||
toKind,
|
||||
start,
|
||||
end
|
||||
);
|
||||
path.style.stroke = "";
|
||||
path.setAttribute("stroke", `url(#${gradId})`);
|
||||
} else {
|
||||
path.dataset.type = connection.kind;
|
||||
path.removeAttribute("stroke");
|
||||
path.style.stroke = getTypeColor(connection.kind);
|
||||
defs.querySelector(`#cgrad-${connection.id}`)?.remove();
|
||||
}
|
||||
});
|
||||
|
||||
this.connectionElements.forEach((path, id) => {
|
||||
if (!activeIds.has(id)) {
|
||||
path.remove();
|
||||
this.connectionElements.delete(id);
|
||||
defs.querySelector(`#cgrad-${id}`)?.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5886,10 +6137,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#arePinKindsCompatible(outputKind, inputKind) {
|
||||
if (inputKind === "any" || outputKind === "any") {
|
||||
return true;
|
||||
}
|
||||
return outputKind === inputKind;
|
||||
return areTypesCompatible(outputKind, inputKind);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6197,12 +6445,14 @@ export class BlueprintWorkspace {
|
||||
row.className = "custom-event-parameter-row";
|
||||
row.dataset.parameterId = parameter.id;
|
||||
row.dataset.parameterType = parameter.type;
|
||||
row.style.setProperty("--overview-variable-color", getTypeColor(parameter.type));
|
||||
|
||||
const typeButton = document.createElement("button");
|
||||
typeButton.type = "button";
|
||||
typeButton.className =
|
||||
"variable-type-button custom-event-parameter-type-button";
|
||||
typeButton.dataset.variableType = parameter.type;
|
||||
typeButton.style.setProperty("--overview-variable-color", getTypeColor(parameter.type));
|
||||
typeButton.setAttribute("aria-haspopup", "menu");
|
||||
typeButton.setAttribute("aria-expanded", "false");
|
||||
row.appendChild(typeButton);
|
||||
@@ -6218,7 +6468,9 @@ export class BlueprintWorkspace {
|
||||
const display = this.#formatVariableType(normalized);
|
||||
const referenceName = nameInputElement.value.trim() || parameter.id;
|
||||
row.dataset.parameterType = normalized;
|
||||
row.style.setProperty("--overview-variable-color", getTypeColor(normalized));
|
||||
typeButton.dataset.variableType = normalized;
|
||||
typeButton.style.setProperty("--overview-variable-color", getTypeColor(normalized));
|
||||
typeButton.title = `Type: ${display}`;
|
||||
typeButton.setAttribute(
|
||||
"aria-label",
|
||||
@@ -6489,6 +6741,7 @@ export class BlueprintWorkspace {
|
||||
this.connectionElements.forEach((path) => path.remove());
|
||||
this.nodeElements.clear();
|
||||
this.connectionElements.clear();
|
||||
this.connectionLayer.querySelector("defs")?.remove();
|
||||
this.inlineEditorManager.clear();
|
||||
this.nodeDragRotation.clear();
|
||||
this.dragManager.stopNodeRotationDecay();
|
||||
@@ -6531,7 +6784,8 @@ export class BlueprintWorkspace {
|
||||
entry.type === "string" ||
|
||||
entry.type === "boolean" ||
|
||||
entry.type === "any" ||
|
||||
entry.type === "table"
|
||||
entry.type === "table" ||
|
||||
entry.type === "color"
|
||||
? entry.type
|
||||
: "any";
|
||||
const defaultValue = this.#normalizeVariableDefault(
|
||||
|
||||
184
scripts/ui/ColorSwatchPicker.js
Normal file
184
scripts/ui/ColorSwatchPicker.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Ordered PICO-8 palette hex strings, indices 0–15.
|
||||
*
|
||||
* @type {ReadonlyArray<string>}
|
||||
*/
|
||||
const PICO8_COLORS = Object.freeze([
|
||||
'#000002', '#1D2B53', '#7E2553', '#008751',
|
||||
'#AB5236', '#5F574F', '#C2C3C7', '#FFF1E8',
|
||||
'#FF004D', '#FFA300', '#FFEC27', '#00E436',
|
||||
'#29ADFF', '#83769C', '#FF77A8', '#FFCCAA',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {{ element: HTMLElement, setValue: (index: number) => void }} ColorSwatchPickerInstance
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds an inline PICO-8 color swatch picker control.
|
||||
*
|
||||
* Renders a colored trigger button that opens a 4×4 grid of all 16 PICO-8
|
||||
* palette swatches. Clicking a swatch fires `onChange` with the selected
|
||||
* palette index (0–15) and closes the dropdown.
|
||||
*
|
||||
* @param {{
|
||||
* id?: string,
|
||||
* value: number,
|
||||
* onChange: (index: number) => void,
|
||||
* }} options Picker configuration.
|
||||
* @returns {ColorSwatchPickerInstance}
|
||||
*/
|
||||
export function createColorSwatchPicker({ id, value, onChange }) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'color-swatch-picker';
|
||||
|
||||
const trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'color-swatch-trigger';
|
||||
if (id) {
|
||||
trigger.id = id;
|
||||
}
|
||||
trigger.setAttribute('aria-haspopup', 'menu');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
wrapper.appendChild(trigger);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'color-swatch-dropdown';
|
||||
dropdown.setAttribute('role', 'menu');
|
||||
// Appended to body on open so it escapes overflow:hidden on node cards.
|
||||
|
||||
/** @type {Array<HTMLButtonElement>} */
|
||||
const swatchButtons = [];
|
||||
|
||||
/** @type {((event: MouseEvent) => void) | null} */
|
||||
let outsideClickHandler = null;
|
||||
|
||||
/** @type {(() => void) | null} */
|
||||
let scrollAbortHandler = null;
|
||||
|
||||
const repositionDropdown = () => {
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
dropdown.style.top = `${rect.bottom + 4}px`;
|
||||
dropdown.style.left = `${rect.left}px`;
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (dropdown.isConnected) {
|
||||
dropdown.remove();
|
||||
}
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
if (outsideClickHandler) {
|
||||
document.removeEventListener('click', outsideClickHandler, true);
|
||||
outsideClickHandler = null;
|
||||
}
|
||||
if (scrollAbortHandler) {
|
||||
document.removeEventListener('scroll', scrollAbortHandler, true);
|
||||
scrollAbortHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openDropdown = () => {
|
||||
repositionDropdown();
|
||||
document.body.appendChild(dropdown);
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
outsideClickHandler = (event) => {
|
||||
if (
|
||||
!(event.target instanceof Node) ||
|
||||
(!wrapper.contains(event.target) && !dropdown.contains(event.target))
|
||||
) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
scrollAbortHandler = () => closeDropdown();
|
||||
document.addEventListener('click', outsideClickHandler, true);
|
||||
document.addEventListener('scroll', scrollAbortHandler, true);
|
||||
};
|
||||
|
||||
let currentIndex = clampColorIndex(value);
|
||||
|
||||
/**
|
||||
* Updates the trigger button to reflect the supplied palette index.
|
||||
*
|
||||
* @param {number} index Palette index (0–15).
|
||||
*/
|
||||
const updateTrigger = (index) => {
|
||||
const hex = PICO8_COLORS[index] ?? '#000000';
|
||||
trigger.style.backgroundColor = hex;
|
||||
trigger.title = `Color ${index}`;
|
||||
trigger.setAttribute('aria-label', `Color ${index}`);
|
||||
};
|
||||
|
||||
PICO8_COLORS.forEach((hex, index) => {
|
||||
const swatch = document.createElement('button');
|
||||
swatch.type = 'button';
|
||||
swatch.className = 'color-swatch-option';
|
||||
swatch.style.backgroundColor = hex;
|
||||
swatch.dataset.colorIndex = String(index);
|
||||
swatch.title = String(index);
|
||||
swatch.setAttribute('role', 'menuitemradio');
|
||||
swatch.setAttribute('aria-label', `Color ${index}`);
|
||||
swatch.setAttribute('aria-checked', 'false');
|
||||
|
||||
swatch.addEventListener('pointerdown', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
swatch.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const selected = index;
|
||||
closeDropdown();
|
||||
if (selected !== currentIndex) {
|
||||
currentIndex = selected;
|
||||
updateTrigger(selected);
|
||||
swatchButtons.forEach((btn, i) => {
|
||||
btn.setAttribute('aria-checked', i === selected ? 'true' : 'false');
|
||||
});
|
||||
onChange(selected);
|
||||
}
|
||||
});
|
||||
|
||||
dropdown.appendChild(swatch);
|
||||
swatchButtons.push(swatch);
|
||||
});
|
||||
|
||||
trigger.addEventListener('pointerdown', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
trigger.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
if (dropdown.isConnected) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates the picker display without firing `onChange`.
|
||||
*
|
||||
* @param {number} newIndex Palette index (0–15).
|
||||
*/
|
||||
const setValue = (newIndex) => {
|
||||
currentIndex = clampColorIndex(newIndex);
|
||||
updateTrigger(currentIndex);
|
||||
swatchButtons.forEach((btn, i) => {
|
||||
btn.setAttribute('aria-checked', i === currentIndex ? 'true' : 'false');
|
||||
});
|
||||
};
|
||||
|
||||
setValue(currentIndex);
|
||||
|
||||
return { element: wrapper, setValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps an arbitrary value to a valid PICO-8 palette index (0–15).
|
||||
*
|
||||
* @param {unknown} value Raw index candidate.
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampColorIndex(value) {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
const safe = Number.isFinite(n) ? Math.round(n) : 0;
|
||||
return Math.max(0, Math.min(15, safe));
|
||||
}
|
||||
@@ -331,7 +331,7 @@ export class EmbeddedPreview {
|
||||
const fallback = (hasUpdateCallback && hasDrawCallback) ? '' :
|
||||
(!hasUpdateCallback ? '\nfunction _update()\nend\n' : '') +
|
||||
(!hasDrawCallback ? '\nfunction _draw()\n cls()\n print("picograph ok",2,60,7)\nend\n' : '');
|
||||
return `pico-8 cartridge // http://www.pico-8.com
|
||||
const content = `pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
${luaCode}${fallback}
|
||||
@@ -345,6 +345,8 @@ __gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
`;
|
||||
// Ensure Unix line endings (PICO-8 tools expect \n, not \r\n)
|
||||
return content.replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
622
scripts/ui/SpriteEditor.js
Normal file
622
scripts/ui/SpriteEditor.js
Normal file
@@ -0,0 +1,622 @@
|
||||
import { SpriteSheet } from "../core/SpriteSheet.js";
|
||||
|
||||
/**
|
||||
* Interactive sprite sheet editor for PICO-8.
|
||||
*/
|
||||
export class SpriteEditor {
|
||||
/**
|
||||
* @param {HTMLElement} container Parent element for the editor.
|
||||
* @param {SpriteSheet} [spriteSheet] Existing sprite sheet or creates new one.
|
||||
*/
|
||||
constructor(container, spriteSheet = null) {
|
||||
this.container = container;
|
||||
this.spriteSheet = spriteSheet || new SpriteSheet();
|
||||
|
||||
/** @type {number} Currently selected color (0-15) */
|
||||
this.selectedColor = 7;
|
||||
|
||||
/** @type {number} Currently selected sprite (0-255) */
|
||||
this.selectedSprite = 0;
|
||||
|
||||
/** @type {string} Current tool: "pen", "fill", "line", "rect", "circle", "erase" */
|
||||
this.currentTool = "pen";
|
||||
|
||||
/** @type {number} Zoom level for sprite sheet canvas */
|
||||
this.sheetZoom = 2;
|
||||
|
||||
/** @type {number} Zoom level for sprite preview */
|
||||
this.previewZoom = 8;
|
||||
|
||||
/** @type {Object|null} Drawing state for line/rect tools */
|
||||
this.drawStart = null;
|
||||
|
||||
this.#createUI();
|
||||
this.#attachEvents();
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the UI structure.
|
||||
*/
|
||||
#createUI() {
|
||||
this.container.innerHTML = "";
|
||||
this.container.className = "sprite-editor";
|
||||
|
||||
// Toolbar
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "sprite-toolbar";
|
||||
|
||||
const tools = [
|
||||
{ id: "pen", label: "Pen", icon: "✏️" },
|
||||
{ id: "fill", label: "Fill", icon: "🪣" },
|
||||
{ id: "line", label: "Line", icon: "📏" },
|
||||
{ id: "rect", label: "Rect", icon: "⬜" },
|
||||
{ id: "circle", label: "Circle", icon: "⭕" },
|
||||
{ id: "erase", label: "Erase", icon: "🧹" }
|
||||
];
|
||||
|
||||
tools.forEach((tool) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "sprite-tool-btn";
|
||||
btn.dataset.tool = tool.id;
|
||||
btn.title = tool.label;
|
||||
btn.textContent = tool.icon;
|
||||
if (tool.id === this.currentTool) {
|
||||
btn.classList.add("active");
|
||||
}
|
||||
toolbar.appendChild(btn);
|
||||
});
|
||||
|
||||
this.container.appendChild(toolbar);
|
||||
|
||||
// Main content area
|
||||
const content = document.createElement("div");
|
||||
content.className = "sprite-content";
|
||||
|
||||
// Left panel: Sprite sheet
|
||||
const leftPanel = document.createElement("div");
|
||||
leftPanel.className = "sprite-panel sprite-sheet-panel";
|
||||
|
||||
const sheetLabel = document.createElement("div");
|
||||
sheetLabel.className = "sprite-panel-label";
|
||||
sheetLabel.textContent = "Sprite Sheet (128x128)";
|
||||
leftPanel.appendChild(sheetLabel);
|
||||
|
||||
this.sheetCanvas = document.createElement("canvas");
|
||||
this.sheetCanvas.className = "sprite-sheet-canvas";
|
||||
this.sheetCanvas.width = 128 * this.sheetZoom;
|
||||
this.sheetCanvas.height = 128 * this.sheetZoom;
|
||||
this.sheetCtx = this.sheetCanvas.getContext("2d", { alpha: false });
|
||||
this.sheetCtx.imageSmoothingEnabled = false;
|
||||
leftPanel.appendChild(this.sheetCanvas);
|
||||
|
||||
content.appendChild(leftPanel);
|
||||
|
||||
// Right panel: Preview and palette
|
||||
const rightPanel = document.createElement("div");
|
||||
rightPanel.className = "sprite-panel sprite-controls-panel";
|
||||
|
||||
// Sprite preview
|
||||
const previewContainer = document.createElement("div");
|
||||
previewContainer.className = "sprite-preview-container";
|
||||
|
||||
const previewLabel = document.createElement("div");
|
||||
previewLabel.className = "sprite-panel-label";
|
||||
previewLabel.textContent = "Sprite Preview";
|
||||
previewContainer.appendChild(previewLabel);
|
||||
|
||||
this.previewCanvas = document.createElement("canvas");
|
||||
this.previewCanvas.className = "sprite-preview-canvas";
|
||||
this.previewCanvas.width = 8 * this.previewZoom;
|
||||
this.previewCanvas.height = 8 * this.previewZoom;
|
||||
this.previewCtx = this.previewCanvas.getContext("2d", { alpha: false });
|
||||
this.previewCtx.imageSmoothingEnabled = false;
|
||||
previewContainer.appendChild(this.previewCanvas);
|
||||
|
||||
this.spriteIndexLabel = document.createElement("div");
|
||||
this.spriteIndexLabel.className = "sprite-index-label";
|
||||
this.spriteIndexLabel.textContent = `Sprite: ${this.selectedSprite}`;
|
||||
previewContainer.appendChild(this.spriteIndexLabel);
|
||||
|
||||
rightPanel.appendChild(previewContainer);
|
||||
|
||||
// Color palette
|
||||
const paletteContainer = document.createElement("div");
|
||||
paletteContainer.className = "sprite-palette-container";
|
||||
|
||||
const paletteLabel = document.createElement("div");
|
||||
paletteLabel.className = "sprite-panel-label";
|
||||
paletteLabel.textContent = "Palette";
|
||||
paletteContainer.appendChild(paletteLabel);
|
||||
|
||||
this.paletteElement = document.createElement("div");
|
||||
this.paletteElement.className = "sprite-palette";
|
||||
|
||||
this.spriteSheet.palette.forEach((color, index) => {
|
||||
const swatch = document.createElement("div");
|
||||
swatch.className = "sprite-palette-swatch";
|
||||
swatch.style.backgroundColor = color;
|
||||
swatch.dataset.color = index;
|
||||
swatch.title = `Color ${index}`;
|
||||
if (index === this.selectedColor) {
|
||||
swatch.classList.add("selected");
|
||||
}
|
||||
this.paletteElement.appendChild(swatch);
|
||||
});
|
||||
|
||||
paletteContainer.appendChild(this.paletteElement);
|
||||
rightPanel.appendChild(paletteContainer);
|
||||
|
||||
// Actions
|
||||
const actionsContainer = document.createElement("div");
|
||||
actionsContainer.className = "sprite-actions";
|
||||
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.className = "sprite-action-btn";
|
||||
clearBtn.textContent = "Clear Sprite";
|
||||
clearBtn.dataset.action = "clear-sprite";
|
||||
actionsContainer.appendChild(clearBtn);
|
||||
|
||||
const clearAllBtn = document.createElement("button");
|
||||
clearAllBtn.className = "sprite-action-btn";
|
||||
clearAllBtn.textContent = "Clear All";
|
||||
clearAllBtn.dataset.action = "clear-all";
|
||||
actionsContainer.appendChild(clearAllBtn);
|
||||
|
||||
rightPanel.appendChild(actionsContainer);
|
||||
|
||||
content.appendChild(rightPanel);
|
||||
this.container.appendChild(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners.
|
||||
*/
|
||||
#attachEvents() {
|
||||
// Tool selection
|
||||
this.container.querySelectorAll(".sprite-tool-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
this.currentTool = btn.dataset.tool;
|
||||
this.container.querySelectorAll(".sprite-tool-btn").forEach((b) => {
|
||||
b.classList.remove("active");
|
||||
});
|
||||
btn.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Color palette
|
||||
this.paletteElement.querySelectorAll(".sprite-palette-swatch").forEach((swatch) => {
|
||||
swatch.addEventListener("click", (e) => {
|
||||
this.selectedColor = parseInt(swatch.dataset.color);
|
||||
this.paletteElement.querySelectorAll(".sprite-palette-swatch").forEach((s) => {
|
||||
s.classList.remove("selected");
|
||||
});
|
||||
swatch.classList.add("selected");
|
||||
});
|
||||
});
|
||||
|
||||
// Actions
|
||||
this.container.querySelectorAll(".sprite-action-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
const action = btn.dataset.action;
|
||||
if (action === "clear-sprite") {
|
||||
this.#clearCurrentSprite();
|
||||
} else if (action === "clear-all") {
|
||||
if (confirm("Clear all sprites? This cannot be undone.")) {
|
||||
this.spriteSheet.clear();
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sheet canvas interactions
|
||||
let isDrawing = false;
|
||||
|
||||
this.sheetCanvas.addEventListener("mousedown", (e) => {
|
||||
isDrawing = true;
|
||||
const coords = this.#getSheetCoords(e);
|
||||
this.#handleToolStart(coords);
|
||||
});
|
||||
|
||||
this.sheetCanvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawing) return;
|
||||
const coords = this.#getSheetCoords(e);
|
||||
this.#handleToolMove(coords);
|
||||
});
|
||||
|
||||
this.sheetCanvas.addEventListener("mouseup", (e) => {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
const coords = this.#getSheetCoords(e);
|
||||
this.#handleToolEnd(coords);
|
||||
});
|
||||
|
||||
this.sheetCanvas.addEventListener("mouseleave", () => {
|
||||
isDrawing = false;
|
||||
this.drawStart = null;
|
||||
});
|
||||
|
||||
// Sprite selection
|
||||
this.sheetCanvas.addEventListener("click", (e) => {
|
||||
const coords = this.#getSheetCoords(e);
|
||||
const spriteX = Math.floor(coords.x / 8);
|
||||
const spriteY = Math.floor(coords.y / 8);
|
||||
this.selectedSprite = spriteY * 16 + spriteX;
|
||||
this.spriteIndexLabel.textContent = `Sprite: ${this.selectedSprite}`;
|
||||
this.#renderPreview();
|
||||
});
|
||||
|
||||
// Preview canvas editing
|
||||
let isDrawingPreview = false;
|
||||
|
||||
this.previewCanvas.addEventListener("mousedown", (e) => {
|
||||
isDrawingPreview = true;
|
||||
const coords = this.#getPreviewCoords(e);
|
||||
this.#drawPixelInPreview(coords);
|
||||
});
|
||||
|
||||
this.previewCanvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawingPreview) return;
|
||||
const coords = this.#getPreviewCoords(e);
|
||||
this.#drawPixelInPreview(coords);
|
||||
});
|
||||
|
||||
this.previewCanvas.addEventListener("mouseup", () => {
|
||||
isDrawingPreview = false;
|
||||
});
|
||||
|
||||
this.previewCanvas.addEventListener("mouseleave", () => {
|
||||
isDrawingPreview = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets sheet coordinates from mouse event.
|
||||
*
|
||||
* @param {MouseEvent} e Mouse event.
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
#getSheetCoords(e) {
|
||||
const rect = this.sheetCanvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / this.sheetZoom);
|
||||
const y = Math.floor((e.clientY - rect.top) / this.sheetZoom);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets preview coordinates from mouse event.
|
||||
*
|
||||
* @param {MouseEvent} e Mouse event.
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
#getPreviewCoords(e) {
|
||||
const rect = this.previewCanvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / this.previewZoom);
|
||||
const y = Math.floor((e.clientY - rect.top) / this.previewZoom);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles tool start.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#handleToolStart(coords) {
|
||||
if (this.currentTool === "pen") {
|
||||
this.#drawPixel(coords);
|
||||
} else if (this.currentTool === "erase") {
|
||||
this.#erasePixel(coords);
|
||||
} else if (this.currentTool === "fill") {
|
||||
this.#floodFill(coords);
|
||||
} else if (["line", "rect", "circle"].includes(this.currentTool)) {
|
||||
this.drawStart = coords;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles tool move.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#handleToolMove(coords) {
|
||||
if (this.currentTool === "pen") {
|
||||
this.#drawPixel(coords);
|
||||
} else if (this.currentTool === "erase") {
|
||||
this.#erasePixel(coords);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles tool end.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#handleToolEnd(coords) {
|
||||
if (!this.drawStart) return;
|
||||
|
||||
if (this.currentTool === "line") {
|
||||
this.#drawLine(this.drawStart, coords);
|
||||
} else if (this.currentTool === "rect") {
|
||||
this.#drawRect(this.drawStart, coords);
|
||||
} else if (this.currentTool === "circle") {
|
||||
this.#drawCircle(this.drawStart, coords);
|
||||
}
|
||||
|
||||
this.drawStart = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a pixel.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#drawPixel(coords) {
|
||||
this.spriteSheet.setPixel(coords.x, coords.y, this.selectedColor);
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erases a pixel.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#erasePixel(coords) {
|
||||
this.spriteSheet.setPixel(coords.x, coords.y, 0);
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flood fill from a point.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Starting coordinates.
|
||||
*/
|
||||
#floodFill(coords) {
|
||||
const targetColor = this.spriteSheet.getPixel(coords.x, coords.y);
|
||||
if (targetColor === this.selectedColor) return;
|
||||
|
||||
const stack = [coords];
|
||||
const visited = new Set();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { x, y } = stack.pop();
|
||||
const key = `${x},${y}`;
|
||||
|
||||
if (visited.has(key)) continue;
|
||||
if (x < 0 || x >= 128 || y < 0 || y >= 128) continue;
|
||||
if (this.spriteSheet.getPixel(x, y) !== targetColor) continue;
|
||||
|
||||
visited.add(key);
|
||||
this.spriteSheet.setPixel(x, y, this.selectedColor);
|
||||
|
||||
stack.push({ x: x + 1, y });
|
||||
stack.push({ x: x - 1, y });
|
||||
stack.push({ x, y: y + 1 });
|
||||
stack.push({ x, y: y - 1 });
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a line.
|
||||
*
|
||||
* @param {{x: number, y: number}} start Start coordinates.
|
||||
* @param {{x: number, y: number}} end End coordinates.
|
||||
*/
|
||||
#drawLine(start, end) {
|
||||
const dx = Math.abs(end.x - start.x);
|
||||
const dy = Math.abs(end.y - start.y);
|
||||
const sx = start.x < end.x ? 1 : -1;
|
||||
const sy = start.y < end.y ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = start.x;
|
||||
let y = start.y;
|
||||
|
||||
while (true) {
|
||||
this.spriteSheet.setPixel(x, y, this.selectedColor);
|
||||
|
||||
if (x === end.x && y === end.y) break;
|
||||
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a rectangle.
|
||||
*
|
||||
* @param {{x: number, y: number}} start Start coordinates.
|
||||
* @param {{x: number, y: number}} end End coordinates.
|
||||
*/
|
||||
#drawRect(start, end) {
|
||||
const x1 = Math.min(start.x, end.x);
|
||||
const y1 = Math.min(start.y, end.y);
|
||||
const x2 = Math.max(start.x, end.x);
|
||||
const y2 = Math.max(start.y, end.y);
|
||||
|
||||
for (let x = x1; x <= x2; x++) {
|
||||
this.spriteSheet.setPixel(x, y1, this.selectedColor);
|
||||
this.spriteSheet.setPixel(x, y2, this.selectedColor);
|
||||
}
|
||||
|
||||
for (let y = y1; y <= y2; y++) {
|
||||
this.spriteSheet.setPixel(x1, y, this.selectedColor);
|
||||
this.spriteSheet.setPixel(x2, y, this.selectedColor);
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a circle.
|
||||
*
|
||||
* @param {{x: number, y: number}} center Center coordinates.
|
||||
* @param {{x: number, y: number}} edge Edge point.
|
||||
*/
|
||||
#drawCircle(center, edge) {
|
||||
const radius = Math.round(
|
||||
Math.sqrt((edge.x - center.x) ** 2 + (edge.y - center.y) ** 2)
|
||||
);
|
||||
|
||||
let x = radius;
|
||||
let y = 0;
|
||||
let err = 0;
|
||||
|
||||
while (x >= y) {
|
||||
this.spriteSheet.setPixel(center.x + x, center.y + y, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x + y, center.y + x, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x - y, center.y + x, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x - x, center.y + y, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x - x, center.y - y, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x - y, center.y - x, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x + y, center.y - x, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x + x, center.y - y, this.selectedColor);
|
||||
|
||||
y += 1;
|
||||
err += 1 + 2 * y;
|
||||
if (2 * (err - x) + 1 > 0) {
|
||||
x -= 1;
|
||||
err += 1 - 2 * x;
|
||||
}
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a pixel in the preview canvas.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates (0-7).
|
||||
*/
|
||||
#drawPixelInPreview(coords) {
|
||||
if (coords.x < 0 || coords.x >= 8 || coords.y < 0 || coords.y >= 8) return;
|
||||
|
||||
const sx = (this.selectedSprite % 16) * 8;
|
||||
const sy = Math.floor(this.selectedSprite / 16) * 8;
|
||||
|
||||
const color = this.currentTool === "erase" ? 0 : this.selectedColor;
|
||||
this.spriteSheet.setPixel(sx + coords.x, sy + coords.y, color);
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the currently selected sprite.
|
||||
*/
|
||||
#clearCurrentSprite() {
|
||||
const clearData = new Uint8Array(64);
|
||||
this.spriteSheet.setSprite(this.selectedSprite, clearData);
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all canvases.
|
||||
*/
|
||||
#render() {
|
||||
this.#renderSheet();
|
||||
this.#renderPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the sprite sheet canvas.
|
||||
*/
|
||||
#renderSheet() {
|
||||
for (let y = 0; y < 128; y++) {
|
||||
for (let x = 0; x < 128; x++) {
|
||||
const color = this.spriteSheet.getPixel(x, y);
|
||||
this.sheetCtx.fillStyle = this.spriteSheet.palette[color];
|
||||
this.sheetCtx.fillRect(
|
||||
x * this.sheetZoom,
|
||||
y * this.sheetZoom,
|
||||
this.sheetZoom,
|
||||
this.sheetZoom
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
this.sheetCtx.strokeStyle = "rgba(255, 255, 255, 0.2)";
|
||||
this.sheetCtx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i <= 16; i++) {
|
||||
const pos = i * 8 * this.sheetZoom;
|
||||
this.sheetCtx.beginPath();
|
||||
this.sheetCtx.moveTo(pos, 0);
|
||||
this.sheetCtx.lineTo(pos, 128 * this.sheetZoom);
|
||||
this.sheetCtx.stroke();
|
||||
|
||||
this.sheetCtx.beginPath();
|
||||
this.sheetCtx.moveTo(0, pos);
|
||||
this.sheetCtx.lineTo(128 * this.sheetZoom, pos);
|
||||
this.sheetCtx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the sprite preview canvas.
|
||||
*/
|
||||
#renderPreview() {
|
||||
const sx = (this.selectedSprite % 16) * 8;
|
||||
const sy = Math.floor(this.selectedSprite / 16) * 8;
|
||||
|
||||
for (let y = 0; y < 8; y++) {
|
||||
for (let x = 0; x < 8; x++) {
|
||||
const color = this.spriteSheet.getPixel(sx + x, sy + y);
|
||||
this.previewCtx.fillStyle = this.spriteSheet.palette[color];
|
||||
this.previewCtx.fillRect(
|
||||
x * this.previewZoom,
|
||||
y * this.previewZoom,
|
||||
this.previewZoom,
|
||||
this.previewZoom
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
this.previewCtx.strokeStyle = "rgba(255, 255, 255, 0.3)";
|
||||
this.previewCtx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
const pos = i * this.previewZoom;
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.moveTo(pos, 0);
|
||||
this.previewCtx.lineTo(pos, 8 * this.previewZoom);
|
||||
this.previewCtx.stroke();
|
||||
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.moveTo(0, pos);
|
||||
this.previewCtx.lineTo(8 * this.previewZoom, pos);
|
||||
this.previewCtx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sprite sheet data.
|
||||
*
|
||||
* @returns {SpriteSheet}
|
||||
*/
|
||||
getSpriteSheet() {
|
||||
return this.spriteSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets sprite sheet data.
|
||||
*
|
||||
* @param {SpriteSheet} spriteSheet New sprite sheet.
|
||||
*/
|
||||
setSpriteSheet(spriteSheet) {
|
||||
this.spriteSheet = spriteSheet;
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
205
scripts/ui/SpriteEditorManager.js
Normal file
205
scripts/ui/SpriteEditorManager.js
Normal file
@@ -0,0 +1,205 @@
|
||||
import { SpriteEditor } from './SpriteEditor.js';
|
||||
import { SpriteSheet } from '../core/SpriteSheet.js';
|
||||
|
||||
/**
|
||||
* Manages the sprite editor modal integration.
|
||||
*/
|
||||
export class SpriteEditorManager {
|
||||
/**
|
||||
* @param {HTMLElement} modal Modal element.
|
||||
* @param {HTMLElement} container Container for sprite editor.
|
||||
*/
|
||||
constructor(modal, container) {
|
||||
this.modal = modal;
|
||||
this.container = container;
|
||||
this.spriteSheet = new SpriteSheet();
|
||||
this.editor = null;
|
||||
this.isOpen = false;
|
||||
|
||||
this.#setupModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up modal event listeners.
|
||||
*/
|
||||
#setupModal() {
|
||||
// Close modal on backdrop click
|
||||
const backdrop = this.modal.querySelector('.modal__backdrop');
|
||||
backdrop?.addEventListener('click', () => this.close());
|
||||
|
||||
// Close modal on close button click
|
||||
const closeButtons = this.modal.querySelectorAll('[data-modal-close]');
|
||||
closeButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', () => this.close());
|
||||
});
|
||||
|
||||
// Export to .p8
|
||||
const exportBtn = document.getElementById('exportP8Button');
|
||||
exportBtn?.addEventListener('click', () => this.#exportToP8());
|
||||
|
||||
// Import from .p8
|
||||
const importBtn = document.getElementById('importP8Button');
|
||||
importBtn?.addEventListener('click', () => this.#importFromP8());
|
||||
|
||||
// Initialize editor on first open
|
||||
this.modal.addEventListener('transitionend', () => {
|
||||
if (this.isOpen && !this.editor) {
|
||||
this.#initializeEditor();
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the sprite editor.
|
||||
*/
|
||||
#initializeEditor() {
|
||||
if (this.editor) return;
|
||||
|
||||
this.editor = new SpriteEditor(this.container, this.spriteSheet);
|
||||
console.log('Sprite editor initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the sprite editor modal.
|
||||
*/
|
||||
open() {
|
||||
if (this.isOpen) return;
|
||||
|
||||
this.isOpen = true;
|
||||
this.modal.removeAttribute('hidden');
|
||||
this.modal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
// Initialize editor immediately if not already done
|
||||
if (!this.editor) {
|
||||
this.#initializeEditor();
|
||||
}
|
||||
|
||||
// Focus trap
|
||||
requestAnimationFrame(() => {
|
||||
const closeBtn = this.modal.querySelector('[data-modal-close]');
|
||||
closeBtn?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the sprite editor modal.
|
||||
*/
|
||||
close() {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
this.isOpen = false;
|
||||
this.modal.setAttribute('hidden', '');
|
||||
this.modal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports sprite data to .p8 format.
|
||||
*/
|
||||
#exportToP8() {
|
||||
if (!this.editor) return;
|
||||
|
||||
const gfxData = this.editor.getSpriteSheet().toP8Gfx();
|
||||
const flagData = this.editor.getSpriteSheet().toP8Flags();
|
||||
|
||||
const p8Content = `pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
-- generated by picoGraph
|
||||
-- paste your blueprinted code here
|
||||
|
||||
__gfx__
|
||||
${gfxData}
|
||||
__gff__
|
||||
${flagData}
|
||||
`;
|
||||
|
||||
const blob = new Blob([p8Content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sprites.p8';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Show success feedback
|
||||
const exportBtn = document.getElementById('exportP8Button');
|
||||
if (exportBtn) {
|
||||
exportBtn.classList.add('is-success');
|
||||
setTimeout(() => {
|
||||
exportBtn.classList.remove('is-success');
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports sprite data from .p8 format.
|
||||
*/
|
||||
#importFromP8() {
|
||||
if (!this.editor) return;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.p8';
|
||||
|
||||
input.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
|
||||
// Extract __gfx__ section
|
||||
const gfxMatch = text.match(/__gfx__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||
if (gfxMatch) {
|
||||
this.editor.getSpriteSheet().fromP8Gfx(gfxMatch[1]);
|
||||
}
|
||||
|
||||
// Extract __gff__ section
|
||||
const gffMatch = text.match(/__gff__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||
if (gffMatch) {
|
||||
this.editor.getSpriteSheet().fromP8Flags(gffMatch[1]);
|
||||
}
|
||||
|
||||
// Re-render
|
||||
this.editor.setSpriteSheet(this.editor.getSpriteSheet());
|
||||
|
||||
// Show success feedback
|
||||
const importBtn = document.getElementById('importP8Button');
|
||||
if (importBtn) {
|
||||
importBtn.classList.add('is-success');
|
||||
setTimeout(() => {
|
||||
importBtn.classList.remove('is-success');
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to import sprite data:', error);
|
||||
alert('Failed to import sprite data. Please ensure the file is a valid .p8 file.');
|
||||
}
|
||||
});
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current sprite sheet data.
|
||||
*
|
||||
* @returns {SpriteSheet}
|
||||
*/
|
||||
getSpriteSheet() {
|
||||
return this.spriteSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets sprite sheet data.
|
||||
*
|
||||
* @param {SpriteSheet} spriteSheet New sprite sheet.
|
||||
*/
|
||||
setSpriteSheet(spriteSheet) {
|
||||
this.spriteSheet = spriteSheet;
|
||||
if (this.editor) {
|
||||
this.editor.setSpriteSheet(spriteSheet);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
scripts/ui/TileManager.js
Normal file
114
scripts/ui/TileManager.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { TileSetBrowser } from "./TileSetBrowser.js";
|
||||
|
||||
/**
|
||||
* Manages the tile browser modal and provides access to tileset management.
|
||||
*/
|
||||
export class TileManager {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.browser = null;
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the tile manager.
|
||||
*/
|
||||
initialize() {
|
||||
this.modal = document.getElementById("tileModal");
|
||||
if (!this.modal) {
|
||||
console.error("Tile modal element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this.modal.querySelector(".tile-browser-container");
|
||||
if (!container) {
|
||||
console.error("Tile browser container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
this.browser = new TileSetBrowser(container);
|
||||
|
||||
const closeBtn = this.modal.querySelector(".close-modal");
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener("click", () => this.close());
|
||||
}
|
||||
|
||||
this.modal.addEventListener("click", (e) => {
|
||||
if (e.target === this.modal) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
const tileBtn = document.getElementById("tileManagerButton");
|
||||
if (tileBtn) {
|
||||
tileBtn.addEventListener("click", () => this.open());
|
||||
}
|
||||
|
||||
this.#loadFromLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the tile manager modal.
|
||||
*/
|
||||
open() {
|
||||
if (!this.modal) return;
|
||||
this.modal.style.display = "flex";
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the tile manager modal.
|
||||
*/
|
||||
close() {
|
||||
if (!this.modal) return;
|
||||
this.modal.style.display = "none";
|
||||
this.isOpen = false;
|
||||
this.#saveToLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all tilesets.
|
||||
*
|
||||
* @returns {Array<TileSet>}
|
||||
*/
|
||||
getTileSets() {
|
||||
return this.browser ? this.browser.getTileSets() : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves tileset data to localStorage.
|
||||
*/
|
||||
#saveToLocalStorage() {
|
||||
if (!this.browser) return;
|
||||
|
||||
try {
|
||||
const data = this.browser.toJSON();
|
||||
localStorage.setItem("picograph_tilesets", JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error("Failed to save tilesets:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads tileset data from localStorage.
|
||||
*/
|
||||
async #loadFromLocalStorage() {
|
||||
if (!this.browser) return;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem("picograph_tilesets");
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored);
|
||||
await this.browser.fromJSON(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load tilesets:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
511
scripts/ui/TileSetBrowser.js
Normal file
511
scripts/ui/TileSetBrowser.js
Normal file
@@ -0,0 +1,511 @@
|
||||
import { TileSet } from "../core/TileSet.js";
|
||||
|
||||
/**
|
||||
* Manages multiple tilesets (8x8 tiles in 128x128px sheets) and provides UI for browsing and editing externally.
|
||||
*/
|
||||
export class TileSetBrowser {
|
||||
/**
|
||||
* @param {HTMLElement} container Parent element for the browser.
|
||||
*/
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
|
||||
/** @type {Map<string, TileSet>} */
|
||||
this.tileSets = new Map();
|
||||
|
||||
/** @type {string | null} */
|
||||
this.selectedTileSetId = null;
|
||||
|
||||
/** @type {number | null} */
|
||||
this.selectedTileIndex = null;
|
||||
|
||||
this.#createUI();
|
||||
this.#attachEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the UI structure.
|
||||
*/
|
||||
#createUI() {
|
||||
this.container.innerHTML = "";
|
||||
this.container.className = "tileset-browser";
|
||||
|
||||
// Header with actions
|
||||
const header = document.createElement("div");
|
||||
header.className = "tileset-header";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "tileset-title";
|
||||
title.textContent = "Tilesets";
|
||||
header.appendChild(title);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tileset-actions";
|
||||
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.className = "tileset-action-btn";
|
||||
addBtn.textContent = "+ Add Tileset";
|
||||
addBtn.dataset.action = "add";
|
||||
actions.appendChild(addBtn);
|
||||
|
||||
header.appendChild(actions);
|
||||
this.container.appendChild(header);
|
||||
|
||||
// Tileset list
|
||||
this.tileSetList = document.createElement("div");
|
||||
this.tileSetList.className = "tileset-list";
|
||||
this.container.appendChild(this.tileSetList);
|
||||
|
||||
// Tile preview panel
|
||||
this.previewPanel = document.createElement("div");
|
||||
this.previewPanel.className = "tileset-preview-panel";
|
||||
this.previewPanel.innerHTML = `
|
||||
<div class="tileset-preview-header">
|
||||
<span class="tileset-preview-title">Tile Preview</span>
|
||||
</div>
|
||||
<div class="tileset-preview-content">
|
||||
<canvas class="tileset-preview-canvas"></canvas>
|
||||
<div class="tileset-preview-info">Select a tileset to preview</div>
|
||||
</div>
|
||||
`;
|
||||
this.container.appendChild(this.previewPanel);
|
||||
|
||||
this.previewCanvas = this.previewPanel.querySelector(".tileset-preview-canvas");
|
||||
this.previewCtx = this.previewCanvas.getContext("2d", { alpha: false });
|
||||
this.previewInfo = this.previewPanel.querySelector(".tileset-preview-info");
|
||||
|
||||
this.#renderEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners.
|
||||
*/
|
||||
#attachEvents() {
|
||||
// Add tileset button
|
||||
this.container.addEventListener("click", (e) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
|
||||
const actionBtn = target.closest("[data-action]");
|
||||
if (actionBtn) {
|
||||
const action = actionBtn.dataset.action;
|
||||
|
||||
if (action === "add") {
|
||||
this.#addTileSet();
|
||||
} else if (action === "open") {
|
||||
const id = actionBtn.dataset.id;
|
||||
if (id) this.#openExternal(id);
|
||||
} else if (action === "reload") {
|
||||
const id = actionBtn.dataset.id;
|
||||
if (id) this.#reloadTileSet(id);
|
||||
} else if (action === "remove") {
|
||||
const id = actionBtn.dataset.id;
|
||||
if (id) this.#removeTileSet(id);
|
||||
} else if (action === "edit-settings") {
|
||||
const id = actionBtn.dataset.id;
|
||||
if (id) this.#editTileSetSettings(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Tileset selection
|
||||
const tilesetItem = target.closest(".tileset-item");
|
||||
if (tilesetItem && tilesetItem.dataset.id) {
|
||||
this.#selectTileSet(tilesetItem.dataset.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Canvas tile selection
|
||||
this.previewCanvas.addEventListener("click", (e) => {
|
||||
if (!this.selectedTileSetId) return;
|
||||
|
||||
const tileSet = this.tileSets.get(this.selectedTileSetId);
|
||||
if (!tileSet || !tileSet.isLoaded) return;
|
||||
|
||||
const rect = this.previewCanvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / (this.previewCanvas.width / tileSet.columns));
|
||||
const y = Math.floor((e.clientY - rect.top) / (this.previewCanvas.height / tileSet.rows));
|
||||
|
||||
this.selectedTileIndex = y * tileSet.columns + x;
|
||||
this.#renderPreview();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new tileset.
|
||||
*/
|
||||
async #addTileSet() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/png";
|
||||
|
||||
input.addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const name = file.name.replace(/\.[^/.]+$/, "");
|
||||
|
||||
let filePath;
|
||||
let nativePath = "";
|
||||
|
||||
// If running in Electron, save the file to app data
|
||||
if (window.electronAPI?.saveTileset) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
const result = await window.electronAPI.saveTileset(uint8Array, file.name);
|
||||
nativePath = result.filePath;
|
||||
// Convert to file:// URL for loading in Image element
|
||||
filePath = `file:///${result.filePath.replace(/\\/g, "/")}`;
|
||||
} else {
|
||||
// Fallback for non-Electron environments (dev/testing)
|
||||
filePath = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
const tileSet = new TileSet({
|
||||
name,
|
||||
filePath,
|
||||
nativePath,
|
||||
tileWidth: 8,
|
||||
tileHeight: 8
|
||||
});
|
||||
|
||||
await tileSet.load();
|
||||
|
||||
this.tileSets.set(tileSet.id, tileSet);
|
||||
this.#render();
|
||||
this.#selectTileSet(tileSet.id);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to add tileset:", error);
|
||||
alert(`Failed to load tileset:\n${error.message || error}`);
|
||||
}
|
||||
});
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens tileset in external editor.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
#openExternal(id) {
|
||||
const tileSet = this.tileSets.get(id);
|
||||
if (!tileSet) return;
|
||||
|
||||
// In Electron, show the file in explorer so user can open with their preferred editor
|
||||
if (window.electronAPI?.showTilesetInFolder && tileSet.nativePath) {
|
||||
window.electronAPI.showTilesetInFolder(tileSet.nativePath);
|
||||
} else {
|
||||
// Fallback: show message
|
||||
const pathToShow = tileSet.nativePath || tileSet.filePath;
|
||||
alert(`To edit this tileset, open:\n${pathToShow}\n\nIn your image editor of choice.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads a tileset from disk.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
async #reloadTileSet(id) {
|
||||
const tileSet = this.tileSets.get(id);
|
||||
if (!tileSet) return;
|
||||
|
||||
try {
|
||||
await tileSet.reload();
|
||||
this.#render();
|
||||
if (this.selectedTileSetId === id) {
|
||||
this.#renderPreview();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reload tileset:", error);
|
||||
alert(`Failed to reload tileset:\n${error.message || error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a tileset.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
#removeTileSet(id) {
|
||||
if (!confirm("Remove this tileset?")) return;
|
||||
|
||||
this.tileSets.delete(id);
|
||||
|
||||
if (this.selectedTileSetId === id) {
|
||||
this.selectedTileSetId = null;
|
||||
this.selectedTileIndex = null;
|
||||
}
|
||||
|
||||
this.#render();
|
||||
this.#renderPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits tileset settings.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
#editTileSetSettings(id) {
|
||||
const tileSet = this.tileSets.get(id);
|
||||
if (!tileSet) return;
|
||||
|
||||
const name = prompt("Tileset name:", tileSet.name);
|
||||
if (!name) return;
|
||||
|
||||
tileSet.name = name;
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a tileset.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
#selectTileSet(id) {
|
||||
this.selectedTileSetId = id;
|
||||
this.selectedTileIndex = null;
|
||||
this.#render();
|
||||
this.#renderPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the tileset list.
|
||||
*/
|
||||
#render() {
|
||||
if (this.tileSets.size === 0) {
|
||||
this.#renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
this.tileSetList.innerHTML = "";
|
||||
|
||||
this.tileSets.forEach((tileSet) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "tileset-item";
|
||||
item.dataset.id = tileSet.id;
|
||||
|
||||
if (this.selectedTileSetId === tileSet.id) {
|
||||
item.classList.add("selected");
|
||||
}
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "tileset-item-preview";
|
||||
|
||||
if (tileSet.isLoaded && tileSet.image) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw scaled down version
|
||||
const scale = Math.min(64 / tileSet.image.width, 64 / tileSet.image.height);
|
||||
const w = tileSet.image.width * scale;
|
||||
const h = tileSet.image.height * scale;
|
||||
ctx.drawImage(tileSet.image, 0, 0, w, h);
|
||||
|
||||
preview.appendChild(canvas);
|
||||
} else {
|
||||
preview.textContent = "?";
|
||||
}
|
||||
|
||||
item.appendChild(preview);
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "tileset-item-info";
|
||||
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.className = "tileset-item-name";
|
||||
nameEl.textContent = tileSet.name;
|
||||
info.appendChild(nameEl);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "tileset-item-meta";
|
||||
meta.textContent = `128×128px • ${tileSet.getTileCount()} tiles (${tileSet.tileWidth}×${tileSet.tileHeight}px each)`;
|
||||
info.appendChild(meta);
|
||||
|
||||
item.appendChild(info);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tileset-item-actions";
|
||||
|
||||
const openBtn = document.createElement("button");
|
||||
openBtn.className = "tileset-item-btn";
|
||||
openBtn.title = "Open in external editor";
|
||||
openBtn.dataset.action = "open";
|
||||
openBtn.dataset.id = tileSet.id;
|
||||
openBtn.textContent = "✏️";
|
||||
actions.appendChild(openBtn);
|
||||
|
||||
const reloadBtn = document.createElement("button");
|
||||
reloadBtn.className = "tileset-item-btn";
|
||||
reloadBtn.title = "Reload from disk";
|
||||
reloadBtn.dataset.action = "reload";
|
||||
reloadBtn.dataset.id = tileSet.id;
|
||||
reloadBtn.textContent = "🔄";
|
||||
actions.appendChild(reloadBtn);
|
||||
|
||||
const settingsBtn = document.createElement("button");
|
||||
settingsBtn.className = "tileset-item-btn";
|
||||
settingsBtn.title = "Settings";
|
||||
settingsBtn.dataset.action = "edit-settings";
|
||||
settingsBtn.dataset.id = tileSet.id;
|
||||
settingsBtn.textContent = "⚙️";
|
||||
actions.appendChild(settingsBtn);
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className = "tileset-item-btn tileset-item-btn--danger";
|
||||
removeBtn.title = "Remove tileset";
|
||||
removeBtn.dataset.action = "remove";
|
||||
removeBtn.dataset.id = tileSet.id;
|
||||
removeBtn.textContent = "🗑️";
|
||||
actions.appendChild(removeBtn);
|
||||
|
||||
item.appendChild(actions);
|
||||
|
||||
this.tileSetList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the preview panel.
|
||||
*/
|
||||
#renderPreview() {
|
||||
if (!this.selectedTileSetId) {
|
||||
this.previewInfo.textContent = "Select a tileset to preview";
|
||||
this.previewInfo.style.display = "block";
|
||||
this.previewCanvas.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const tileSet = this.tileSets.get(this.selectedTileSetId);
|
||||
if (!tileSet || !tileSet.isLoaded || !tileSet.image) {
|
||||
this.previewInfo.textContent = "Tileset not loaded";
|
||||
this.previewInfo.style.display = "block";
|
||||
this.previewCanvas.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
this.previewInfo.style.display = "none";
|
||||
this.previewCanvas.style.display = "block";
|
||||
|
||||
// Set canvas size
|
||||
const scale = 2;
|
||||
this.previewCanvas.width = tileSet.getWidth() * scale;
|
||||
this.previewCanvas.height = tileSet.getHeight() * scale;
|
||||
|
||||
this.previewCtx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw tileset
|
||||
this.previewCtx.drawImage(
|
||||
tileSet.image,
|
||||
0, 0,
|
||||
tileSet.image.width, tileSet.image.height,
|
||||
0, 0,
|
||||
this.previewCanvas.width, this.previewCanvas.height
|
||||
);
|
||||
|
||||
// Draw grid
|
||||
this.previewCtx.strokeStyle = "rgba(255, 255, 255, 0.3)";
|
||||
this.previewCtx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x <= tileSet.columns; x++) {
|
||||
const px = x * tileSet.tileWidth * scale;
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.moveTo(px, 0);
|
||||
this.previewCtx.lineTo(px, this.previewCanvas.height);
|
||||
this.previewCtx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= tileSet.rows; y++) {
|
||||
const py = y * tileSet.tileHeight * scale;
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.moveTo(0, py);
|
||||
this.previewCtx.lineTo(this.previewCanvas.width, py);
|
||||
this.previewCtx.stroke();
|
||||
}
|
||||
|
||||
// Highlight selected tile
|
||||
if (this.selectedTileIndex !== null) {
|
||||
const rect = tileSet.getTileRect(this.selectedTileIndex);
|
||||
this.previewCtx.strokeStyle = "rgba(255, 127, 17, 1)";
|
||||
this.previewCtx.lineWidth = 2;
|
||||
this.previewCtx.strokeRect(
|
||||
rect.x * scale,
|
||||
rect.y * scale,
|
||||
rect.width * scale,
|
||||
rect.height * scale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders empty state.
|
||||
*/
|
||||
#renderEmpty() {
|
||||
this.tileSetList.innerHTML = `
|
||||
<div class="tileset-empty">
|
||||
<div class="tileset-empty-icon">🎨</div>
|
||||
<div class="tileset-empty-text">No tilesets added</div>
|
||||
<div class="tileset-empty-hint">Click "+ Add Tileset" to import a 128x128px PNG</div>
|
||||
</div>
|
||||
`;
|
||||
this.#renderPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all tilesets.
|
||||
*
|
||||
* @returns {Array<TileSet>}
|
||||
*/
|
||||
getTileSets() {
|
||||
return Array.from(this.tileSets.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a tileset programmatically.
|
||||
*
|
||||
* @param {TileSet} tileSet Tileset to add.
|
||||
*/
|
||||
addTileSet(tileSet) {
|
||||
this.tileSets.set(tileSet.id, tileSet);
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes all tilesets to JSON.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
tileSets: Array.from(this.tileSets.values()).map((ts) => ts.toJSON())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads tilesets from JSON.
|
||||
*
|
||||
* @param {Object} data Serialized data.
|
||||
*/
|
||||
async fromJSON(data) {
|
||||
if (!data.tileSets) return;
|
||||
|
||||
this.tileSets.clear();
|
||||
|
||||
for (const tsData of data.tileSets) {
|
||||
try {
|
||||
const tileSet = TileSet.fromJSON(tsData);
|
||||
await tileSet.load();
|
||||
this.tileSets.set(tileSet.id, tileSet);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tileset:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export class WorkspaceDragManager {
|
||||
|
||||
/**
|
||||
* Updates the palette ghost transform to match the supplied position.
|
||||
* The ghost is offset so the cursor appears at its center.
|
||||
*
|
||||
* @param {{ x: number, y: number }} position Snapped workspace position.
|
||||
*/
|
||||
@@ -57,8 +58,14 @@ export class WorkspaceDragManager {
|
||||
if (!paletteDragGhost) {
|
||||
return;
|
||||
}
|
||||
const width = paletteDragGhost.width || 220;
|
||||
const height = paletteDragGhost.height || 140;
|
||||
const centered = {
|
||||
x: position.x - width / 2,
|
||||
y: position.y - height / 2,
|
||||
};
|
||||
paletteDragGhost.element.style.transform =
|
||||
WorkspaceGeometry.positionToTransform(position);
|
||||
WorkspaceGeometry.positionToTransform(centered);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,7 +217,7 @@ export class WorkspaceDragManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerWorld = this.#clientToWorkspace(
|
||||
const pointerWorld = this.clientToWorkspace(
|
||||
hasClientX ? pointer.clientX : context.previousClientX,
|
||||
hasClientY ? pointer.clientY : context.previousClientY
|
||||
);
|
||||
@@ -526,7 +533,7 @@ export class WorkspaceDragManager {
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
#eventToWorkspacePoint(event) {
|
||||
return this.#clientToWorkspace(event.clientX, event.clientY);
|
||||
return this.clientToWorkspace(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,15 +567,20 @@ export class WorkspaceDragManager {
|
||||
* @param {number | undefined} clientY Viewport Y coordinate.
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
#clientToWorkspace(clientX, clientY) {
|
||||
clientToWorkspace(clientX, clientY) {
|
||||
const rect = this.workspace.workspaceElement.getBoundingClientRect();
|
||||
const zoom = this.workspace.zoomLevel || 1;
|
||||
const zoom = this.workspace.navigationManager?.getZoomLevel() ?? this.workspace.zoomLevel ?? 1;
|
||||
const clampedZoom = Math.max(0.01, zoom);
|
||||
const safeX = Number.isFinite(clientX) ? clientX : rect.left;
|
||||
const safeY = Number.isFinite(clientY) ? clientY : rect.top;
|
||||
const pending = this.workspace.navigationManager?.getPendingLayerOffset?.() ?? { x: 0, y: 0 };
|
||||
const effectiveOffset = this.workspace.navigationManager?.getEffectiveOffset?.();
|
||||
const offset = {
|
||||
x: Number.isFinite(effectiveOffset?.x) ? effectiveOffset.x : 0,
|
||||
y: Number.isFinite(effectiveOffset?.y) ? effectiveOffset.y : 0,
|
||||
};
|
||||
return {
|
||||
x: (safeX - rect.left) / zoom - pending.x,
|
||||
y: (safeY - rect.top) / zoom - pending.y,
|
||||
x: (safeX - rect.left) / clampedZoom - offset.x,
|
||||
y: (safeY - rect.top) / clampedZoom - offset.y,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -188,10 +188,14 @@ export class WorkspaceDropManager {
|
||||
|
||||
this.#workspace.paletteManager.handleDragEnd();
|
||||
|
||||
const spawn = WorkspaceGeometry.snapPositionToGrid(
|
||||
this.#workspace,
|
||||
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
|
||||
);
|
||||
// Snap to grid first, then offset for centering (consistent with ghost positioning)
|
||||
const cursorWorld = WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event);
|
||||
const snapped = WorkspaceGeometry.snapPositionToGrid(this.#workspace, cursorWorld);
|
||||
// Offset spawn to match the centered ghost position (roughly half ghost size)
|
||||
const spawn = {
|
||||
x: snapped.x - 110,
|
||||
y: snapped.y - 70,
|
||||
};
|
||||
|
||||
this.#workspace.addNode(definitionId, spawn);
|
||||
}
|
||||
@@ -214,10 +218,12 @@ export class WorkspaceDropManager {
|
||||
|
||||
const definitions = this.#getVariableSpawnDefinitions(variableId);
|
||||
if (!definitions.length) {
|
||||
const spawn = WorkspaceGeometry.snapPositionToGrid(
|
||||
this.#workspace,
|
||||
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
|
||||
);
|
||||
const cursorWorld = WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event);
|
||||
const snapped = WorkspaceGeometry.snapPositionToGrid(this.#workspace, cursorWorld);
|
||||
const spawn = {
|
||||
x: snapped.x - 110,
|
||||
y: snapped.y - 70,
|
||||
};
|
||||
const fallback = this.#workspace.addNode("get_var", spawn);
|
||||
if (fallback) {
|
||||
const nodeName = variable.name || variable.id;
|
||||
|
||||
@@ -29,20 +29,11 @@ export class WorkspaceGeometry {
|
||||
* 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.
|
||||
* @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,
|
||||
};
|
||||
return workspace.dragManager.clientToWorkspace(event?.clientX, event?.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,8 +45,9 @@ export class WorkspaceGeometry {
|
||||
*/
|
||||
static snapPositionToGrid(workspace, position) {
|
||||
const size = workspace.gridSize || DEFAULT_GRID_SIZE;
|
||||
const offsetX = workspace.workspaceBackgroundOffset?.x ?? 0;
|
||||
const offsetY = workspace.workspaceBackgroundOffset?.y ?? 0;
|
||||
const effectiveOffset = workspace.navigationManager?.getEffectiveOffset?.();
|
||||
const offsetX = Number.isFinite(effectiveOffset?.x) ? effectiveOffset.x : 0;
|
||||
const offsetY = Number.isFinite(effectiveOffset?.y) ? effectiveOffset.y : 0;
|
||||
const snap = (value, offset) => {
|
||||
const local = value - offset;
|
||||
const snapped = Math.round(local / size) * size;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
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 */
|
||||
@@ -75,6 +78,7 @@ export class WorkspaceInlineEditorManager {
|
||||
"number_literal",
|
||||
"string_literal",
|
||||
"boolean_literal",
|
||||
"color_literal",
|
||||
]);
|
||||
if (!supported.has(node.type)) {
|
||||
return null;
|
||||
@@ -100,6 +104,27 @@ export class WorkspaceInlineEditorManager {
|
||||
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);
|
||||
@@ -176,6 +201,7 @@ export class WorkspaceInlineEditorManager {
|
||||
{ value: "string", label: "String" },
|
||||
{ value: "boolean", label: "Boolean" },
|
||||
{ value: "table", label: "Table" },
|
||||
{ value: "color", label: "Color" },
|
||||
];
|
||||
|
||||
const ensureBody = () => {
|
||||
@@ -274,6 +300,8 @@ export class WorkspaceInlineEditorManager {
|
||||
return "valueBoolean";
|
||||
case "table":
|
||||
return "valueTable";
|
||||
case "color":
|
||||
return "valueColor";
|
||||
case "number":
|
||||
default:
|
||||
return "valueNumber";
|
||||
@@ -288,6 +316,8 @@ export class WorkspaceInlineEditorManager {
|
||||
return "string";
|
||||
case "table":
|
||||
return "table";
|
||||
case "color":
|
||||
return "color";
|
||||
default:
|
||||
return "number";
|
||||
}
|
||||
@@ -301,6 +331,8 @@ export class WorkspaceInlineEditorManager {
|
||||
return false;
|
||||
case "table":
|
||||
return "{}";
|
||||
case "color":
|
||||
return 7;
|
||||
case "number":
|
||||
default:
|
||||
return 0;
|
||||
@@ -313,6 +345,7 @@ export class WorkspaceInlineEditorManager {
|
||||
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(
|
||||
@@ -378,6 +411,20 @@ export class WorkspaceInlineEditorManager {
|
||||
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";
|
||||
@@ -407,6 +454,10 @@ export class WorkspaceInlineEditorManager {
|
||||
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) {
|
||||
@@ -534,18 +585,10 @@ export class WorkspaceInlineEditorManager {
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@@ -555,31 +598,14 @@ export class WorkspaceInlineEditorManager {
|
||||
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;
|
||||
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);
|
||||
@@ -591,7 +617,7 @@ export class WorkspaceInlineEditorManager {
|
||||
});
|
||||
|
||||
let wrapper;
|
||||
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
|
||||
if (pinOptions) {
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.className = "pin-inline-control pin-inline-dropdown";
|
||||
container.appendChild(wrapper);
|
||||
@@ -631,29 +657,14 @@ export class WorkspaceInlineEditorManager {
|
||||
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 : [];
|
||||
if (pinOptions) {
|
||||
const optionValues = pinOptions.map((o) => String(o.value));
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.className = "pin-inline-select";
|
||||
const ariaLabel = isExtcmdCommand
|
||||
? "Command"
|
||||
: isNamedButtonSelector
|
||||
? "Button"
|
||||
: "Player";
|
||||
select.setAttribute("aria-label", ariaLabel);
|
||||
select.setAttribute("aria-label", pin.name || pin.id);
|
||||
|
||||
const values = options.map((option) => String(option.value));
|
||||
options.forEach((option) => {
|
||||
pinOptions.forEach((option) => {
|
||||
const element = document.createElement("option");
|
||||
element.value = String(option.value);
|
||||
element.textContent = option.label ?? String(option.value);
|
||||
@@ -661,30 +672,14 @@ export class WorkspaceInlineEditorManager {
|
||||
});
|
||||
|
||||
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 candidate = value == null ? "" : String(value);
|
||||
return optionValues.includes(candidate)
|
||||
? candidate
|
||||
: (optionValues[0] ?? "");
|
||||
};
|
||||
|
||||
const setValue = (value) => {
|
||||
const normalized = normalizeValue(
|
||||
(isNamedButtonSelector || isNamedPlayerSelector) &&
|
||||
typeof node.properties[targetKey] === "string"
|
||||
? node.properties[targetKey]
|
||||
: value
|
||||
);
|
||||
select.value = normalized;
|
||||
select.value = normalizeValue(value);
|
||||
};
|
||||
|
||||
const setConnected = (connected) => {
|
||||
@@ -694,23 +689,42 @@ export class WorkspaceInlineEditorManager {
|
||||
|
||||
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);
|
||||
}
|
||||
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));
|
||||
@@ -765,45 +779,12 @@ export class WorkspaceInlineEditorManager {
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -905,6 +886,8 @@ export class WorkspaceInlineEditorManager {
|
||||
return 0;
|
||||
case "string":
|
||||
return "";
|
||||
case "color":
|
||||
return 7;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -106,10 +106,6 @@ export class WorkspaceNavigationManager {
|
||||
this.#targetZoomLevel = clamped;
|
||||
this.#zoomLevel = clamped;
|
||||
this.updateZoomDisplay(skipConnectionRender);
|
||||
if (!silent) {
|
||||
this.#markViewDirty("view", 200);
|
||||
this.#schedulePersist();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -151,6 +147,9 @@ export class WorkspaceNavigationManager {
|
||||
|
||||
/**
|
||||
* Converts a screen-space point to workspace coordinates.
|
||||
* Note: This returns screen coordinates divided by zoom only, without subtracting
|
||||
* the background offset. This is intentional for zoom pivot calculations.
|
||||
* For true world coordinates, use dragManager.clientToWorkspace().
|
||||
*
|
||||
* @param {{ x: number, y: number }} point Screen-space point relative to the workspace element.
|
||||
* @returns {{ x: number, y: number }}
|
||||
@@ -273,8 +272,6 @@ export class WorkspaceNavigationManager {
|
||||
}
|
||||
// Restore clean scale-only transform now that node positions are committed.
|
||||
this.updateZoomDisplay(false);
|
||||
this.#markViewDirty("view", 0);
|
||||
this.#schedulePersist();
|
||||
} else {
|
||||
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
|
||||
}
|
||||
@@ -320,6 +317,7 @@ export class WorkspaceNavigationManager {
|
||||
this.#rebasePanGestureAfterZoom(pointer);
|
||||
}
|
||||
|
||||
this.updateZoomDisplay(true);
|
||||
this.#renderConnections();
|
||||
}
|
||||
|
||||
@@ -417,6 +415,7 @@ export class WorkspaceNavigationManager {
|
||||
});
|
||||
|
||||
this.translateWorkspace(delta);
|
||||
this.updateZoomDisplay(false);
|
||||
this.#workspace.selectNode(nodeId);
|
||||
}
|
||||
|
||||
@@ -489,30 +488,29 @@ export class WorkspaceNavigationManager {
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
// Directly compute the background offset that places contentCenter at screen center,
|
||||
// then set zoom + offset together so a single updateZoomDisplay call applies both.
|
||||
const newOffsetX = rect.width / 2 / targetZoom - contentCenter.x;
|
||||
const newOffsetY = rect.height / 2 / targetZoom - contentCenter.y;
|
||||
|
||||
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();
|
||||
// Cancel any running smooth zoom before snapping.
|
||||
if (this.#smoothZoomRafId !== null) {
|
||||
cancelAnimationFrame(this.#smoothZoomRafId);
|
||||
this.#smoothZoomRafId = null;
|
||||
this.#smoothZoomFocus = null;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
this.#markViewDirty("view", 200);
|
||||
this.#schedulePersist();
|
||||
this.#targetZoomLevel = targetZoom;
|
||||
this.#zoomLevel = targetZoom;
|
||||
this.setBackgroundOffset({ x: newOffsetX, y: newOffsetY });
|
||||
this.updateZoomDisplay(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -536,6 +534,7 @@ export class WorkspaceNavigationManager {
|
||||
startY: event.clientY,
|
||||
hasMoved: false,
|
||||
backgroundOrigin: { ...this.#backgroundOffset },
|
||||
lastDelta: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
@@ -564,12 +563,14 @@ export class WorkspaceNavigationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#panState.lastDelta = worldDelta;
|
||||
|
||||
const backgroundOffset = {
|
||||
x: this.#panState.backgroundOrigin.x + worldDelta.x,
|
||||
y: this.#panState.backgroundOrigin.y + worldDelta.y,
|
||||
};
|
||||
this.setBackgroundOffset(backgroundOffset);
|
||||
this.updateZoomDisplay();
|
||||
this.updateZoomDisplay(true);
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent) => {
|
||||
@@ -589,6 +590,7 @@ export class WorkspaceNavigationManager {
|
||||
}
|
||||
|
||||
this.#lastPanTimestamp = this.#timestamp();
|
||||
this.#renderConnections();
|
||||
};
|
||||
|
||||
this.#panState = state;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
|
||||
import { getTypeColor } from "../../types/TypeRegistry.js";
|
||||
|
||||
/**
|
||||
* @typedef {"input"|"output"} PinDirection
|
||||
@@ -300,6 +301,7 @@ export class WorkspaceNodeRenderer {
|
||||
container.dataset.pinId = pin.id;
|
||||
container.dataset.nodeId = node.id;
|
||||
container.dataset.type = pin.kind;
|
||||
container.style.setProperty("--pin-kind-color", getTypeColor(pin.kind));
|
||||
const isStandardExec =
|
||||
pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
|
||||
if (isStandardExec) {
|
||||
|
||||
@@ -54,28 +54,48 @@ export class WorkspacePersistenceManager {
|
||||
}
|
||||
|
||||
this.cancelScheduledPersist();
|
||||
this.#workspace.handlePersistenceStateChange?.({
|
||||
status: "pending",
|
||||
source: "auto",
|
||||
});
|
||||
this.#timerId = window.setTimeout(() => {
|
||||
this.#timerId = null;
|
||||
this.persistImmediately();
|
||||
this.persistImmediately("auto");
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes current workspace state and writes it to localStorage immediately.
|
||||
*
|
||||
* @param {'manual'|'auto'} [source]
|
||||
*/
|
||||
persistImmediately() {
|
||||
persistImmediately(source = "manual") {
|
||||
if (!this.#storageAvailable || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelScheduledPersist();
|
||||
this.#workspace.handlePersistenceStateChange?.({
|
||||
status: "saving",
|
||||
source,
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = this.serializeWorkspace();
|
||||
window.localStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEY,
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
this.#workspace.handlePersistenceStateChange?.({
|
||||
status: "saved",
|
||||
source,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to persist workspace state", error);
|
||||
this.#workspace.handlePersistenceStateChange?.({
|
||||
status: "error",
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +110,7 @@ export class WorkspacePersistenceManager {
|
||||
.filter((entry) => Boolean(entry))
|
||||
.map((entry) => {
|
||||
const variable =
|
||||
/** @type {{ id: string, name: string, type: 'any'|'number'|'string'|'boolean'|'table', defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue }} */ (
|
||||
/** @type {{ id: string, name: string, type: 'any'|'number'|'string'|'boolean'|'table'|'color', defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue }} */ (
|
||||
entry
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -90,10 +90,12 @@ export class WorkspaceWireCutManager {
|
||||
*/
|
||||
#clientToSvg(clientX, clientY) {
|
||||
const zoom = this.#workspace.zoomLevel || 1;
|
||||
const offset = this.#workspace.navigationManager?.getEffectiveOffset() ??
|
||||
this.#workspace.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||
const rect = this.#workspace.workspaceElement.getBoundingClientRect();
|
||||
return {
|
||||
x: (clientX - rect.left) / zoom,
|
||||
y: (clientY - rect.top) / zoom,
|
||||
x: (clientX - rect.left) / zoom - offset.x,
|
||||
y: (clientY - rect.top) / zoom - offset.y,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { VARIABLE_SORT_MIME } from "../BlueprintWorkspaceConstants.js";
|
||||
import { getTypeColor } from "../../types/TypeRegistry.js";
|
||||
|
||||
/**
|
||||
* Renders the global variable summary list.
|
||||
@@ -225,6 +226,7 @@ export function renderVariableList({
|
||||
row.setAttribute("role", "button");
|
||||
row.dataset.variableId = variable.id;
|
||||
row.dataset.variableType = variable.type;
|
||||
row.style.setProperty("--overview-variable-color", getTypeColor(variable.type));
|
||||
row.draggable = true;
|
||||
row.setAttribute("aria-pressed", "false");
|
||||
|
||||
@@ -330,6 +332,7 @@ export function renderVariableList({
|
||||
typeButton.type = "button";
|
||||
typeButton.className = "variable-type-button";
|
||||
typeButton.dataset.variableType = variable.type;
|
||||
typeButton.style.setProperty("--overview-variable-color", getTypeColor(variable.type));
|
||||
typeButton.setAttribute("aria-haspopup", "menu");
|
||||
typeButton.setAttribute("aria-expanded", "false");
|
||||
typeButton.title = `Type: ${formatVariableType(variable.type)}`;
|
||||
|
||||
Reference in New Issue
Block a user