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

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