134 lines
4.7 KiB
JavaScript
134 lines
4.7 KiB
JavaScript
"use strict";
|
|
|
|
// User-facing undo/redo for direct editing actions.
|
|
(function (global) {
|
|
const MAX_HISTORY = 32;
|
|
const MAX_HISTORY_BYTES = 8 * 1024 * 1024;
|
|
|
|
function ensure(worldRef) {
|
|
if (!worldRef) return null;
|
|
if (!Array.isArray(worldRef._undoStack)) worldRef._undoStack = [];
|
|
if (!Array.isArray(worldRef._redoStack)) worldRef._redoStack = [];
|
|
return worldRef;
|
|
}
|
|
|
|
function codec() {
|
|
const value = global.TarinaiSaveCodec;
|
|
if (!value || typeof value.encodeBinarySnapshot !== "function" || typeof value.decodeBinarySnapshot !== "function") {
|
|
throw new Error("TarinaiSaveCodec binary history API is unavailable");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// Two independent 32-bit accumulators make accidental duplicate suppression
|
|
// extremely unlikely without converting the complete snapshot to JSON text.
|
|
function fingerprintBytes(bytes) {
|
|
let h1 = 0x811c9dc5;
|
|
let h2 = 0x9e3779b9;
|
|
for (let i = 0; i < bytes.length; i += 1) {
|
|
const value = bytes[i];
|
|
h1 ^= value;
|
|
h1 = Math.imul(h1, 0x01000193) >>> 0;
|
|
h2 ^= value + ((i & 255) << 8);
|
|
h2 = Math.imul(h2 ^ (h2 >>> 16), 0x85ebca6b) >>> 0;
|
|
}
|
|
return `${bytes.length}:${h1.toString(36)}:${h2.toString(36)}`;
|
|
}
|
|
|
|
function makeHistoryEntry(world, snapshot, label) {
|
|
if (!snapshot) return null;
|
|
const bytes = codec().encodeBinarySnapshot(snapshot);
|
|
return {
|
|
bytes,
|
|
key: fingerprintBytes(bytes),
|
|
label: String(label || "action"),
|
|
time: Number(world?.time) || 0,
|
|
};
|
|
}
|
|
|
|
function captureEntry(world, label) {
|
|
const snapshot = global.TarinaiSnapshot.createSnapshot(world);
|
|
return makeHistoryEntry(world, snapshot, label);
|
|
}
|
|
|
|
function stackBytes(stack) {
|
|
let total = 0;
|
|
for (const entry of stack || []) total += Number(entry?.bytes?.byteLength || entry?.bytes?.length || 0) || 0;
|
|
return total;
|
|
}
|
|
|
|
function trim(stack, maxBytes = MAX_HISTORY_BYTES, keepRecent = 1) {
|
|
if (stack.length > MAX_HISTORY) stack.splice(0, stack.length - MAX_HISTORY);
|
|
let total = stackBytes(stack);
|
|
const minKeep = Math.max(0, Math.min(stack.length, Math.floor(Number(keepRecent) || 0)));
|
|
while (stack.length > minKeep && total > maxBytes) {
|
|
const removed = stack.shift();
|
|
total -= Number(removed?.bytes?.byteLength || removed?.bytes?.length || 0) || 0;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function trimMemory(worldRef = global.world, options = {}) {
|
|
const world = ensure(worldRef);
|
|
if (!world) return 0;
|
|
const targetBytes = Math.max(1 * 1024 * 1024, Number(options.targetBytes || MAX_HISTORY_BYTES) || MAX_HISTORY_BYTES);
|
|
const keepRecent = Math.max(1, Math.floor(Number(options.keepRecent || 6) || 6));
|
|
const half = Math.floor(targetBytes * 0.5);
|
|
const undoBytes = trim(world._undoStack, half, keepRecent);
|
|
const redoBytes = trim(world._redoStack, targetBytes - Math.min(half, undoBytes), Math.min(keepRecent, 4));
|
|
return undoBytes + redoBytes;
|
|
}
|
|
|
|
function capture(worldRef = global.world, label = "action") {
|
|
const world = ensure(worldRef);
|
|
if (!world || world._historyRestoring) return false;
|
|
const entry = captureEntry(world, label);
|
|
if (!entry) return false;
|
|
if (entry.key && entry.key === world._lastUndoSnapshotKey) return false;
|
|
world._undoStack.push(entry);
|
|
trim(world._undoStack);
|
|
world._redoStack.length = 0;
|
|
world._lastUndoSnapshotKey = entry.key;
|
|
return true;
|
|
}
|
|
|
|
function restore(worldRef, entry) {
|
|
if (!worldRef || !entry?.bytes) return false;
|
|
const snapshot = codec().decodeBinarySnapshot(entry.bytes);
|
|
if (!snapshot) return false;
|
|
worldRef._historyRestoring = true;
|
|
try {
|
|
global.TarinaiRestoreCoordinator.restoreSnapshot(snapshot, worldRef, { syncUi: true });
|
|
worldRef._lastUndoSnapshotKey = entry.key || fingerprintBytes(entry.bytes);
|
|
return true;
|
|
} finally {
|
|
worldRef._historyRestoring = false;
|
|
}
|
|
}
|
|
|
|
function moveHistory(worldRef, fromKey, toKey, fallbackLabel) {
|
|
const world = ensure(worldRef);
|
|
const from = world?.[fromKey];
|
|
const to = world?.[toKey];
|
|
if (!world || !from?.length || !Array.isArray(to)) return { ok: false, reason: "empty" };
|
|
|
|
const current = captureEntry(world, fallbackLabel);
|
|
const target = from.pop();
|
|
if (current) {
|
|
to.push(current);
|
|
trim(to);
|
|
}
|
|
const ok = restore(world, target);
|
|
return { ok, label: target?.label || "" };
|
|
}
|
|
|
|
function undo(worldRef = global.world) {
|
|
return moveHistory(worldRef, "_undoStack", "_redoStack", "redo");
|
|
}
|
|
|
|
function redo(worldRef = global.world) {
|
|
return moveHistory(worldRef, "_redoStack", "_undoStack", "undo");
|
|
}
|
|
|
|
global.TarinaiHistory = Object.freeze({ capture, undo, redo, trimMemory });
|
|
})(typeof window !== "undefined" ? window : globalThis);
|