feat: add session undo and redo
This commit is contained in:
@@ -59,10 +59,16 @@ Everything is stored in this browser's IndexedDB. **Export regularly** from
|
||||
Settings → Your data: the JSON export is both your backup and how you move your
|
||||
budget to another device.
|
||||
|
||||
Use the header's Undo and Redo controls to reverse saved edits in the current
|
||||
session. Ctrl/Cmd+Z undoes; Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redoes. While typing
|
||||
in a field, those shortcuts use the browser's text undo. History holds up to
|
||||
50 edits and disappears when the page reloads.
|
||||
|
||||
Both destructive actions there — Import, and "Reset everything" — download a
|
||||
backup of your current data before replacing it, and abort if that download
|
||||
fails. Reset puts the app back to a brand-new install: the three starter posts,
|
||||
the DKK/USD/EUR currency table, this month at zero income, no purchases.
|
||||
Import and reset clear session undo history after they succeed.
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
@@ -101,6 +101,21 @@ uses on a first run, so "reset" and "a brand-new browser" cannot drift apart.
|
||||
Both are preceded in the UI by a JSON backup download that must succeed before
|
||||
the destructive write is attempted.
|
||||
|
||||
## Session undo and redo
|
||||
|
||||
The snapshot store keeps up to 50 prior saved datasets in memory. A changed
|
||||
`mutate` adds one step after its write succeeds; an unchanged mutation adds
|
||||
none. Undo and redo run through the same queue and persist their target dataset
|
||||
before changing the visible snapshot or either history stack. A failed write
|
||||
therefore leaves both the data and the available undo/redo actions intact.
|
||||
|
||||
Import and reset clear the stacks only after their write succeeds. They are
|
||||
whole-dataset replacement flows with backup-first protection, not ordinary
|
||||
undoable edits. History is not in `Dataset` or IndexedDB and vanishes on reload.
|
||||
The header reads a stable history capability snapshot from the store to enable
|
||||
its controls; keyboard shortcuts skip editable fields so browser text undo
|
||||
still works.
|
||||
|
||||
## Money
|
||||
|
||||
A float, deliberately, with two rules that make it safe:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Session undo and redo implementation
|
||||
|
||||
1. Extend snapshot-store tests with changed and unchanged edits, undo/redo
|
||||
ordering, a new edit after undo, the 50-step bound, queued writes, failed
|
||||
writes, import/reset boundaries, and session reload behavior. Run them red.
|
||||
2. Add history state and queued undo/redo operations in `snapshot.ts`. Ensure
|
||||
history changes only after persistence commits. Run the store tests green.
|
||||
3. Add header controls and keyboard handling with an interaction test for
|
||||
disabled states, error display, and native editable shortcuts. Run it red,
|
||||
implement, then run it green.
|
||||
4. Update architecture and user-facing documentation. Run `bun test`,
|
||||
`bunx tsc --noEmit`, build, and inspect desktop/mobile browser captures.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Session undo and redo
|
||||
|
||||
The owner needs to reverse an accidental saved edit without reconstructing a
|
||||
budget from an export. History lasts for the current page session only; a reload
|
||||
starts with the saved dataset and empty history.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Every successful, changed `mutate` produces one undo step. Unchanged edits
|
||||
produce none. The last 50 prior datasets are retained.
|
||||
- Undo and redo restore complete datasets, including IDs and dated rules, by
|
||||
writing through the store's existing serialization queue. A failed write
|
||||
changes neither the displayed dataset nor either history stack.
|
||||
- A new successful edit after undo clears redo. A failed edit leaves redo
|
||||
available.
|
||||
- Import and reset are destructive whole-dataset operations. Their existing
|
||||
backup-first flow remains mandatory. On success they clear both history
|
||||
stacks; on failure they leave history intact. Neither can be undone with the
|
||||
session controls.
|
||||
- The header offers Undo and Redo buttons. Ctrl/Cmd+Z undoes and
|
||||
Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redoes when focus is outside an editable
|
||||
control. Native text editing shortcuts remain native.
|
||||
- A failed undo or redo shows its persistence error near the controls.
|
||||
|
||||
## Implementation boundary
|
||||
|
||||
History belongs to `src/store/snapshot.ts`, where edits already serialize and
|
||||
only become visible after persistence succeeds. The UI reads stable capability
|
||||
state from the same store and invokes its methods; no domain or dataset schema
|
||||
change is needed. History is deliberately memory-only, so exports contain only
|
||||
the current dataset and IndexedDB needs no upgrade.
|
||||
@@ -286,3 +286,118 @@ describe("seed rates", () => {
|
||||
expect(store.get().fxRates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session history", () => {
|
||||
test("undo and redo persist complete prior snapshots in order", async () => {
|
||||
const fake = fakePersistence(createSeedDataset("2026-09"));
|
||||
const store = createSnapshotStore(fake.persistence, "2026-09");
|
||||
await store.load();
|
||||
const originalId = store.get().posts[0]!.id;
|
||||
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
|
||||
await store.mutate(draft => { draft.posts[0]!.name = "Second"; });
|
||||
|
||||
expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false });
|
||||
expect(await store.undo()).toBe(true);
|
||||
expect(store.get().posts[0]).toMatchObject({ id: originalId, name: "First" });
|
||||
expect(fake.stored!.posts[0]!.name).toBe("First");
|
||||
expect(await store.undo()).toBe(true);
|
||||
expect(store.get().posts[0]!.name).toBe("Video Games");
|
||||
expect(await store.undo()).toBe(false);
|
||||
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true });
|
||||
expect(await store.redo()).toBe(true);
|
||||
expect(await store.redo()).toBe(true);
|
||||
expect(await store.redo()).toBe(false);
|
||||
expect(store.get().posts[0]!.name).toBe("Second");
|
||||
expect(fake.stored!.posts[0]!.name).toBe("Second");
|
||||
});
|
||||
|
||||
test("an unchanged edit creates no step and does not discard redo", async () => {
|
||||
const store = createSnapshotStore(fakePersistence(createSeedDataset("2026-09")).persistence, "2026-09");
|
||||
await store.load();
|
||||
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
|
||||
await store.undo();
|
||||
await store.mutate(draft => { draft.posts[0]!.name = "Video Games"; });
|
||||
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true });
|
||||
expect(await store.redo()).toBe(true);
|
||||
expect(store.get().posts[0]!.name).toBe("First");
|
||||
});
|
||||
|
||||
test("a new saved edit after undo discards redo", async () => {
|
||||
const store = createSnapshotStore(fakePersistence(createSeedDataset("2026-09")).persistence, "2026-09");
|
||||
await store.load();
|
||||
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
|
||||
await store.undo();
|
||||
await store.mutate(draft => { draft.posts[1]!.name = "Changed"; });
|
||||
expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false });
|
||||
expect(await store.redo()).toBe(false);
|
||||
});
|
||||
|
||||
test("a rejected undo leaves data and history available for retry", async () => {
|
||||
const fake = fakePersistence(createSeedDataset("2026-09"));
|
||||
const store = createSnapshotStore(fake.persistence, "2026-09");
|
||||
await store.load();
|
||||
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
|
||||
fake.persistence.write = async () => { throw new Error("disk full"); };
|
||||
await expect(store.undo()).rejects.toThrow("disk full");
|
||||
expect(store.get().posts[0]!.name).toBe("First");
|
||||
expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false });
|
||||
fake.persistence.write = async () => {};
|
||||
expect(await store.undo()).toBe(true);
|
||||
});
|
||||
|
||||
test("a rejected new edit preserves redo", async () => {
|
||||
const fake = fakePersistence(createSeedDataset("2026-09"));
|
||||
const store = createSnapshotStore(fake.persistence, "2026-09");
|
||||
await store.load();
|
||||
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
|
||||
await store.undo();
|
||||
fake.persistence.write = async () => { throw new Error("disk full"); };
|
||||
await expect(store.mutate(draft => { draft.posts[1]!.name = "Fail"; })).rejects.toThrow("disk full");
|
||||
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true });
|
||||
});
|
||||
|
||||
test("undo runs after already queued edits", async () => {
|
||||
const fake = fakePersistence(createSeedDataset("2026-09"));
|
||||
const store = createSnapshotStore(fake.persistence, "2026-09");
|
||||
await store.load();
|
||||
const first = store.mutate(draft => { draft.posts[0]!.name = "First"; });
|
||||
const second = store.mutate(draft => { draft.posts[0]!.name = "Second"; });
|
||||
const undo = store.undo();
|
||||
await Promise.all([first, second, undo]);
|
||||
expect(store.get().posts[0]!.name).toBe("First");
|
||||
expect(fake.stored!.posts[0]!.name).toBe("First");
|
||||
});
|
||||
|
||||
test("import and reset clear history only after their writes succeed", async () => {
|
||||
const fake = fakePersistence(createSeedDataset("2026-09"));
|
||||
const store = createSnapshotStore(fake.persistence, "2026-09");
|
||||
await store.load();
|
||||
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
|
||||
const incoming = createSeedDataset("2020-01");
|
||||
fake.persistence.write = async () => { throw new Error("disk full"); };
|
||||
await expect(store.replace(incoming)).rejects.toThrow("disk full");
|
||||
expect(store.getHistory().canUndo).toBe(true);
|
||||
fake.persistence.write = async () => {};
|
||||
await store.replace(incoming);
|
||||
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: false });
|
||||
await store.mutate(draft => { draft.posts[0]!.name = "New"; });
|
||||
await store.reset();
|
||||
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: false });
|
||||
});
|
||||
|
||||
test("history is limited to fifty edits and disappears on reload", async () => {
|
||||
const fake = fakePersistence(createSeedDataset("2026-09"));
|
||||
const store = createSnapshotStore(fake.persistence, "2026-09");
|
||||
await store.load();
|
||||
for (let n = 1; n <= 51; n++) {
|
||||
await store.mutate(draft => { draft.posts[0]!.name = `Edit ${n}`; });
|
||||
}
|
||||
for (let n = 0; n < 50; n++) expect(await store.undo()).toBe(true);
|
||||
expect(store.get().posts[0]!.name).toBe("Edit 1");
|
||||
expect(await store.undo()).toBe(false);
|
||||
const reopened = createSnapshotStore(fake.persistence, "2026-09");
|
||||
await reopened.load();
|
||||
expect(reopened.get().posts[0]!.name).toBe("Edit 1");
|
||||
expect(reopened.getHistory()).toEqual({ canUndo: false, canRedo: false });
|
||||
});
|
||||
});
|
||||
|
||||
+76
-13
@@ -8,12 +8,20 @@ export interface Persistence {
|
||||
|
||||
export type Listener = () => void;
|
||||
|
||||
export interface HistoryState {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
}
|
||||
|
||||
export interface SnapshotStore {
|
||||
load(): Promise<void>;
|
||||
get(): Dataset;
|
||||
mutate(fn: (draft: Dataset) => void): Promise<void>;
|
||||
replace(dataset: Dataset): Promise<void>;
|
||||
reset(): Promise<void>;
|
||||
getHistory(): HistoryState;
|
||||
undo(): Promise<boolean>;
|
||||
redo(): Promise<boolean>;
|
||||
subscribe(listener: Listener): () => void;
|
||||
}
|
||||
|
||||
@@ -30,6 +38,10 @@ export function createSnapshotStore(
|
||||
): SnapshotStore {
|
||||
let snapshot: Dataset | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
const undoStack: Dataset[] = [];
|
||||
const redoStack: Dataset[] = [];
|
||||
let historyState: HistoryState = { canUndo: false, canRedo: false };
|
||||
const historyLimit = 50;
|
||||
|
||||
// All mutations run through this chain, one at a time, so a mutation
|
||||
// always clones from the snapshot the previous mutation committed rather
|
||||
@@ -51,42 +63,81 @@ export function createSnapshotStore(
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function updateHistoryState() {
|
||||
historyState = { canUndo: undoStack.length > 0, canRedo: redoStack.length > 0 };
|
||||
}
|
||||
|
||||
function pushHistory(stack: Dataset[], dataset: Dataset) {
|
||||
stack.push(dataset);
|
||||
if (stack.length > historyLimit) stack.shift();
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
undoStack.length = 0;
|
||||
redoStack.length = 0;
|
||||
updateHistoryState();
|
||||
}
|
||||
|
||||
function get(): Dataset {
|
||||
if (!snapshot) throw new Error("Snapshot store is not loaded yet");
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function commit(next: Dataset) {
|
||||
async function commit(next: Dataset, afterWrite?: () => void) {
|
||||
// Write first: if persistence fails, the in-memory snapshot must not have
|
||||
// moved, or the UI would show data that was never saved.
|
||||
await persistence.write(next);
|
||||
snapshot = next;
|
||||
afterWrite?.();
|
||||
notify();
|
||||
}
|
||||
|
||||
function travel(from: Dataset[], to: Dataset[]): Promise<boolean> {
|
||||
return enqueue(async () => {
|
||||
const target = from.at(-1);
|
||||
if (!target) return false;
|
||||
const previous = get();
|
||||
await commit(structuredClone(target), () => {
|
||||
from.pop();
|
||||
pushHistory(to, previous);
|
||||
updateHistoryState();
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async load() {
|
||||
const stored = await persistence.read();
|
||||
if (stored) {
|
||||
snapshot = stored;
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
await commit(createSeedDataset(currentMonth, seedRates));
|
||||
load() {
|
||||
return enqueue(async () => {
|
||||
const stored = await persistence.read();
|
||||
if (stored) {
|
||||
snapshot = stored;
|
||||
clearHistory();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
await commit(createSeedDataset(currentMonth, seedRates), clearHistory);
|
||||
});
|
||||
},
|
||||
|
||||
get,
|
||||
|
||||
mutate(fn) {
|
||||
return enqueue(async () => {
|
||||
const draft = structuredClone(get());
|
||||
const previous = get();
|
||||
const draft = structuredClone(previous);
|
||||
fn(draft);
|
||||
await commit(draft);
|
||||
if (JSON.stringify(draft) === JSON.stringify(previous)) return;
|
||||
await commit(draft, () => {
|
||||
pushHistory(undoStack, previous);
|
||||
redoStack.length = 0;
|
||||
updateHistoryState();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
replace(dataset) {
|
||||
return enqueue(() => commit(dataset));
|
||||
return enqueue(() => commit(structuredClone(dataset), clearHistory));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -99,7 +150,19 @@ export function createSnapshotStore(
|
||||
* mutation is still in flight cannot be overtaken by it.
|
||||
*/
|
||||
reset() {
|
||||
return enqueue(() => commit(createSeedDataset(currentMonth, seedRates)));
|
||||
return enqueue(() => commit(createSeedDataset(currentMonth, seedRates), clearHistory));
|
||||
},
|
||||
|
||||
getHistory() {
|
||||
return historyState;
|
||||
},
|
||||
|
||||
undo() {
|
||||
return travel(undoStack, redoStack);
|
||||
},
|
||||
|
||||
redo() {
|
||||
return travel(redoStack, undoStack);
|
||||
},
|
||||
|
||||
subscribe(listener) {
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@ import { SettingsRoute } from "./routes/SettingsRoute.tsx";
|
||||
import { UpdatePrompt } from "./components/UpdatePrompt.tsx";
|
||||
import { RouteErrorBoundaryReset } from "./components/ErrorBoundary.tsx";
|
||||
import { ThemeToggle } from "./components/ThemeToggle.tsx";
|
||||
import { HistoryControls } from "./components/HistoryControls.tsx";
|
||||
|
||||
/**
|
||||
* One nav item. The active route is marked with weight and an accent
|
||||
@@ -44,7 +45,7 @@ export function AppRoutes() {
|
||||
it. Translucent with a blur so the paper ground still reads as one
|
||||
continuous sheet scrolling underneath. */}
|
||||
<header className="sticky top-0 z-30 border-b border-budget-rule bg-budget-paper/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-1 px-4 py-2.5 sm:px-6">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-1 gap-y-1 px-4 py-2.5 sm:px-6">
|
||||
<span className="mr-3 hidden text-sm font-semibold tracking-tight text-budget-ink sm:block">
|
||||
Budget<span className="text-budget-accent"> 2.0</span>
|
||||
</span>
|
||||
@@ -54,6 +55,7 @@ export function AppRoutes() {
|
||||
<NavItem to="/summary">Summary</NavItem>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<HistoryControls />
|
||||
<ThemeToggle />
|
||||
<NavItem to="/settings">Settings</NavItem>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { createSeedDataset } from "../../domain/seed.ts";
|
||||
import { createSnapshotStore } from "../../store/snapshot.ts";
|
||||
import { store } from "../../store/index.ts";
|
||||
import { HistoryControls } from "./HistoryControls.tsx";
|
||||
|
||||
const original = { getHistory: store.getHistory, subscribe: store.subscribe, undo: store.undo, redo: store.redo };
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root!.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
Object.assign(store, original);
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
let saved = createSeedDataset("2026-09");
|
||||
let fail = false;
|
||||
const history = createSnapshotStore({
|
||||
async read() { return saved; },
|
||||
async write(next) {
|
||||
if (fail) throw new Error("Disk full");
|
||||
saved = next;
|
||||
},
|
||||
}, "2026-09");
|
||||
await history.load();
|
||||
Object.assign(store, {
|
||||
getHistory: history.getHistory,
|
||||
subscribe: history.subscribe,
|
||||
undo: history.undo,
|
||||
redo: history.redo,
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
act(() => root!.render(<HistoryControls />));
|
||||
return { history, failWrites(value: boolean) { fail = value; } };
|
||||
}
|
||||
|
||||
test("buttons follow saved history and restore data", async () => {
|
||||
const { history } = await setup();
|
||||
const undo = container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!;
|
||||
const redo = container!.querySelector<HTMLButtonElement>('button[aria-label="Redo"]')!;
|
||||
expect(undo.disabled).toBe(true);
|
||||
expect(redo.disabled).toBe(true);
|
||||
|
||||
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
|
||||
expect(undo.disabled).toBe(false);
|
||||
await act(async () => { undo.click(); });
|
||||
expect(history.get().posts[0]!.name).toBe("Video Games");
|
||||
expect(redo.disabled).toBe(false);
|
||||
await act(async () => { redo.click(); });
|
||||
expect(history.get().posts[0]!.name).toBe("Changed");
|
||||
});
|
||||
|
||||
test("a failed undo shows an error and permits retry", async () => {
|
||||
const { history, failWrites } = await setup();
|
||||
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
|
||||
failWrites(true);
|
||||
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
|
||||
expect(container!.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
|
||||
expect(history.get().posts[0]!.name).toBe("Changed");
|
||||
failWrites(false);
|
||||
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
|
||||
expect(container!.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
test("keyboard shortcut reverses saved edits outside editable controls", async () => {
|
||||
const { history } = await setup();
|
||||
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
|
||||
const field = document.createElement("input");
|
||||
container!.append(field);
|
||||
await act(async () => {
|
||||
field.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(history.get().posts[0]!.name).toBe("Changed");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(history.get().posts[0]!.name).toBe("Video Games");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(history.get().posts[0]!.name).toBe("Changed");
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { store } from "../../store/index.ts";
|
||||
|
||||
/** Session history controls. Dataset writes remain serialized by the store. */
|
||||
export function HistoryControls() {
|
||||
const { canUndo, canRedo } = useSyncExternalStore(store.subscribe, store.getHistory);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
const run = useCallback(async (direction: "undo" | "redo") => {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
await store[direction]();
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(`Could not ${direction}: ${message}. Your data has not changed.`);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || event.altKey || !(event.ctrlKey || event.metaKey)) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest("input, textarea, select, [contenteditable]")) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const direction = key === "z"
|
||||
? (event.shiftKey ? "redo" : "undo")
|
||||
: key === "y" && !event.shiftKey ? "redo" : null;
|
||||
if (!direction) return;
|
||||
event.preventDefault();
|
||||
void run(direction);
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [run]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 border-r border-budget-rule pr-1.5 mr-1.5">
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Undo"
|
||||
title="Undo (Ctrl/Cmd+Z)" disabled={busy || !canUndo} onClick={() => void run("undo")}>
|
||||
<Undo2 aria-hidden="true" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Redo"
|
||||
title="Redo (Ctrl/Cmd+Shift+Z)" disabled={busy || !canRedo} onClick={() => void run("redo")}>
|
||||
<Redo2 aria-hidden="true" />
|
||||
</Button>
|
||||
{error && <p role="alert" className="absolute right-4 top-full max-w-[calc(100vw-2rem)] rounded-b-md border border-t-0 border-destructive/40 bg-background px-3 py-2 text-sm text-destructive shadow-sm sm:right-6">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user