"use strict"; // User-facing undo/redo for direct editing actions. (function (global) { const MAX_HISTORY = 48; function cloneSnapshot(snapshot) { if (!snapshot) return null; if (typeof structuredClone === "function") return structuredClone(snapshot); return JSON.parse(JSON.stringify(snapshot)); } function ensure(worldRef) { if (!worldRef) return null; if (!Array.isArray(worldRef._undoStack)) worldRef._undoStack = []; if (!Array.isArray(worldRef._redoStack)) worldRef._redoStack = []; return worldRef; } function snapshotKey(snapshot) { try { // Snapshot payloads are produced as plain serializable data; stringify them // directly for duplicate suppression instead of deep-cloning once just to // compute the history key. The stored history entry is still cloned below. return JSON.stringify(snapshot); } catch (_) { return ""; } } const DELETE_MARK = Object.freeze({ __delete: true }); const HISTORY_FULL_INTERVAL = 6; const HISTORY_DELTA_MIN_SAVING = 0.72; function isPlainObject(value) { if (!value || typeof value !== "object") return false; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } function diffValue(prev, next) { if (Object.is(prev, next)) return undefined; if (Array.isArray(prev) || Array.isArray(next)) { const a = JSON.stringify(prev); const b = JSON.stringify(next); return a === b ? undefined : cloneSnapshot(next); } if (!isPlainObject(prev) || !isPlainObject(next)) return cloneSnapshot(next); const patch = {}; const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); for (const key of keys) { if (!(key in next)) { patch[key] = DELETE_MARK; continue; } const child = diffValue(prev[key], next[key]); if (child !== undefined) patch[key] = child; } return Object.keys(patch).length ? patch : undefined; } function applyPatch(base, patch) { if (patch === undefined) return cloneSnapshot(base); if (!isPlainObject(patch) || Array.isArray(patch)) return cloneSnapshot(patch); const out = isPlainObject(base) ? cloneSnapshot(base) : {}; for (const [key, value] of Object.entries(patch)) { if (value && value.__delete === true && Object.keys(value).length === 1) { delete out[key]; continue; } out[key] = isPlainObject(value) && isPlainObject(out[key]) ? applyPatch(out[key], value) : cloneSnapshot(value); } return out; } function materializeHistoryEntry(stack, entry) { if (!entry) return null; if (entry.snapshot) return cloneSnapshot(entry.snapshot); const sequence = [...(stack || []), entry]; let start = -1; for (let i = sequence.length - 1; i >= 0; i -= 1) { if (sequence[i]?.snapshot) { start = i; break; } } if (start < 0) return null; let snap = cloneSnapshot(sequence[start].snapshot); for (let i = start + 1; i < sequence.length; i += 1) { const patch = sequence[i]?.patch; if (patch !== undefined) snap = applyPatch(snap, patch); } return snap; } function makeHistoryEntry(world, snap, key, label, stack) { const fullEntry = { snapshot: cloneSnapshot(snap), key, label: String(label || "action"), time: world.time || 0 }; const stackLen = Array.isArray(stack) ? stack.length : 0; if (stackLen <= 0 || stackLen % HISTORY_FULL_INTERVAL === 0) return fullEntry; const prev = materializeHistoryEntry(stack.slice(0, -1), stack[stack.length - 1]); if (!prev) return fullEntry; const patch = diffValue(prev, snap); if (patch === undefined) return { patch: {}, key, label: String(label || "action"), time: world.time || 0 }; try { const fullSize = JSON.stringify(fullEntry.snapshot).length; const patchSize = JSON.stringify(patch).length; if (patchSize > fullSize * HISTORY_DELTA_MIN_SAVING) return fullEntry; } catch (_) { return fullEntry; } return { patch, key, label: String(label || "action"), time: world.time || 0 }; } function capture(worldRef = global.world, label = "action") { const world = ensure(worldRef); if (!world || world._historyRestoring) return false; const snap = global.TarinaiSnapshot.createSnapshot(world); if (!snap) return false; const key = snapshotKey(snap); if (key && key === world._lastUndoSnapshotKey) return false; world._undoStack.push(makeHistoryEntry(world, snap, key, label, world._undoStack)); if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY); world._redoStack.length = 0; world._lastUndoSnapshotKey = key; return true; } function restore(worldRef, entry, stack = []) { const snapshot = materializeHistoryEntry(stack, entry); if (!worldRef || !snapshot) return false; worldRef._historyRestoring = true; try { global.TarinaiRestoreCoordinator.restoreSnapshot(snapshot, worldRef, { syncUi: true }); worldRef._lastUndoSnapshotKey = snapshotKey(global.TarinaiSnapshot.createSnapshot(worldRef)); return true; } finally { worldRef._historyRestoring = false; } } function undo(worldRef = global.world) { const world = ensure(worldRef); if (!world || !world._undoStack.length) return { ok: false, reason: "empty" }; const current = global.TarinaiSnapshot.createSnapshot(world); const prev = world._undoStack.pop(); if (current) world._redoStack.push(makeHistoryEntry(world, current, snapshotKey(current), "redo", world._redoStack)); if (world._redoStack.length > MAX_HISTORY) world._redoStack.splice(0, world._redoStack.length - MAX_HISTORY); const ok = restore(world, prev, world._undoStack); return { ok, label: prev.label || "" }; } function redo(worldRef = global.world) { const world = ensure(worldRef); if (!world || !world._redoStack.length) return { ok: false, reason: "empty" }; const current = global.TarinaiSnapshot.createSnapshot(world); const next = world._redoStack.pop(); if (current) world._undoStack.push(makeHistoryEntry(world, current, snapshotKey(current), "undo", world._undoStack)); if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY); const ok = restore(world, next, world._redoStack); return { ok, label: next.label || "" }; } global.TarinaiHistory = Object.freeze({ capture, undo, redo }); })(typeof window !== "undefined" ? window : globalThis);