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:
@@ -54,6 +54,10 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
addFromBestiary: vi.fn(),
|
||||
addFromPlayerCharacter: vi.fn(),
|
||||
makeStore: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
events: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
export function TurnNavigation() {
|
||||
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
|
||||
useEncounterContext();
|
||||
const {
|
||||
encounter,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
clearEncounter,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
} = useEncounterContext();
|
||||
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -24,6 +32,29 @@ export function TurnNavigation() {
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="Undo"
|
||||
aria-label="Undo"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="Redo"
|
||||
aria-label="Redo"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
<span className="-mt-[3px] inline-block">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useEncounter } from "../hooks/use-encounter.js";
|
||||
import { useUndoRedoShortcuts } from "../hooks/use-undo-redo-shortcuts.js";
|
||||
|
||||
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
||||
|
||||
@@ -7,6 +8,7 @@ const EncounterContext = createContext<EncounterContextValue | null>(null);
|
||||
|
||||
export function EncounterProvider({ children }: { children: ReactNode }) {
|
||||
const value = useEncounter();
|
||||
useUndoRedoShortcuts(value.undo, value.redo, value.canUndo, value.canRedo);
|
||||
return (
|
||||
<EncounterContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { EncounterStore } from "@initiative/application";
|
||||
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||
import {
|
||||
addCombatantUseCase,
|
||||
adjustHpUseCase,
|
||||
advanceTurnUseCase,
|
||||
clearEncounterUseCase,
|
||||
editCombatantUseCase,
|
||||
redoUseCase,
|
||||
removeCombatantUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
setTempHpUseCase,
|
||||
toggleConcentrationUseCase,
|
||||
toggleConditionUseCase,
|
||||
undoUseCase,
|
||||
} from "@initiative/application";
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
@@ -23,11 +25,14 @@ import type {
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
clearHistory,
|
||||
combatantId,
|
||||
isDomainError,
|
||||
creatureId as makeCreatureId,
|
||||
pushUndo,
|
||||
resolveCreatureName,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -35,6 +40,10 @@ import {
|
||||
loadEncounter,
|
||||
saveEncounter,
|
||||
} from "../persistence/encounter-storage.js";
|
||||
import {
|
||||
loadUndoRedoStacks,
|
||||
saveUndoRedoStacks,
|
||||
} from "../persistence/undo-redo-storage.js";
|
||||
|
||||
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||
|
||||
@@ -65,13 +74,21 @@ function deriveNextId(encounter: Encounter): number {
|
||||
export function useEncounter() {
|
||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||
const [undoRedoState, setUndoRedoState] =
|
||||
useState<UndoRedoState>(loadUndoRedoStacks);
|
||||
const encounterRef = useRef(encounter);
|
||||
encounterRef.current = encounter;
|
||||
const undoRedoRef = useRef(undoRedoState);
|
||||
undoRedoRef.current = undoRedoState;
|
||||
|
||||
useEffect(() => {
|
||||
saveEncounter(encounter);
|
||||
}, [encounter]);
|
||||
|
||||
useEffect(() => {
|
||||
saveUndoRedoStacks(undoRedoState);
|
||||
}, [undoRedoState]);
|
||||
|
||||
const makeStore = useCallback((): EncounterStore => {
|
||||
return {
|
||||
get: () => encounterRef.current,
|
||||
@@ -82,32 +99,55 @@ export function useEncounter() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
||||
return {
|
||||
get: () => undoRedoRef.current,
|
||||
save: (s) => {
|
||||
undoRedoRef.current = s;
|
||||
setUndoRedoState(s);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const withUndo = useCallback(<T>(action: () => T): T => {
|
||||
const snapshot = encounterRef.current;
|
||||
const result = action();
|
||||
if (!isDomainError(result)) {
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const advanceTurn = useCallback(() => {
|
||||
const result = advanceTurnUseCase(makeStore());
|
||||
const result = withUndo(() => advanceTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
}, [makeStore, withUndo]);
|
||||
|
||||
const retreatTurn = useCallback(() => {
|
||||
const result = retreatTurnUseCase(makeStore());
|
||||
const result = withUndo(() => retreatTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
}, [makeStore, withUndo]);
|
||||
|
||||
const nextId = useRef(deriveNextId(encounter));
|
||||
|
||||
const addCombatant = useCallback(
|
||||
(name: string, init?: CombatantInit) => {
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, name, init);
|
||||
const result = withUndo(() =>
|
||||
addCombatantUseCase(makeStore(), id, name, init),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -115,12 +155,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const removeCombatant = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = removeCombatantUseCase(makeStore(), id);
|
||||
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -128,12 +168,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const editCombatant = useCallback(
|
||||
(id: CombatantId, newName: string) => {
|
||||
const result = editCombatantUseCase(makeStore(), id, newName);
|
||||
const result = withUndo(() =>
|
||||
editCombatantUseCase(makeStore(), id, newName),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -141,12 +183,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setInitiative = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setInitiativeUseCase(makeStore(), id, value);
|
||||
const result = withUndo(() =>
|
||||
setInitiativeUseCase(makeStore(), id, value),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -154,12 +198,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setHp = useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) => {
|
||||
const result = setHpUseCase(makeStore(), id, maxHp);
|
||||
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -167,12 +211,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const adjustHp = useCallback(
|
||||
(id: CombatantId, delta: number) => {
|
||||
const result = adjustHpUseCase(makeStore(), id, delta);
|
||||
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -180,12 +224,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setTempHp = useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) => {
|
||||
const result = setTempHpUseCase(makeStore(), id, tempHp);
|
||||
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -193,12 +237,12 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setAcUseCase(makeStore(), id, value);
|
||||
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -206,12 +250,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const toggleCondition = useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) => {
|
||||
const result = toggleConditionUseCase(makeStore(), id, conditionId);
|
||||
const result = withUndo(() =>
|
||||
toggleConditionUseCase(makeStore(), id, conditionId),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -219,12 +265,14 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const toggleConcentration = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = toggleConcentrationUseCase(makeStore(), id);
|
||||
const result = withUndo(() =>
|
||||
toggleConcentrationUseCase(makeStore(), id),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
@@ -232,7 +280,7 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, withUndo],
|
||||
);
|
||||
|
||||
const clearEncounter = useCallback(() => {
|
||||
@@ -242,12 +290,17 @@ export function useEncounter() {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleared = clearHistory();
|
||||
undoRedoRef.current = cleared;
|
||||
setUndoRedoState(cleared);
|
||||
|
||||
nextId.current = 0;
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
const snapshot = encounterRef.current;
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
@@ -277,6 +330,10 @@ export function useEncounter() {
|
||||
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
return cId;
|
||||
},
|
||||
@@ -285,6 +342,7 @@ export function useEncounter() {
|
||||
|
||||
const addFromPlayerCharacter = useCallback(
|
||||
(pc: PlayerCharacter) => {
|
||||
const snapshot = encounterRef.current;
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
||||
@@ -307,11 +365,26 @@ export function useEncounter() {
|
||||
|
||||
if (isDomainError(result)) return;
|
||||
|
||||
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||
undoRedoRef.current = newState;
|
||||
setUndoRedoState(newState);
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const undoAction = useCallback(() => {
|
||||
undoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
const redoAction = useCallback(() => {
|
||||
redoUseCase(makeStore(), makeUndoRedoStore());
|
||||
}, [makeStore, makeUndoRedoStore]);
|
||||
|
||||
const canUndo = undoRedoState.undoStack.length > 0;
|
||||
const canRedo = undoRedoState.redoStack.length > 0;
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
@@ -331,6 +404,8 @@ export function useEncounter() {
|
||||
hasTempHp,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
canUndo,
|
||||
canRedo,
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
@@ -346,6 +421,8 @@ export function useEncounter() {
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
undo: undoAction,
|
||||
redo: redoAction,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
|
||||
42
apps/web/src/hooks/use-undo-redo-shortcuts.ts
Normal file
42
apps/web/src/hooks/use-undo-redo-shortcuts.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
const SUPPRESSED_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
||||
|
||||
function isTextInputFocused(): boolean {
|
||||
const active = document.activeElement;
|
||||
if (!active) return false;
|
||||
if (SUPPRESSED_TAGS.has(active.tagName)) return true;
|
||||
return active instanceof HTMLElement && active.isContentEditable;
|
||||
}
|
||||
|
||||
function isUndoShortcut(e: KeyboardEvent): boolean {
|
||||
return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey;
|
||||
}
|
||||
|
||||
function isRedoShortcut(e: KeyboardEvent): boolean {
|
||||
return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && e.shiftKey;
|
||||
}
|
||||
|
||||
export function useUndoRedoShortcuts(
|
||||
undo: () => void,
|
||||
redo: () => void,
|
||||
canUndo: boolean,
|
||||
canRedo: boolean,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (isTextInputFocused()) return;
|
||||
|
||||
if (isUndoShortcut(e) && canUndo) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
} else if (isRedoShortcut(e) && canRedo) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [undo, redo, canUndo, canRedo]);
|
||||
}
|
||||
@@ -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