tarinai/js/history_system.js
2026-07-18 22:15:26 +09:00

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);