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:
Lukas
2026-03-26 23:30:33 +01:00
parent 9d81c8ad27
commit 17cc6ed72c
22 changed files with 1127 additions and 61 deletions

View File

@@ -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: [],
};

View File

@@ -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">

View File

@@ -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}

View File

@@ -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;
}

View 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]);
}

View File

@@ -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;
}

View 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 };
}