You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

StateManager.ts 10KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import createVanilla, { StoreApi } from 'zustand/vanilla'
  2. import create, { UseBoundStore } from 'zustand'
  3. import * as idb from 'idb-keyval'
  4. import { deepCopy } from './copy'
  5. import { merge } from './merge'
  6. import type { Patch, Command } from '../../types'
  7. export class StateManager<T extends Record<string, any>> {
  8. /**
  9. * An ID used to persist state in indexdb.
  10. */
  11. protected _idbId?: string
  12. /**
  13. * The initial state.
  14. */
  15. private initialState: T
  16. /**
  17. * A zustand store that also holds the state.
  18. */
  19. private store: StoreApi<T>
  20. /**
  21. * The index of the current command.
  22. */
  23. protected pointer = -1
  24. /**
  25. * The current state.
  26. */
  27. private _state: T
  28. /**
  29. * The state manager's current status, with regard to restoring persisted state.
  30. */
  31. private _status: 'loading' | 'ready' = 'loading'
  32. /**
  33. * A stack of commands used for history (undo and redo).
  34. */
  35. protected stack: Command<T>[] = []
  36. /**
  37. * A snapshot of the current state.
  38. */
  39. protected _snapshot: T
  40. /**
  41. * A React hook for accessing the zustand store.
  42. */
  43. public readonly useStore: UseBoundStore<T>
  44. /**
  45. * A promise that will resolve when the state manager has loaded any peristed state.
  46. */
  47. public ready: Promise<'none' | 'restored' | 'migrated'>
  48. public isPaused = false
  49. constructor(
  50. initialState: T,
  51. id?: string,
  52. version?: number,
  53. update?: (prev: T, next: T, prevVersion: number) => T
  54. ) {
  55. this._idbId = id
  56. this._state = deepCopy(initialState)
  57. this._snapshot = deepCopy(initialState)
  58. this.initialState = deepCopy(initialState)
  59. this.store = createVanilla(() => this._state)
  60. this.useStore = create(this.store)
  61. this.ready = new Promise<'none' | 'restored' | 'migrated'>((resolve) => {
  62. let message: 'none' | 'restored' | 'migrated' = 'none'
  63. if (this._idbId) {
  64. message = 'restored'
  65. idb
  66. .get(this._idbId)
  67. .then(async (saved) => {
  68. if (saved) {
  69. let next = saved
  70. if (version) {
  71. const savedVersion = await idb.get<number>(id + '_version')
  72. if (savedVersion && savedVersion < version) {
  73. next = update ? update(saved, initialState, savedVersion) : initialState
  74. message = 'migrated'
  75. }
  76. }
  77. await idb.set(id + '_version', version || -1)
  78. this._state = deepCopy(next)
  79. this._snapshot = deepCopy(next)
  80. this.store.setState(this._state, true)
  81. } else {
  82. await idb.set(id + '_version', version || -1)
  83. }
  84. this._status = 'ready'
  85. resolve(message)
  86. })
  87. .catch((e) => console.error(e))
  88. } else {
  89. // We need to wait for any override to `onReady` to take effect.
  90. this._status = 'ready'
  91. resolve(message)
  92. }
  93. resolve(message)
  94. }).then((message) => {
  95. if (this.onReady) this.onReady(message)
  96. return message
  97. })
  98. }
  99. /**
  100. * Save the current state to indexdb.
  101. */
  102. protected persist = (id?: string): void | Promise<void> => {
  103. if (this.onPersist) {
  104. this.onPersist(this._state, id)
  105. }
  106. if (this._idbId) {
  107. return idb.set(this._idbId, this._state).catch((e) => console.error(e))
  108. }
  109. }
  110. /**
  111. * Apply a patch to the current state.
  112. * This does not effect the undo/redo stack.
  113. * This does not persist the state.
  114. * @param patch The patch to apply.
  115. * @param id (optional) An id for the patch.
  116. */
  117. private applyPatch = (patch: Patch<T>, id?: string) => {
  118. const prev = this._state
  119. const next = merge(this._state, patch)
  120. const final = this.cleanup(next, prev, patch, id)
  121. if (this.onStateWillChange) {
  122. this.onStateWillChange(final, id)
  123. }
  124. this._state = final
  125. this.store.setState(this._state, true)
  126. if (this.onStateDidChange) {
  127. this.onStateDidChange(this._state, id)
  128. }
  129. return this
  130. }
  131. // Internal API ---------------------------------
  132. /**
  133. * Perform any last changes to the state before updating.
  134. * Override this on your extending class.
  135. * @param nextState The next state.
  136. * @param prevState The previous state.
  137. * @param patch The patch that was just applied.
  138. * @param id (optional) An id for the just-applied patch.
  139. * @returns The final new state to apply.
  140. */
  141. protected cleanup = (nextState: T, prevState: T, patch: Patch<T>, id?: string): T => nextState
  142. /**
  143. * A life-cycle method called when the state is about to change.
  144. * @param state The next state.
  145. * @param id An id for the change.
  146. */
  147. protected onStateWillChange?: (state: T, id?: string) => void
  148. /**
  149. * A life-cycle method called when the state has changed.
  150. * @param state The next state.
  151. * @param id An id for the change.
  152. */
  153. protected onStateDidChange?: (state: T, id?: string) => void
  154. /**
  155. * Apply a patch to the current state.
  156. * This does not effect the undo/redo stack.
  157. * This does not persist the state.
  158. * @param patch The patch to apply.
  159. * @param id (optional) An id for this patch.
  160. */
  161. protected patchState = (patch: Patch<T>, id?: string): this => {
  162. this.applyPatch(patch, id)
  163. if (this.onPatch) {
  164. this.onPatch(this._state, id)
  165. }
  166. return this
  167. }
  168. /**
  169. * Replace the current state.
  170. * This does not effect the undo/redo stack.
  171. * This does not persist the state.
  172. * @param state The new state.
  173. * @param id An id for this change.
  174. */
  175. protected replaceState = (state: T, id?: string): this => {
  176. const final = this.cleanup(state, this._state, state, id)
  177. if (this.onStateWillChange) {
  178. this.onStateWillChange(final, 'replace')
  179. }
  180. this._state = final
  181. this.store.setState(this._state, true)
  182. if (this.onStateDidChange) {
  183. this.onStateDidChange(this._state, 'replace')
  184. }
  185. return this
  186. }
  187. /**
  188. * Update the state using a Command.
  189. * This effects the undo/redo stack.
  190. * This persists the state.
  191. * @param command The command to apply and add to the undo/redo stack.
  192. * @param id (optional) An id for this command.
  193. */
  194. protected setState = (command: Command<T>, id = command.id) => {
  195. if (this.pointer < this.stack.length - 1) {
  196. this.stack = this.stack.slice(0, this.pointer + 1)
  197. }
  198. this.stack.push({ ...command, id })
  199. this.pointer = this.stack.length - 1
  200. this.applyPatch(command.after, id)
  201. if (this.onCommand) this.onCommand(this._state, id)
  202. this.persist(id)
  203. return this
  204. }
  205. // Public API ---------------------------------
  206. public pause() {
  207. this.isPaused = true
  208. }
  209. public resume() {
  210. this.isPaused = false
  211. }
  212. /**
  213. * A callback fired when the constructor finishes loading any
  214. * persisted data.
  215. */
  216. protected onReady?: (message: 'none' | 'restored' | 'migrated') => void
  217. /**
  218. * A callback fired when a patch is applied.
  219. */
  220. public onPatch?: (state: T, id?: string) => void
  221. /**
  222. * A callback fired when a patch is applied.
  223. */
  224. public onCommand?: (state: T, id?: string) => void
  225. /**
  226. * A callback fired when the state is persisted.
  227. */
  228. public onPersist?: (state: T, id?: string) => void
  229. /**
  230. * A callback fired when the state is replaced.
  231. */
  232. public onReplace?: (state: T) => void
  233. /**
  234. * A callback fired when the state is reset.
  235. */
  236. public onReset?: (state: T) => void
  237. /**
  238. * A callback fired when the history is reset.
  239. */
  240. public onResetHistory?: (state: T) => void
  241. /**
  242. * A callback fired when a command is undone.
  243. */
  244. public onUndo?: (state: T) => void
  245. /**
  246. * A callback fired when a command is redone.
  247. */
  248. public onRedo?: (state: T) => void
  249. /**
  250. * Reset the state to the initial state and reset history.
  251. */
  252. public reset = () => {
  253. if (this.onStateWillChange) {
  254. this.onStateWillChange(this.initialState, 'reset')
  255. }
  256. this._state = this.initialState
  257. this.store.setState(this._state, true)
  258. this.resetHistory()
  259. this.persist('reset')
  260. if (this.onStateDidChange) {
  261. this.onStateDidChange(this._state, 'reset')
  262. }
  263. if (this.onReset) {
  264. this.onReset(this._state)
  265. }
  266. return this
  267. }
  268. /**
  269. * Force replace a new undo/redo history. It's your responsibility
  270. * to make sure that this is compatible with the current state!
  271. * @param history The new array of commands.
  272. * @param pointer (optional) The new pointer position.
  273. */
  274. public replaceHistory = (history: Command<T>[], pointer = history.length - 1): this => {
  275. this.stack = history
  276. this.pointer = pointer
  277. if (this.onReplace) {
  278. this.onReplace(this._state)
  279. }
  280. return this
  281. }
  282. /**
  283. * Reset the history stack (without resetting the state).
  284. */
  285. public resetHistory = (): this => {
  286. this.stack = []
  287. this.pointer = -1
  288. if (this.onResetHistory) {
  289. this.onResetHistory(this._state)
  290. }
  291. return this
  292. }
  293. /**
  294. * Move backward in the undo/redo stack.
  295. */
  296. public undo = (): this => {
  297. if (!this.isPaused) {
  298. if (!this.canUndo) return this
  299. const command = this.stack[this.pointer]
  300. this.pointer--
  301. this.applyPatch(command.before, `undo`)
  302. this.persist('undo')
  303. }
  304. if (this.onUndo) this.onUndo(this._state)
  305. return this
  306. }
  307. /**
  308. * Move forward in the undo/redo stack.
  309. */
  310. public redo = (): this => {
  311. if (!this.isPaused) {
  312. if (!this.canRedo) return this
  313. this.pointer++
  314. const command = this.stack[this.pointer]
  315. this.applyPatch(command.after, 'redo')
  316. this.persist('undo')
  317. }
  318. if (this.onRedo) this.onRedo(this._state)
  319. return this
  320. }
  321. /**
  322. * Save a snapshot of the current state, accessible at `this.snapshot`.
  323. */
  324. public setSnapshot = (): this => {
  325. this._snapshot = { ...this._state }
  326. return this
  327. }
  328. /**
  329. * Force the zustand state to update.
  330. */
  331. public forceUpdate = () => {
  332. this.store.setState(this._state, true)
  333. }
  334. /**
  335. * Get whether the state manager can undo.
  336. */
  337. public get canUndo(): boolean {
  338. return this.pointer > -1
  339. }
  340. /**
  341. * Get whether the state manager can redo.
  342. */
  343. public get canRedo(): boolean {
  344. return this.pointer < this.stack.length - 1
  345. }
  346. /**
  347. * The current state.
  348. */
  349. public get state(): T {
  350. return this._state
  351. }
  352. /**
  353. * The current status.
  354. */
  355. public get status(): string {
  356. return this._status
  357. }
  358. /**
  359. * The most-recent snapshot.
  360. */
  361. protected get snapshot(): T {
  362. return this._snapshot
  363. }
  364. }