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:
2026-03-21 18:38:43 +11:00
parent 71354ad463
commit f917cf6ad5
56 changed files with 5200 additions and 688 deletions

View File

@@ -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(

View File

@@ -0,0 +1,184 @@
/**
* Ordered PICO-8 palette hex strings, indices 015.
*
* @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 (015) 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 (015).
*/
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 (015).
*/
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 (015).
*
* @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));
}

View File

@@ -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
View 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();
}
}

View 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
View 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);
}
}
}

View 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();
}
}

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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)}`;