mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Add action and keybinding management with React hooks
This commit is contained in:
@@ -0,0 +1,278 @@
|
|||||||
|
/* 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }; // Args needed for seeking forward
|
||||||
|
"seek-backward": { seconds: number }; // Args needed for seeking backward
|
||||||
|
};
|
||||||
|
|
||||||
|
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]?.push(handler);
|
||||||
|
} else {
|
||||||
|
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
|
||||||
|
boundActions[action] = [handler] as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 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) {
|
||||||
|
const checkActive = () => {
|
||||||
|
const shouldBind = isActive.current;
|
||||||
|
if (shouldBind && !isBound) {
|
||||||
|
bindAction(action, stableHandler);
|
||||||
|
setIsBound(true);
|
||||||
|
} else if (!shouldBind && isBound) {
|
||||||
|
unbindAction(action, stableHandler);
|
||||||
|
setIsBound(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkActive();
|
||||||
|
}
|
||||||
|
}, [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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { ActionWithOptionalArgs, invokeAction } from "./actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This variable keeps track whether keybindings are being accepted
|
||||||
|
* true -> Keybindings are checked
|
||||||
|
* false -> Key presses are ignored (Keybindings are not checked)
|
||||||
|
*/
|
||||||
|
let keybindingsEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alt is also regarded as macOS OPTION (⌥) key
|
||||||
|
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
|
||||||
|
*/
|
||||||
|
type ModifierKeys =
|
||||||
|
| "ctrl"
|
||||||
|
| "alt"
|
||||||
|
| "shift"
|
||||||
|
| "ctrl-shift"
|
||||||
|
| "alt-shift"
|
||||||
|
| "ctrl-alt"
|
||||||
|
| "ctrl-alt-shift";
|
||||||
|
|
||||||
|
/* eslint-disable prettier/prettier */
|
||||||
|
// prettier-ignore
|
||||||
|
type Key =
|
||||||
|
| "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j"
|
||||||
|
| "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
|
||||||
|
| "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
|
||||||
|
| "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
|
||||||
|
| "right" | "/" | "?" | "." | "enter" | "tab" | "space";
|
||||||
|
/* eslint-enable */
|
||||||
|
|
||||||
|
type ModifierBasedShortcutKey = `${ModifierKeys}-${Key}`;
|
||||||
|
// Singular keybindings (these will be disabled when an input-ish area has been focused)
|
||||||
|
type SingleCharacterShortcutKey = `${Key}`;
|
||||||
|
|
||||||
|
type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
|
||||||
|
|
||||||
|
// Base bindings available on all platforms
|
||||||
|
const baseBindings: {
|
||||||
|
[_ in ShortcutKey]?: ActionWithOptionalArgs;
|
||||||
|
} = {
|
||||||
|
space: "toggle-play",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bindings based on the current kernel mode
|
||||||
|
*/
|
||||||
|
function getActiveBindings(): typeof baseBindings {
|
||||||
|
return baseBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bindings = getActiveBindings();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable that hooks to the caller component's
|
||||||
|
* lifecycle and hooks to the keyboard events to fire
|
||||||
|
* the appropriate actions based on keybindings
|
||||||
|
*/
|
||||||
|
export function useKeybindingsListener() {
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(ev: KeyboardEvent) {
|
||||||
|
// Do not check keybinds if the mode is disabled
|
||||||
|
if (!keybindingsEnabled) return;
|
||||||
|
|
||||||
|
const binding = generateKeybindingString(ev);
|
||||||
|
if (!binding) return;
|
||||||
|
|
||||||
|
const activeBindings = getActiveBindings();
|
||||||
|
const boundAction = activeBindings[binding];
|
||||||
|
if (!boundAction) return;
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
invokeAction(boundAction, undefined, "keypress");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
|
||||||
|
const target = ev.target;
|
||||||
|
|
||||||
|
// We may or may not have a modifier key
|
||||||
|
const modifierKey = getActiveModifier(ev);
|
||||||
|
|
||||||
|
// We will always have a non-modifier key
|
||||||
|
const key = getPressedKey(ev);
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
// All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
|
||||||
|
if (modifierKey) {
|
||||||
|
// If the modifier is shift and the target is an input, we ignore
|
||||||
|
if (
|
||||||
|
modifierKey === "shift" &&
|
||||||
|
isDOMElement(target) &&
|
||||||
|
isTypableElement(target)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${modifierKey}-${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no modifier key here then we do not do anything while on input
|
||||||
|
if (isDOMElement(target) && isTypableElement(target)) return null;
|
||||||
|
|
||||||
|
// single key while not input
|
||||||
|
return `${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPressedKey(ev: KeyboardEvent): Key | null {
|
||||||
|
// Sometimes the property code is not available on the KeyboardEvent object
|
||||||
|
const key = (ev.key ?? "").toLowerCase();
|
||||||
|
|
||||||
|
// Check arrow keys
|
||||||
|
if (key.startsWith("arrow")) {
|
||||||
|
return key.slice(5) as Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Tab key
|
||||||
|
if (key === "tab") return "tab";
|
||||||
|
|
||||||
|
// Check letter keys
|
||||||
|
const isLetter = key.length === 1 && key >= "a" && key <= "z";
|
||||||
|
if (isLetter) return key as Key;
|
||||||
|
|
||||||
|
// Check if number keys
|
||||||
|
const isDigit = key.length === 1 && key >= "0" && key <= "9";
|
||||||
|
if (isDigit) return key as Key;
|
||||||
|
|
||||||
|
// Check if slash, period or enter
|
||||||
|
if (key === "/" || key === "." || key === "enter") return key;
|
||||||
|
|
||||||
|
// If no other cases match, this is not a valid key
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
|
||||||
|
const modifierKeys = {
|
||||||
|
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
||||||
|
alt: ev.altKey,
|
||||||
|
shift: ev.shiftKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// active modifier: ctrl | alt | ctrl-alt | ctrl-shift | ctrl-alt-shift | alt-shift
|
||||||
|
// modiferKeys object's keys are sorted to match the above order
|
||||||
|
const activeModifier = Object.keys(modifierKeys)
|
||||||
|
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||||
|
.join("-");
|
||||||
|
|
||||||
|
return activeModifier === "" ? null : (activeModifier as ModifierKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This composable allows for the UI component to be disabled if the component in question is mounted
|
||||||
|
*/
|
||||||
|
export function useKeybindingDisabler() {
|
||||||
|
// TODO: Move to a lock based system that keeps the bindings disabled until all locks are lifted
|
||||||
|
const disableKeybindings = () => {
|
||||||
|
keybindingsEnabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enableKeybindings = () => {
|
||||||
|
keybindingsEnabled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
disableKeybindings,
|
||||||
|
enableKeybindings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,7 +13,10 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
*/
|
*/
|
||||||
export function generateUUID(): string {
|
export function generateUUID(): string {
|
||||||
// Use the native crypto.randomUUID if available
|
// Use the native crypto.randomUUID if available
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (
|
||||||
|
typeof crypto !== "undefined" &&
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
) {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,13 +29,49 @@ export function generateUUID(): string {
|
|||||||
// Set variant 10xxxxxx
|
// Set variant 10xxxxxx
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
|
||||||
const hex = [...bytes].map(b => b.toString(16).padStart(2, '0'));
|
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
hex.slice(0, 4).join('') + '-' +
|
hex.slice(0, 4).join("") +
|
||||||
hex.slice(4, 6).join('') + '-' +
|
"-" +
|
||||||
hex.slice(6, 8).join('') + '-' +
|
hex.slice(4, 6).join("") +
|
||||||
hex.slice(8, 10).join('') + '-' +
|
"-" +
|
||||||
hex.slice(10, 16).join('')
|
hex.slice(6, 8).join("") +
|
||||||
|
"-" +
|
||||||
|
hex.slice(8, 10).join("") +
|
||||||
|
"-" +
|
||||||
|
hex.slice(10, 16).join("")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isDOMElement(el: any): el is HTMLElement {
|
||||||
|
return !!el && (el instanceof Element || el instanceof HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTypableElement(el: HTMLElement): boolean {
|
||||||
|
// If content editable, then it is editable
|
||||||
|
if (el.isContentEditable) return true;
|
||||||
|
|
||||||
|
// If element is an input and the input is enabled, then it is typable
|
||||||
|
if (el.tagName === "INPUT") {
|
||||||
|
return !(el as HTMLInputElement).disabled;
|
||||||
|
}
|
||||||
|
// If element is a textarea and the input is enabled, then it is typable
|
||||||
|
if (el.tagName === "TEXTAREA") {
|
||||||
|
return !(el as HTMLTextAreaElement).disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppleDevice() {
|
||||||
|
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlatformSpecialKey() {
|
||||||
|
return isAppleDevice() ? "⌘" : "Ctrl";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlatformAlternateKey() {
|
||||||
|
return isAppleDevice() ? "⌥" : "Alt";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user