y
This commit is contained in:
parent
61718e2981
commit
f657b6a4a4
94 changed files with 3970 additions and 1241 deletions
|
|
@ -3,12 +3,7 @@
|
|||
// 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));
|
||||
}
|
||||
const MAX_HISTORY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function ensure(worldRef) {
|
||||
if (!worldRef) return null;
|
||||
|
|
@ -17,147 +12,123 @@
|
|||
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 "";
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
// 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 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 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 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 captureEntry(world, label) {
|
||||
const snapshot = global.TarinaiSnapshot.createSnapshot(world);
|
||||
return makeHistoryEntry(world, snapshot, label);
|
||||
}
|
||||
|
||||
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 stackBytes(stack) {
|
||||
let total = 0;
|
||||
for (const entry of stack || []) total += Number(entry?.bytes?.byteLength || entry?.bytes?.length || 0) || 0;
|
||||
return total;
|
||||
}
|
||||
|
||||
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;
|
||||
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 { patch, key, label: String(label || "action"), time: world.time || 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 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);
|
||||
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 = key;
|
||||
world._lastUndoSnapshotKey = entry.key;
|
||||
return true;
|
||||
}
|
||||
|
||||
function restore(worldRef, entry, stack = []) {
|
||||
const snapshot = materializeHistoryEntry(stack, entry);
|
||||
if (!worldRef || !snapshot) return false;
|
||||
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 = snapshotKey(global.TarinaiSnapshot.createSnapshot(worldRef));
|
||||
worldRef._lastUndoSnapshotKey = entry.key || fingerprintBytes(entry.bytes);
|
||||
return true;
|
||||
} finally {
|
||||
worldRef._historyRestoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
function undo(worldRef = global.world) {
|
||||
function moveHistory(worldRef, fromKey, toKey, fallbackLabel) {
|
||||
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 || "" };
|
||||
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) {
|
||||
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 || "" };
|
||||
return moveHistory(worldRef, "_redoStack", "_undoStack", "undo");
|
||||
}
|
||||
|
||||
|
||||
global.TarinaiHistory = Object.freeze({ capture, undo, redo });
|
||||
global.TarinaiHistory = Object.freeze({ capture, undo, redo, trimMemory });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue