/** * HistoryManager — Undo/Redo через snapshot-историю. * * Каждое действие пользователя (поставил блок, сдвинул модель, удалил и т.д.) * создаёт снимок состояния сцены через scene.serialize(). История хранит до * MAX_HISTORY последних снимков. Ctrl+Z откатывает на предыдущий, Ctrl+Y возвращает. * * Простая реализация (не diff-based): * - snapshot = JSON.stringify(scene.serialize()) * - размер на сцену ~10-50 КБ * - 50 снапшотов = ~1-2 МБ памяти максимум * * Запись: * markChange() — снять текущий snapshot (вызывается debounced при изменениях) * Откат: * undo() — применить предыдущий snapshot * redo() — применить следующий */ const MAX_HISTORY = 50; const DEBOUNCE_MS = 250; // не снимаем snapshot чаще чем раз в 250мс export class HistoryManager { /** * @param {() => string} serializer — возвращает текущее состояние как строку * @param {(state: object) => Promise} restorer — восстанавливает состояние */ constructor(serializer, restorer) { this.serializer = serializer; this.restorer = restorer; this._past = []; // [snapshot, ...] — история до текущего this._future = []; // [snapshot, ...] — для redo (заполняется при undo) this._current = null; // snapshot текущего состояния this._debounceTimer = null; this._isApplying = false; // защита от рекурсии при restoreFromState → markChange } /** * Сделать snapshot текущего состояния. Debounced — частые изменения * (например drag-постановка 30 блоков подряд) сольются в один snapshot. */ markChange() { if (this._isApplying) return; if (this._debounceTimer) clearTimeout(this._debounceTimer); this._debounceTimer = setTimeout(() => { this._debounceTimer = null; this._takeSnapshot(); }, DEBOUNCE_MS); } /** Принудительный snapshot прямо сейчас (без debounce). */ flushPending() { if (this._debounceTimer) { clearTimeout(this._debounceTimer); this._debounceTimer = null; this._takeSnapshot(); } } _takeSnapshot() { const snap = this.serializer(); if (!snap) return; // Если ничего не изменилось — пропускаем if (snap === this._current) return; // Любая новая запись стирает future (классическое поведение) if (this._current !== null) { this._past.push(this._current); if (this._past.length > MAX_HISTORY) this._past.shift(); } this._current = snap; this._future = []; } /** Инициализация: запомнить начальное состояние (вызвать после loadFromState). */ initialize() { this._past = []; this._future = []; this._current = this.serializer(); } /** Можно ли откатиться. */ canUndo() { return this._past.length > 0; } /** Можно ли вернуть. */ canRedo() { return this._future.length > 0; } async undo() { this.flushPending(); if (!this.canUndo()) return false; const prev = this._past.pop(); if (this._current !== null) this._future.push(this._current); this._current = prev; await this._apply(prev); return true; } async redo() { this.flushPending(); if (!this.canRedo()) return false; const next = this._future.pop(); if (this._current !== null) this._past.push(this._current); this._current = next; await this._apply(next); return true; } async _apply(snap) { this._isApplying = true; try { const obj = JSON.parse(snap); await this.restorer(obj); } catch (e) { // eslint-disable-next-line no-console console.error('[HistoryManager] restore error', e); } finally { this._isApplying = false; } } /** Полная очистка (при закрытии сцены). */ dispose() { if (this._debounceTimer) clearTimeout(this._debounceTimer); this._past = []; this._future = []; this._current = null; } }