Add undo/redo for all encounter actions
Memento-based undo/redo with full encounter snapshots. Undo stack capped at 50 entries, persisted to localStorage. Triggered via buttons in the top bar (inboard of turn navigation) and keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac, case-insensitive key matching). Clear encounter resets both stacks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -108,45 +108,44 @@ function isValidCombatantEntry(c: unknown): boolean {
|
||||
return typeof entry.id === "string" && typeof entry.name === "string";
|
||||
}
|
||||
|
||||
export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
return null;
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
if (!Array.isArray(obj.combatants)) return null;
|
||||
if (typeof obj.activeIndex !== "number") return null;
|
||||
if (typeof obj.roundNumber !== "number") return null;
|
||||
|
||||
const combatants = obj.combatants as unknown[];
|
||||
|
||||
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
||||
if (combatants.length === 0) {
|
||||
return {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (!combatants.every(isValidCombatantEntry)) return null;
|
||||
|
||||
const rehydrated = combatants.map(rehydrateCombatant);
|
||||
|
||||
const result = createEncounter(rehydrated, obj.activeIndex, obj.roundNumber);
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function loadEncounter(): Encounter | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return null;
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
return null;
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
if (!Array.isArray(obj.combatants)) return null;
|
||||
if (typeof obj.activeIndex !== "number") return null;
|
||||
if (typeof obj.roundNumber !== "number") return null;
|
||||
|
||||
const combatants = obj.combatants as unknown[];
|
||||
|
||||
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
||||
if (combatants.length === 0) {
|
||||
return {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (!combatants.every(isValidCombatantEntry)) return null;
|
||||
|
||||
const rehydrated = combatants.map(rehydrateCombatant);
|
||||
|
||||
const result = createEncounter(
|
||||
rehydrated,
|
||||
obj.activeIndex,
|
||||
obj.roundNumber,
|
||||
);
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
return result;
|
||||
return rehydrateEncounter(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
45
apps/web/src/persistence/undo-redo-storage.ts
Normal file
45
apps/web/src/persistence/undo-redo-storage.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Encounter, UndoRedoState } from "@initiative/domain";
|
||||
import { EMPTY_UNDO_REDO_STATE } from "@initiative/domain";
|
||||
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||
|
||||
const UNDO_KEY = "initiative:encounter:undo";
|
||||
const REDO_KEY = "initiative:encounter:redo";
|
||||
|
||||
export function saveUndoRedoStacks(state: UndoRedoState): void {
|
||||
try {
|
||||
localStorage.setItem(UNDO_KEY, JSON.stringify(state.undoStack));
|
||||
localStorage.setItem(REDO_KEY, JSON.stringify(state.redoStack));
|
||||
} catch {
|
||||
// Silently swallow errors (quota exceeded, storage unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
function loadStack(key: string): readonly Encounter[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return [];
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const valid: Encounter[] = [];
|
||||
for (const entry of parsed) {
|
||||
const rehydrated = rehydrateEncounter(entry);
|
||||
if (rehydrated !== null) {
|
||||
valid.push(rehydrated);
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function loadUndoRedoStacks(): UndoRedoState {
|
||||
const undoStack = loadStack(UNDO_KEY);
|
||||
const redoStack = loadStack(REDO_KEY);
|
||||
if (undoStack.length === 0 && redoStack.length === 0) {
|
||||
return EMPTY_UNDO_REDO_STATE;
|
||||
}
|
||||
return { undoStack, redoStack };
|
||||
}
|
||||
Reference in New Issue
Block a user