diff options
Diffstat (limited to 'apps/web/src/hooks')
| -rw-r--r-- | apps/web/src/hooks/use-disable-browser-zoom.ts | 63 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-editor-actions.ts | 161 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-keybinding-conflicts.ts | 59 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-keybindings.ts | 64 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-keyboard-shortcuts-help.ts | 129 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-keyboard-shortcuts.ts | 334 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-timeline-element-resize.ts | 86 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-timeline-playhead.ts | 202 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-timeline-zoom.ts | 103 |
9 files changed, 635 insertions, 566 deletions
diff --git a/apps/web/src/hooks/use-disable-browser-zoom.ts b/apps/web/src/hooks/use-disable-browser-zoom.ts deleted file mode 100644 index 9467cb71..00000000 --- a/apps/web/src/hooks/use-disable-browser-zoom.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect } from "react"; - -/** - * useDisableBrowserZoom - * - * This React hook prevents users from zooming in/out the browser window using keyboard shortcuts, - * mouse wheel, or gesture events (such as pinch-to-zoom on trackpads or touch devices). - * - * Typical use case: apps with fixed-size UIs, custom editors, or when zooming would break layout. - * - * NOTE: Disabling browser zoom may negatively affect accessibility and user experience. - * Use with caution, especially for public-facing applications. - */ -export const useDisableBrowserZoom = () => { - useEffect(() => { - /** - * Prevents browser zoom when user holds Ctrl (or Cmd on Mac) and scrolls the mouse wheel. - */ - const handleWheel = (e: WheelEvent) => { - if (e.ctrlKey) { - e.preventDefault(); - } - }; - - /** - * Prevents browser zoom via keyboard shortcuts: - * Ctrl/Cmd + '+', '-', '=', or '0' (reset zoom) - * Some keyboards emit '=' for '+' (without Shift). - */ - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey) { - const key = e.key; - if (key === "+" || key === "-" || key === "0" || key === "=") { - e.preventDefault(); - } - } - }; - - /** - * Prevents pinch-zoom gestures on supported browsers (mainly Safari/macOS/iOS). - * gesturestart, gesturechange, and gestureend are non-standard and not supported everywhere. - */ - const handleGesture = (e: Event) => { - e.preventDefault(); - }; - - // Attach event listeners (passive: false is required for preventDefault to work) - window.addEventListener("wheel", handleWheel, { passive: false }); - window.addEventListener("keydown", handleKeyDown, { passive: false }); - window.addEventListener("gesturestart", handleGesture, { passive: false }); - window.addEventListener("gesturechange", handleGesture, { passive: false }); - window.addEventListener("gestureend", handleGesture, { passive: false }); - - // Cleanup listeners on unmount to avoid memory leaks - return () => { - window.removeEventListener("wheel", handleWheel); - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("gesturestart", handleGesture); - window.removeEventListener("gesturechange", handleGesture); - window.removeEventListener("gestureend", handleGesture); - }; - }, []); -}; diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts new file mode 100644 index 00000000..4e8f6aef --- /dev/null +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect } from "react"; +import { useActionHandler } from "@/constants/actions"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { toast } from "sonner"; + +export function useEditorActions() { + const { + tracks, + selectedElements, + clearSelectedElements, + setSelectedElements, + removeElementFromTrack, + splitElement, + addElementToTrack, + snappingEnabled, + toggleSnapping, + undo, + redo, + } = useTimelineStore(); + + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + const { activeProject } = useProjectStore(); + + // Playback actions + useActionHandler("toggle-play", () => { + toggle(); + }); + + useActionHandler("stop-playback", () => { + if (isPlaying) { + toggle(); + } + seek(0); + }); + + useActionHandler("seek-forward", (args) => { + const seconds = args?.seconds ?? 1; + seek(Math.min(duration, currentTime + seconds)); + }); + + useActionHandler("seek-backward", (args) => { + const seconds = args?.seconds ?? 1; + seek(Math.max(0, currentTime - seconds)); + }); + + useActionHandler("frame-step-forward", () => { + const projectFps = activeProject?.fps || 30; + seek(Math.min(duration, currentTime + 1 / projectFps)); + }); + + useActionHandler("frame-step-backward", () => { + const projectFps = activeProject?.fps || 30; + seek(Math.max(0, currentTime - 1 / projectFps)); + }); + + useActionHandler("jump-forward", (args) => { + const seconds = args?.seconds ?? 5; + seek(Math.min(duration, currentTime + seconds)); + }); + + useActionHandler("jump-backward", (args) => { + const seconds = args?.seconds ?? 5; + seek(Math.max(0, currentTime - seconds)); + }); + + useActionHandler("goto-start", () => { + seek(0); + }); + + useActionHandler("goto-end", () => { + seek(duration); + }); + + // Timeline editing actions + useActionHandler("split-element", () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element to split"); + return; + } + + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t: any) => t.id === trackId); + const element = track?.elements.find((el: any) => el.id === elementId); + + if (element) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + splitElement(trackId, elementId, currentTime); + } else { + toast.error("Playhead must be within selected element"); + } + } + }); + + useActionHandler("delete-selected", () => { + if (selectedElements.length === 0) { + return; + } + selectedElements.forEach( + ({ trackId, elementId }: { trackId: string; elementId: string }) => { + removeElementFromTrack(trackId, elementId); + } + ); + clearSelectedElements(); + }); + + useActionHandler("select-all", () => { + const allElements = tracks.flatMap((track: any) => + track.elements.map((element: any) => ({ + trackId: track.id, + elementId: element.id, + })) + ); + setSelectedElements(allElements); + }); + + useActionHandler("duplicate-selected", () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element to duplicate"); + return; + } + + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t: any) => t.id === trackId); + const element = track?.elements.find((el: any) => el.id === elementId); + + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + const { id, ...elementWithoutId } = element; + + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + } + }); + + useActionHandler("toggle-snapping", () => { + toggleSnapping(); + }); + + // History actions + useActionHandler("undo", () => { + undo(); + }); + + useActionHandler("redo", () => { + redo(); + }); +} diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts new file mode 100644 index 00000000..cc5dd340 --- /dev/null +++ b/apps/web/src/hooks/use-keybinding-conflicts.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useMemo } from "react"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { ActionWithOptionalArgs } from "@/constants/actions"; + +export interface KeybindingConflictInfo { + key: string; + actions: ActionWithOptionalArgs[]; + isConflict: boolean; +} + +export const useKeybindingConflicts = () => { + const { keybindings } = useKeybindingsStore(); + + const conflicts = useMemo(() => { + const keyToActions: Record<string, ActionWithOptionalArgs[]> = {}; + const conflictList: KeybindingConflictInfo[] = []; + + // Group actions by key + Object.entries(keybindings).forEach(([key, action]) => { + if (!keyToActions[key]) { + keyToActions[key] = []; + } + keyToActions[key].push(action); + }); + + // Find conflicts + Object.entries(keyToActions).forEach(([key, actions]) => { + const uniqueActions = [...new Set(actions)]; + conflictList.push({ + key, + actions: uniqueActions, + isConflict: uniqueActions.length > 1, + }); + }); + + return conflictList.filter((item) => item.isConflict); + }, [keybindings]); + + const hasConflicts = conflicts.length > 0; + + const getConflictsForKey = (key: string): KeybindingConflictInfo | null => { + return conflicts.find((conflict) => conflict.key === key) || null; + }; + + const getConflictsForAction = ( + action: ActionWithOptionalArgs + ): KeybindingConflictInfo[] => { + return conflicts.filter((conflict) => conflict.actions.includes(action)); + }; + + return { + conflicts, + hasConflicts, + getConflictsForKey, + getConflictsForAction, + }; +}; diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts new file mode 100644 index 00000000..7887f3f1 --- /dev/null +++ b/apps/web/src/hooks/use-keybindings.ts @@ -0,0 +1,64 @@ +import { useEffect } from "react"; +import { invokeAction } from "../constants/actions"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; + +/** + * 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() { + const { keybindings, getKeybindingString, keybindingsEnabled } = + useKeybindingsStore(); + + useEffect(() => { + const handleKeyDown = (ev: KeyboardEvent) => { + // Do not check keybinds if the mode is disabled + if (!keybindingsEnabled) return; + + const binding = getKeybindingString(ev); + if (!binding) return; + + const boundAction = keybindings[binding]; + if (!boundAction) return; + + ev.preventDefault(); + + // Handle actions with default arguments + let actionArgs: any = undefined; + + if (boundAction === "seek-forward") { + actionArgs = { seconds: 1 }; + } else if (boundAction === "seek-backward") { + actionArgs = { seconds: 1 }; + } else if (boundAction === "jump-forward") { + actionArgs = { seconds: 5 }; + } else if (boundAction === "jump-backward") { + actionArgs = { seconds: 5 }; + } + + invokeAction(boundAction, actionArgs, "keypress"); + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [keybindings, getKeybindingString, keybindingsEnabled]); +} + +/** + * This composable allows for the UI component to be disabled if the component in question is mounted + */ +export function useKeybindingDisabler() { + const { disableKeybindings, enableKeybindings } = useKeybindingsStore(); + + return { + disableKeybindings, + enableKeybindings, + }; +} + +// Export the bindings for backward compatibility +export const bindings = {}; diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts new file mode 100644 index 00000000..c7de8466 --- /dev/null +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -0,0 +1,129 @@ +"use client"; + +import { useMemo } from "react"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { Action } from "@/constants/actions"; + +export interface KeyboardShortcut { + id: string; + keys: string[]; + description: string; + category: string; + action: Action; + icon?: React.ReactNode; +} + +// Map actions to their descriptions and categories +const actionDescriptions: Record< + Action, + { description: string; category: string } +> = { + "toggle-play": { description: "Play/Pause", category: "Playback" }, + "stop-playback": { description: "Stop playback", category: "Playback" }, + "seek-forward": { + description: "Seek forward 1 second", + category: "Playback", + }, + "seek-backward": { + description: "Seek backward 1 second", + category: "Playback", + }, + "frame-step-forward": { + description: "Frame step forward", + category: "Navigation", + }, + "frame-step-backward": { + description: "Frame step backward", + category: "Navigation", + }, + "jump-forward": { + description: "Jump forward 5 seconds", + category: "Navigation", + }, + "jump-backward": { + description: "Jump backward 5 seconds", + category: "Navigation", + }, + "goto-start": { description: "Go to timeline start", category: "Navigation" }, + "goto-end": { description: "Go to timeline end", category: "Navigation" }, + "split-element": { + description: "Split element at playhead", + category: "Editing", + }, + "delete-selected": { + description: "Delete selected elements", + category: "Editing", + }, + "select-all": { description: "Select all elements", category: "Selection" }, + "duplicate-selected": { + description: "Duplicate selected element", + category: "Selection", + }, + "toggle-snapping": { description: "Toggle snapping", category: "Editing" }, + undo: { description: "Undo", category: "History" }, + redo: { description: "Redo", category: "History" }, +}; + +// Convert key binding format to display format +const formatKey = (key: string): string => { + return key + .replace("ctrl", "Cmd") + .replace("alt", "Alt") + .replace("shift", "Shift") + .replace("left", "ArrowLeft") + .replace("right", "ArrowRight") + .replace("up", "ArrowUp") + .replace("down", "ArrowDown") + .replace("space", "Space") + .replace("home", "Home") + .replace("end", "End") + .replace("delete", "Delete") + .replace("backspace", "Backspace") + .replace("-", "+"); +}; + +export const useKeyboardShortcutsHelp = () => { + const { keybindings } = useKeybindingsStore(); + + const shortcuts = useMemo(() => { + const result: KeyboardShortcut[] = []; + + // Group keybindings by action + const actionToKeys: Record<Action, string[]> = {} as any; + + Object.entries(keybindings).forEach(([key, action]) => { + if (action) { + if (!actionToKeys[action]) { + actionToKeys[action] = []; + } + actionToKeys[action].push(formatKey(key)); + } + }); + + // Convert to shortcuts format + Object.entries(actionToKeys).forEach(([action, keys]) => { + const actionInfo = actionDescriptions[action as Action]; + if (actionInfo) { + result.push({ + id: action, + keys, + description: actionInfo.description, + category: actionInfo.category, + action: action as Action, + }); + } + }); + + // Sort shortcuts by category first, then by description to ensure consistent ordering + return result.sort((a, b) => { + if (a.category !== b.category) { + return a.category.localeCompare(b.category); + } + return a.description.localeCompare(b.description); + }); + }, [keybindings]); + + return { + shortcuts, + }; +}; diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts deleted file mode 100644 index 84cb78f8..00000000 --- a/apps/web/src/hooks/use-keyboard-shortcuts.ts +++ /dev/null @@ -1,334 +0,0 @@ -"use client";
-
-import { useEffect, useCallback } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useProjectStore } from "@/stores/project-store";
-import { toast } from "sonner";
-
-export interface KeyboardShortcut {
- id: string;
- keys: string[];
- description: string;
- category: string;
- action: () => void;
- enabled?: boolean;
- requiresSelection?: boolean;
- icon?: React.ReactNode;
-}
-
-interface UseKeyboardShortcutsOptions {
- enabled?: boolean;
- context?: "global" | "timeline" | "editor";
-}
-
-export const useKeyboardShortcuts = (
- options: UseKeyboardShortcutsOptions = {}
-) => {
- const { enabled = true, context = "editor" } = options;
-
- const {
- tracks,
- selectedElements,
- clearSelectedElements,
- setSelectedElements,
- removeElementFromTrack,
- splitElement,
- addElementToTrack,
- snappingEnabled,
- toggleSnapping,
- undo,
- redo,
- } = useTimelineStore();
-
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
-
- const { activeProject } = useProjectStore();
-
- // Check if user is typing in an input field
- const isInputFocused = useCallback(() => {
- const activeElement = document.activeElement as HTMLElement;
- return (
- activeElement &&
- (activeElement.tagName === "INPUT" ||
- activeElement.tagName === "TEXTAREA" ||
- activeElement.contentEditable === "true")
- );
- }, []);
-
- // Define all shortcuts in one place
- const shortcuts: KeyboardShortcut[] = [
- // Playback Controls
- {
- id: "play-pause",
- keys: ["Space"],
- description: "Play/Pause",
- category: "Playback",
- action: () => {
- toggle();
- },
- },
- {
- id: "rewind",
- keys: ["j", "J"],
- description: "Rewind 1 second",
- category: "Playback",
- action: () => {
- seek(Math.max(0, currentTime - 1));
- },
- },
- {
- id: "play-pause-alt",
- keys: ["k", "K"],
- description: "Play/Pause (alternative)",
- category: "Playback",
- action: () => {
- toggle();
- },
- },
- {
- id: "fast-forward",
- keys: ["l", "L"],
- description: "Fast forward 1 second",
- category: "Playback",
- action: () => {
- seek(Math.min(duration, currentTime + 1));
- },
- },
-
- // Navigation
- {
- id: "frame-backward",
- keys: ["ArrowLeft"],
- description: "Frame step backward",
- category: "Navigation",
- action: () => {
- const projectFps = activeProject?.fps || 30;
- seek(Math.max(0, currentTime - 1 / projectFps));
- },
- },
- {
- id: "frame-forward",
- keys: ["ArrowRight"],
- description: "Frame step forward",
- category: "Navigation",
- action: () => {
- const projectFps = activeProject?.fps || 30;
- seek(Math.min(duration, currentTime + 1 / projectFps));
- },
- },
- {
- id: "jump-backward",
- keys: ["Shift+ArrowLeft"],
- description: "Jump back 5 seconds",
- category: "Navigation",
- action: () => {
- seek(Math.max(0, currentTime - 5));
- },
- },
- {
- id: "jump-forward",
- keys: ["Shift+ArrowRight"],
- description: "Jump forward 5 seconds",
- category: "Navigation",
- action: () => {
- seek(Math.min(duration, currentTime + 5));
- },
- },
- {
- id: "goto-start",
- keys: ["Home"],
- description: "Go to timeline start",
- category: "Navigation",
- action: () => {
- seek(0);
- },
- },
- {
- id: "goto-end",
- keys: ["End"],
- description: "Go to timeline end",
- category: "Navigation",
- action: () => {
- seek(duration);
- },
- },
-
- // Editing
- {
- id: "split-element",
- keys: ["s", "S"],
- description: "Split element at playhead",
- category: "Editing",
- requiresSelection: true,
- action: () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element to split");
- return;
- }
-
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t: any) => t.id === trackId);
- const element = track?.elements.find((el: any) => el.id === elementId);
-
- if (element) {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitElement(trackId, elementId, currentTime);
- } else {
- toast.error("Playhead must be within selected element");
- }
- }
- },
- },
- {
- id: "delete-elements",
- keys: ["Delete", "Backspace"],
- description: "Delete selected elements",
- category: "Editing",
- requiresSelection: true,
- action: () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- selectedElements.forEach(
- ({ trackId, elementId }: { trackId: string; elementId: string }) => {
- removeElementFromTrack(trackId, elementId);
- }
- );
- clearSelectedElements();
- },
- },
- {
- id: "toggle-snapping",
- keys: ["n", "N"],
- description: "Toggle snapping",
- category: "Editing",
- action: () => {
- toggleSnapping();
- },
- },
-
- // Selection & Organization
- {
- id: "select-all",
- keys: ["Cmd+a", "Ctrl+a"],
- description: "Select all elements",
- category: "Selection",
- action: () => {
- const allElements = tracks.flatMap((track: any) =>
- track.elements.map((element: any) => ({
- trackId: track.id,
- elementId: element.id,
- }))
- );
- setSelectedElements(allElements);
- },
- },
- {
- id: "duplicate-element",
- keys: ["Cmd+d", "Ctrl+d"],
- description: "Duplicate selected element",
- category: "Selection",
- requiresSelection: true,
- action: () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element to duplicate");
- return;
- }
-
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t: any) => t.id === trackId);
- const element = track?.elements.find((el: any) => el.id === elementId);
-
- if (element) {
- const newStartTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1;
- const { id, ...elementWithoutId } = element;
-
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
- }
- },
- },
-
- // History
- {
- id: "undo",
- keys: ["Cmd+z", "Ctrl+z"],
- description: "Undo",
- category: "History",
- action: () => {
- undo();
- },
- },
- {
- id: "redo",
- keys: ["Cmd+Shift+z", "Ctrl+Shift+z", "Cmd+y", "Ctrl+y"],
- description: "Redo",
- category: "History",
- action: () => {
- redo();
- },
- },
- ];
-
- // Parse keyboard event to match against shortcuts
- const parseKeyboardEvent = useCallback((e: KeyboardEvent): string => {
- const parts: string[] = [];
-
- if (e.metaKey || e.ctrlKey) parts.push(e.metaKey ? "Cmd" : "Ctrl");
- if (e.shiftKey) parts.push("Shift");
- if (e.altKey) parts.push("Alt");
-
- parts.push(e.key);
-
- return parts.join("+");
- }, []);
-
- // Handle keyboard events
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- if (!enabled || isInputFocused()) return;
-
- const keyCombo = parseKeyboardEvent(e);
- const shortcut = shortcuts.find((s) =>
- s.keys.some((key) => key === keyCombo || key === e.key)
- );
-
- if (shortcut) {
- // Check if shortcut requires selection
- if (shortcut.requiresSelection && selectedElements.length === 0) {
- return;
- }
-
- e.preventDefault();
- shortcut.action();
- }
- },
- [enabled, shortcuts, selectedElements, parseKeyboardEvent, isInputFocused]
- );
-
- // Set up event listener
- useEffect(() => {
- if (!enabled) return;
-
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [enabled, handleKeyDown]);
-
- // Return shortcuts for help component
- return {
- shortcuts: shortcuts.filter((s) => s.enabled !== false),
- enabled,
- };
-};
diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts index 0e409977..3afa637b 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; interface UseTimelineElementResizeProps { element: TimelineElement; @@ -28,6 +29,12 @@ export function useTimelineElementResize({ }: UseTimelineElementResizeProps) { const [resizing, setResizing] = useState<ResizeState | null>(null); const { mediaItems } = useMediaStore(); + const { + updateElementStartTime, + updateElementTrim, + updateElementDuration, + pushHistory, + } = useTimelineStore(); // Set up document-level mouse listeners during resize (like proper drag behavior) useEffect(() => { @@ -59,6 +66,9 @@ export function useTimelineElementResize({ e.stopPropagation(); e.preventDefault(); + // Push history once at the start of the resize operation + pushHistory(); + setResizing({ elementId, side, @@ -100,12 +110,58 @@ export function useTimelineElementResize({ const deltaTime = deltaX / (50 * zoomLevel); if (resizing.side === "left") { - // Left resize - only trim within original duration + // Left resize - different behavior for media vs text/image elements const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1; const calculated = resizing.initialTrimStart + deltaTime; - const newTrimStart = Math.max(0, Math.min(maxAllowed, calculated)); - onUpdateTrim(track.id, element.id, newTrimStart, resizing.initialTrimEnd); + if (calculated >= 0) { + // Normal trimming within available content + const newTrimStart = Math.min(maxAllowed, calculated); + const trimDelta = newTrimStart - resizing.initialTrimStart; + const newStartTime = element.startTime + trimDelta; + + updateElementTrim( + track.id, + element.id, + newTrimStart, + resizing.initialTrimEnd, + false + ); + updateElementStartTime(track.id, element.id, newStartTime, false); + } else { + // Trying to extend beyond trimStart = 0 + if (canExtendElementDuration()) { + // Text/Image: extend element to the left by moving startTime and increasing duration + const extensionAmount = Math.abs(calculated); + const newStartTime = element.startTime - extensionAmount; + const newDuration = element.duration + extensionAmount; + + // Keep trimStart at 0 and extend the element + updateElementTrim( + track.id, + element.id, + 0, + resizing.initialTrimEnd, + false + ); + updateElementDuration(track.id, element.id, newDuration, false); + updateElementStartTime(track.id, element.id, newStartTime, false); + } else { + // Video/Audio: can't extend beyond original content - limit to trimStart = 0 + const newTrimStart = 0; + const trimDelta = newTrimStart - resizing.initialTrimStart; + const newStartTime = element.startTime + trimDelta; + + updateElementTrim( + track.id, + element.id, + newTrimStart, + resizing.initialTrimEnd, + false + ); + updateElementStartTime(track.id, element.id, newStartTime, false); + } + } } else { // Right resize - can extend duration for supported element types const calculated = resizing.initialTrimEnd - deltaTime; @@ -119,36 +175,40 @@ export function useTimelineElementResize({ const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending // Update duration first, then trim - onUpdateDuration(track.id, element.id, newDuration); - onUpdateTrim( + updateElementDuration(track.id, element.id, newDuration, false); + updateElementTrim( track.id, element.id, resizing.initialTrimStart, - newTrimEnd + newTrimEnd, + false ); } else { // Can't extend - just set trimEnd to 0 (maximum possible extension) - onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0); + updateElementTrim( + track.id, + element.id, + resizing.initialTrimStart, + 0, + false + ); } } else { // Normal trimming within original duration const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated)); - onUpdateTrim( + updateElementTrim( track.id, element.id, resizing.initialTrimStart, - newTrimEnd + newTrimEnd, + false ); } } }; - const handleResizeMove = (e: React.MouseEvent) => { - updateTrimFromMouseMove(e); - }; - const handleResizeEnd = () => { setResizing(null); }; diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index 9eb06485..9a82c23f 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -1,6 +1,6 @@ import { snapTimeToFrame } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
-import { useRef, useState, useCallback } from "react";
+import { useState, useEffect, useCallback } from "react";
interface UseTimelinePlayheadProps {
currentTime: number;
@@ -9,15 +9,10 @@ interface UseTimelinePlayheadProps { seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
}
-/**
- * useTimelinePlayhead
- *
- * Custom hook to manage playhead (scrubbing) logic for a timeline editor.
- * Handles mouse interaction for timeline ruler, calculates time from mouse,
- * and enables smooth scrubbing without unnecessary re-renders.
- */
export function useTimelinePlayhead({
currentTime,
duration,
@@ -25,101 +20,138 @@ export function useTimelinePlayhead({ seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
}: UseTimelinePlayheadProps) {
- // Get current project info (especially FPS) from global store
- const { activeProject } = useProjectStore();
+ // Playhead scrubbing state
+ const [isScrubbing, setIsScrubbing] = useState(false);
+ const [scrubTime, setScrubTime] = useState<number | null>(null);
- // Ref to track if currently scrubbing
- const isScrubbingRef = useRef(false);
+ // Ruler drag detection state
+ const [isDraggingRuler, setIsDraggingRuler] = useState(false);
+ const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
- // Ref to hold the playhead time while scrubbing (does not trigger re-render)
- const scrubTimeRef = useRef<number | null>(null);
-
- // State to force re-render when scrubbing ends
- const [_, forceRerender] = useState(0);
-
- /**
- * Determines the actual playhead position:
- * - If scrubbing, use the value in scrubTimeRef
- * - Otherwise, use the currentTime (controlled by external state)
- */
const playheadPosition =
- isScrubbingRef.current && scrubTimeRef.current !== null
- ? scrubTimeRef.current
- : currentTime;
-
- /**
- * Calculate timeline time (in seconds) based on mouse X position.
- * - Gets bounding rect of the ruler
- * - Adjusts for scroll position if ruler is scrollable
- * - Converts X pixel offset to seconds using current zoom level
- * - Snaps to nearest frame using FPS
- */
- const getTimeFromMouse = useCallback(
- (e: MouseEvent | React.MouseEvent) => {
- const ruler = rulerRef.current;
- const scrollArea = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
-
- if (!ruler || !scrollArea) return 0;
+ isScrubbing && scrubTime !== null ? scrubTime : currentTime;
- const rect = ruler.getBoundingClientRect();
- const scrollLeft = scrollArea.scrollLeft;
- const x = e.clientX - rect.left + scrollLeft;
-
- // Calculate how many pixels represent one second, depending on zoom
- const pixelsPerSecond = 50 * zoomLevel;
- const rawTime = Math.max(0, Math.min(duration, x / pixelsPerSecond));
- const time = snapTimeToFrame(rawTime, activeProject?.fps || 30);
- return time;
+ // --- Playhead Scrubbing Handlers ---
+ const handlePlayheadMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation(); // Prevent ruler drag from triggering
+ setIsScrubbing(true);
+ handleScrub(e);
},
- [rulerRef, rulerScrollRef, duration, zoomLevel, activeProject?.fps]
+ [duration, zoomLevel]
);
- /**
- * Handle mouse down event on the ruler:
- * - Starts scrubbing and updates playhead immediately
- * - Registers mousemove/mouseup events to allow scrubbing
- * - Updates time as mouse moves and seeks to new time
- * - Cleans up listeners and triggers re-render when scrubbing ends
- */
+ // Ruler mouse down handler
const handleRulerMouseDown = useCallback(
(e: React.MouseEvent) => {
+ // Only handle left mouse button
if (e.button !== 0) return;
- e.preventDefault();
- const time = getTimeFromMouse(e);
- isScrubbingRef.current = true;
- scrubTimeRef.current = time;
- seek(time);
+ // Don't interfere if clicking on the playhead itself
+ if (playheadRef?.current?.contains(e.target as Node)) return;
- // Mouse move handler: update playhead and seek to new time
- const onMouseMove = (e: MouseEvent) => {
- const t = getTimeFromMouse(e);
- if (t !== scrubTimeRef.current) {
- scrubTimeRef.current = t;
- seek(t);
- }
- };
+ e.preventDefault();
+ setIsDraggingRuler(true);
+ setHasDraggedRuler(false);
- // Mouse up handler: stop scrubbing, cleanup, and force re-render
- const onMouseUp = () => {
- isScrubbingRef.current = false;
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- forceRerender((v) => v + 1);
- };
+ // Start scrubbing immediately
+ setIsScrubbing(true);
+ handleScrub(e);
+ },
+ [duration, zoomLevel]
+ );
- // Attach listeners to window for drag outside the ruler area
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
+ const handleScrub = useCallback(
+ (e: MouseEvent | React.MouseEvent) => {
+ const ruler = rulerRef.current;
+ if (!ruler) return;
+ const rect = ruler.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
+ // Use frame snapping for playhead scrubbing
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const time = snapTimeToFrame(rawTime, projectFps);
+ setScrubTime(time);
+ seek(time); // update video preview in real time
},
- [getTimeFromMouse, seek]
+ [duration, zoomLevel, seek, rulerRef]
);
+ // Mouse move/up event handlers
+ useEffect(() => {
+ if (!isScrubbing) return;
+ const onMouseMove = (e: MouseEvent) => {
+ handleScrub(e);
+ // Mark that we've dragged if ruler drag is active
+ if (isDraggingRuler) {
+ setHasDraggedRuler(true);
+ }
+ };
+ const onMouseUp = (e: MouseEvent) => {
+ setIsScrubbing(false);
+ if (scrubTime !== null) seek(scrubTime); // finalize seek
+ setScrubTime(null);
+
+ // Handle ruler click vs drag
+ if (isDraggingRuler) {
+ setIsDraggingRuler(false);
+ // If we didn't drag, treat it as a click-to-seek
+ if (!hasDraggedRuler) {
+ handleScrub(e);
+ }
+ setHasDraggedRuler(false);
+ }
+ };
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [
+ isScrubbing,
+ scrubTime,
+ seek,
+ handleScrub,
+ isDraggingRuler,
+ hasDraggedRuler,
+ ]);
+
+ // --- Playhead auto-scroll effect ---
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!rulerViewport || !tracksViewport) return;
+ const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMin = 0;
+ const scrollMax = rulerViewport.scrollWidth - viewportWidth;
+ // Center the playhead if it's not visible (100px buffer)
+ const desiredScroll = Math.max(
+ scrollMin,
+ Math.min(scrollMax, playheadPx - viewportWidth / 2)
+ );
+ if (
+ playheadPx < rulerViewport.scrollLeft + 100 ||
+ playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
+ ) {
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
+
return {
- playheadPosition, // Current playhead position (real-time while scrubbing)
- handleRulerMouseDown, // Attach to the ruler's onMouseDown
+ playheadPosition,
+ handlePlayheadMouseDown,
+ handleRulerMouseDown,
+ isDraggingRuler,
};
}
diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts index c8780f9a..ce32c9f0 100644 --- a/apps/web/src/hooks/use-timeline-zoom.ts +++ b/apps/web/src/hooks/use-timeline-zoom.ts @@ -1,5 +1,4 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useState, useCallback, RefObject, useMemo } from "react";
+import { useState, useCallback, useEffect, RefObject } from "react";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
@@ -8,86 +7,48 @@ interface UseTimelineZoomProps { interface UseTimelineZoomReturn {
zoomLevel: number;
- zoomStep: number;
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
- handleChangeZoomStep: (zoomStep: number) => void;
- handleChangeZoomLevel: (zoomStep: number) => void;
handleWheel: (e: React.WheelEvent) => void;
}
-/**
- * useTimelineZoom
- *
- * Custom hook to manage zoom logic for a timeline component.
- * Handles zoom state, step calculation, level changes, and mouse wheel zooming (with ctrl/meta).
- */
-export function useTimelineZoom(): UseTimelineZoomReturn {
- // Current zoom level (1 = default)
+export function useTimelineZoom({
+ containerRef,
+ isInTimeline = false,
+}: UseTimelineZoomProps): UseTimelineZoomReturn {
const [zoomLevel, setZoomLevel] = useState(1);
- /**
- * Calculate the current zoom step based on the zoom level and a base step constant.
- * Ensures minimum step is 1 (prevents zero or negative step).
- */
- const zoomStep = useMemo(
- () =>
- Math.max(1, Math.round(zoomLevel / TIMELINE_CONSTANTS.ZOOM_STEP_BASE)),
- [zoomLevel]
- );
-
- /**
- * Update the zoom level using a given step.
- * The zoom level is clamped to a minimum value defined in constants.
- */
- const handleChangeZoomStep = useCallback((newStep: number) => {
- setZoomLevel(
- Math.max(
- TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN,
- newStep * TIMELINE_CONSTANTS.ZOOM_STEP_BASE
- )
- );
- }, []);
-
- /**
- * Update the zoom level directly.
- * The new value is clamped between the defined minimum and maximum levels.
- */
- const handleChangeZoomLevel = useCallback((newLevel: number) => {
- setZoomLevel(
- Math.max(
- TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN,
- Math.min(TIMELINE_CONSTANTS.ZOOM_LEVEL_MAX, newLevel)
- )
- );
- }, []);
-
- /**
- * Handle zooming using mouse wheel + ctrl/meta key (like browser zoom).
- * Prevents default browser behavior and updates zoom level accordingly.
- */
const handleWheel = useCallback((e: React.WheelEvent) => {
+ // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
if (e.ctrlKey || e.metaKey) {
- e.preventDefault?.();
- const delta =
- e.deltaY > 0
- ? -TIMELINE_CONSTANTS.ZOOM_STEP_BASE
- : TIMELINE_CONSTANTS.ZOOM_STEP_BASE;
- setZoomLevel((prev) =>
- Math.max(
- TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN,
- Math.min(TIMELINE_CONSTANTS.ZOOM_LEVEL_MAX, prev + delta)
- )
- );
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? -0.15 : 0.15;
+ setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
}
+ // Otherwise, allow normal scrolling
}, []);
- // Return all handlers and state values for use in timeline components
+ // Prevent browser zooming in/out when in timeline
+ useEffect(() => {
+ const preventZoom = (e: WheelEvent) => {
+ if (
+ isInTimeline &&
+ (e.ctrlKey || e.metaKey) &&
+ containerRef.current?.contains(e.target as Node)
+ ) {
+ e.preventDefault();
+ }
+ };
+
+ document.addEventListener("wheel", preventZoom, { passive: false });
+
+ return () => {
+ document.removeEventListener("wheel", preventZoom);
+ };
+ }, [isInTimeline, containerRef]);
+
return {
- zoomLevel, // Current zoom level
- zoomStep, // Current zoom step (calculated from zoomLevel)
- setZoomLevel, // Directly set the zoom level (accepts value or updater function)
- handleChangeZoomStep, // Change zoom by step value
- handleChangeZoomLevel, // Change zoom by level value
- handleWheel, // Handler for mouse wheel zooming
+ zoomLevel,
+ setZoomLevel,
+ handleWheel,
};
}
|
