summaryrefslogtreecommitdiff
path: root/apps/web/src/stores
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/stores')
-rw-r--r--apps/web/src/stores/keybindings-store.ts241
-rw-r--r--apps/web/src/stores/timeline-store.ts338
2 files changed, 410 insertions, 169 deletions
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
new file mode 100644
index 00000000..60c71afd
--- /dev/null
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -0,0 +1,241 @@
+"use client";
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { ActionWithOptionalArgs } from "@/constants/actions";
+import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils";
+import { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
+
+// Default keybindings configuration
+export const defaultKeybindings: KeybindingConfig = {
+ space: "toggle-play",
+ j: "seek-backward",
+ k: "toggle-play",
+ l: "seek-forward",
+ left: "frame-step-backward",
+ right: "frame-step-forward",
+ "shift+left": "jump-backward",
+ "shift+right": "jump-forward",
+ home: "goto-start",
+ end: "goto-end",
+ s: "split-element",
+ n: "toggle-snapping",
+ "ctrl+a": "select-all",
+ "ctrl+d": "duplicate-selected",
+ "ctrl+z": "undo",
+ "ctrl+shift+z": "redo",
+ "ctrl+y": "redo",
+ delete: "delete-selected",
+ backspace: "delete-selected",
+};
+
+export interface KeybindingConflict {
+ key: ShortcutKey;
+ existingAction: ActionWithOptionalArgs;
+ newAction: ActionWithOptionalArgs;
+}
+
+interface KeybindingsState {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+ keybindingsEnabled: boolean;
+
+ // Actions
+ updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
+ removeKeybinding: (key: ShortcutKey) => void;
+ resetToDefaults: () => void;
+ importKeybindings: (config: KeybindingConfig) => void;
+ exportKeybindings: () => KeybindingConfig;
+ enableKeybindings: () => void;
+ disableKeybindings: () => void;
+
+ // Validation
+ validateKeybinding: (
+ key: ShortcutKey,
+ action: ActionWithOptionalArgs
+ ) => KeybindingConflict | null;
+ getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[];
+
+ // Utility
+ getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
+}
+
+export const useKeybindingsStore = create<KeybindingsState>()(
+ persist(
+ (set, get) => ({
+ keybindings: { ...defaultKeybindings },
+ isCustomized: false,
+ keybindingsEnabled: true,
+
+ updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
+ set((state) => {
+ const newKeybindings = { ...state.keybindings };
+ newKeybindings[key] = action;
+
+ return {
+ keybindings: newKeybindings,
+ isCustomized: true,
+ };
+ });
+ },
+
+ removeKeybinding: (key: ShortcutKey) => {
+ set((state) => {
+ const newKeybindings = { ...state.keybindings };
+ delete newKeybindings[key];
+
+ return {
+ keybindings: newKeybindings,
+ isCustomized: true,
+ };
+ });
+ },
+
+ resetToDefaults: () => {
+ set({
+ keybindings: { ...defaultKeybindings },
+ isCustomized: false,
+ });
+ },
+
+ enableKeybindings: () => {
+ set({ keybindingsEnabled: true });
+ },
+
+ disableKeybindings: () => {
+ set({ keybindingsEnabled: false });
+ },
+
+ importKeybindings: (config: KeybindingConfig) => {
+ // Validate all keys and actions
+ for (const [key, action] of Object.entries(config)) {
+ // Validate the key format
+ if (typeof key !== "string" || key.length === 0) {
+ throw new Error(`Invalid key format: ${key}`);
+ }
+ }
+ set({
+ keybindings: { ...config },
+ isCustomized: true,
+ });
+ },
+
+ exportKeybindings: () => {
+ return get().keybindings;
+ },
+
+ validateKeybinding: (
+ key: ShortcutKey,
+ action: ActionWithOptionalArgs
+ ) => {
+ const { keybindings } = get();
+ const existingAction = keybindings[key];
+
+ if (existingAction && existingAction !== action) {
+ return {
+ key,
+ existingAction,
+ newAction: action,
+ };
+ }
+
+ return null;
+ },
+
+ getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
+ const { keybindings } = get();
+ return Object.keys(keybindings).filter(
+ (key) => keybindings[key as ShortcutKey] === action
+ ) as ShortcutKey[];
+ },
+
+ getKeybindingString: (ev: KeyboardEvent) => {
+ return generateKeybindingString(ev) as ShortcutKey | null;
+ },
+ }),
+ {
+ name: "opencut-keybindings",
+ version: 1,
+ }
+ )
+);
+
+// Utility functions
+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}` as ShortcutKey;
+ }
+
+ // 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}` as ShortcutKey;
+}
+
+function getPressedKey(ev: KeyboardEvent): string | 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);
+ }
+
+ // Check for special keys
+ if (key === "tab") return "tab";
+ if (key === " " || key === "space") return "space";
+ if (key === "home") return "home";
+ if (key === "end") return "end";
+ if (key === "delete") return "delete";
+ if (key === "backspace") return "backspace";
+
+ // Check letter keys
+ const isLetter = key.length === 1 && key >= "a" && key <= "z";
+ if (isLetter) return key;
+
+ // Check if number keys
+ const isDigit = key.length === 1 && key >= "0" && key <= "9";
+ if (isDigit) return 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): string | 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;
+}
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 2e645415..1fcfc60e 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -52,12 +52,12 @@ interface TimelineStore {
// Snapping settings
snappingEnabled: boolean;
+
// Snapping actions
toggleSnapping: () => void;
// Ripple editing mode
rippleEditingEnabled: boolean;
- // Controls whether ripple editing affects all tracks or just the current track
toggleRippleEditing: () => void;
// Multi-selection
@@ -572,6 +572,78 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}
},
+ removeElementFromTrackWithRipple: (trackId, elementId) => {
+ const { _tracks, rippleEditingEnabled } = get();
+
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular removal
+ get().removeElementFromTrack(trackId, elementId);
+ return;
+ }
+
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+
+ if (!element || !track) return;
+
+ get().pushHistory();
+
+ const elementStartTime = element.startTime;
+ const elementDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ const elementEndTime = elementStartTime + elementDuration;
+
+ // Remove the element and shift all elements that come after it
+ const updatedTracks = _tracks
+ .map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
+
+ const updatedElements = currentTrack.elements
+ .filter((currentElement) => {
+ // Remove the target element
+ if (
+ currentElement.id === elementId &&
+ currentTrack.id === trackId
+ ) {
+ return false;
+ }
+ return true;
+ })
+ .map((currentElement) => {
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
+
+ // Shift elements that start after the removed element
+ if (currentElement.startTime >= elementEndTime) {
+ return {
+ ...currentElement,
+ startTime: Math.max(
+ 0,
+ currentElement.startTime - elementDuration
+ ),
+ };
+ }
+ return currentElement;
+ });
+
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
+
+ return { ...currentTrack, elements: updatedElements };
+ })
+ .filter((track) => track.elements.length > 0 || track.isMain);
+
+ updateTracksAndSave(updatedTracks);
+ },
+
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
get().pushHistory();
@@ -665,6 +737,95 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
+ updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
+ const { _tracks, rippleEditingEnabled } = get();
+
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular update
+ get().updateElementStartTime(trackId, elementId, newStartTime);
+ return;
+ }
+
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+
+ if (!element || !track) return;
+
+ get().pushHistory();
+
+ const oldStartTime = element.startTime;
+ const oldEndTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ const newEndTime =
+ newStartTime + (element.duration - element.trimStart - element.trimEnd);
+ const timeDelta = newStartTime - oldStartTime;
+
+ // Update tracks based on multi-track ripple setting
+ const updatedTracks = _tracks.map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
+
+ const updatedElements = currentTrack.elements.map((currentElement) => {
+ if (currentElement.id === elementId && currentTrack.id === trackId) {
+ // Update the moved element
+ return { ...currentElement, startTime: newStartTime };
+ }
+
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
+
+ // For ripple editing, we need to move elements that come after the moved element
+ const currentElementStart = currentElement.startTime;
+ const currentElementEnd =
+ currentElement.startTime +
+ (currentElement.duration -
+ currentElement.trimStart -
+ currentElement.trimEnd);
+
+ // If moving element to the right (positive delta)
+ if (timeDelta > 0) {
+ // Move elements that start after the original position of the moved element
+ if (currentElementStart >= oldEndTime) {
+ return {
+ ...currentElement,
+ startTime: currentElementStart + timeDelta,
+ };
+ }
+ }
+ // If moving element to the left (negative delta)
+ else if (timeDelta < 0) {
+ // Move elements that start after the new position of the moved element
+ if (
+ currentElementStart >= newEndTime &&
+ currentElementStart >= oldStartTime
+ ) {
+ return {
+ ...currentElement,
+ startTime: Math.max(0, currentElementStart + timeDelta),
+ };
+ }
+ }
+
+ return currentElement;
+ });
+
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
+
+ return { ...currentTrack, elements: updatedElements };
+ });
+
+ updateTracksAndSave(updatedTracks);
+ },
+
toggleTrackMute: (trackId) => {
get().pushHistory();
updateTracksAndSave(
@@ -1103,6 +1264,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
},
+ // Ripple editing functions
+ toggleRippleEditing: () => {
+ set((state) => ({
+ rippleEditingEnabled: !state.rippleEditingEnabled,
+ }));
+ },
+
checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
const track = get()._tracks.find((t) => t.id === trackId);
if (!track) return false;
@@ -1246,173 +1414,5 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
});
return true;
},
-
- // Ripple editing functions
- toggleRippleEditing: () => {
- set((state) => ({
- rippleEditingEnabled: !state.rippleEditingEnabled,
- }));
- },
-
- updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
- const { _tracks, rippleEditingEnabled } = get();
-
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular update
- get().updateElementStartTime(trackId, elementId, newStartTime);
- return;
- }
-
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
-
- if (!element || !track) return;
-
- get().pushHistory();
-
- const oldStartTime = element.startTime;
- const oldEndTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- const newEndTime =
- newStartTime + (element.duration - element.trimStart - element.trimEnd);
- const timeDelta = newStartTime - oldStartTime;
-
- // Update tracks based on multi-track ripple setting
- const updatedTracks = _tracks.map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
-
- const updatedElements = currentTrack.elements.map((currentElement) => {
- if (currentElement.id === elementId && currentTrack.id === trackId) {
- // Update the moved element
- return { ...currentElement, startTime: newStartTime };
- }
-
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
-
- // For ripple editing, we need to move elements that come after the moved element
- const currentElementStart = currentElement.startTime;
- const currentElementEnd =
- currentElement.startTime +
- (currentElement.duration -
- currentElement.trimStart -
- currentElement.trimEnd);
-
- // If moving element to the right (positive delta)
- if (timeDelta > 0) {
- // Move elements that start after the original position of the moved element
- if (currentElementStart >= oldEndTime) {
- return {
- ...currentElement,
- startTime: currentElementStart + timeDelta,
- };
- }
- }
- // If moving element to the left (negative delta)
- else if (timeDelta < 0) {
- // Move elements that start after the new position of the moved element
- if (
- currentElementStart >= newEndTime &&
- currentElementStart >= oldStartTime
- ) {
- return {
- ...currentElement,
- startTime: Math.max(0, currentElementStart + timeDelta),
- };
- }
- }
-
- return currentElement;
- });
-
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
-
- return { ...currentTrack, elements: updatedElements };
- });
-
- updateTracksAndSave(updatedTracks);
- },
-
- removeElementFromTrackWithRipple: (trackId, elementId) => {
- const { _tracks, rippleEditingEnabled } = get();
-
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular removal
- get().removeElementFromTrack(trackId, elementId);
- return;
- }
-
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
-
- if (!element || !track) return;
-
- get().pushHistory();
-
- const elementStartTime = element.startTime;
- const elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementEndTime = elementStartTime + elementDuration;
-
- // Remove the element and shift all elements that come after it
- const updatedTracks = _tracks
- .map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
-
- const updatedElements = currentTrack.elements
- .filter((currentElement) => {
- // Remove the target element
- if (
- currentElement.id === elementId &&
- currentTrack.id === trackId
- ) {
- return false;
- }
- return true;
- })
- .map((currentElement) => {
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
-
- // Shift elements that start after the removed element
- if (currentElement.startTime >= elementEndTime) {
- return {
- ...currentElement,
- startTime: Math.max(
- 0,
- currentElement.startTime - elementDuration
- ),
- };
- }
- return currentElement;
- });
-
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
-
- return { ...currentTrack, elements: updatedElements };
- })
- .filter((track) => track.elements.length > 0 || track.isMain);
-
- updateTracksAndSave(updatedTracks);
- },
};
});