Skip to content

Commit

Permalink
feat: useHistoryState hook
Browse files Browse the repository at this point in the history
  • Loading branch information
congweibai committed Aug 22, 2024
1 parent 751616d commit 33fc55f
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from "./useWindowSize";
export * from "./useVisibilityChange";
export * from "./useObjectState";
export * from "./useDebounce";
export * from "./useHistoryState";
1 change: 1 addition & 0 deletions src/hooks/useHistoryState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./useHistoryState";
80 changes: 80 additions & 0 deletions src/hooks/useHistoryState/useHistoryState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { renderHook, act } from "@testing-library/react-hooks";
import useHistoryState from "./useHistoryState";
import { describe, expect, it } from "vitest";

describe("useHistoryState", () => {
it("should initialize with the initial present state", () => {
const { result } = renderHook(() => useHistoryState("initial"));

expect(result.current.state).toBe("initial");
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});

it("should set a new present state and track history", () => {
const { result } = renderHook(() => useHistoryState("initial"));

act(() => {
result.current.set("newState1");
});

expect(result.current.state).toBe("newState1");
expect(result.current.canUndo).toBe(true);
expect(result.current.canRedo).toBe(false);
});

it("should undo the last state change", () => {
const { result } = renderHook(() => useHistoryState("initial"));

act(() => {
result.current.set("newState1");
});

act(() => {
result.current.undo();
});

expect(result.current.state).toBe("initial");
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(true);
});

it("should redo the undone state change", () => {
const { result } = renderHook(() => useHistoryState("initial"));

act(() => {
result.current.set("newState1");
result.current.undo();
result.current.redo();
});

expect(result.current.state).toBe("newState1");
expect(result.current.canUndo).toBe(true);
expect(result.current.canRedo).toBe(false);
});

it("should clear history and set to the initial present state", () => {
const { result } = renderHook(() => useHistoryState("initial"));

act(() => {
result.current.set("newState1");
result.current.clear();
});

expect(result.current.state).toBe("initial");
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});

it("should not set a new state if the present state is the same", () => {
const { result } = renderHook(() => useHistoryState("initial"));

act(() => {
result.current.set("initial");
});

expect(result.current.state).toBe("initial");
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});
});
112 changes: 112 additions & 0 deletions src/hooks/useHistoryState/useHistoryState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useCallback, useReducer, useRef } from "react";

const initialState = {
past: [],
present: null,
future: [],
};

type UndoAction = {
type: "UNDO";
};

type RedoAction = {
type: "REDO";
};

type SetAction = {
type: "SET";
newPresent: unknown;
};

type ClearAction = {
type: "CLEAR";
initialPresent: unknown;
};

type HistoryAction = UndoAction | RedoAction | SetAction | ClearAction;

type HistoryState = {
past: unknown[];
future: unknown[];
present: unknown;
};

const reducer = (state: HistoryState, action: HistoryAction) => {
const { past, present, future } = state;

if (action.type === "UNDO") {
return {
past: past.slice(0, past.length - 1),
present: past[past.length - 1],
future: [present, ...future],
};
} else if (action.type === "REDO") {
return {
past: [...past, present],
present: future[0],
future: future.slice(1),
};
} else if (action.type === "SET") {
const { newPresent } = action;

if (action.newPresent === present) {
return state;
}

return {
past: [...past, present],
present: newPresent,
future: [],
};
} else if (action.type === "CLEAR") {
return {
...initialState,
present: action.initialPresent,
};
} else {
throw new Error("Unsupported action type");
}
};

export default function useHistoryState(initialPresent = {}) {
const [state, dispatch] = useReducer(reducer, {
...initialState,
present: initialPresent,
});

const initialPresentRef = useRef(initialPresent);

const canUndo = state.past.length !== 0;
const canRedo = state.future.length !== 0;

const undo = useCallback(() => {
if (canUndo) {
dispatch({ type: "UNDO" });
}
}, [canUndo]);

const redo = useCallback(() => {
if (canRedo) {
dispatch({ type: "REDO" });
}
}, [canRedo]);

const set = useCallback((newPresent: unknown) => {
dispatch({ type: "SET", newPresent });
}, []);

const clear = useCallback(() => {
dispatch({ type: "CLEAR", initialPresent: initialPresentRef.current });
}, []);

return {
state: state.present,
canUndo: canUndo,
canRedo: canRedo,
set: set,
undo: undo,
redo: redo,
clear: clear,
};
}

0 comments on commit 33fc55f

Please sign in to comment.