2026-06-26 19:04:32 +09:00
|
|
|
"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 {
|
2026-07-05 18:01:36 +09:00
|
|
|
// 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);
|
2026-06-26 19:04:32 +09:00
|
|
|
} catch (_) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 18:01:36 +09:00
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:04:32 +09:00
|
|
|
function capture(worldRef = global.world, label = "action") {
|
|
|
|
|
const world = ensure(worldRef);
|
|
|
|
|
if (!world || world._historyRestoring) return false;
|
2026-06-29 16:21:58 +09:00
|
|
|
const snap = global.TarinaiSnapshot.createSnapshot(world);
|
2026-06-26 19:04:32 +09:00
|
|
|
if (!snap) return false;
|
|
|
|
|
const key = snapshotKey(snap);
|
|
|
|
|
if (key && key === world._lastUndoSnapshotKey) return false;
|
2026-07-05 18:01:36 +09:00
|
|
|
world._undoStack.push(makeHistoryEntry(world, snap, key, label, world._undoStack));
|
2026-06-26 19:04:32 +09:00
|
|
|
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
|
|
|
|
|
world._redoStack.length = 0;
|
|
|
|
|
world._lastUndoSnapshotKey = key;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 18:01:36 +09:00
|
|
|
function restore(worldRef, entry, stack = []) {
|
|
|
|
|
const snapshot = materializeHistoryEntry(stack, entry);
|
|
|
|
|
if (!worldRef || !snapshot) return false;
|
2026-06-26 19:04:32 +09:00
|
|
|
worldRef._historyRestoring = true;
|
|
|
|
|
try {
|
2026-07-05 18:01:36 +09:00
|
|
|
global.TarinaiRestoreCoordinator.restoreSnapshot(snapshot, worldRef, { syncUi: true });
|
2026-06-29 16:21:58 +09:00
|
|
|
worldRef._lastUndoSnapshotKey = snapshotKey(global.TarinaiSnapshot.createSnapshot(worldRef));
|
2026-06-26 19:04:32 +09:00
|
|
|
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" };
|
2026-06-29 16:21:58 +09:00
|
|
|
const current = global.TarinaiSnapshot.createSnapshot(world);
|
2026-06-26 19:04:32 +09:00
|
|
|
const prev = world._undoStack.pop();
|
2026-07-05 18:01:36 +09:00
|
|
|
if (current) world._redoStack.push(makeHistoryEntry(world, current, snapshotKey(current), "redo", world._redoStack));
|
2026-06-26 19:04:32 +09:00
|
|
|
if (world._redoStack.length > MAX_HISTORY) world._redoStack.splice(0, world._redoStack.length - MAX_HISTORY);
|
2026-07-05 18:01:36 +09:00
|
|
|
const ok = restore(world, prev, world._undoStack);
|
2026-06-26 19:04:32 +09:00
|
|
|
return { ok, label: prev.label || "" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function redo(worldRef = global.world) {
|
|
|
|
|
const world = ensure(worldRef);
|
|
|
|
|
if (!world || !world._redoStack.length) return { ok: false, reason: "empty" };
|
2026-06-29 16:21:58 +09:00
|
|
|
const current = global.TarinaiSnapshot.createSnapshot(world);
|
2026-06-26 19:04:32 +09:00
|
|
|
const next = world._redoStack.pop();
|
2026-07-05 18:01:36 +09:00
|
|
|
if (current) world._undoStack.push(makeHistoryEntry(world, current, snapshotKey(current), "undo", world._undoStack));
|
2026-06-26 19:04:32 +09:00
|
|
|
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
|
2026-07-05 18:01:36 +09:00
|
|
|
const ok = restore(world, next, world._redoStack);
|
2026-06-26 19:04:32 +09:00
|
|
|
return { ok, label: next.label || "" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 16:21:58 +09:00
|
|
|
global.TarinaiHistory = Object.freeze({ capture, undo, redo });
|
2026-06-26 19:04:32 +09:00
|
|
|
})(typeof window !== "undefined" ? window : globalThis);
|