-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
871 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,57 @@ | ||
# use-travel | ||
|
||
A React hook for state time travel with undo, redo, and reset functionalities. | ||
|
||
TODO | ||
### Installation | ||
|
||
```bash | ||
npm install use-travel mutative | ||
# or | ||
yarn add use-travel mutative | ||
``` | ||
|
||
### Features | ||
|
||
- Undo/Redo/Reset | ||
- Small size for time travel with Patches history | ||
- Customizable history size | ||
- Customizable initial patches | ||
- High performance | ||
- Mark function for custom immutability | ||
|
||
### TODO | ||
|
||
- [ ] add `archive` functionality | ||
|
||
### API | ||
|
||
```jsx | ||
import { useTravel } from 'use-travel'; | ||
|
||
const App = () => { | ||
const [state, setState, controls]} = useTravel(0, { | ||
maxHistory: 10, | ||
initialPatches: [], | ||
}); | ||
return ( | ||
<div> | ||
<div>{state}</div> | ||
<button onClick={() => setState(state + 1)}>Increment</button> | ||
<button onClick={() => setState(state - 1)}>Decrement</button> | ||
<button onClick={controls.back} disabled={!controls.canUndo()}>Undo</button> | ||
<button onClick={controls.forward} disabled={!controls.canRedo()}>Redo</button> | ||
<button onClick={controls.reset}>Reset</button> | ||
{controls.getHistory().map((state, index) => ( | ||
<div key={index}>{state}</div> | ||
))} | ||
{controls.patches.map((patch, index) => ( | ||
<div key={index}>{patch}</div> | ||
))} | ||
<div>{controls.position}</div> | ||
<button onClick={() => { | ||
controls.go(1); | ||
}}>Go</button> | ||
</div> | ||
); | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,209 @@ | ||
export const useTravel = (state: any) => { | ||
// | ||
import { useCallback, useEffect, useMemo } from 'react'; | ||
import { | ||
type Options as MutativeOptions, | ||
type Patches, | ||
type Draft, | ||
type Immutable, | ||
apply, | ||
rawReturn, | ||
} from 'mutative'; | ||
import { useMutative } from 'use-mutative'; | ||
|
||
type TravelPatches = { | ||
patches: Patches[]; | ||
inversePatches: Patches[]; | ||
}; | ||
|
||
type Options<A extends boolean, F extends boolean> = { | ||
maxHistory?: number; | ||
initialPatches?: TravelPatches; | ||
autoArchive?: A; | ||
} & MutativeOptions<true, F>; | ||
|
||
type InitialValue<I extends any> = I extends (...args: any) => infer R ? R : I; | ||
type DraftFunction<S> = (draft: Draft<S>) => void; | ||
type Updater<S> = (value: S | (() => S) | DraftFunction<S>) => void; | ||
type Value<S, F extends boolean> = F extends true | ||
? Immutable<InitialValue<S>> | ||
: InitialValue<S>; | ||
type StateValue<S> = | ||
| InitialValue<S> | ||
| (() => InitialValue<S>) | ||
| DraftFunction<InitialValue<S>>; | ||
|
||
type Result<S, F extends boolean> = [ | ||
Value<S, F>, | ||
Updater<InitialValue<S>>, | ||
{ | ||
/** | ||
* The current position in the history | ||
*/ | ||
position: number; | ||
/** | ||
* Get the history of the state | ||
*/ | ||
getHistory: () => Value<S, F>[]; | ||
/** | ||
* The patches of the history | ||
*/ | ||
patches: TravelPatches; | ||
/** | ||
* Go back in the history | ||
*/ | ||
back: (amount?: number) => void; | ||
/** | ||
* Go forward in the history | ||
*/ | ||
forward: (amount?: number) => void; | ||
/** | ||
* Reset the history | ||
*/ | ||
reset: () => void; | ||
/** | ||
* Go to a specific position in the history | ||
*/ | ||
go: (position: number) => void; | ||
/** | ||
* Check if it's possible to go back | ||
*/ | ||
canBack: () => boolean; | ||
/** | ||
* Check if it's possible to go forward | ||
*/ | ||
canForward: () => boolean; | ||
} | ||
]; | ||
|
||
/** | ||
* A hook to travel in the history of a state | ||
*/ | ||
export const useTravel = <S, A extends boolean, F extends boolean>( | ||
initialState: S, | ||
{ maxHistory = 10, initialPatches, ...options }: Options<A, F> = {} | ||
) => { | ||
const [position, setPosition] = useMutative(-1); | ||
const [allPatches, setAllPatches] = useMutative( | ||
() => | ||
(initialPatches ?? { | ||
patches: [], | ||
inversePatches: [], | ||
}) as TravelPatches | ||
); | ||
const [state, setState, patches, inversePatches] = useMutative(initialState, { | ||
...options, | ||
enablePatches: true, | ||
}); | ||
useEffect(() => { | ||
if (position === -1 && patches.length > 0) { | ||
setAllPatches((_allPatches) => { | ||
_allPatches.patches.push(patches); | ||
_allPatches.inversePatches.push(inversePatches); | ||
if (maxHistory < _allPatches.patches.length) { | ||
_allPatches.patches = _allPatches.patches.slice(-maxHistory); | ||
_allPatches.inversePatches = _allPatches.inversePatches.slice( | ||
-maxHistory | ||
); | ||
} | ||
}); | ||
} | ||
}, [maxHistory, patches, inversePatches, position]); | ||
const cachedPosition = useMemo( | ||
() => (position === -1 ? allPatches.patches.length : position), | ||
[position, allPatches.patches.length] | ||
); | ||
const cachedTravels = useMemo(() => { | ||
const go = (nextPosition: number) => { | ||
const back = nextPosition < cachedPosition; | ||
if (nextPosition > allPatches.patches.length) { | ||
console.warn(`Can't go forward to position ${nextPosition}`); | ||
nextPosition = allPatches.patches.length; | ||
} | ||
if (nextPosition < 0) { | ||
console.warn(`Can't go back to position ${nextPosition}`); | ||
nextPosition = 0; | ||
} | ||
if (nextPosition === cachedPosition) return; | ||
setPosition(nextPosition); | ||
setState(() => | ||
rawReturn( | ||
apply( | ||
state as object, | ||
back | ||
? allPatches.inversePatches.slice(nextPosition).flat().reverse() | ||
: allPatches.patches.slice(position, nextPosition).flat() | ||
) | ||
) | ||
); | ||
}; | ||
return { | ||
position: cachedPosition, | ||
getHistory: () => { | ||
const history = [state]; | ||
let currentState = state as any; | ||
for (let i = cachedPosition; i < allPatches.patches.length; i++) { | ||
currentState = apply( | ||
currentState as object, | ||
allPatches.patches[i] | ||
) as S; | ||
history.push(currentState); | ||
} | ||
currentState = state as any; | ||
const inversePatches = allPatches.inversePatches; | ||
const stateIndex = | ||
inversePatches.length === cachedPosition | ||
? inversePatches.length - 1 | ||
: inversePatches.length - cachedPosition - 1; | ||
for (let i = stateIndex; i > -1; i--) { | ||
currentState = apply( | ||
currentState as object, | ||
allPatches.inversePatches[i] | ||
) as S; | ||
history.unshift(currentState); | ||
} | ||
return history; | ||
}, | ||
patches: allPatches, | ||
back: (amount = 1) => { | ||
go(cachedPosition - amount); | ||
}, | ||
forward: (amount = 1) => { | ||
go(cachedPosition + amount); | ||
}, | ||
reset: () => { | ||
setPosition(-1); | ||
setAllPatches( | ||
() => initialPatches ?? { patches: [], inversePatches: [] } | ||
); | ||
setState(() => initialState); | ||
}, | ||
go, | ||
canBack: () => cachedPosition > 0, | ||
canForward: () => cachedPosition < allPatches.patches.length, | ||
}; | ||
}, [ | ||
cachedPosition, | ||
allPatches, | ||
setPosition, | ||
setAllPatches, | ||
setState, | ||
initialState, | ||
state, | ||
]); | ||
const cachedSetState = useCallback( | ||
(value: StateValue<S>) => { | ||
setPosition(-1); | ||
if (position !== -1) { | ||
setAllPatches((_allPatches) => { | ||
_allPatches.patches = _allPatches.patches.slice(0, position); | ||
_allPatches.inversePatches = _allPatches.inversePatches.slice( | ||
0, | ||
position | ||
); | ||
}); | ||
} | ||
setState(value); | ||
}, | ||
[setState, setPosition, position, setAllPatches] | ||
); | ||
return [state, cachedSetState, cachedTravels] as Result<S, F>; | ||
}; |
Oops, something went wrong.