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.ts250
-rw-r--r--apps/web/src/stores/timeline-store.ts358
2 files changed, 583 insertions, 25 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..3e525b86
--- /dev/null
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -0,0 +1,250 @@
+"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();
+ const code = ev.code ?? "";
+
+ // 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 number keys using physical position for AZERTY support
+ if (code.startsWith("Digit")) {
+ const digit = code.slice(5);
+ if (digit.length === 1 && digit >= "0" && digit <= "9") {
+ return digit;
+ }
+ }
+
+ // Fallback for other layouts
+ 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 9a2c7361..01ad1635 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -21,6 +21,7 @@ import { useProjectStore } from "./project-store";
import { generateUUID } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { toast } from "sonner";
+import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
@@ -55,6 +56,10 @@ interface TimelineStore {
// Snapping actions
toggleSnapping: () => void;
+ // Ripple editing mode
+ rippleEditingEnabled: boolean;
+ toggleRippleEditing: () => void;
+
// Multi-selection
selectedElements: { trackId: string; elementId: string }[];
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
@@ -89,6 +94,7 @@ interface TimelineStore {
addTrack: (type: TrackType) => string;
insertTrackAt: (type: TrackType, index: number) => string;
removeTrack: (trackId: string) => void;
+ removeTrackWithRipple: (trackId: string) => void;
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
removeElementFromTrack: (trackId: string, elementId: string) => void;
moveElementToTrack: (
@@ -100,17 +106,20 @@ interface TimelineStore {
trackId: string,
elementId: string,
trimStart: number,
- trimEnd: number
+ trimEnd: number,
+ pushHistory?: boolean
) => void;
updateElementDuration: (
trackId: string,
elementId: string,
- duration: number
+ duration: number,
+ pushHistory?: boolean
) => void;
updateElementStartTime: (
trackId: string,
elementId: string,
- startTime: number
+ startTime: number,
+ pushHistory?: boolean
) => void;
toggleTrackMute: (trackId: string) => void;
@@ -139,6 +148,17 @@ interface TimelineStore {
newFile: File
) => Promise<boolean>;
+ // Ripple editing functions
+ updateElementStartTimeWithRipple: (
+ trackId: string,
+ elementId: string,
+ newStartTime: number
+ ) => void;
+ removeElementFromTrackWithRipple: (
+ trackId: string,
+ elementId: string
+ ) => void;
+
// Computed values
getTotalDuration: () => number;
@@ -226,6 +246,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
history: [],
redoStack: [],
selectedElements: [],
+ rippleEditingEnabled: false,
// Snapping settings defaults
snappingEnabled: true,
@@ -346,10 +367,107 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
},
removeTrack: (trackId) => {
+ const { rippleEditingEnabled } = get();
+
+ if (rippleEditingEnabled) {
+ get().removeTrackWithRipple(trackId);
+ } else {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.filter((track) => track.id !== trackId)
+ );
+ }
+ },
+
+ removeTrackWithRipple: (trackId) => {
+ const { _tracks } = get();
+ const trackToRemove = _tracks.find((t) => t.id === trackId);
+
+ if (!trackToRemove) return;
+
get().pushHistory();
- updateTracksAndSave(
- get()._tracks.filter((track) => track.id !== trackId)
- );
+
+ // If track has no elements, just remove it normally
+ if (trackToRemove.elements.length === 0) {
+ updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
+ return;
+ }
+
+ // Find all the time ranges occupied by elements in the track being removed
+ const occupiedRanges = trackToRemove.elements.map((element) => ({
+ startTime: element.startTime,
+ endTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd),
+ }));
+
+ // Sort ranges by start time
+ occupiedRanges.sort((a, b) => a.startTime - b.startTime);
+
+ // Merge overlapping ranges to get consolidated gaps
+ const mergedRanges: Array<{
+ startTime: number;
+ endTime: number;
+ duration: number;
+ }> = [];
+
+ for (const range of occupiedRanges) {
+ if (mergedRanges.length === 0) {
+ mergedRanges.push({
+ startTime: range.startTime,
+ endTime: range.endTime,
+ duration: range.endTime - range.startTime,
+ });
+ } else {
+ const lastRange = mergedRanges[mergedRanges.length - 1];
+ if (range.startTime <= lastRange.endTime) {
+ // Overlapping or adjacent ranges, merge them
+ lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
+ lastRange.duration = lastRange.endTime - lastRange.startTime;
+ } else {
+ // Non-overlapping range, add as new
+ mergedRanges.push({
+ startTime: range.startTime,
+ endTime: range.endTime,
+ duration: range.endTime - range.startTime,
+ });
+ }
+ }
+ }
+
+ // Remove the track and apply ripple effects to remaining tracks
+ const updatedTracks = _tracks
+ .filter((track) => track.id !== trackId)
+ .map((track) => {
+ const updatedElements = track.elements.map((element) => {
+ let newStartTime = element.startTime;
+
+ // Process gaps from right to left (latest to earliest) to avoid cumulative shifts
+ for (let i = mergedRanges.length - 1; i >= 0; i--) {
+ const gap = mergedRanges[i];
+ // If this element starts after the gap, shift it left by the gap duration
+ if (newStartTime >= gap.endTime) {
+ newStartTime -= gap.duration;
+ }
+ }
+
+ return {
+ ...element,
+ startTime: Math.max(0, newStartTime),
+ };
+ });
+
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...track, elements: resolvedElements };
+ }
+
+ return { ...track, elements: updatedElements };
+ });
+
+ updateTracksAndSave(updatedTracks);
},
addElementToTrack: (trackId, elementData) => {
@@ -434,21 +552,99 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
},
removeElementFromTrack: (trackId, elementId) => {
+ const { rippleEditingEnabled } = get();
+
+ if (rippleEditingEnabled) {
+ get().removeElementFromTrackWithRipple(trackId, elementId);
+ } else {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()
+ ._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
+ }
+ : track
+ )
+ .filter((track) => track.elements.length > 0)
+ );
+ }
+ },
+
+ 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();
- updateTracksAndSave(
- get()
- ._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId
+
+ 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
),
- }
- : track
- )
- .filter((track) => track.elements.length > 0)
- );
+ };
+ }
+ 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) => {
@@ -494,8 +690,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
updateTracksAndSave(newTracks);
},
- updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
- get().pushHistory();
+ updateElementTrim: (
+ trackId,
+ elementId,
+ trimStart,
+ trimEnd,
+ pushHistory = true
+ ) => {
+ if (pushHistory) get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
@@ -512,8 +714,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
- updateElementDuration: (trackId, elementId, duration) => {
- get().pushHistory();
+ updateElementDuration: (
+ trackId,
+ elementId,
+ duration,
+ pushHistory = true
+ ) => {
+ if (pushHistory) get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
@@ -528,8 +735,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
- updateElementStartTime: (trackId, elementId, startTime) => {
- get().pushHistory();
+ updateElementStartTime: (
+ trackId,
+ elementId,
+ startTime,
+ pushHistory = true
+ ) => {
+ if (pushHistory) get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
@@ -544,6 +756,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(
@@ -982,6 +1283,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;