Files
house-design/src/state.js
m 36bc0aedd7 Add DesignState class with observable state and undo/redo
Lightweight state management for furniture editing. All mutations go
through named methods that snapshot state for undo, then notify
listeners. Supports move, rotate, add, remove, full design load,
and serialization via toJSON().
2026-02-07 12:20:03 +01:00

192 lines
5.2 KiB
JavaScript

/**
* DesignState — observable state with undo/redo for furniture design editing.
*
* All mutations go through methods that snapshot state for undo, then notify
* listeners so the renderer (and future UI panels) can react.
*/
export class DesignState {
constructor(initialDesign) {
this._state = structuredClone(initialDesign);
this._listeners = new Set();
this._undoStack = [];
this._redoStack = [];
this._maxUndo = 50;
// Index rooms by id for fast lookup
this._roomIndex = new Map();
this._rebuildIndex();
}
// ---- Read ----
/** Full design object (read-only reference — do not mutate directly). */
get design() {
return this._state;
}
/** Get room design entry by roomId, or undefined. */
getRoomDesign(roomId) {
return this._roomIndex.get(roomId);
}
/** Get a single furniture placement by roomId and index. */
getFurniture(roomId, index) {
const room = this._roomIndex.get(roomId);
return room?.furniture[index];
}
/** Get all furniture for a room. */
getRoomFurniture(roomId) {
const room = this._roomIndex.get(roomId);
return room?.furniture ?? [];
}
// ---- Write (all mutations go through these) ----
/** Generic update: merge `changes` into furniture at [roomId][index]. */
updateFurniture(roomId, index, changes) {
const item = this._getFurnitureOrThrow(roomId, index);
this._pushUndo();
Object.assign(item, changes);
this._notify('furniture-update', { roomId, index });
}
/** Move furniture to a new position {x, z} (and optionally y). */
moveFurniture(roomId, index, newPosition) {
const item = this._getFurnitureOrThrow(roomId, index);
this._pushUndo();
item.position = { ...item.position, ...newPosition };
this._notify('furniture-move', { roomId, index });
}
/** Set furniture rotation in degrees. */
rotateFurniture(roomId, index, degrees) {
const item = this._getFurnitureOrThrow(roomId, index);
this._pushUndo();
item.rotation = degrees;
this._notify('furniture-rotate', { roomId, index });
}
/** Add a new furniture item to a room. Returns the new index. */
addFurniture(roomId, placement) {
this._pushUndo();
let room = this._roomIndex.get(roomId);
if (!room) {
// Create room entry if it doesn't exist yet
room = { roomId, furniture: [] };
this._state.rooms.push(room);
this._roomIndex.set(roomId, room);
}
const index = room.furniture.length;
room.furniture.push(structuredClone(placement));
this._notify('furniture-add', { roomId, index });
return index;
}
/** Remove furniture at [roomId][index]. Returns the removed item. */
removeFurniture(roomId, index) {
const room = this._roomIndex.get(roomId);
if (!room || index < 0 || index >= room.furniture.length) return null;
this._pushUndo();
const [removed] = room.furniture.splice(index, 1);
this._notify('furniture-remove', { roomId, index });
return removed;
}
/** Replace the entire design (e.g. when loading a saved file). */
loadDesign(newDesign) {
this._pushUndo();
this._state = structuredClone(newDesign);
this._rebuildIndex();
this._notify('design-load', {});
}
// ---- Undo / Redo ----
get canUndo() {
return this._undoStack.length > 0;
}
get canRedo() {
return this._redoStack.length > 0;
}
undo() {
if (!this.canUndo) return false;
this._redoStack.push(structuredClone(this._state));
this._state = this._undoStack.pop();
this._rebuildIndex();
this._notify('undo', {});
return true;
}
redo() {
if (!this.canRedo) return false;
this._undoStack.push(structuredClone(this._state));
this._state = this._redoStack.pop();
this._rebuildIndex();
this._notify('redo', {});
return true;
}
// ---- Observers ----
/**
* Register a change listener. Called with (type, detail) on every mutation.
* Returns an unsubscribe function.
*
* Event types:
* furniture-update, furniture-move, furniture-rotate,
* furniture-add, furniture-remove, design-load, undo, redo
*/
onChange(listener) {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
// ---- Serialization ----
/** Export current state as a plain JSON-serializable object. */
toJSON() {
return structuredClone(this._state);
}
// ---- Internal ----
_notify(type, detail) {
for (const fn of this._listeners) {
try {
fn(type, detail);
} catch (err) {
console.error('DesignState listener error:', err);
}
}
}
_pushUndo() {
this._undoStack.push(structuredClone(this._state));
if (this._undoStack.length > this._maxUndo) {
this._undoStack.shift();
}
// Any new mutation clears the redo stack
this._redoStack = [];
}
_rebuildIndex() {
this._roomIndex.clear();
if (this._state?.rooms) {
for (const room of this._state.rooms) {
this._roomIndex.set(room.roomId, room);
}
}
}
_getFurnitureOrThrow(roomId, index) {
const room = this._roomIndex.get(roomId);
if (!room) throw new Error(`Room not found: ${roomId}`);
const item = room.furniture[index];
if (!item) throw new Error(`Furniture not found: ${roomId}[${index}]`);
return item;
}
}