@voidhash/mimic-react 0.0.1-alpha.5 → 0.0.1-alpha.7
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 +3 -3
- package/package.json +10 -4
- package/src/zustand/middleware.ts +1 -1
- 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-commander/commander.test.ts +774 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @voidhash/mimic-react/zustand-commander
|
|
3
|
+
*
|
|
4
|
+
* A typesafe command system for zustand + mimic that enables business logic
|
|
5
|
+
* encapsulation and client-side undo/redo capabilities.
|
|
6
|
+
*
|
|
7
|
+
* @since 0.0.1
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createCommander, useCommander, useUndoRedo } from "@voidhash/mimic-react/zustand-commander";
|
|
12
|
+
* import { Schema } from "effect";
|
|
13
|
+
*
|
|
14
|
+
* // 1. Create commander bound to your store type
|
|
15
|
+
* const commander = createCommander<StoreState>();
|
|
16
|
+
*
|
|
17
|
+
* // 2. Define regular actions
|
|
18
|
+
* const selectCard = commander.action(
|
|
19
|
+
* Schema.Struct({ cardId: Schema.String }),
|
|
20
|
+
* (ctx, params) => {
|
|
21
|
+
* ctx.setState({ selectedCardId: params.cardId });
|
|
22
|
+
* }
|
|
23
|
+
* );
|
|
24
|
+
*
|
|
25
|
+
* // 3. Define undoable actions
|
|
26
|
+
* const moveCard = commander.undoableAction(
|
|
27
|
+
* Schema.Struct({ cardId: Schema.String, toColumnId: Schema.String }),
|
|
28
|
+
* (ctx, params) => {
|
|
29
|
+
* const { mimic } = ctx.getState();
|
|
30
|
+
* const fromColumnId = // get current column
|
|
31
|
+
*
|
|
32
|
+
* mimic.document.transaction(root => {
|
|
33
|
+
* // move card to new column
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* return { fromColumnId }; // Return data needed for revert
|
|
37
|
+
* },
|
|
38
|
+
* (ctx, params, result) => {
|
|
39
|
+
* // Revert: move card back to original column
|
|
40
|
+
* ctx.dispatch(moveCard)({
|
|
41
|
+
* cardId: params.cardId,
|
|
42
|
+
* toColumnId: result.fromColumnId
|
|
43
|
+
* });
|
|
44
|
+
* }
|
|
45
|
+
* );
|
|
46
|
+
*
|
|
47
|
+
* // 4. Create store with commander middleware
|
|
48
|
+
* const useStore = create(
|
|
49
|
+
* commander.middleware(
|
|
50
|
+
* mimic(document, (set, get) => ({
|
|
51
|
+
* selectedCardId: null as string | null,
|
|
52
|
+
* }))
|
|
53
|
+
* )
|
|
54
|
+
* );
|
|
55
|
+
*
|
|
56
|
+
* // 5. Use in components
|
|
57
|
+
* function MyComponent() {
|
|
58
|
+
* const dispatch = useCommander(useStore);
|
|
59
|
+
* const { canUndo, canRedo, undo, redo } = useUndoRedo(useStore);
|
|
60
|
+
*
|
|
61
|
+
* return (
|
|
62
|
+
* <>
|
|
63
|
+
* <button onClick={() => dispatch(moveCard)({ cardId: "1", toColumnId: "2" })}>
|
|
64
|
+
* Move Card
|
|
65
|
+
* </button>
|
|
66
|
+
* <button onClick={undo} disabled={!canUndo}>Undo</button>
|
|
67
|
+
* <button onClick={redo} disabled={!canRedo}>Redo</button>
|
|
68
|
+
* </>
|
|
69
|
+
* );
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// Commander
|
|
76
|
+
// =============================================================================
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
createCommander,
|
|
80
|
+
performUndo,
|
|
81
|
+
performRedo,
|
|
82
|
+
clearUndoHistory,
|
|
83
|
+
} from "./commander.js";
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Hooks
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
export {
|
|
90
|
+
useCommander,
|
|
91
|
+
useUndoRedo,
|
|
92
|
+
useUndoRedoKeyboard,
|
|
93
|
+
type UndoRedoState,
|
|
94
|
+
type UseUndoRedoKeyboardOptions,
|
|
95
|
+
} from "./hooks.js";
|
|
96
|
+
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// Types
|
|
99
|
+
// =============================================================================
|
|
100
|
+
|
|
101
|
+
export type {
|
|
102
|
+
// Schema types
|
|
103
|
+
AnyEffectSchema,
|
|
104
|
+
InferSchemaType,
|
|
105
|
+
// Command types
|
|
106
|
+
Command,
|
|
107
|
+
UndoableCommand,
|
|
108
|
+
AnyCommand,
|
|
109
|
+
AnyUndoableCommand,
|
|
110
|
+
// Context & functions
|
|
111
|
+
CommandContext,
|
|
112
|
+
CommandFn,
|
|
113
|
+
RevertFn,
|
|
114
|
+
CommandDispatch,
|
|
115
|
+
// Undo/Redo
|
|
116
|
+
UndoEntry,
|
|
117
|
+
CommanderSlice,
|
|
118
|
+
// Commander
|
|
119
|
+
Commander,
|
|
120
|
+
CommanderOptions,
|
|
121
|
+
CommanderMiddleware,
|
|
122
|
+
// Helpers
|
|
123
|
+
CommandParams,
|
|
124
|
+
CommandReturn,
|
|
125
|
+
CommandStore,
|
|
126
|
+
ExtractState,
|
|
127
|
+
} from "./types.js";
|
|
128
|
+
|
|
129
|
+
// =============================================================================
|
|
130
|
+
// Symbols & Type Guards
|
|
131
|
+
// =============================================================================
|
|
132
|
+
|
|
133
|
+
export {
|
|
134
|
+
COMMAND_SYMBOL,
|
|
135
|
+
UNDOABLE_COMMAND_SYMBOL,
|
|
136
|
+
isCommand,
|
|
137
|
+
isUndoableCommand,
|
|
138
|
+
} from "./types.js";
|
|
139
|
+
|