-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Expand file tree
/
Copy pathactions.ts
More file actions
294 lines (250 loc) · 8.45 KB
/
actions.ts
File metadata and controls
294 lines (250 loc) · 8.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
/* An `action` is a unique verb that is associated with certain thing that can be done on OpenCut.
* For example, toggling playback or seeking.
*/
import {
useEffect,
useRef,
useState,
useCallback,
MutableRefObject,
} from "react";
// Simple event emitter for action changes
class ActionEmitter {
private listeners: Array<(actions: Action[]) => void> = [];
subscribe(listener: (actions: Action[]) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
emit(actions: Action[]) {
this.listeners.forEach((listener) => listener(actions));
}
}
const actionEmitter = new ActionEmitter();
export type Action =
| "toggle-play" // Toggle play/pause state
| "stop-playback" // Stop playback
| "seek-forward" // Seek forward in playback
| "seek-backward" // Seek backward in playback
| "frame-step-forward" // Step forward by one frame
| "frame-step-backward" // Step backward by one frame
| "jump-forward" // Jump forward by 5 seconds
| "jump-backward" // Jump backward by 5 seconds
| "goto-start" // Go to timeline start
| "goto-end" // Go to timeline end
| "split-element" // Split element at current time
| "delete-selected" // Delete selected elements
| "select-all" // Select all elements
| "duplicate-selected" // Duplicate selected element
| "toggle-snapping" // Toggle snapping
| "undo" // Undo last action
| "redo" // Redo last undone action
| "copy-selected" // Copy selected elements to clipboard
| "paste-selected"; // Paste elements from clipboard at playhead
/**
* Defines the arguments, if present for a given type that is required to be passed on
* invocation and will be passed to action handlers.
*
* This type is supposed to be an object with the key being one of the actions mentioned above.
* The value to the key can be anything.
* If an action has no argument, you do not need to add it to this type.
*
* NOTE: We can't enforce type checks to make sure the key is Action, you
* will know if you got something wrong if there is a type error in this file
*/
type ActionArgsMap = {
"seek-forward": { seconds: number } | undefined; // Args needed for seeking forward (default: 1)
"seek-backward": { seconds: number } | undefined; // Args needed for seeking backward (default: 1)
"jump-forward": { seconds: number } | undefined; // Args needed for jumping forward (default: 5)
"jump-backward": { seconds: number } | undefined; // Args needed for jumping backward (default: 5)
};
type KeysWithValueUndefined<T> = {
[K in keyof T]: undefined extends T[K] ? K : never;
}[keyof T];
/**
* Actions which require arguments for their invocation
*/
export type ActionWithArgs = keyof ActionArgsMap;
/**
* Actions which optionally takes in arguments for their invocation
*/
export type ActionWithOptionalArgs =
| ActionWithNoArgs
| KeysWithValueUndefined<ActionArgsMap>;
/**
* Actions which do not require arguments for their invocation
*/
export type ActionWithNoArgs = Exclude<Action, ActionWithArgs>;
/**
* Resolves the argument type for a given Action
*/
type ArgOfHoppAction<A extends Action> = A extends ActionWithArgs
? ActionArgsMap[A]
: undefined;
/**
* Resolves the action function for a given Action, used by action handler function defs
*/
type ActionFunc<A extends Action> = A extends ActionWithArgs
? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void
: (_?: undefined, trigger?: InvocationTriggers) => void;
type BoundActionList = {
[A in Action]?: Array<ActionFunc<A>>;
};
const boundActions: BoundActionList = {};
let currentActiveActions: Action[] = [];
function updateActiveActions() {
const newActions = Object.keys(boundActions) as Action[];
currentActiveActions = newActions;
actionEmitter.emit(newActions);
}
export function bindAction<A extends Action>(
action: A,
handler: ActionFunc<A>
) {
if (!boundActions[action]) {
boundActions[action] = [];
}
boundActions[action].push(handler);
updateActiveActions();
}
export type InvocationTriggers = "keypress" | "mouseclick";
type InvokeActionFunc = {
(
action: ActionWithOptionalArgs,
args?: undefined,
trigger?: InvocationTriggers
): void;
<A extends ActionWithArgs>(action: A, args: ActionArgsMap[A]): void;
};
/**
* Invokes an action, triggering action handlers if any registered.
* The second and third arguments are optional
* @param action The action to fire
* @param args The argument passed to the action handler. Optional if action has no args required
* @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick)
*/
export const invokeAction: InvokeActionFunc = <A extends Action>(
action: A,
args?: ArgOfHoppAction<A>,
trigger?: InvocationTriggers
) => {
boundActions[action]?.forEach((handler) => (handler as any)(args, trigger));
};
export function unbindAction<A extends Action>(
action: A,
handler: ActionFunc<A>
) {
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
boundActions[action] = boundActions[action]?.filter(
(x) => x !== handler
) as any;
if (boundActions[action]?.length === 0) {
delete boundActions[action];
}
updateActiveActions();
}
/**
* Returns whether a given action is bound at a given time
*
* @param action The action to check
*/
export function isActionBound(action: Action): boolean {
return !!boundActions[action];
}
/**
* A React hook that defines a component can handle a given
* Action. The handler will be bound when the component is mounted
* and unbound when the component is unmounted.
* @param action The action to be bound
* @param handler The function to be called when the action is invoked
* @param isActive A ref that indicates whether the action is active
*/
export function useActionHandler<A extends Action>(
action: A,
handler: ActionFunc<A>,
isActive: MutableRefObject<boolean> | boolean | undefined
) {
const handlerRef = useRef(handler);
const [isBound, setIsBound] = useState(false);
// Update handler ref when handler changes
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
// Create a stable handler wrapper
const stableHandler = useCallback(
(args: any, trigger?: InvocationTriggers) => {
(handlerRef.current as any)(args, trigger);
},
[]
) as ActionFunc<A>;
useEffect(() => {
const shouldBind =
isActive === undefined ||
(typeof isActive === "boolean" ? isActive : isActive.current);
if (shouldBind && !isBound) {
bindAction(action, stableHandler);
setIsBound(true);
} else if (!shouldBind && isBound) {
unbindAction(action, stableHandler);
setIsBound(false);
}
return () => {
if (isBound) {
unbindAction(action, stableHandler);
setIsBound(false);
}
};
}, [action, stableHandler, isActive, isBound]);
// Handle ref-based isActive changes
useEffect(() => {
if (isActive && typeof isActive === "object" && "current" in isActive) {
// Poll for ref changes
const interval = setInterval(() => {
const shouldBind = isActive.current;
if (shouldBind !== isBound) {
if (shouldBind) {
bindAction(action, stableHandler);
} else {
unbindAction(action, stableHandler);
}
setIsBound(shouldBind);
}
}, 100);
return () => clearInterval(interval);
}
}, [action, stableHandler, isActive, isBound]);
}
/**
* A React hook that returns the current list of active actions
* and re-renders when the list changes
*/
export function useActiveActions(): Action[] {
const [activeActions, setActiveActions] = useState<Action[]>([]);
useEffect(() => {
// Set initial value
setActiveActions(currentActiveActions);
// Subscribe to changes
const unsubscribe = actionEmitter.subscribe(setActiveActions);
return unsubscribe;
}, []);
return activeActions;
}
/**
* A React hook that returns whether a specific action is currently bound
* and re-renders when the binding state changes
*/
export function useIsActionBound(action: Action): boolean {
const [isBound, setIsBound] = useState(() => isActionBound(action));
useEffect(() => {
const updateBoundState = () => {
setIsBound(isActionBound(action));
};
// Set initial value
updateBoundState();
// Subscribe to changes
const unsubscribe = actionEmitter.subscribe(updateBoundState);
return unsubscribe;
}, [action]);
return isBound;
}