@voidhash/mimic-react 0.0.1-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +35 -0
- package/LICENSE.md +663 -0
- package/dist/index.cjs +0 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +1 -0
- package/dist/objectSpread2-CIP_6jda.cjs +73 -0
- package/dist/objectSpread2-CxTyNSYl.mjs +67 -0
- package/dist/zustand/index.cjs +95 -0
- package/dist/zustand/index.d.cts +115 -0
- package/dist/zustand/index.d.cts.map +1 -0
- package/dist/zustand/index.d.mts +115 -0
- package/dist/zustand/index.d.mts.map +1 -0
- package/dist/zustand/index.mjs +96 -0
- package/dist/zustand/index.mjs.map +1 -0
- package/dist/zustand-commander/index.cjs +364 -0
- package/dist/zustand-commander/index.d.cts +325 -0
- package/dist/zustand-commander/index.d.cts.map +1 -0
- package/dist/zustand-commander/index.d.mts +325 -0
- package/dist/zustand-commander/index.d.mts.map +1 -0
- package/dist/zustand-commander/index.mjs +355 -0
- package/dist/zustand-commander/index.mjs.map +1 -0
- package/package.json +53 -0
- package/src/index.ts +0 -0
- package/src/zustand/index.ts +24 -0
- package/src/zustand/middleware.ts +171 -0
- package/src/zustand/types.ts +117 -0
- package/src/zustand-commander/commander.ts +395 -0
- package/src/zustand-commander/hooks.ts +259 -0
- package/src/zustand-commander/index.ts +139 -0
- package/src/zustand-commander/types.ts +347 -0
- package/tests/zustand/middleware.test.ts +584 -0
- package/tests/zustand-commander/commander.test.ts +774 -0
- package/tsconfig.build.json +24 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +18 -0
- package/vitest.mts +11 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @voidhash/mimic-react/zustand-commander
|
|
3
|
+
*
|
|
4
|
+
* Commander creation and command definition.
|
|
5
|
+
*
|
|
6
|
+
* @since 0.0.1
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { StoreApi } from "zustand";
|
|
10
|
+
import {
|
|
11
|
+
COMMAND_SYMBOL,
|
|
12
|
+
UNDOABLE_COMMAND_SYMBOL,
|
|
13
|
+
isUndoableCommand,
|
|
14
|
+
type AnyEffectSchema,
|
|
15
|
+
type Command,
|
|
16
|
+
type Commander,
|
|
17
|
+
type CommanderOptions,
|
|
18
|
+
type CommanderSlice,
|
|
19
|
+
type CommandContext,
|
|
20
|
+
type CommandDispatch,
|
|
21
|
+
type CommandFn,
|
|
22
|
+
type InferSchemaType,
|
|
23
|
+
type RevertFn,
|
|
24
|
+
type UndoableCommand,
|
|
25
|
+
type UndoEntry,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Default Options
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
const DEFAULT_OPTIONS: Required<CommanderOptions> = {
|
|
33
|
+
maxUndoStackSize: 100,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Commander Implementation
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a commander instance bound to a specific store type.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* // Create commander for your store type
|
|
46
|
+
* const commander = createCommander<StoreState>();
|
|
47
|
+
*
|
|
48
|
+
* // Define commands
|
|
49
|
+
* const addItem = commander.action(
|
|
50
|
+
* Schema.Struct({ name: Schema.String }),
|
|
51
|
+
* (ctx, params) => {
|
|
52
|
+
* const { mimic } = ctx.getState();
|
|
53
|
+
* mimic.document.transaction(root => {
|
|
54
|
+
* // add item
|
|
55
|
+
* });
|
|
56
|
+
* }
|
|
57
|
+
* );
|
|
58
|
+
*
|
|
59
|
+
* // Create store with middleware
|
|
60
|
+
* const useStore = create(
|
|
61
|
+
* commander.middleware(
|
|
62
|
+
* mimic(document, (set, get) => ({
|
|
63
|
+
* // your state
|
|
64
|
+
* }))
|
|
65
|
+
* )
|
|
66
|
+
* );
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function createCommander<TStore extends object>(
|
|
70
|
+
options: CommanderOptions = {}
|
|
71
|
+
): Commander<TStore & CommanderSlice> {
|
|
72
|
+
const { maxUndoStackSize } = { ...DEFAULT_OPTIONS, ...options };
|
|
73
|
+
|
|
74
|
+
// Track the store API once middleware is applied
|
|
75
|
+
let _storeApi: StoreApi<TStore & CommanderSlice> | null = null;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates the dispatch function for use within command handlers.
|
|
79
|
+
*/
|
|
80
|
+
const createDispatch = (): CommandDispatch<TStore & CommanderSlice> => {
|
|
81
|
+
return <TParams, TReturn>(
|
|
82
|
+
command: Command<TStore & CommanderSlice, TParams, TReturn>
|
|
83
|
+
) => {
|
|
84
|
+
return (params: TParams): TReturn => {
|
|
85
|
+
if (!_storeApi) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"Commander: Store not initialized. Make sure to use the commander middleware."
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Create context for the command
|
|
92
|
+
const ctx: CommandContext<TStore & CommanderSlice> = {
|
|
93
|
+
getState: () => _storeApi!.getState(),
|
|
94
|
+
setState: (partial) => _storeApi!.setState(partial as any),
|
|
95
|
+
dispatch: createDispatch(),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Execute the command
|
|
99
|
+
const result = command.fn(ctx, params);
|
|
100
|
+
|
|
101
|
+
// If it's an undoable command, add to undo stack
|
|
102
|
+
if (isUndoableCommand(command)) {
|
|
103
|
+
const entry: UndoEntry<TParams, TReturn> = {
|
|
104
|
+
command,
|
|
105
|
+
params,
|
|
106
|
+
result,
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
_storeApi.setState((state: TStore & CommanderSlice) => {
|
|
111
|
+
const { undoStack, redoStack } = state._commander;
|
|
112
|
+
|
|
113
|
+
// Add to undo stack, respecting max size
|
|
114
|
+
const newUndoStack = [...undoStack, entry].slice(-maxUndoStackSize);
|
|
115
|
+
|
|
116
|
+
// Clear redo stack when a new command is executed
|
|
117
|
+
return {
|
|
118
|
+
...state,
|
|
119
|
+
_commander: {
|
|
120
|
+
undoStack: newUndoStack,
|
|
121
|
+
redoStack: [],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a regular command (no undo support).
|
|
134
|
+
*/
|
|
135
|
+
function action<TParamsSchema extends AnyEffectSchema, TReturn = void>(
|
|
136
|
+
paramsSchema: TParamsSchema,
|
|
137
|
+
fn: CommandFn<TStore & CommanderSlice, InferSchemaType<TParamsSchema>, TReturn>
|
|
138
|
+
): Command<TStore & CommanderSlice, InferSchemaType<TParamsSchema>, TReturn>;
|
|
139
|
+
function action<TReturn = void>(
|
|
140
|
+
fn: CommandFn<TStore & CommanderSlice, void, TReturn>
|
|
141
|
+
): Command<TStore & CommanderSlice, void, TReturn>;
|
|
142
|
+
function action<TParamsSchema extends AnyEffectSchema, TReturn = void>(
|
|
143
|
+
paramsSchemaOrFn:
|
|
144
|
+
| TParamsSchema
|
|
145
|
+
| CommandFn<TStore & CommanderSlice, void, TReturn>,
|
|
146
|
+
maybeFn?: CommandFn<
|
|
147
|
+
TStore & CommanderSlice,
|
|
148
|
+
InferSchemaType<TParamsSchema>,
|
|
149
|
+
TReturn
|
|
150
|
+
>
|
|
151
|
+
): Command<TStore & CommanderSlice, any, TReturn> {
|
|
152
|
+
// Check if we have two arguments (schema + fn) or just one (fn only)
|
|
153
|
+
if (maybeFn !== undefined) {
|
|
154
|
+
// First arg is schema, second is fn
|
|
155
|
+
return {
|
|
156
|
+
[COMMAND_SYMBOL]: true,
|
|
157
|
+
fn: maybeFn,
|
|
158
|
+
paramsSchema: paramsSchemaOrFn as TParamsSchema,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Single argument - must be the action function (no params schema)
|
|
163
|
+
if (typeof paramsSchemaOrFn !== "function") {
|
|
164
|
+
throw new Error("Commander: action requires a function");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
[COMMAND_SYMBOL]: true,
|
|
169
|
+
fn: paramsSchemaOrFn as CommandFn<TStore & CommanderSlice, void, TReturn>,
|
|
170
|
+
paramsSchema: null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Create an undoable command with undo/redo support.
|
|
176
|
+
*/
|
|
177
|
+
function undoableAction<TParamsSchema extends AnyEffectSchema, TReturn>(
|
|
178
|
+
paramsSchema: TParamsSchema,
|
|
179
|
+
fn: CommandFn<TStore & CommanderSlice, InferSchemaType<TParamsSchema>, TReturn>,
|
|
180
|
+
revert: RevertFn<TStore & CommanderSlice, InferSchemaType<TParamsSchema>, TReturn>
|
|
181
|
+
): UndoableCommand<TStore & CommanderSlice, InferSchemaType<TParamsSchema>, TReturn>;
|
|
182
|
+
function undoableAction<TReturn>(
|
|
183
|
+
fn: CommandFn<TStore & CommanderSlice, void, TReturn>,
|
|
184
|
+
revert: RevertFn<TStore & CommanderSlice, void, TReturn>
|
|
185
|
+
): UndoableCommand<TStore & CommanderSlice, void, TReturn>;
|
|
186
|
+
function undoableAction<TParamsSchema extends AnyEffectSchema, TReturn>(
|
|
187
|
+
paramsSchemaOrFn:
|
|
188
|
+
| TParamsSchema
|
|
189
|
+
| CommandFn<TStore & CommanderSlice, void, TReturn>,
|
|
190
|
+
fnOrRevert:
|
|
191
|
+
| CommandFn<TStore & CommanderSlice, InferSchemaType<TParamsSchema>, TReturn>
|
|
192
|
+
| RevertFn<TStore & CommanderSlice, void, TReturn>,
|
|
193
|
+
maybeRevert?: RevertFn<
|
|
194
|
+
TStore & CommanderSlice,
|
|
195
|
+
InferSchemaType<TParamsSchema>,
|
|
196
|
+
TReturn
|
|
197
|
+
>
|
|
198
|
+
): UndoableCommand<TStore & CommanderSlice, any, TReturn> {
|
|
199
|
+
// Check if we have three arguments (schema + fn + revert) or two (fn + revert)
|
|
200
|
+
if (maybeRevert !== undefined) {
|
|
201
|
+
// First arg is schema, second is fn, third is revert
|
|
202
|
+
return {
|
|
203
|
+
[COMMAND_SYMBOL]: true,
|
|
204
|
+
[UNDOABLE_COMMAND_SYMBOL]: true,
|
|
205
|
+
fn: fnOrRevert as CommandFn<
|
|
206
|
+
TStore & CommanderSlice,
|
|
207
|
+
InferSchemaType<TParamsSchema>,
|
|
208
|
+
TReturn
|
|
209
|
+
>,
|
|
210
|
+
paramsSchema: paramsSchemaOrFn as TParamsSchema,
|
|
211
|
+
revert: maybeRevert,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Two arguments - fn + revert (no params schema)
|
|
216
|
+
if (typeof paramsSchemaOrFn !== "function") {
|
|
217
|
+
throw new Error("Commander: undoableAction requires a function");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
[COMMAND_SYMBOL]: true,
|
|
222
|
+
[UNDOABLE_COMMAND_SYMBOL]: true,
|
|
223
|
+
fn: paramsSchemaOrFn as CommandFn<TStore & CommanderSlice, void, TReturn>,
|
|
224
|
+
paramsSchema: null,
|
|
225
|
+
revert: fnOrRevert as RevertFn<TStore & CommanderSlice, void, TReturn>,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Zustand middleware that adds commander functionality.
|
|
231
|
+
*/
|
|
232
|
+
const middleware = <T extends object>(
|
|
233
|
+
config: (
|
|
234
|
+
set: StoreApi<T & CommanderSlice>["setState"],
|
|
235
|
+
get: StoreApi<T & CommanderSlice>["getState"],
|
|
236
|
+
api: StoreApi<T & CommanderSlice>
|
|
237
|
+
) => T
|
|
238
|
+
) => {
|
|
239
|
+
return (
|
|
240
|
+
set: StoreApi<T & CommanderSlice>["setState"],
|
|
241
|
+
get: StoreApi<T & CommanderSlice>["getState"],
|
|
242
|
+
api: StoreApi<T & CommanderSlice>
|
|
243
|
+
): T & CommanderSlice => {
|
|
244
|
+
// Store the API reference for dispatch
|
|
245
|
+
_storeApi = api as unknown as StoreApi<TStore & CommanderSlice>;
|
|
246
|
+
|
|
247
|
+
// Get user's state
|
|
248
|
+
const userState = config(set, get, api);
|
|
249
|
+
|
|
250
|
+
// Add commander slice
|
|
251
|
+
return {
|
|
252
|
+
...userState,
|
|
253
|
+
_commander: {
|
|
254
|
+
undoStack: [],
|
|
255
|
+
redoStack: [],
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
action,
|
|
263
|
+
undoableAction,
|
|
264
|
+
middleware: middleware as Commander<TStore & CommanderSlice>["middleware"],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// =============================================================================
|
|
269
|
+
// Undo/Redo Functions
|
|
270
|
+
// =============================================================================
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Perform an undo operation on the store.
|
|
274
|
+
* Returns true if an undo was performed, false if undo stack was empty.
|
|
275
|
+
*/
|
|
276
|
+
export function performUndo<TStore extends CommanderSlice>(
|
|
277
|
+
storeApi: StoreApi<TStore>
|
|
278
|
+
): boolean {
|
|
279
|
+
const state = storeApi.getState();
|
|
280
|
+
const { undoStack, redoStack } = state._commander;
|
|
281
|
+
|
|
282
|
+
// Pop the last entry from undo stack
|
|
283
|
+
const entry = undoStack[undoStack.length - 1];
|
|
284
|
+
if (!entry) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const newUndoStack = undoStack.slice(0, -1);
|
|
289
|
+
|
|
290
|
+
// Create context for the revert function
|
|
291
|
+
const ctx: CommandContext<TStore> = {
|
|
292
|
+
getState: () => storeApi.getState(),
|
|
293
|
+
setState: (partial) => storeApi.setState(partial as any),
|
|
294
|
+
dispatch: createDispatchForUndo(storeApi),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Execute the revert function
|
|
298
|
+
entry.command.revert(ctx, entry.params, entry.result);
|
|
299
|
+
|
|
300
|
+
// Move entry to redo stack
|
|
301
|
+
storeApi.setState((state: TStore) => ({
|
|
302
|
+
...state,
|
|
303
|
+
_commander: {
|
|
304
|
+
undoStack: newUndoStack,
|
|
305
|
+
redoStack: [...redoStack, entry],
|
|
306
|
+
},
|
|
307
|
+
}));
|
|
308
|
+
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Perform a redo operation on the store.
|
|
314
|
+
* Returns true if a redo was performed, false if redo stack was empty.
|
|
315
|
+
*/
|
|
316
|
+
export function performRedo<TStore extends CommanderSlice>(
|
|
317
|
+
storeApi: StoreApi<TStore>
|
|
318
|
+
): boolean {
|
|
319
|
+
const state = storeApi.getState();
|
|
320
|
+
const { undoStack, redoStack } = state._commander;
|
|
321
|
+
|
|
322
|
+
// Pop the last entry from redo stack
|
|
323
|
+
const entry = redoStack[redoStack.length - 1];
|
|
324
|
+
if (!entry) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const newRedoStack = redoStack.slice(0, -1);
|
|
329
|
+
|
|
330
|
+
// Create context for re-executing the command
|
|
331
|
+
const ctx: CommandContext<TStore> = {
|
|
332
|
+
getState: () => storeApi.getState(),
|
|
333
|
+
setState: (partial) => storeApi.setState(partial as any),
|
|
334
|
+
dispatch: createDispatchForUndo(storeApi),
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// Re-execute the command
|
|
338
|
+
const result = entry.command.fn(ctx, entry.params);
|
|
339
|
+
|
|
340
|
+
// Create new entry with potentially new result
|
|
341
|
+
const newEntry: UndoEntry = {
|
|
342
|
+
command: entry.command,
|
|
343
|
+
params: entry.params,
|
|
344
|
+
result,
|
|
345
|
+
timestamp: Date.now(),
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Move entry back to undo stack
|
|
349
|
+
storeApi.setState((state: TStore) => ({
|
|
350
|
+
...state,
|
|
351
|
+
_commander: {
|
|
352
|
+
undoStack: [...undoStack, newEntry],
|
|
353
|
+
redoStack: newRedoStack,
|
|
354
|
+
},
|
|
355
|
+
}));
|
|
356
|
+
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Creates a dispatch function for use during undo/redo operations.
|
|
362
|
+
* This dispatch does NOT add to undo stack (to avoid infinite loops).
|
|
363
|
+
*/
|
|
364
|
+
function createDispatchForUndo<TStore>(
|
|
365
|
+
storeApi: StoreApi<TStore>
|
|
366
|
+
): CommandDispatch<TStore> {
|
|
367
|
+
return <TParams, TReturn>(command: Command<TStore, TParams, TReturn>) => {
|
|
368
|
+
return (params: TParams): TReturn => {
|
|
369
|
+
const ctx: CommandContext<TStore> = {
|
|
370
|
+
getState: () => storeApi.getState(),
|
|
371
|
+
setState: (partial) => storeApi.setState(partial as any),
|
|
372
|
+
dispatch: createDispatchForUndo(storeApi),
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// Execute without adding to undo stack
|
|
376
|
+
return command.fn(ctx, params);
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Clear the undo and redo stacks.
|
|
383
|
+
*/
|
|
384
|
+
export function clearUndoHistory<TStore extends CommanderSlice>(
|
|
385
|
+
storeApi: StoreApi<TStore>
|
|
386
|
+
): void {
|
|
387
|
+
storeApi.setState((state: TStore) => ({
|
|
388
|
+
...state,
|
|
389
|
+
_commander: {
|
|
390
|
+
undoStack: [],
|
|
391
|
+
redoStack: [],
|
|
392
|
+
},
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @voidhash/mimic-react/zustand-commander
|
|
3
|
+
*
|
|
4
|
+
* React hooks for zustand-commander.
|
|
5
|
+
*
|
|
6
|
+
* @since 0.0.1
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useMemo } from "react";
|
|
10
|
+
import { useStore, type StoreApi, type UseBoundStore } from "zustand";
|
|
11
|
+
import { performRedo, performUndo, clearUndoHistory } from "./commander";
|
|
12
|
+
import {
|
|
13
|
+
isUndoableCommand,
|
|
14
|
+
type Command,
|
|
15
|
+
type CommandContext,
|
|
16
|
+
type CommandDispatch,
|
|
17
|
+
type CommanderSlice,
|
|
18
|
+
type ExtractState,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// useCommander Hook
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a dispatch function for commands.
|
|
27
|
+
* This is for use outside of React components (e.g., in command handlers).
|
|
28
|
+
*/
|
|
29
|
+
function createDispatchFromApi<TStore extends CommanderSlice>(
|
|
30
|
+
storeApi: StoreApi<TStore>,
|
|
31
|
+
maxUndoStackSize = 100
|
|
32
|
+
): CommandDispatch<TStore> {
|
|
33
|
+
const dispatch: CommandDispatch<TStore> = <TParams, TReturn>(
|
|
34
|
+
command: Command<TStore, TParams, TReturn>
|
|
35
|
+
) => {
|
|
36
|
+
return (params: TParams): TReturn => {
|
|
37
|
+
// Create context for the command
|
|
38
|
+
const ctx: CommandContext<TStore> = {
|
|
39
|
+
getState: () => storeApi.getState(),
|
|
40
|
+
setState: (partial) => storeApi.setState(partial as Partial<TStore>),
|
|
41
|
+
dispatch,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Execute the command
|
|
45
|
+
const result = command.fn(ctx, params);
|
|
46
|
+
|
|
47
|
+
// If it's an undoable command, add to undo stack
|
|
48
|
+
if (isUndoableCommand(command)) {
|
|
49
|
+
storeApi.setState((state: TStore) => {
|
|
50
|
+
const { undoStack } = state._commander;
|
|
51
|
+
|
|
52
|
+
// Add to undo stack, respecting max size
|
|
53
|
+
const newUndoStack = [
|
|
54
|
+
...undoStack,
|
|
55
|
+
{
|
|
56
|
+
command,
|
|
57
|
+
params,
|
|
58
|
+
result,
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
},
|
|
61
|
+
].slice(-maxUndoStackSize);
|
|
62
|
+
|
|
63
|
+
// Clear redo stack when a new command is executed
|
|
64
|
+
return {
|
|
65
|
+
...state,
|
|
66
|
+
_commander: {
|
|
67
|
+
undoStack: newUndoStack,
|
|
68
|
+
redoStack: [],
|
|
69
|
+
},
|
|
70
|
+
} as TStore;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return dispatch;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* React hook to get a dispatch function for commands.
|
|
83
|
+
* The dispatch function executes commands and manages undo/redo state.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```tsx
|
|
87
|
+
* const dispatch = useCommander(useStore);
|
|
88
|
+
*
|
|
89
|
+
* const handleClick = () => {
|
|
90
|
+
* dispatch(addCard)({ columnId: "col-1", title: "New Card" });
|
|
91
|
+
* };
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function useCommander<TStore extends CommanderSlice>(
|
|
95
|
+
store: UseBoundStore<StoreApi<TStore>>
|
|
96
|
+
): CommandDispatch<TStore> {
|
|
97
|
+
// Get the store API
|
|
98
|
+
const storeApi = useMemo(() => {
|
|
99
|
+
// UseBoundStore has the StoreApi attached
|
|
100
|
+
return store as unknown as StoreApi<TStore>;
|
|
101
|
+
}, [store]);
|
|
102
|
+
|
|
103
|
+
// Create a stable dispatch function
|
|
104
|
+
const dispatch = useMemo(
|
|
105
|
+
() => createDispatchFromApi<TStore>(storeApi),
|
|
106
|
+
[storeApi]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return dispatch as CommandDispatch<TStore>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// useUndoRedo Hook
|
|
114
|
+
// =============================================================================
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* State and actions for undo/redo functionality.
|
|
118
|
+
*/
|
|
119
|
+
export interface UndoRedoState {
|
|
120
|
+
/** Whether there are actions that can be undone */
|
|
121
|
+
readonly canUndo: boolean;
|
|
122
|
+
/** Whether there are actions that can be redone */
|
|
123
|
+
readonly canRedo: boolean;
|
|
124
|
+
/** Number of items in the undo stack */
|
|
125
|
+
readonly undoCount: number;
|
|
126
|
+
/** Number of items in the redo stack */
|
|
127
|
+
readonly redoCount: number;
|
|
128
|
+
/** Undo the last action */
|
|
129
|
+
readonly undo: () => boolean;
|
|
130
|
+
/** Redo the last undone action */
|
|
131
|
+
readonly redo: () => boolean;
|
|
132
|
+
/** Clear the undo/redo history */
|
|
133
|
+
readonly clear: () => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* React hook for undo/redo functionality.
|
|
138
|
+
* Provides state (canUndo, canRedo) and actions (undo, redo, clear).
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```tsx
|
|
142
|
+
* const { canUndo, canRedo, undo, redo } = useUndoRedo(useStore);
|
|
143
|
+
*
|
|
144
|
+
* return (
|
|
145
|
+
* <>
|
|
146
|
+
* <button onClick={undo} disabled={!canUndo}>Undo</button>
|
|
147
|
+
* <button onClick={redo} disabled={!canRedo}>Redo</button>
|
|
148
|
+
* </>
|
|
149
|
+
* );
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export function useUndoRedo<TStore extends CommanderSlice>(
|
|
153
|
+
store: UseBoundStore<StoreApi<TStore>>
|
|
154
|
+
): UndoRedoState {
|
|
155
|
+
// Get the store API
|
|
156
|
+
const storeApi = useMemo(() => {
|
|
157
|
+
return store as unknown as StoreApi<TStore>;
|
|
158
|
+
}, [store]);
|
|
159
|
+
|
|
160
|
+
// Subscribe to commander state
|
|
161
|
+
const commanderState = useStore(
|
|
162
|
+
store,
|
|
163
|
+
(state: TStore) => state._commander
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const canUndo = commanderState.undoStack.length > 0;
|
|
167
|
+
const canRedo = commanderState.redoStack.length > 0;
|
|
168
|
+
const undoCount = commanderState.undoStack.length;
|
|
169
|
+
const redoCount = commanderState.redoStack.length;
|
|
170
|
+
|
|
171
|
+
const undo = useCallback(() => {
|
|
172
|
+
return performUndo(storeApi);
|
|
173
|
+
}, [storeApi]);
|
|
174
|
+
|
|
175
|
+
const redo = useCallback(() => {
|
|
176
|
+
return performRedo(storeApi);
|
|
177
|
+
}, [storeApi]);
|
|
178
|
+
|
|
179
|
+
const clear = useCallback(() => {
|
|
180
|
+
clearUndoHistory(storeApi);
|
|
181
|
+
}, [storeApi]);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
canUndo,
|
|
185
|
+
canRedo,
|
|
186
|
+
undoCount,
|
|
187
|
+
redoCount,
|
|
188
|
+
undo,
|
|
189
|
+
redo,
|
|
190
|
+
clear,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// =============================================================================
|
|
195
|
+
// Keyboard Shortcut Hook
|
|
196
|
+
// =============================================================================
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Options for the keyboard shortcut hook.
|
|
200
|
+
*/
|
|
201
|
+
export interface UseUndoRedoKeyboardOptions {
|
|
202
|
+
/** Enable Ctrl/Cmd+Z for undo (default: true) */
|
|
203
|
+
readonly enableUndo?: boolean;
|
|
204
|
+
/** Enable Ctrl/Cmd+Shift+Z or Ctrl+Y for redo (default: true) */
|
|
205
|
+
readonly enableRedo?: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* React hook that adds keyboard shortcuts for undo/redo.
|
|
210
|
+
* Listens for Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Shift+Z or Ctrl+Y (redo).
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* ```tsx
|
|
214
|
+
* // In your app component
|
|
215
|
+
* useUndoRedoKeyboard(useStore);
|
|
216
|
+
* ```
|
|
217
|
+
*/
|
|
218
|
+
export function useUndoRedoKeyboard<TStore extends CommanderSlice>(
|
|
219
|
+
store: UseBoundStore<StoreApi<TStore>>,
|
|
220
|
+
options: UseUndoRedoKeyboardOptions = {}
|
|
221
|
+
): void {
|
|
222
|
+
const { enableUndo = true, enableRedo = true } = options;
|
|
223
|
+
|
|
224
|
+
const storeApi = useMemo(() => {
|
|
225
|
+
return store as unknown as StoreApi<TStore>;
|
|
226
|
+
}, [store]);
|
|
227
|
+
|
|
228
|
+
// Set up keyboard listener
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (typeof window === "undefined") {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
235
|
+
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
236
|
+
const modKey = isMac ? event.metaKey : event.ctrlKey;
|
|
237
|
+
|
|
238
|
+
if (!modKey) return;
|
|
239
|
+
|
|
240
|
+
// Undo: Ctrl/Cmd + Z (without Shift)
|
|
241
|
+
if (enableUndo && event.key === "z" && !event.shiftKey) {
|
|
242
|
+
event.preventDefault();
|
|
243
|
+
performUndo(storeApi);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Redo: Ctrl/Cmd + Shift + Z or Ctrl + Y
|
|
248
|
+
if (enableRedo) {
|
|
249
|
+
if ((event.key === "z" && event.shiftKey) || event.key === "y") {
|
|
250
|
+
event.preventDefault();
|
|
251
|
+
performRedo(storeApi);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
257
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
258
|
+
}, [storeApi, enableUndo, enableRedo]);
|
|
259
|
+
}
|