summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks')
-rw-r--r--apps/web/src/hooks/actions/use-action-handler.ts52
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts312
-rw-r--r--apps/web/src/hooks/auth/useLogin.ts60
-rw-r--r--apps/web/src/hooks/auth/useSignUp.ts65
-rw-r--r--apps/web/src/hooks/storage/use-local-storage.ts58
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts619
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts331
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-selection.ts114
-rw-r--r--apps/web/src/hooks/timeline/use-edge-auto-scroll.ts100
-rw-r--r--apps/web/src/hooks/timeline/use-scroll-sync.ts173
-rw-r--r--apps/web/src/hooks/timeline/use-selection-box.ts243
-rw-r--r--apps/web/src/hooks/timeline/use-snap-indicator-position.ts62
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts460
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts218
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-seek.ts197
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-snapping.ts159
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts224
-rw-r--r--apps/web/src/hooks/use-aspect-ratio.ts97
-rw-r--r--apps/web/src/hooks/use-drag-drop.ts85
-rw-r--r--apps/web/src/hooks/use-edge-auto-scroll.ts101
-rw-r--r--apps/web/src/hooks/use-editor-actions.ts241
-rw-r--r--apps/web/src/hooks/use-editor.ts39
-rw-r--r--apps/web/src/hooks/use-file-upload.ts103
-rw-r--r--apps/web/src/hooks/use-frame-cache.ts348
-rw-r--r--apps/web/src/hooks/use-highlight-scroll.ts36
-rw-r--r--apps/web/src/hooks/use-infinite-scroll.ts46
-rw-r--r--apps/web/src/hooks/use-keybinding-conflicts.ts59
-rw-r--r--apps/web/src/hooks/use-keybindings.ts102
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts189
-rw-r--r--apps/web/src/hooks/use-mobile.ts19
-rw-r--r--apps/web/src/hooks/use-mobile.tsx21
-rw-r--r--apps/web/src/hooks/use-playback-controls.ts109
-rw-r--r--apps/web/src/hooks/use-raf-loop.ts20
-rw-r--r--apps/web/src/hooks/use-reveal-item.ts36
-rw-r--r--apps/web/src/hooks/use-selection-box.ts215
-rw-r--r--apps/web/src/hooks/use-sound-search.ts270
-rw-r--r--apps/web/src/hooks/use-timeline-element-resize.ts257
-rw-r--r--apps/web/src/hooks/use-timeline-playhead.ts218
-rw-r--r--apps/web/src/hooks/use-timeline-snapping.ts178
-rw-r--r--apps/web/src/hooks/use-timeline-zoom.ts60
-rw-r--r--apps/web/src/hooks/use-toast.ts191
41 files changed, 3813 insertions, 2674 deletions
diff --git a/apps/web/src/hooks/actions/use-action-handler.ts b/apps/web/src/hooks/actions/use-action-handler.ts
new file mode 100644
index 00000000..3f6dbd21
--- /dev/null
+++ b/apps/web/src/hooks/actions/use-action-handler.ts
@@ -0,0 +1,52 @@
+import { useCallback, useEffect, useRef } from "react";
+import type {
+ TAction,
+ TActionFunc,
+ TActionHandlerOptions,
+ TArgOfAction,
+ TInvocationTrigger,
+} from "@/lib/actions";
+import { bindAction, unbindAction } from "@/lib/actions";
+
+export function useActionHandler<A extends TAction>(
+ action: A,
+ handler: TActionFunc<A>,
+ isActive: TActionHandlerOptions,
+) {
+ const handlerRef = useRef<TActionFunc<A>>(handler);
+ const isBoundRef = useRef(false);
+
+ useEffect(() => {
+ handlerRef.current = handler;
+ }, [handler]);
+
+ const stableHandler = useCallback(
+ (...parameters: [TArgOfAction<A>, TInvocationTrigger?]) => {
+ (
+ handlerRef.current as (
+ ...handlerParameters: [TArgOfAction<A>, TInvocationTrigger?]
+ ) => void
+ )(...parameters);
+ },
+ [],
+ ) as TActionFunc<A>;
+
+ useEffect(() => {
+ const shouldBind =
+ isActive === undefined ||
+ (typeof isActive === "boolean" ? isActive : isActive.current);
+
+ if (shouldBind && !isBoundRef.current) {
+ bindAction(action, stableHandler);
+ isBoundRef.current = true;
+ } else if (!shouldBind && isBoundRef.current) {
+ unbindAction(action, stableHandler);
+ isBoundRef.current = false;
+ }
+
+ return () => {
+ unbindAction(action, stableHandler);
+ isBoundRef.current = false;
+ };
+ }, [action, stableHandler, isActive]);
+}
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
new file mode 100644
index 00000000..d1ff6c92
--- /dev/null
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -0,0 +1,312 @@
+"use client";
+
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useActionHandler } from "@/hooks/actions/use-action-handler";
+import { useEditor } from "../use-editor";
+import { useElementSelection } from "../timeline/element/use-element-selection";
+import { getElementsAtTime } from "@/lib/timeline";
+
+export function useEditorActions() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const { selectedElements, setElementSelection } = useElementSelection();
+ const { clipboard, setClipboard, toggleSnapping } = useTimelineStore();
+
+ useActionHandler(
+ "toggle-play",
+ () => {
+ editor.playback.toggle();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "stop-playback",
+ () => {
+ if (editor.playback.getIsPlaying()) {
+ editor.playback.toggle();
+ }
+ editor.playback.seek({ time: 0 });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "seek-forward",
+ (args) => {
+ const seconds = args?.seconds ?? 1;
+ editor.playback.seek({
+ time: Math.min(
+ editor.timeline.getTotalDuration(),
+ editor.playback.getCurrentTime() + seconds,
+ ),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "seek-backward",
+ (args) => {
+ const seconds = args?.seconds ?? 1;
+ editor.playback.seek({
+ time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "frame-step-forward",
+ () => {
+ const fps = activeProject.settings.fps;
+ editor.playback.seek({
+ time: Math.min(
+ editor.timeline.getTotalDuration(),
+ editor.playback.getCurrentTime() + 1 / fps,
+ ),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "frame-step-backward",
+ () => {
+ const fps = activeProject.settings.fps;
+ editor.playback.seek({
+ time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "jump-forward",
+ (args) => {
+ const seconds = args?.seconds ?? 5;
+ editor.playback.seek({
+ time: Math.min(
+ editor.timeline.getTotalDuration(),
+ editor.playback.getCurrentTime() + seconds,
+ ),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "jump-backward",
+ (args) => {
+ const seconds = args?.seconds ?? 5;
+ editor.playback.seek({
+ time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "goto-start",
+ () => {
+ editor.playback.seek({ time: 0 });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "goto-end",
+ () => {
+ editor.playback.seek({ time: editor.timeline.getTotalDuration() });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "split",
+ () => {
+ const currentTime = editor.playback.getCurrentTime();
+ const elementsToSplit =
+ selectedElements.length > 0
+ ? selectedElements
+ : getElementsAtTime({
+ tracks: editor.timeline.getTracks(),
+ time: currentTime,
+ });
+
+ if (elementsToSplit.length === 0) return;
+
+ editor.timeline.splitElements({
+ elements: elementsToSplit,
+ splitTime: currentTime,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "split-left",
+ () => {
+ const currentTime = editor.playback.getCurrentTime();
+ const elementsToSplit =
+ selectedElements.length > 0
+ ? selectedElements
+ : getElementsAtTime({
+ tracks: editor.timeline.getTracks(),
+ time: currentTime,
+ });
+
+ if (elementsToSplit.length === 0) return;
+
+ editor.timeline.splitElements({
+ elements: elementsToSplit,
+ splitTime: currentTime,
+ retainSide: "right",
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "split-right",
+ () => {
+ const currentTime = editor.playback.getCurrentTime();
+ const elementsToSplit =
+ selectedElements.length > 0
+ ? selectedElements
+ : getElementsAtTime({
+ tracks: editor.timeline.getTracks(),
+ time: currentTime,
+ });
+
+ if (elementsToSplit.length === 0) return;
+
+ editor.timeline.splitElements({
+ elements: elementsToSplit,
+ splitTime: currentTime,
+ retainSide: "left",
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "delete-selected",
+ () => {
+ if (selectedElements.length === 0) {
+ return;
+ }
+ editor.timeline.deleteElements({
+ elements: selectedElements,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "select-all",
+ () => {
+ const allElements = editor.timeline.getTracks().flatMap((track) =>
+ track.elements.map((element) => ({
+ trackId: track.id,
+ elementId: element.id,
+ })),
+ );
+ setElementSelection({ elements: allElements });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "duplicate-selected",
+ () => {
+ editor.timeline.duplicateElements({
+ elements: selectedElements,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-elements-muted-selected",
+ () => {
+ editor.timeline.toggleElementsMuted({ elements: selectedElements });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-elements-visibility-selected",
+ () => {
+ editor.timeline.toggleElementsVisibility({ elements: selectedElements });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-bookmark",
+ () => {
+ editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "copy-selected",
+ () => {
+ if (selectedElements.length === 0) return;
+
+ const results = editor.timeline.getElementsWithTracks({
+ elements: selectedElements,
+ });
+ const items = results.map(({ track, element }) => {
+ const { ...elementWithoutId } = element;
+ return {
+ trackId: track.id,
+ trackType: track.type,
+ element: elementWithoutId,
+ };
+ });
+
+ setClipboard({ items });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "paste-copied",
+ () => {
+ if (!clipboard?.items.length) return;
+
+ editor.timeline.pasteAtTime({
+ time: editor.playback.getCurrentTime(),
+ clipboardItems: clipboard.items,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "toggle-snapping",
+ () => {
+ toggleSnapping();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "undo",
+ () => {
+ editor.command.undo();
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "redo",
+ () => {
+ editor.command.redo();
+ },
+ undefined,
+ );
+}
diff --git a/apps/web/src/hooks/auth/useLogin.ts b/apps/web/src/hooks/auth/useLogin.ts
deleted file mode 100644
index b2c5ef98..00000000
--- a/apps/web/src/hooks/auth/useLogin.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useCallback, useState } from "react";
-import { useRouter } from "next/navigation";
-import { signIn } from "@opencut/auth/client";
-
-export function useLogin() {
- const router = useRouter();
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
- const [error, setError] = useState<string | null>(null);
- const [isEmailLoading, setIsEmailLoading] = useState(false);
- const [isGoogleLoading, setIsGoogleLoading] = useState(false);
-
- const handleLogin = useCallback(async () => {
- setError(null);
- setIsEmailLoading(true);
-
- const { error } = await signIn.email({
- email,
- password,
- });
-
- if (error) {
- setError(error.message || "An unexpected error occurred.");
- setIsEmailLoading(false);
- return;
- }
-
- router.push("/projects");
- }, [router, email, password]);
-
- const handleGoogleLogin = async () => {
- setError(null);
- setIsGoogleLoading(true);
-
- try {
- await signIn.social({
- provider: "google",
- callbackURL: "/projects",
- });
- } catch (error) {
- setError("Failed to sign in with Google. Please try again.");
- setIsGoogleLoading(false);
- }
- };
-
- const isAnyLoading = isEmailLoading || isGoogleLoading;
-
- return {
- email,
- setEmail,
- password,
- setPassword,
- error,
- isEmailLoading,
- isGoogleLoading,
- isAnyLoading,
- handleLogin,
- handleGoogleLogin,
- };
-}
diff --git a/apps/web/src/hooks/auth/useSignUp.ts b/apps/web/src/hooks/auth/useSignUp.ts
deleted file mode 100644
index b6039f85..00000000
--- a/apps/web/src/hooks/auth/useSignUp.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { useState, useCallback } from "react";
-import { useRouter } from "next/navigation";
-import { signUp, signIn } from "@opencut/auth/client";
-
-export function useSignUp() {
- const router = useRouter();
- const [name, setName] = useState("");
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
- const [error, setError] = useState<string | null>(null);
- const [isEmailLoading, setIsEmailLoading] = useState(false);
- const [isGoogleLoading, setIsGoogleLoading] = useState(false);
-
- const handleSignUp = useCallback(async () => {
- setError(null);
- setIsEmailLoading(true);
-
- const { error } = await signUp.email({
- name,
- email,
- password,
- });
-
- if (error) {
- setError(error.message || "An unexpected error occurred.");
- setIsEmailLoading(false);
- return;
- }
-
- router.push("/login");
- }, [name, email, password, router]);
-
- const handleGoogleSignUp = useCallback(async () => {
- setError(null);
- setIsGoogleLoading(true);
-
- try {
- await signIn.social({
- provider: "google",
- });
-
- router.push("/editor");
- } catch (error) {
- setError("Failed to sign up with Google. Please try again.");
- setIsGoogleLoading(false);
- }
- }, [router]);
-
- const isAnyLoading = isEmailLoading || isGoogleLoading;
-
- return {
- name,
- setName,
- email,
- setEmail,
- password,
- setPassword,
- error,
- isEmailLoading,
- isGoogleLoading,
- isAnyLoading,
- handleSignUp,
- handleGoogleSignUp,
- };
-}
diff --git a/apps/web/src/hooks/storage/use-local-storage.ts b/apps/web/src/hooks/storage/use-local-storage.ts
new file mode 100644
index 00000000..d0188ab3
--- /dev/null
+++ b/apps/web/src/hooks/storage/use-local-storage.ts
@@ -0,0 +1,58 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+
+export function useLocalStorage<T>({
+ key,
+ defaultValue,
+}: {
+ key: string;
+ defaultValue: T;
+}): [
+ T,
+ ({ value }: { value: T | ((previousValue: T) => T) }) => void,
+ boolean,
+] {
+ const [value, setValue] = useState<T>(defaultValue);
+ const [isReady, setIsReady] = useState(false);
+ const valueRef = useRef(defaultValue);
+
+ // avoid hydration mismatch by reading after mount
+ useEffect(() => {
+ try {
+ const storedValue = localStorage.getItem(key);
+ if (storedValue !== null) {
+ const parsedValue = JSON.parse(storedValue) as T;
+ valueRef.current = parsedValue;
+ setValue(parsedValue);
+ }
+ } catch {
+ // localstorage might be unavailable
+ }
+ setIsReady(true);
+ }, [key]);
+
+ // sync to localstorage after hydration
+ useEffect(() => {
+ if (!isReady) return;
+
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ // localstorage might be full or disabled
+ }
+ }, [key, value, isReady]);
+
+ const setValueWithCallback = useCallback(
+ ({ value: nextValue }: { value: T | ((previousValue: T) => T) }) => {
+ const resolvedValue =
+ typeof nextValue === "function"
+ ? (nextValue as (previousValue: T) => T)(valueRef.current)
+ : nextValue;
+
+ valueRef.current = resolvedValue;
+ setValue(resolvedValue);
+ },
+ [],
+ );
+
+ return [value, setValueWithCallback, isReady];
+}
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
new file mode 100644
index 00000000..c92d6722
--- /dev/null
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -0,0 +1,619 @@
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type MouseEvent as ReactMouseEvent,
+ type RefObject,
+} from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { snapTimeToFrame } from "@/lib/time";
+import { computeDropTarget } from "@/lib/timeline/drop-utils";
+import { generateUUID } from "@/utils/id";
+import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
+import type {
+ DropTarget,
+ ElementDragState,
+ TimelineElement,
+ TimelineTrack,
+} from "@/types/timeline";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+
+const DRAG_THRESHOLD_PX = 5;
+
+interface UseElementInteractionProps {
+ zoomLevel: number;
+ timelineRef: RefObject<HTMLDivElement | null>;
+ tracksContainerRef: RefObject<HTMLDivElement | null>;
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ snappingEnabled: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}
+
+const initialDragState: ElementDragState = {
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startMouseX: 0,
+ startMouseY: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ currentMouseY: 0,
+};
+
+interface PendingDragState {
+ elementId: string;
+ trackId: string;
+ startMouseX: number;
+ startMouseY: number;
+ startElementTime: number;
+ clickOffsetTime: number;
+}
+
+function getMouseTimeFromClientX({
+ clientX,
+ containerRect,
+ zoomLevel,
+ scrollLeft,
+}: {
+ clientX: number;
+ containerRect: DOMRect;
+ zoomLevel: number;
+ scrollLeft: number;
+}): number {
+ const mouseX = clientX - containerRect.left + scrollLeft;
+ return Math.max(
+ 0,
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ );
+}
+
+function getClickOffsetTime({
+ clientX,
+ elementRect,
+ zoomLevel,
+}: {
+ clientX: number;
+ elementRect: DOMRect;
+ zoomLevel: number;
+}): number {
+ const clickOffsetX = clientX - elementRect.left;
+ return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+}
+
+function getVerticalDragDirection({
+ startMouseY,
+ currentMouseY,
+}: {
+ startMouseY: number;
+ currentMouseY: number;
+}): "up" | "down" | null {
+ if (currentMouseY < startMouseY) return "up";
+ if (currentMouseY > startMouseY) return "down";
+ return null;
+}
+
+function getDragDropTarget({
+ clientX,
+ clientY,
+ elementId,
+ trackId,
+ tracks,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ zoomLevel,
+ snappedTime,
+ verticalDragDirection,
+}: {
+ clientX: number;
+ clientY: number;
+ elementId: string;
+ trackId: string;
+ tracks: TimelineTrack[];
+ tracksContainerRef: RefObject<HTMLDivElement | null>;
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ zoomLevel: number;
+ snappedTime: number;
+ verticalDragDirection?: "up" | "down" | null;
+}): DropTarget | null {
+ const containerRect = tracksContainerRef.current?.getBoundingClientRect();
+ const scrollContainer = tracksScrollRef.current;
+ if (!containerRect || !scrollContainer) return null;
+
+ const sourceTrack = tracks.find(({ id }) => id === trackId);
+ const movingElement = sourceTrack?.elements.find(
+ ({ id }) => id === elementId,
+ );
+ if (!movingElement) return null;
+
+ const elementDuration = movingElement.duration;
+ const scrollLeft = scrollContainer.scrollLeft;
+ const scrollTop = scrollContainer.scrollTop;
+ const scrollContainerRect = scrollContainer.getBoundingClientRect();
+ const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const mouseX = clientX - scrollContainerRect.left + scrollLeft;
+ const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight;
+
+ return computeDropTarget({
+ elementType: movingElement.type,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime: snappedTime,
+ isExternalDrop: false,
+ elementDuration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ startTimeOverride: snappedTime,
+ excludeElementId: movingElement.id,
+ verticalDragDirection,
+ });
+}
+
+interface StartDragParams
+ extends Omit<
+ ElementDragState,
+ "isDragging" | "currentTime" | "currentMouseY"
+ > {
+ initialCurrentTime: number;
+ initialCurrentMouseY: number;
+}
+
+export function useElementInteraction({
+ zoomLevel,
+ timelineRef,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ snappingEnabled,
+ onSnapPointChange,
+}: UseElementInteractionProps) {
+ const editor = useEditor();
+ const tracks = editor.timeline.getTracks();
+ const { snapElementEdge } = useTimelineSnapping();
+ const {
+ isElementSelected,
+ selectElement,
+ handleElementClick: handleSelectionClick,
+ } = useElementSelection();
+
+ const [dragState, setDragState] =
+ useState<ElementDragState>(initialDragState);
+ const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
+ const [isPendingDrag, setIsPendingDrag] = useState(false);
+ const pendingDragRef = useRef<PendingDragState | null>(null);
+ const lastMouseXRef = useRef(0);
+ const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
+
+ const startDrag = useCallback(
+ ({
+ elementId,
+ trackId,
+ startMouseX,
+ startMouseY,
+ startElementTime,
+ clickOffsetTime,
+ initialCurrentTime,
+ initialCurrentMouseY,
+ }: StartDragParams) => {
+ setDragState({
+ isDragging: true,
+ elementId,
+ trackId,
+ startMouseX,
+ startMouseY,
+ startElementTime,
+ clickOffsetTime,
+ currentTime: initialCurrentTime,
+ currentMouseY: initialCurrentMouseY,
+ });
+ },
+ [],
+ );
+
+ const endDrag = useCallback(() => {
+ setDragState(initialDragState);
+ setDragDropTarget(null);
+ }, []);
+
+ const getDragSnapResult = useCallback(
+ ({
+ frameSnappedTime,
+ movingElement,
+ }: {
+ frameSnappedTime: number;
+ movingElement: TimelineElement | null | undefined;
+ }) => {
+ if (!snappingEnabled || !movingElement) {
+ return { snappedTime: frameSnappedTime, snapPoint: null };
+ }
+
+ const elementDuration = movingElement.duration;
+ const playheadTime = editor.playback.getCurrentTime();
+
+ const startSnap = snapElementEdge({
+ targetTime: frameSnappedTime,
+ elementDuration,
+ tracks,
+ playheadTime,
+ zoomLevel,
+ excludeElementId: movingElement.id,
+ snapToStart: true,
+ });
+
+ const endSnap = snapElementEdge({
+ targetTime: frameSnappedTime,
+ elementDuration,
+ tracks,
+ playheadTime,
+ zoomLevel,
+ excludeElementId: movingElement.id,
+ snapToStart: false,
+ });
+
+ const snapResult =
+ startSnap.snapDistance <= endSnap.snapDistance ? startSnap : endSnap;
+ if (!snapResult.snapPoint) {
+ return { snappedTime: frameSnappedTime, snapPoint: null };
+ }
+
+ return {
+ snappedTime: snapResult.snappedTime,
+ snapPoint: snapResult.snapPoint,
+ };
+ },
+ [snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
+ let startedDragThisEvent = false;
+ const timeline = timelineRef.current;
+ const scrollContainer = tracksScrollRef.current;
+ if (!timeline || !scrollContainer) return;
+ lastMouseXRef.current = clientX;
+
+ if (isPendingDrag && pendingDragRef.current) {
+ const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
+ const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
+ if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const adjustedTime = Math.max(
+ 0,
+ mouseTime - pendingDragRef.current.clickOffsetTime,
+ );
+ const snappedTime = snapTimeToFrame({
+ time: adjustedTime,
+ fps: activeProject.settings.fps,
+ });
+ startDrag({
+ ...pendingDragRef.current,
+ initialCurrentTime: snappedTime,
+ initialCurrentMouseY: clientY,
+ });
+ startedDragThisEvent = true;
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ } else {
+ return;
+ }
+ }
+
+ if (startedDragThisEvent) {
+ return;
+ }
+
+ if (dragState.elementId && dragState.trackId) {
+ const alreadySelected = isElementSelected({
+ trackId: dragState.trackId,
+ elementId: dragState.elementId,
+ });
+ if (!alreadySelected) {
+ selectElement({
+ trackId: dragState.trackId,
+ elementId: dragState.elementId,
+ });
+ }
+ }
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
+ const fps = activeProject.settings.fps;
+ const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps });
+
+ const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ ({ id }) => id === dragState.elementId,
+ );
+ const { snappedTime, snapPoint } = getDragSnapResult({
+ frameSnappedTime,
+ movingElement,
+ });
+ setDragState((previousDragState) => ({
+ ...previousDragState,
+ currentTime: snappedTime,
+ currentMouseY: clientY,
+ }));
+ onSnapPointChange?.(snapPoint);
+
+ if (dragState.elementId && dragState.trackId) {
+ const verticalDragDirection = getVerticalDragDirection({
+ startMouseY: dragState.startMouseY,
+ currentMouseY: clientY,
+ });
+ const dropTarget = getDragDropTarget({
+ clientX,
+ clientY,
+ elementId: dragState.elementId,
+ trackId: dragState.trackId,
+ tracks,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ zoomLevel,
+ snappedTime,
+ verticalDragDirection,
+ });
+ setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null);
+ }
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ return () => document.removeEventListener("mousemove", handleMouseMove);
+ }, [
+ dragState.isDragging,
+ dragState.clickOffsetTime,
+ dragState.elementId,
+ dragState.startMouseY,
+ dragState.trackId,
+ zoomLevel,
+ isElementSelected,
+ selectElement,
+ editor.project,
+ timelineRef,
+ tracksScrollRef,
+ tracksContainerRef,
+ headerRef,
+ tracks,
+ isPendingDrag,
+ startDrag,
+ getDragSnapResult,
+ onSnapPointChange,
+ ]);
+
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
+ if (!dragState.elementId || !dragState.trackId) return;
+
+ if (mouseDownLocationRef.current) {
+ const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
+ const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
+ if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
+ mouseDownLocationRef.current = null;
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+ }
+
+ const dropTarget = getDragDropTarget({
+ clientX,
+ clientY,
+ elementId: dragState.elementId,
+ trackId: dragState.trackId,
+ tracks,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ zoomLevel,
+ snappedTime: dragState.currentTime,
+ verticalDragDirection: getVerticalDragDirection({
+ startMouseY: dragState.startMouseY,
+ currentMouseY: clientY,
+ }),
+ });
+ if (!dropTarget) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+ const snappedTime = dragState.currentTime;
+
+ const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
+ if (!sourceTrack) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+
+ if (dropTarget.isNewTrack) {
+ const newTrackId = generateUUID();
+
+ editor.timeline.moveElement({
+ sourceTrackId: dragState.trackId,
+ targetTrackId: newTrackId,
+ elementId: dragState.elementId,
+ newStartTime: snappedTime,
+ createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
+ });
+ } else {
+ const targetTrack = tracks[dropTarget.trackIndex];
+ if (targetTrack) {
+ editor.timeline.moveElement({
+ sourceTrackId: dragState.trackId,
+ targetTrackId: targetTrack.id,
+ elementId: dragState.elementId,
+ newStartTime: snappedTime,
+ });
+ }
+ }
+
+ endDrag();
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ dragState.isDragging,
+ dragState.elementId,
+ dragState.startMouseY,
+ dragState.trackId,
+ dragState.currentTime,
+ zoomLevel,
+ tracks,
+ endDrag,
+ onSnapPointChange,
+ editor.timeline,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ ]);
+
+ useEffect(() => {
+ if (!isPendingDrag) return;
+
+ const handleMouseUp = () => {
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [isPendingDrag, onSnapPointChange]);
+
+ const handleElementMouseDown = useCallback(
+ ({
+ event,
+ element,
+ track,
+ }: {
+ event: ReactMouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ }) => {
+ const isRightClick = event.button === 2;
+
+ // right-click: don't stop propagation so ContextMenu can open
+ if (isRightClick) {
+ const alreadySelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ if (!alreadySelected) {
+ handleSelectionClick({
+ trackId: track.id,
+ elementId: element.id,
+ isMultiKey: false,
+ });
+ }
+ return;
+ }
+
+ // left-click: stop propagation for drag operations
+ event.stopPropagation();
+ mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
+
+ const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
+
+ // multi-select: toggle selection
+ if (isMultiSelect) {
+ handleSelectionClick({
+ trackId: track.id,
+ elementId: element.id,
+ isMultiKey: true,
+ });
+ }
+
+ // start drag
+ const clickOffsetTime = getClickOffsetTime({
+ clientX: event.clientX,
+ elementRect: event.currentTarget.getBoundingClientRect(),
+ zoomLevel,
+ });
+ pendingDragRef.current = {
+ elementId: element.id,
+ trackId: track.id,
+ startMouseX: event.clientX,
+ startMouseY: event.clientY,
+ startElementTime: element.startTime,
+ clickOffsetTime,
+ };
+ setIsPendingDrag(true);
+ },
+ [zoomLevel, isElementSelected, handleSelectionClick],
+ );
+
+ const handleElementClick = useCallback(
+ ({
+ event,
+ element,
+ track,
+ }: {
+ event: ReactMouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ }) => {
+ event.stopPropagation();
+
+ // was it a drag or a click?
+ if (mouseDownLocationRef.current) {
+ const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
+ const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
+ if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
+ mouseDownLocationRef.current = null;
+ return;
+ }
+ }
+
+ // modifier keys already handled in mousedown
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+
+ // single click: select if not selected
+ const alreadySelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ if (!alreadySelected) {
+ selectElement({ trackId: track.id, elementId: element.id });
+ }
+ },
+ [isElementSelected, selectElement],
+ );
+
+ return {
+ dragState,
+ dragDropTarget,
+ handleElementMouseDown,
+ handleElementClick,
+ lastMouseXRef,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
new file mode 100644
index 00000000..803d9788
--- /dev/null
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -0,0 +1,331 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import { snapTimeToFrame } from "@/lib/time";
+import { EditorCore } from "@/core";
+import {
+ useTimelineSnapping,
+ type SnapPoint,
+} from "@/hooks/timeline/use-timeline-snapping";
+import { useTimelineStore } from "@/stores/timeline-store";
+
+export interface ResizeState {
+ elementId: string;
+ side: "left" | "right";
+ startX: number;
+ initialTrimStart: number;
+ initialTrimEnd: number;
+ initialStartTime: number;
+ initialDuration: number;
+}
+
+interface UseTimelineElementResizeProps {
+ element: TimelineElement;
+ track: TimelineTrack;
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+}
+
+export function useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onSnapPointChange,
+ onResizeStateChange,
+}: UseTimelineElementResizeProps) {
+ const editor = EditorCore.getInstance();
+ const activeProject = editor.project.getActive();
+ const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
+ const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
+
+ const [resizing, setResizing] = useState<ResizeState | null>(null);
+ const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
+ const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd);
+ const [currentStartTime, setCurrentStartTime] = useState(element.startTime);
+ const [currentDuration, setCurrentDuration] = useState(element.duration);
+ const currentTrimStartRef = useRef(element.trimStart);
+ const currentTrimEndRef = useRef(element.trimEnd);
+ const currentStartTimeRef = useRef(element.startTime);
+ const currentDurationRef = useRef(element.duration);
+
+ const handleResizeStart = ({
+ e,
+ elementId,
+ side,
+ }: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ setResizing({
+ elementId,
+ side,
+ startX: e.clientX,
+ initialTrimStart: element.trimStart,
+ initialTrimEnd: element.trimEnd,
+ initialStartTime: element.startTime,
+ initialDuration: element.duration,
+ });
+
+ setCurrentTrimStart(element.trimStart);
+ setCurrentTrimEnd(element.trimEnd);
+ setCurrentStartTime(element.startTime);
+ setCurrentDuration(element.duration);
+ currentTrimStartRef.current = element.trimStart;
+ currentTrimEndRef.current = element.trimEnd;
+ currentStartTimeRef.current = element.startTime;
+ currentDurationRef.current = element.duration;
+ onResizeStateChange?.({ isResizing: true });
+ };
+
+ const canExtendElementDuration = useCallback(() => {
+ if (element.type === "text" || element.type === "image") {
+ return true;
+ }
+
+ return false;
+ }, [element.type]);
+
+ const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => {
+ if (!resizing) return;
+
+ const deltaX = clientX - resizing.startX;
+ let deltaTime = deltaX / (50 * zoomLevel);
+ let resizeSnapPoint: SnapPoint | null = null;
+
+ const projectFps = activeProject.settings.fps;
+ const minDurationSeconds = 1 / projectFps;
+ const canSnap = snappingEnabled;
+ if (canSnap) {
+ const tracks = editor.timeline.getTracks();
+ const playheadTime = editor.playback.getCurrentTime();
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId: element.id,
+ });
+ if (resizing.side === "left") {
+ const targetStartTime = resizing.initialStartTime + deltaTime;
+ const snapResult = snapToNearestPoint({
+ targetTime: targetStartTime,
+ snapPoints,
+ zoomLevel,
+ });
+ resizeSnapPoint = snapResult.snapPoint;
+ if (snapResult.snapPoint) {
+ deltaTime = snapResult.snappedTime - resizing.initialStartTime;
+ }
+ } else {
+ const baseEndTime =
+ resizing.initialStartTime + resizing.initialDuration;
+ const targetEndTime = baseEndTime + deltaTime;
+ const snapResult = snapToNearestPoint({
+ targetTime: targetEndTime,
+ snapPoints,
+ zoomLevel,
+ });
+ resizeSnapPoint = snapResult.snapPoint;
+ if (snapResult.snapPoint) {
+ deltaTime = snapResult.snappedTime - baseEndTime;
+ }
+ }
+ }
+ onSnapPointChange?.(resizeSnapPoint);
+
+ if (resizing.side === "left") {
+ const sourceDuration =
+ resizing.initialTrimStart +
+ resizing.initialDuration +
+ resizing.initialTrimEnd;
+ const maxAllowed =
+ sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
+ const calculated = resizing.initialTrimStart + deltaTime;
+
+ if (calculated >= 0 && calculated <= maxAllowed) {
+ const newTrimStart = snapTimeToFrame({
+ time: Math.min(maxAllowed, calculated),
+ fps: projectFps,
+ });
+ const trimDelta = newTrimStart - resizing.initialTrimStart;
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime + trimDelta,
+ fps: projectFps,
+ });
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration - trimDelta,
+ fps: projectFps,
+ });
+
+ setCurrentTrimStart(newTrimStart);
+ setCurrentStartTime(newStartTime);
+ setCurrentDuration(newDuration);
+ currentTrimStartRef.current = newTrimStart;
+ currentStartTimeRef.current = newStartTime;
+ currentDurationRef.current = newDuration;
+ } else if (calculated < 0) {
+ if (canExtendElementDuration()) {
+ const extensionAmount = Math.abs(calculated);
+ const maxExtension = resizing.initialStartTime;
+ const actualExtension = Math.min(extensionAmount, maxExtension);
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime - actualExtension,
+ fps: projectFps,
+ });
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration + actualExtension,
+ fps: projectFps,
+ });
+
+ setCurrentTrimStart(0);
+ setCurrentStartTime(newStartTime);
+ setCurrentDuration(newDuration);
+ currentTrimStartRef.current = 0;
+ currentStartTimeRef.current = newStartTime;
+ currentDurationRef.current = newDuration;
+ } else {
+ const trimDelta = 0 - resizing.initialTrimStart;
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime + trimDelta,
+ fps: projectFps,
+ });
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration - trimDelta,
+ fps: projectFps,
+ });
+
+ setCurrentTrimStart(0);
+ setCurrentStartTime(newStartTime);
+ setCurrentDuration(newDuration);
+ currentTrimStartRef.current = 0;
+ currentStartTimeRef.current = newStartTime;
+ currentDurationRef.current = newDuration;
+ }
+ }
+ } else {
+ const sourceDuration =
+ resizing.initialTrimStart +
+ resizing.initialDuration +
+ resizing.initialTrimEnd;
+ const newTrimEnd = resizing.initialTrimEnd - deltaTime;
+
+ if (newTrimEnd < 0) {
+ if (canExtendElementDuration()) {
+ const extensionNeeded = Math.abs(newTrimEnd);
+ const baseDuration =
+ resizing.initialDuration + resizing.initialTrimEnd;
+ const newDuration = snapTimeToFrame({
+ time: baseDuration + extensionNeeded,
+ fps: projectFps,
+ });
+
+ setCurrentDuration(newDuration);
+ setCurrentTrimEnd(0);
+ currentDurationRef.current = newDuration;
+ currentTrimEndRef.current = 0;
+ } else {
+ const extensionToLimit = resizing.initialTrimEnd;
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration + extensionToLimit,
+ fps: projectFps,
+ });
+
+ setCurrentDuration(newDuration);
+ setCurrentTrimEnd(0);
+ currentDurationRef.current = newDuration;
+ currentTrimEndRef.current = 0;
+ }
+ } else {
+ const maxTrimEnd =
+ sourceDuration - resizing.initialTrimStart - minDurationSeconds;
+ const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
+ const finalTrimEnd = snapTimeToFrame({
+ time: clampedTrimEnd,
+ fps: projectFps,
+ });
+ const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration - trimDelta,
+ fps: projectFps,
+ });
+
+ setCurrentTrimEnd(finalTrimEnd);
+ setCurrentDuration(newDuration);
+ currentTrimEndRef.current = finalTrimEnd;
+ currentDurationRef.current = newDuration;
+ }
+ }
+ }, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]);
+
+ const handleResizeEnd = useCallback(() => {
+ if (!resizing) return;
+
+ const finalTrimStart = currentTrimStartRef.current;
+ const finalTrimEnd = currentTrimEndRef.current;
+ const finalStartTime = currentStartTimeRef.current;
+ const finalDuration = currentDurationRef.current;
+ const trimStartChanged = finalTrimStart !== resizing.initialTrimStart;
+ const trimEndChanged = finalTrimEnd !== resizing.initialTrimEnd;
+ const startTimeChanged = finalStartTime !== resizing.initialStartTime;
+ const durationChanged = finalDuration !== resizing.initialDuration;
+
+ if (trimStartChanged || trimEndChanged) {
+ editor.timeline.updateElementTrim({
+ elementId: element.id,
+ trimStart: finalTrimStart,
+ trimEnd: finalTrimEnd,
+ });
+ }
+
+ if (startTimeChanged) {
+ editor.timeline.updateElementStartTime({
+ elements: [{ trackId: track.id, elementId: element.id }],
+ startTime: finalStartTime,
+ });
+ }
+
+ if (durationChanged) {
+ editor.timeline.updateElementDuration({
+ trackId: track.id,
+ elementId: element.id,
+ duration: finalDuration,
+ });
+ }
+
+ setResizing(null);
+ onResizeStateChange?.({ isResizing: false });
+ onSnapPointChange?.(null);
+ }, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]);
+
+ useEffect(() => {
+ if (!resizing) return;
+
+ const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
+ updateTrimFromMouseMove({ clientX });
+ };
+
+ const handleDocumentMouseUp = () => {
+ handleResizeEnd();
+ };
+
+ document.addEventListener("mousemove", handleDocumentMouseMove);
+ document.addEventListener("mouseup", handleDocumentMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleDocumentMouseMove);
+ document.removeEventListener("mouseup", handleDocumentMouseUp);
+ };
+ }, [resizing, handleResizeEnd, updateTrimFromMouseMove]);
+
+ return {
+ resizing,
+ isResizing: resizing !== null,
+ handleResizeStart,
+ currentTrimStart,
+ currentTrimEnd,
+ currentStartTime,
+ currentDuration,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts
new file mode 100644
index 00000000..38694c8b
--- /dev/null
+++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts
@@ -0,0 +1,114 @@
+import { useCallback, useSyncExternalStore } from "react";
+import { useEditor } from "@/hooks/use-editor";
+
+type ElementRef = { trackId: string; elementId: string };
+
+export function useElementSelection() {
+ const editor = useEditor();
+ const selectedElements = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getSelectedElements(),
+ );
+
+ const isElementSelected = useCallback(
+ ({ trackId, elementId }: ElementRef) =>
+ selectedElements.some(
+ (element) =>
+ element.trackId === trackId && element.elementId === elementId,
+ ),
+ [selectedElements],
+ );
+
+ const selectElement = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
+ },
+ [editor],
+ );
+
+ const addElementToSelection = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ const alreadySelected = selectedElements.some(
+ (element) =>
+ element.trackId === trackId && element.elementId === elementId,
+ );
+ if (alreadySelected) return;
+
+ editor.selection.setSelectedElements({
+ elements: [...selectedElements, { trackId, elementId }],
+ });
+ },
+ [selectedElements, editor],
+ );
+
+ const removeElementFromSelection = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ editor.selection.setSelectedElements({
+ elements: selectedElements.filter(
+ (element) =>
+ !(element.trackId === trackId && element.elementId === elementId),
+ ),
+ });
+ },
+ [selectedElements, editor],
+ );
+
+ const toggleElementSelection = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ const alreadySelected = selectedElements.some(
+ (element) =>
+ element.trackId === trackId && element.elementId === elementId,
+ );
+
+ if (alreadySelected) {
+ removeElementFromSelection({ trackId, elementId });
+ } else {
+ addElementToSelection({ trackId, elementId });
+ }
+ },
+ [selectedElements, addElementToSelection, removeElementFromSelection],
+ );
+
+ const clearElementSelection = useCallback(() => {
+ editor.selection.clearSelection();
+ }, [editor]);
+
+ const setElementSelection = useCallback(
+ ({ elements }: { elements: ElementRef[] }) => {
+ editor.selection.setSelectedElements({ elements });
+ },
+ [editor],
+ );
+
+ /**
+ * Handles click interaction on an element.
+ * - Regular click: select only this element
+ * - Multi-key click (Ctrl/Cmd): toggle this element in selection
+ */
+ const handleElementClick = useCallback(
+ ({
+ trackId,
+ elementId,
+ isMultiKey,
+ }: ElementRef & { isMultiKey: boolean }) => {
+ if (isMultiKey) {
+ toggleElementSelection({ trackId, elementId });
+ } else {
+ selectElement({ trackId, elementId });
+ }
+ },
+ [toggleElementSelection, selectElement],
+ );
+
+ return {
+ selectedElements,
+ isElementSelected,
+ selectElement,
+ setElementSelection,
+ addElementToSelection,
+ removeElementFromSelection,
+ toggleElementSelection,
+ clearElementSelection,
+ handleElementClick,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts
new file mode 100644
index 00000000..0ddab654
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts
@@ -0,0 +1,100 @@
+import { useEffect, useRef } from "react";
+
+interface UseEdgeAutoScrollParams {
+ isActive: boolean;
+ getMouseClientX: () => number;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ contentWidth: number;
+ edgeThreshold?: number;
+ maxScrollSpeed?: number;
+}
+
+export function useEdgeAutoScroll({
+ isActive,
+ getMouseClientX,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth,
+ edgeThreshold = 100,
+ maxScrollSpeed = 15,
+}: UseEdgeAutoScrollParams): void {
+ const rafRef = useRef<number | null>(null);
+
+ useEffect(() => {
+ if (!isActive) {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ return;
+ }
+
+ const step = () => {
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) {
+ rafRef.current = requestAnimationFrame(step);
+ return;
+ }
+
+ const viewportRect = rulerViewport.getBoundingClientRect();
+ const mouseX = getMouseClientX();
+ const mouseXRelative = mouseX - viewportRect.left;
+
+ const viewportWidth = rulerViewport.clientWidth;
+ const intrinsicContentWidth = rulerViewport.scrollWidth;
+ const effectiveContentWidth = Math.max(
+ contentWidth,
+ intrinsicContentWidth,
+ );
+ const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
+
+ let scrollSpeed = 0;
+
+ if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
+ const edgeDistance = Math.max(0, mouseXRelative);
+ const intensity = 1 - edgeDistance / edgeThreshold;
+ scrollSpeed = -maxScrollSpeed * intensity;
+ } else if (
+ mouseXRelative > viewportWidth - edgeThreshold &&
+ rulerViewport.scrollLeft < scrollMax
+ ) {
+ const edgeDistance = Math.max(
+ 0,
+ viewportWidth - edgeThreshold - mouseXRelative,
+ );
+ const intensity = 1 - edgeDistance / edgeThreshold;
+ scrollSpeed = maxScrollSpeed * intensity;
+ }
+
+ if (scrollSpeed !== 0) {
+ const newScrollLeft = Math.max(
+ 0,
+ Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed),
+ );
+ rulerViewport.scrollLeft = newScrollLeft;
+ tracksViewport.scrollLeft = newScrollLeft;
+ }
+
+ rafRef.current = requestAnimationFrame(step);
+ };
+
+ rafRef.current = requestAnimationFrame(step);
+
+ return () => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ };
+ }, [
+ isActive,
+ getMouseClientX,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth,
+ edgeThreshold,
+ maxScrollSpeed,
+ ]);
+}
diff --git a/apps/web/src/hooks/timeline/use-scroll-sync.ts b/apps/web/src/hooks/timeline/use-scroll-sync.ts
new file mode 100644
index 00000000..cdd915b0
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-scroll-sync.ts
@@ -0,0 +1,173 @@
+import { useEffect, useRef } from "react";
+
+interface UseScrollSyncProps {
+ rulerScrollRef?: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
+ bookmarksScrollRef?: React.RefObject<HTMLDivElement>;
+}
+
+export function useScrollSync({
+ tracksScrollRef,
+ rulerScrollRef,
+ trackLabelsScrollRef,
+ bookmarksScrollRef,
+}: UseScrollSyncProps) {
+ const isUpdatingRef = useRef(false);
+ const lastRulerSync = useRef(0);
+ const lastTracksSync = useRef(0);
+ const lastVerticalSync = useRef(0);
+ const lastBookmarksSync = useRef(0);
+
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef?.current ?? null;
+ const tracksViewport = tracksScrollRef.current;
+ const trackLabelsViewport = trackLabelsScrollRef?.current;
+ const bookmarksViewport = bookmarksScrollRef?.current;
+ let handleBookmarksScroll: (() => void) | null = null;
+
+ if (!tracksViewport) return;
+
+ const syncScrollLeft = ({
+ target,
+ scrollLeft,
+ }: {
+ target: HTMLDivElement | null | undefined;
+ scrollLeft: number;
+ }) => {
+ if (target) {
+ target.scrollLeft = scrollLeft;
+ }
+ };
+
+ const handleRulerScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
+ lastRulerSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0;
+ syncScrollLeft({
+ target: bookmarksViewport,
+ scrollLeft: rulerViewport?.scrollLeft ?? 0,
+ });
+ isUpdatingRef.current = false;
+ };
+
+ const handleTracksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
+ lastTracksSync.current = now;
+ isUpdatingRef.current = true;
+ syncScrollLeft({
+ target: rulerViewport,
+ scrollLeft: tracksViewport.scrollLeft,
+ });
+ syncScrollLeft({
+ target: bookmarksViewport,
+ scrollLeft: tracksViewport.scrollLeft,
+ });
+ isUpdatingRef.current = false;
+ };
+
+ if (rulerViewport && rulerViewport !== tracksViewport) {
+ rulerViewport.addEventListener("scroll", handleRulerScroll);
+ }
+ if (tracksViewport !== rulerViewport) {
+ tracksViewport.addEventListener("scroll", handleTracksScroll);
+ }
+
+ if (bookmarksViewport) {
+ handleBookmarksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastBookmarksSync.current < 16)
+ return;
+ lastBookmarksSync.current = now;
+ isUpdatingRef.current = true;
+ const nextScrollLeft = bookmarksViewport.scrollLeft;
+ tracksViewport.scrollLeft = nextScrollLeft;
+ syncScrollLeft({
+ target: rulerViewport,
+ scrollLeft: nextScrollLeft,
+ });
+ isUpdatingRef.current = false;
+ };
+
+ if (bookmarksViewport !== tracksViewport) {
+ bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
+ }
+ }
+
+ if (trackLabelsViewport) {
+ const handleTrackLabelsScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ const handleTracksVerticalScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
+
+ return () => {
+ if (rulerViewport && rulerViewport !== tracksViewport) {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ }
+ if (tracksViewport !== rulerViewport) {
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ }
+ if (
+ bookmarksViewport &&
+ bookmarksViewport !== tracksViewport &&
+ handleBookmarksScroll
+ ) {
+ bookmarksViewport.removeEventListener(
+ "scroll",
+ handleBookmarksScroll,
+ );
+ }
+ trackLabelsViewport.removeEventListener(
+ "scroll",
+ handleTrackLabelsScroll,
+ );
+ tracksViewport.removeEventListener(
+ "scroll",
+ handleTracksVerticalScroll,
+ );
+ };
+ }
+
+ return () => {
+ if (rulerViewport && rulerViewport !== tracksViewport) {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ }
+ if (tracksViewport !== rulerViewport) {
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ }
+ if (
+ bookmarksViewport &&
+ bookmarksViewport !== tracksViewport &&
+ handleBookmarksScroll
+ ) {
+ bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
+ }
+ };
+ }, [
+ rulerScrollRef,
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ bookmarksScrollRef,
+ ]);
+}
diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts
new file mode 100644
index 00000000..702abad9
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-selection-box.ts
@@ -0,0 +1,243 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
+import { useEditor } from "../use-editor";
+
+interface UseSelectionBoxProps {
+ containerRef: React.RefObject<HTMLElement>;
+ onSelectionComplete: (
+ elements: { trackId: string; elementId: string }[],
+ ) => void;
+ isEnabled?: boolean;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ zoomLevel: number;
+}
+
+interface SelectionBoxState {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ isActive: boolean;
+}
+
+interface SelectionRectangle {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+function getNormalizedRectangle({
+ startPos,
+ endPos,
+}: {
+ startPos: { x: number; y: number };
+ endPos: { x: number; y: number };
+}): SelectionRectangle {
+ return {
+ left: Math.min(startPos.x, endPos.x),
+ top: Math.min(startPos.y, endPos.y),
+ right: Math.max(startPos.x, endPos.x),
+ bottom: Math.max(startPos.y, endPos.y),
+ };
+}
+
+function getSelectionRectangleInContent({
+ container,
+ scrollContainer,
+ startPos,
+ endPos,
+}: {
+ container: HTMLElement;
+ scrollContainer: HTMLDivElement | null;
+ startPos: { x: number; y: number };
+ endPos: { x: number; y: number };
+}): SelectionRectangle {
+ const containerRect = container.getBoundingClientRect();
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+
+ const adjustedStart = {
+ x: startPos.x - containerRect.left + scrollLeft,
+ y: startPos.y - containerRect.top + scrollTop,
+ };
+ const adjustedEnd = {
+ x: endPos.x - containerRect.left + scrollLeft,
+ y: endPos.y - containerRect.top + scrollTop,
+ };
+
+ return getNormalizedRectangle({
+ startPos: adjustedStart,
+ endPos: adjustedEnd,
+ });
+}
+
+function isRectangleIntersecting({
+ elementRectangle,
+ selectionRectangle,
+}: {
+ elementRectangle: SelectionRectangle;
+ selectionRectangle: SelectionRectangle;
+}): boolean {
+ return !(
+ elementRectangle.right < selectionRectangle.left ||
+ elementRectangle.left > selectionRectangle.right ||
+ elementRectangle.bottom < selectionRectangle.top ||
+ elementRectangle.top > selectionRectangle.bottom
+ );
+}
+
+export function useSelectionBox({
+ containerRef,
+ onSelectionComplete,
+ isEnabled = true,
+ tracksScrollRef,
+ zoomLevel,
+}: UseSelectionBoxProps) {
+ const editor = useEditor();
+ const tracks = editor.timeline.getTracks();
+ const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
+ null,
+ );
+ const justFinishedSelectingRef = useRef(false);
+
+ const handleMouseDown = useCallback(
+ ({ clientX, clientY }: React.MouseEvent) => {
+ if (!isEnabled) return;
+
+ setSelectionBox({
+ startPos: { x: clientX, y: clientY },
+ currentPos: { x: clientX, y: clientY },
+ isActive: false,
+ });
+ },
+ [isEnabled],
+ );
+
+ const selectElementsInBox = useCallback(
+ ({
+ startPos,
+ endPos,
+ }: {
+ startPos: { x: number; y: number };
+ endPos: { x: number; y: number };
+ }) => {
+ if (!containerRef.current) return;
+
+ const container = containerRef.current;
+ const selectionRectangle = getSelectionRectangleInContent({
+ container,
+ scrollContainer: tracksScrollRef.current,
+ startPos,
+ endPos,
+ });
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const selectedElements: { trackId: string; elementId: string }[] = [];
+
+ for (const [trackIndex, track] of tracks.entries()) {
+ const trackTop = getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+ });
+ const trackHeight = getTrackHeight({ type: track.type });
+ const elementTop = trackTop;
+ const elementBottom = trackTop + trackHeight;
+
+ for (const element of track.elements) {
+ const elementLeft = element.startTime * pixelsPerSecond;
+ const elementRight = elementLeft + element.duration * pixelsPerSecond;
+
+ const elementRectangle = {
+ left: elementLeft,
+ top: elementTop,
+ right: elementRight,
+ bottom: elementBottom,
+ };
+
+ const intersects = isRectangleIntersecting({
+ elementRectangle,
+ selectionRectangle,
+ });
+
+ if (intersects) {
+ selectedElements.push({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ }
+ }
+ }
+ onSelectionComplete(selectedElements);
+ },
+ [containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
+ );
+
+ useEffect(() => {
+ if (!selectionBox) return;
+
+ const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
+ const deltaX = Math.abs(clientX - selectionBox.startPos.x);
+ const deltaY = Math.abs(clientY - selectionBox.startPos.y);
+ const shouldActivate = deltaX > 5 || deltaY > 5;
+
+ const newSelectionBox = {
+ ...selectionBox,
+ currentPos: { x: clientX, y: clientY },
+ isActive: shouldActivate || selectionBox.isActive,
+ };
+
+ setSelectionBox(newSelectionBox);
+
+ if (newSelectionBox.isActive) {
+ selectElementsInBox({
+ startPos: newSelectionBox.startPos,
+ endPos: newSelectionBox.currentPos,
+ });
+ }
+ };
+
+ const handleMouseUp = () => {
+ if (selectionBox?.isActive) {
+ justFinishedSelectingRef.current = true;
+ requestAnimationFrame(() => {
+ justFinishedSelectingRef.current = false;
+ });
+ }
+ setSelectionBox(null);
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [selectionBox, selectElementsInBox]);
+
+ useEffect(() => {
+ if (!selectionBox) return;
+
+ const previousBodyUserSelect = document.body.style.userSelect;
+ const container = containerRef.current;
+ const previousContainerUserSelect = container?.style.userSelect ?? "";
+
+ document.body.style.userSelect = "none";
+ if (container) container.style.userSelect = "none";
+
+ return () => {
+ document.body.style.userSelect = previousBodyUserSelect;
+ if (container) container.style.userSelect = previousContainerUserSelect;
+ };
+ }, [selectionBox, containerRef]);
+
+ const shouldIgnoreClick = useCallback(() => {
+ return justFinishedSelectingRef.current;
+ }, []);
+
+ return {
+ selectionBox,
+ handleMouseDown,
+ isSelecting: selectionBox?.isActive || false,
+ shouldIgnoreClick,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
new file mode 100644
index 00000000..c6ecbf7c
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
@@ -0,0 +1,62 @@
+import { useEffect, useState } from "react";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { TimelineTrack } from "@/types/timeline";
+
+interface UseSnapIndicatorPositionParams {
+ snapPoint: { time: number } | null;
+ zoomLevel: number;
+ tracks: TimelineTrack[];
+ timelineRef: React.RefObject<HTMLDivElement>;
+ trackLabelsRef?: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+}
+
+interface SnapIndicatorPosition {
+ leftPosition: number;
+ topPosition: number;
+ height: number;
+}
+
+export function useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+ tracksScrollRef,
+}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
+ const [scrollLeft, setScrollLeft] = useState(0);
+
+ useEffect(() => {
+ const tracksViewport = tracksScrollRef.current;
+
+ if (!tracksViewport) return;
+
+ const handleScroll = () => {
+ setScrollLeft(tracksViewport.scrollLeft);
+ };
+
+ setScrollLeft(tracksViewport.scrollLeft);
+
+ tracksViewport.addEventListener("scroll", handleScroll);
+ return () => tracksViewport.removeEventListener("scroll", handleScroll);
+ }, [tracksScrollRef]);
+
+ const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
+
+ const trackLabelsWidth =
+ tracks.length > 0 && trackLabelsRef?.current
+ ? trackLabelsRef.current.offsetWidth
+ : 0;
+
+ const timelinePosition =
+ (snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
+
+ return {
+ leftPosition,
+ topPosition: 0,
+ height: totalHeight,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
new file mode 100644
index 00000000..01af9362
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -0,0 +1,460 @@
+import { useState, useCallback, type RefObject } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { processMediaAssets } from "@/lib/media/processing";
+import { toast } from "sonner";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { snapTimeToFrame } from "@/lib/time";
+import {
+ buildTextElement,
+ buildStickerElement,
+ buildUploadAudioElement,
+ buildVideoElement,
+ buildImageElement,
+} from "@/lib/timeline/element-utils";
+import { computeDropTarget } from "@/lib/timeline/drop-utils";
+import { getDragData, hasDragData } from "@/lib/drag-data";
+import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
+import type { MediaDragData, StickerDragData } from "@/types/drag";
+
+interface UseTimelineDragDropProps {
+ containerRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ zoomLevel: number;
+}
+
+export function useTimelineDragDrop({
+ containerRef,
+ headerRef,
+ zoomLevel,
+}: UseTimelineDragDropProps) {
+ const editor = useEditor();
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
+ const [dragElementType, setElementType] = useState<ElementType | null>(null);
+
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const mediaAssets = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+
+ const getSnappedTime = useCallback(
+ ({ time }: { time: number }) => {
+ const projectFps = activeProject.settings.fps;
+ return snapTimeToFrame({ time, fps: projectFps });
+ },
+ [activeProject.settings.fps],
+ );
+
+ const getElementType = useCallback(
+ ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
+ const dragData = getDragData({ dataTransfer });
+ if (!dragData) return null;
+
+ if (dragData.type === "text") return "text";
+ if (dragData.type === "sticker") return "sticker";
+ if (dragData.type === "media") {
+ return dragData.mediaType;
+ }
+ return null;
+ },
+ [],
+ );
+
+ const getElementDuration = useCallback(
+ ({
+ elementType,
+ mediaId,
+ }: {
+ elementType: ElementType;
+ mediaId?: string;
+ }): number => {
+ if (elementType === "text" || elementType === "sticker") {
+ return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ }
+ if (mediaId) {
+ const media = mediaAssets.find((m) => m.id === mediaId);
+ return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ }
+ return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ },
+ [mediaAssets],
+ );
+
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
+ const hasFiles = e.dataTransfer.types.includes("Files");
+ if (!hasAsset && !hasFiles) return;
+ setIsDragOver(true);
+ }, []);
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect) return;
+
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const hasFiles = e.dataTransfer.types.includes("Files");
+ const isExternal =
+ hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
+
+ const elementType = getElementType({ dataTransfer: e.dataTransfer });
+
+ if (!elementType && hasFiles && isExternal) {
+ setDropTarget(null);
+ setElementType(null);
+ return;
+ }
+
+ if (!elementType) return;
+
+ setElementType(elementType);
+
+ const dragData = getDragData({ dataTransfer: e.dataTransfer });
+ const duration = getElementDuration({
+ elementType,
+ mediaId: dragData?.type === "media" ? dragData.id : undefined,
+ });
+
+ const mouseX = e.clientX - rect.left;
+ const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+
+ const target = computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime: currentTime,
+ isExternalDrop: isExternal,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ });
+
+ target.xPosition = getSnappedTime({ time: target.xPosition });
+
+ setDropTarget(target);
+ e.dataTransfer.dropEffect = "copy";
+ },
+ [
+ containerRef,
+ headerRef,
+ tracks,
+ currentTime,
+ zoomLevel,
+ getElementType,
+ getElementDuration,
+ getSnappedTime,
+ ],
+ );
+
+ const handleDragLeave = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (rect) {
+ const { clientX, clientY } = e;
+ if (
+ clientX < rect.left ||
+ clientX > rect.right ||
+ clientY < rect.top ||
+ clientY > rect.bottom
+ ) {
+ setIsDragOver(false);
+ setDropTarget(null);
+ setElementType(null);
+ }
+ }
+ },
+ [containerRef],
+ );
+
+ const executeTextDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: { name?: string; content?: string };
+ }) => {
+ let trackId: string;
+
+ if (target.isNewTrack) {
+ trackId = editor.timeline.addTrack({
+ type: "text",
+ index: target.trackIndex,
+ });
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ trackId = track.id;
+ }
+
+ const element = buildTextElement({
+ raw: {
+ name: dragData.name ?? "",
+ content: dragData.content ?? "",
+ },
+ startTime: target.xPosition,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element,
+ });
+ },
+ [editor.timeline, tracks],
+ );
+
+ const executeStickerDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: StickerDragData;
+ }) => {
+ let trackId: string;
+
+ if (target.isNewTrack) {
+ trackId = editor.timeline.addTrack({
+ type: "sticker",
+ index: target.trackIndex,
+ });
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ trackId = track.id;
+ }
+
+ const element = buildStickerElement({
+ iconName: dragData.iconName,
+ startTime: target.xPosition,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element,
+ });
+ },
+ [editor.timeline, tracks],
+ );
+
+ const executeMediaDrop = useCallback(
+ ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
+ const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
+ if (!mediaAsset) return;
+
+ const trackType: TrackType =
+ dragData.mediaType === "audio" ? "audio" : "video";
+ let trackId: string;
+
+ if (target.isNewTrack) {
+ trackId = editor.timeline.addTrack({
+ type: trackType,
+ index: target.trackIndex,
+ });
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ trackId = track.id;
+ }
+
+ const duration =
+ mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+
+ if (dragData.mediaType === "audio") {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildUploadAudioElement({
+ mediaId: mediaAsset.id,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ }),
+ });
+ } else if (dragData.mediaType === "video") {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildVideoElement({
+ mediaId: mediaAsset.id,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ }),
+ });
+ } else {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildImageElement({
+ mediaId: mediaAsset.id,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ }),
+ });
+ }
+ },
+ [editor.timeline, mediaAssets, tracks],
+ );
+
+ const executeFileDrop = useCallback(
+ async ({
+ files,
+ mouseX,
+ mouseY,
+ }: {
+ files: File[];
+ mouseX: number;
+ mouseY: number;
+ }) => {
+ if (!activeProject) return;
+
+ const processedAssets = await processMediaAssets({ files });
+
+ for (const asset of processedAssets) {
+ await editor.media.addMediaAsset({
+ projectId: activeProject.metadata.id,
+ asset,
+ });
+
+ const added = editor.media
+ .getAssets()
+ .find((m) => m.name === asset.name && m.url === asset.url);
+
+ if (added) {
+ const duration =
+ added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const currentTracks = editor.timeline.getTracks();
+ const dropTarget = computeDropTarget({
+ elementType: added.type,
+ mouseX,
+ mouseY,
+ tracks: currentTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ });
+
+ const trackType: TrackType =
+ added.type === "audio" ? "audio" : "video";
+ const trackId = dropTarget.isNewTrack
+ ? editor.timeline.addTrack({
+ type: trackType,
+ index: dropTarget.trackIndex,
+ })
+ : currentTracks[dropTarget.trackIndex]?.id;
+
+ if (!trackId) return;
+
+ if (added.type === "audio") {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildUploadAudioElement({
+ mediaId: added.id,
+ name: added.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
+ }),
+ });
+ } else if (added.type === "video") {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildVideoElement({
+ mediaId: added.id,
+ name: added.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ }),
+ });
+ } else {
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element: buildImageElement({
+ mediaId: added.id,
+ name: added.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ }),
+ });
+ }
+ }
+ }
+ },
+ [activeProject, editor.media, editor.timeline, currentTime, zoomLevel],
+ );
+
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
+ const hasFiles = e.dataTransfer.files?.length > 0;
+
+ if (!hasAsset && !hasFiles) return;
+
+ const currentTarget = dropTarget;
+ setIsDragOver(false);
+ setDropTarget(null);
+ setElementType(null);
+
+ try {
+ if (hasAsset) {
+ if (!currentTarget) return;
+ const dragData = getDragData({ dataTransfer: e.dataTransfer });
+ if (!dragData) return;
+
+ if (dragData.type === "text") {
+ executeTextDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "sticker") {
+ executeStickerDrop({ target: currentTarget, dragData });
+ } else {
+ executeMediaDrop({ target: currentTarget, dragData });
+ }
+ } else if (hasFiles) {
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ const mouseX = e.clientX - rect.left;
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+ await executeFileDrop({
+ files: Array.from(e.dataTransfer.files),
+ mouseX,
+ mouseY,
+ });
+ }
+ } catch (err) {
+ console.error("Failed to process drop:", err);
+ toast.error("Failed to process drop");
+ }
+ },
+ [
+ dropTarget,
+ executeTextDrop,
+ executeStickerDrop,
+ executeMediaDrop,
+ executeFileDrop,
+ containerRef,
+ headerRef,
+ ],
+ );
+
+ return {
+ isDragOver,
+ dropTarget,
+ dragElementType,
+ dragProps: {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ },
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
new file mode 100644
index 00000000..55e04a68
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -0,0 +1,218 @@
+import { getSnappedSeekTime } from "@/lib/time";
+import { useState, useEffect, useCallback, useRef } from "react";
+import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import { useEditor } from "../use-editor";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+interface UseTimelinePlayheadProps {
+ zoomLevel: number;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
+}
+
+export function useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+}: UseTimelinePlayheadProps) {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const currentTime = editor.playback.getCurrentTime();
+ const duration = editor.timeline.getTotalDuration();
+ const isPlaying = editor.playback.getIsPlaying();
+
+ const seek = useCallback(
+ ({ time }: { time: number }) => editor.playback.seek({ time }),
+ [editor.playback],
+ );
+
+ const [isScrubbing, setIsScrubbing] = useState(false);
+ const [scrubTime, setScrubTime] = useState<number | null>(null);
+
+ const [isDraggingRuler, setIsDraggingRuler] = useState(false);
+ const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
+ const lastMouseXRef = useRef<number>(0);
+
+ const playheadPosition =
+ isScrubbing && scrubTime !== null ? scrubTime : currentTime;
+
+ const handleScrub = useCallback(
+ ({ event }: { event: MouseEvent | React.MouseEvent }) => {
+ const ruler = rulerRef.current;
+ if (!ruler) return;
+ const rulerRect = ruler.getBoundingClientRect();
+ const relativeMouseX = event.clientX - rulerRect.left;
+
+ const timelineContentWidth =
+ duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ const clampedMouseX = Math.max(
+ 0,
+ Math.min(timelineContentWidth, relativeMouseX),
+ );
+
+ const rawTime = Math.max(
+ 0,
+ Math.min(
+ duration,
+ clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+ const framesPerSecond = activeProject.settings.fps;
+ const time = getSnappedSeekTime({
+ rawTime,
+ duration,
+ fps: framesPerSecond,
+ });
+
+ setScrubTime(time);
+ seek({ time });
+
+ lastMouseXRef.current = event.clientX;
+ },
+ [duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
+ );
+
+ const handlePlayheadMouseDown = useCallback(
+ ({ event }: { event: React.MouseEvent }) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setIsScrubbing(true);
+ handleScrub({ event });
+ },
+ [handleScrub],
+ );
+
+ const handleRulerMouseDown = useCallback(
+ ({ event }: { event: React.MouseEvent }) => {
+ if (event.button !== 0) return;
+
+ if (playheadRef?.current?.contains(event.target as Node)) return;
+
+ event.preventDefault();
+ setIsDraggingRuler(true);
+ setHasDraggedRuler(false);
+
+ setIsScrubbing(true);
+ handleScrub({ event });
+ },
+ [handleScrub, playheadRef],
+ );
+
+ const handlePlayheadMouseDownEvent = useCallback(
+ (event: React.MouseEvent) => handlePlayheadMouseDown({ event }),
+ [handlePlayheadMouseDown],
+ );
+
+ const handleRulerMouseDownEvent = useCallback(
+ (event: React.MouseEvent) => handleRulerMouseDown({ event }),
+ [handleRulerMouseDown],
+ );
+
+ useEdgeAutoScroll({
+ isActive: isScrubbing,
+ getMouseClientX: () => lastMouseXRef.current,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ });
+
+ useEffect(() => {
+ if (!isScrubbing) return;
+
+ const handleMouseMove = ({ event }: { event: MouseEvent }) => {
+ handleScrub({ event });
+ if (isDraggingRuler) {
+ setHasDraggedRuler(true);
+ }
+ };
+
+ const handleMouseUp = ({ event }: { event: MouseEvent }) => {
+ setIsScrubbing(false);
+ if (scrubTime !== null) {
+ seek({ time: scrubTime });
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
+ playheadTime: scrubTime,
+ },
+ });
+ }
+ setScrubTime(null);
+
+ if (isDraggingRuler) {
+ setIsDraggingRuler(false);
+ if (!hasDraggedRuler) {
+ handleScrub({ event });
+ }
+ setHasDraggedRuler(false);
+ }
+ };
+
+ const onMouseMove = (event: MouseEvent) => handleMouseMove({ event });
+ const onMouseUp = (event: MouseEvent) => handleMouseUp({ event });
+
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+
+ return () => {
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [
+ isScrubbing,
+ scrubTime,
+ seek,
+ handleScrub,
+ isDraggingRuler,
+ hasDraggedRuler,
+ editor,
+ tracksScrollRef,
+ zoomLevel,
+ ]);
+
+ useEffect(() => {
+ if (!isPlaying || isScrubbing) return;
+
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) return;
+
+ const playheadPixels =
+ playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMinimum = 0;
+ const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+
+ const needsScroll =
+ playheadPixels < rulerViewport.scrollLeft ||
+ playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+
+ if (needsScroll) {
+ const desiredScroll = Math.max(
+ scrollMinimum,
+ Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
+ );
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ }, [
+ playheadPosition,
+ zoomLevel,
+ rulerScrollRef,
+ tracksScrollRef,
+ isScrubbing,
+ isPlaying,
+ ]);
+
+ return {
+ playheadPosition,
+ handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
+ handleRulerMouseDown: handleRulerMouseDownEvent,
+ isDraggingRuler,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts
new file mode 100644
index 00000000..f5dc6ddb
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts
@@ -0,0 +1,197 @@
+import { useCallback, useRef } from "react";
+import type { MutableRefObject, RefObject } from "react";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { getSnappedSeekTime } from "@/lib/time";
+import { useEditor } from "../use-editor";
+
+interface UseTimelineSeekProps {
+ playheadRef: RefObject<HTMLDivElement>;
+ trackLabelsRef: RefObject<HTMLDivElement>;
+ rulerScrollRef: RefObject<HTMLDivElement>;
+ tracksScrollRef: RefObject<HTMLDivElement>;
+ zoomLevel: number;
+ duration: number;
+ isSelecting: boolean;
+ clearSelectedElements: () => void;
+ seek: (time: number) => void;
+}
+
+function resetMouseTracking({
+ mouseTrackingRef,
+}: {
+ mouseTrackingRef: MutableRefObject<{
+ isMouseDown: boolean;
+ downX: number;
+ downY: number;
+ downTime: number;
+ }>;
+}) {
+ mouseTrackingRef.current = {
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ };
+}
+
+function setMouseTracking({
+ mouseTrackingRef,
+ event,
+}: {
+ mouseTrackingRef: MutableRefObject<{
+ isMouseDown: boolean;
+ downX: number;
+ downY: number;
+ downTime: number;
+ }>;
+ event: React.MouseEvent;
+}) {
+ mouseTrackingRef.current = {
+ isMouseDown: true,
+ downX: event.clientX,
+ downY: event.clientY,
+ downTime: event.timeStamp,
+ };
+}
+
+export function useTimelineSeek({
+ playheadRef,
+ trackLabelsRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ zoomLevel,
+ duration,
+ isSelecting,
+ clearSelectedElements,
+ seek,
+}: UseTimelineSeekProps) {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+
+ const mouseTrackingRef = useRef({
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ });
+
+ const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
+ if (event.button !== 0) return;
+ setMouseTracking({ mouseTrackingRef, event });
+ }, []);
+
+ const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
+ if (event.button !== 0) return;
+ setMouseTracking({ mouseTrackingRef, event });
+ }, []);
+
+ const shouldProcessTimelineClick = useCallback(
+ ({ event }: { event: React.MouseEvent }) => {
+ const target = event.target as HTMLElement;
+ const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
+ const deltaX = Math.abs(event.clientX - downX);
+ const deltaY = Math.abs(event.clientY - downY);
+ const deltaTime = event.timeStamp - downTime;
+ const isPlayhead = !!playheadRef.current?.contains(target);
+ const isTrackLabels = !!trackLabelsRef.current?.contains(target);
+ const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
+
+ if (!isMouseDown) return false;
+ if (shouldBlockForDrag) return false;
+ if (isSelecting) return false;
+ if (isPlayhead) return false;
+ if (isTrackLabels) {
+ clearSelectedElements();
+ return false;
+ }
+
+ return true;
+ },
+ [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
+ );
+
+ const handleTimelineSeek = useCallback(
+ ({
+ event,
+ source,
+ }: {
+ event: React.MouseEvent;
+ source: "ruler" | "tracks";
+ }) => {
+ const scrollContainer =
+ source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
+
+ if (!scrollContainer) return;
+
+ const rect = scrollContainer.getBoundingClientRect();
+ const mouseX = event.clientX - rect.left;
+ const scrollLeft = scrollContainer.scrollLeft;
+
+ const rawTime = Math.max(
+ 0,
+ Math.min(
+ duration,
+ (mouseX + scrollLeft) /
+ (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+
+ const projectFps = activeProject?.settings.fps || 30;
+ const time = getSnappedSeekTime({
+ rawTime,
+ duration,
+ fps: projectFps,
+ });
+ seek(time);
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft: scrollContainer.scrollLeft,
+ playheadTime: time,
+ },
+ });
+ },
+ [
+ duration,
+ zoomLevel,
+ rulerScrollRef,
+ tracksScrollRef,
+ seek,
+ editor,
+ activeProject?.settings.fps,
+ ],
+ );
+
+ const handleTracksClick = useCallback(
+ (event: React.MouseEvent) => {
+ const shouldProcess = shouldProcessTimelineClick({ event });
+ resetMouseTracking({ mouseTrackingRef });
+
+ if (shouldProcess) {
+ clearSelectedElements();
+ handleTimelineSeek({ event, source: "tracks" });
+ }
+ },
+ [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
+ );
+
+ const handleRulerClick = useCallback(
+ (event: React.MouseEvent) => {
+ const shouldProcess = shouldProcessTimelineClick({ event });
+ resetMouseTracking({ mouseTrackingRef });
+
+ if (shouldProcess) {
+ clearSelectedElements();
+ handleTimelineSeek({ event, source: "ruler" });
+ }
+ },
+ [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
+ );
+
+ return {
+ handleTracksMouseDown,
+ handleTracksClick,
+ handleRulerMouseDown,
+ handleRulerClick,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts
new file mode 100644
index 00000000..4cd6f775
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-timeline-snapping.ts
@@ -0,0 +1,159 @@
+import { useCallback } from "react";
+import type { TimelineTrack } from "@/types/timeline";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+export interface SnapPoint {
+ time: number;
+ type: "element-start" | "element-end" | "playhead";
+ elementId?: string;
+ trackId?: string;
+}
+
+export interface SnapResult {
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
+}
+
+export interface UseTimelineSnappingOptions {
+ snapThreshold?: number;
+ enableElementSnapping?: boolean;
+ enablePlayheadSnapping?: boolean;
+}
+
+export function useTimelineSnapping({
+ snapThreshold = 10,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
+}: UseTimelineSnappingOptions = {}) {
+ const findSnapPoints = useCallback(
+ ({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ }: {
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ excludeElementId?: string;
+ }): SnapPoint[] => {
+ const snapPoints: SnapPoint[] = [];
+
+ if (enableElementSnapping) {
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.id === excludeElementId) continue;
+
+ const elementStart = element.startTime;
+ const elementEnd = element.startTime + element.duration;
+
+ snapPoints.push(
+ {
+ time: elementStart,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: elementEnd,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ );
+ }
+ }
+ }
+
+ if (enablePlayheadSnapping) {
+ snapPoints.push({
+ time: playheadTime,
+ type: "playhead",
+ });
+ }
+
+ return snapPoints;
+ },
+ [enableElementSnapping, enablePlayheadSnapping],
+ );
+
+ const snapToNearestPoint = useCallback(
+ ({
+ targetTime,
+ snapPoints,
+ zoomLevel,
+ }: {
+ targetTime: number;
+ snapPoints: Array<SnapPoint>;
+ zoomLevel: number;
+ }): SnapResult => {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const thresholdInSeconds = snapThreshold / pixelsPerSecond;
+
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
+
+ for (const snapPoint of snapPoints) {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance < thresholdInSeconds && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ }
+
+ return {
+ snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+ },
+ [snapThreshold],
+ );
+
+ const snapElementEdge = useCallback(
+ ({
+ targetTime,
+ elementDuration,
+ tracks,
+ playheadTime,
+ zoomLevel,
+ excludeElementId,
+ snapToStart = true,
+ }: {
+ targetTime: number;
+ elementDuration: number;
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ zoomLevel: number;
+ excludeElementId?: string;
+ snapToStart?: boolean;
+ }): SnapResult => {
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ });
+
+ const effectiveTargetTime = snapToStart
+ ? targetTime
+ : targetTime + elementDuration;
+ const snapResult = snapToNearestPoint({
+ targetTime: effectiveTargetTime,
+ snapPoints,
+ zoomLevel,
+ });
+
+ if (!snapToStart && snapResult.snapPoint) {
+ snapResult.snappedTime = snapResult.snappedTime - elementDuration;
+ }
+
+ return snapResult;
+ },
+ [findSnapPoints, snapToNearestPoint],
+ );
+
+ return {
+ snapElementEdge,
+ findSnapPoints,
+ snapToNearestPoint,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
new file mode 100644
index 00000000..96a4be43
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
@@ -0,0 +1,224 @@
+import {
+ type WheelEvent as ReactWheelEvent,
+ type RefObject,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useEditor } from "@/hooks/use-editor";
+
+interface UseTimelineZoomProps {
+ containerRef: RefObject<HTMLDivElement>;
+ minZoom?: number;
+ initialZoom?: number;
+ initialScrollLeft?: number;
+ initialPlayheadTime?: number;
+ tracksScrollRef: RefObject<HTMLDivElement>;
+ rulerScrollRef: RefObject<HTMLDivElement>;
+}
+
+interface UseTimelineZoomReturn {
+ zoomLevel: number;
+ setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
+ handleWheel: (event: ReactWheelEvent) => void;
+ saveScrollPosition: () => void;
+}
+
+export function useTimelineZoom({
+ containerRef,
+ minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
+ initialZoom,
+ initialScrollLeft,
+ initialPlayheadTime,
+ tracksScrollRef,
+ rulerScrollRef,
+}: UseTimelineZoomProps): UseTimelineZoomReturn {
+ const editor = useEditor();
+ const hasInitializedRef = useRef(false);
+ const hasRestoredPlayheadRef = useRef(false);
+ const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
+ null,
+ );
+
+ const [zoomLevel, setZoomLevel] = useState(() => {
+ if (initialZoom !== undefined) {
+ hasInitializedRef.current = true;
+ return Math.max(
+ minZoom,
+ Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom),
+ );
+ }
+ return minZoom;
+ });
+ const previousZoomRef = useRef(zoomLevel);
+ const hasRestoredScrollRef = useRef(false);
+
+ const handleWheel = useCallback(
+ (event: ReactWheelEvent) => {
+ const isZoomGesture = event.ctrlKey || event.metaKey;
+ const isHorizontalScrollGesture =
+ event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
+
+ if (isHorizontalScrollGesture) {
+ return;
+ }
+
+ // pinch-zoom (ctrl/meta + wheel)
+ if (isZoomGesture) {
+ const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
+ setZoomLevel((prev) => {
+ const nextZoom = Math.max(
+ minZoom,
+ Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
+ );
+ return nextZoom;
+ });
+ // for horizontal scrolling (when shift is held or horizontal wheel movement),
+ // let the event bubble up to allow ScrollArea to handle it
+ return;
+ }
+ },
+ [minZoom],
+ );
+
+ useEffect(() => {
+ if (initialZoom !== undefined && !hasInitializedRef.current) {
+ hasInitializedRef.current = true;
+ setZoomLevel(
+ Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)),
+ );
+ return;
+ }
+ setZoomLevel((prev) => {
+ if (prev < minZoom) {
+ return minZoom;
+ }
+ return prev;
+ });
+ }, [minZoom, initialZoom]);
+
+ const wrappedSetZoomLevel = useCallback(
+ (zoomLevelOrUpdater: number | ((prev: number) => number)) => {
+ setZoomLevel((prev) => {
+ const nextZoom =
+ typeof zoomLevelOrUpdater === "function"
+ ? zoomLevelOrUpdater(prev)
+ : zoomLevelOrUpdater;
+ const clampedZoom = Math.max(
+ minZoom,
+ Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom),
+ );
+ return clampedZoom;
+ });
+ },
+ [minZoom],
+ );
+
+ useLayoutEffect(() => {
+ const previousZoom = previousZoomRef.current;
+ if (previousZoom === zoomLevel) return;
+
+ const scrollElement = tracksScrollRef.current;
+ if (scrollElement) {
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft: scrollElement.scrollLeft,
+ playheadTime: editor.playback.getCurrentTime(),
+ },
+ });
+ }
+
+ previousZoomRef.current = zoomLevel;
+ }, [zoomLevel, editor, tracksScrollRef]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
+ const saveScrollPosition = useCallback(() => {
+ if (scrollSaveTimeoutRef.current) {
+ clearTimeout(scrollSaveTimeoutRef.current);
+ }
+ scrollSaveTimeoutRef.current = setTimeout(() => {
+ const scrollElement = tracksScrollRef.current;
+ if (scrollElement) {
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft: scrollElement.scrollLeft,
+ playheadTime: editor.playback.getCurrentTime(),
+ },
+ });
+ }
+ }, 300);
+ }, [zoomLevel, editor]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
+ useEffect(() => {
+ if (initialScrollLeft === undefined) return;
+ if (hasRestoredScrollRef.current) return;
+ const scrollElement = tracksScrollRef.current;
+ if (!scrollElement) return;
+
+ const restoreScroll = () => {
+ scrollElement.scrollLeft = initialScrollLeft;
+ if (rulerScrollRef.current) {
+ rulerScrollRef.current.scrollLeft = initialScrollLeft;
+ }
+ hasRestoredScrollRef.current = true;
+ };
+
+ if (scrollElement.scrollWidth > 0) {
+ restoreScroll();
+ } else {
+ const observer = new ResizeObserver(() => {
+ if (scrollElement.scrollWidth > 0) {
+ restoreScroll();
+ hasRestoredScrollRef.current = true;
+ observer.disconnect();
+ }
+ });
+ observer.observe(scrollElement);
+ return () => observer.disconnect();
+ }
+ }, [initialScrollLeft]);
+
+ useEffect(() => {
+ if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
+ hasRestoredPlayheadRef.current = true;
+ editor.playback.seek({ time: initialPlayheadTime });
+ }
+ }, [initialPlayheadTime, editor]);
+
+ // prevent browser zoom in the timeline
+ useEffect(() => {
+ const preventZoom = (event: WheelEvent) => {
+ const isZoomKeyPressed = event.ctrlKey || event.metaKey;
+ const isInContainer = containerRef.current?.contains(
+ event.target as Node,
+ );
+ // only check isInContainer, not isInTimeline state - the state check
+ // causes race conditions where the closure captures stale state
+ if (isZoomKeyPressed && isInContainer) {
+ event.preventDefault();
+ }
+ };
+
+ document.addEventListener("wheel", preventZoom, {
+ passive: false,
+ capture: true,
+ });
+
+ return () => {
+ document.removeEventListener("wheel", preventZoom, { capture: true });
+ };
+ }, [containerRef]);
+
+ return {
+ zoomLevel,
+ setZoomLevel: wrappedSetZoomLevel,
+ handleWheel,
+ saveScrollPosition,
+ };
+}
diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts
deleted file mode 100644
index b721b74e..00000000
--- a/apps/web/src/hooks/use-aspect-ratio.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { useEditorStore } from "@/stores/editor-store";
-import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store";
-
-export function useAspectRatio() {
- const { canvasPresets } = useEditorStore();
- const { activeProject } = useProjectStore();
- const { mediaFiles } = useMediaStore();
- const { tracks } = useTimelineStore();
-
- const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE;
- const canvasMode = activeProject?.canvasMode || "preset";
-
- // Find the current preset based on canvas size
- const currentPreset = canvasPresets.find(
- (preset) =>
- preset.width === canvasSize.width && preset.height === canvasSize.height
- );
-
- // Get the original aspect ratio from the first video/image in timeline
- const getOriginalAspectRatio = (): number => {
- // Find first video or image in timeline
- for (const track of tracks) {
- for (const element of track.elements) {
- if (element.type === "media") {
- const mediaFile = mediaFiles.find(
- (file) => file.id === element.mediaId
- );
- if (
- mediaFile &&
- (mediaFile.type === "video" || mediaFile.type === "image")
- ) {
- return getMediaAspectRatio(mediaFile);
- }
- }
- }
- }
- return 16 / 9; // Default aspect ratio
- };
-
- // Get current aspect ratio
- const getCurrentAspectRatio = (): number => {
- return canvasSize.width / canvasSize.height;
- };
-
- // Format aspect ratio as a readable string
- const formatAspectRatio = (aspectRatio: number): string => {
- // Check if it matches a common aspect ratio
- const ratios = [
- { ratio: 16 / 9, label: "16:9" },
- { ratio: 9 / 16, label: "9:16" },
- { ratio: 1, label: "1:1" },
- { ratio: 4 / 3, label: "4:3" },
- { ratio: 3 / 4, label: "3:4" },
- { ratio: 21 / 9, label: "21:9" },
- ];
-
- for (const { ratio, label } of ratios) {
- if (Math.abs(aspectRatio - ratio) < 0.01) {
- return label;
- }
- }
-
- // If not a common ratio, format as decimal
- return aspectRatio.toFixed(2);
- };
-
- // Check if current mode is "Original"
- const isOriginal = canvasMode === "original";
-
- // Get display name for current aspect ratio
- const getDisplayName = (): string => {
- // If explicitly set to original mode, always show "Original"
- if (canvasMode === "original") {
- return "Original";
- }
-
- if (currentPreset) {
- return currentPreset.name;
- }
-
- return formatAspectRatio(getCurrentAspectRatio());
- };
-
- return {
- currentPreset,
- canvasMode,
- isOriginal,
- getCurrentAspectRatio,
- getOriginalAspectRatio,
- formatAspectRatio,
- getDisplayName,
- canvasSize,
- canvasPresets,
- };
-}
diff --git a/apps/web/src/hooks/use-drag-drop.ts b/apps/web/src/hooks/use-drag-drop.ts
deleted file mode 100644
index 0b1d4008..00000000
--- a/apps/web/src/hooks/use-drag-drop.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useState, useRef } from "react";
-
-interface UseDragDropOptions {
- onDrop?: (files: FileList) => void;
-}
-
-// Helper function to check if drag contains files from external sources (not internal app drags)
-const containsFiles = (dataTransfer: DataTransfer): boolean => {
- // Check if this is an internal app drag (media item)
- if (dataTransfer.types.includes("application/x-media-item")) {
- return false;
- }
-
- // Only show overlay for external file drags
- return dataTransfer.types.includes("Files");
-};
-
-export function useDragDrop(options: UseDragDropOptions = {}) {
- const [isDragOver, setIsDragOver] = useState(false);
- const dragCounterRef = useRef(0);
-
- const handleDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Only handle external file drags, not internal app element drags
- if (!containsFiles(e.dataTransfer)) {
- return;
- }
-
- dragCounterRef.current += 1;
- if (!isDragOver) {
- setIsDragOver(true);
- }
- };
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Only handle file drags
- if (!containsFiles(e.dataTransfer)) {
- return;
- }
- };
-
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Only handle file drags
- if (!containsFiles(e.dataTransfer)) {
- return;
- }
-
- dragCounterRef.current -= 1;
- if (dragCounterRef.current === 0) {
- setIsDragOver(false);
- }
- };
-
- const handleDrop = (e: React.DragEvent) => {
- e.preventDefault();
- setIsDragOver(false);
- dragCounterRef.current = 0;
-
- // Only handle file drops
- if (
- options.onDrop &&
- e.dataTransfer.files &&
- containsFiles(e.dataTransfer)
- ) {
- options.onDrop(e.dataTransfer.files);
- }
- };
-
- const dragProps = {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- };
-
- return {
- isDragOver,
- dragProps,
- };
-}
diff --git a/apps/web/src/hooks/use-edge-auto-scroll.ts b/apps/web/src/hooks/use-edge-auto-scroll.ts
deleted file mode 100644
index d3cf8127..00000000
--- a/apps/web/src/hooks/use-edge-auto-scroll.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useEffect, useRef } from "react";
-
-interface UseEdgeAutoScrollParams {
- isActive: boolean;
- getMouseClientX: () => number;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
- contentWidth: number;
- edgeThreshold?: number;
- maxScrollSpeed?: number;
-}
-
-// Provides smooth edge auto-scrolling for horizontal timeline interactions.
-export function useEdgeAutoScroll({
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold = 100,
- maxScrollSpeed = 15,
-}: UseEdgeAutoScrollParams): void {
- const rafRef = useRef<number | null>(null);
-
- useEffect(() => {
- if (!isActive) {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- return;
- }
-
- const step = () => {
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) {
- rafRef.current = requestAnimationFrame(step);
- return;
- }
-
- const viewportRect = rulerViewport.getBoundingClientRect();
- const mouseX = getMouseClientX();
- const mouseXRelative = mouseX - viewportRect.left;
-
- const viewportWidth = rulerViewport.clientWidth;
- const intrinsicContentWidth = rulerViewport.scrollWidth;
- const effectiveContentWidth = Math.max(
- contentWidth,
- intrinsicContentWidth
- );
- const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
-
- let scrollSpeed = 0;
-
- if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
- const edgeDistance = Math.max(0, mouseXRelative);
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = -maxScrollSpeed * intensity;
- } else if (
- mouseXRelative > viewportWidth - edgeThreshold &&
- rulerViewport.scrollLeft < scrollMax
- ) {
- const edgeDistance = Math.max(
- 0,
- viewportWidth - edgeThreshold - mouseXRelative
- );
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = maxScrollSpeed * intensity;
- }
-
- if (scrollSpeed !== 0) {
- const newScrollLeft = Math.max(
- 0,
- Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed)
- );
- rulerViewport.scrollLeft = newScrollLeft;
- tracksViewport.scrollLeft = newScrollLeft;
- }
-
- rafRef.current = requestAnimationFrame(step);
- };
-
- rafRef.current = requestAnimationFrame(step);
-
- return () => {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- };
- }, [
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold,
- maxScrollSpeed,
- ]);
-}
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts
deleted file mode 100644
index 9287d2e0..00000000
--- a/apps/web/src/hooks/use-editor-actions.ts
+++ /dev/null
@@ -1,241 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useActionHandler } from "@/constants/actions";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
-import { toast } from "sonner";
-
-export function useEditorActions() {
- const {
- tracks,
- selectedElements,
- clearSelectedElements,
- setSelectedElements,
- deleteSelected,
- splitSelected,
- addElementToTrack,
- snappingEnabled,
- toggleSnapping,
- undo,
- redo,
- } = useTimelineStore();
-
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
- const { activeProject } = useProjectStore();
-
- // Playback actions
- useActionHandler(
- "toggle-play",
- () => {
- toggle();
- },
- undefined
- );
-
- useActionHandler(
- "stop-playback",
- () => {
- if (isPlaying) {
- toggle();
- }
- seek(0);
- },
- undefined
- );
-
- useActionHandler(
- "seek-forward",
- (args) => {
- const seconds = args?.seconds ?? 1;
- seek(Math.min(duration, currentTime + seconds));
- },
- undefined
- );
-
- useActionHandler(
- "seek-backward",
- (args) => {
- const seconds = args?.seconds ?? 1;
- seek(Math.max(0, currentTime - seconds));
- },
- undefined
- );
-
- useActionHandler(
- "frame-step-forward",
- () => {
- const projectFps = activeProject?.fps || DEFAULT_FPS;
- seek(Math.min(duration, currentTime + 1 / projectFps));
- },
- undefined
- );
-
- useActionHandler(
- "frame-step-backward",
- () => {
- const projectFps = activeProject?.fps || DEFAULT_FPS;
- seek(Math.max(0, currentTime - 1 / projectFps));
- },
- undefined
- );
-
- useActionHandler(
- "jump-forward",
- (args) => {
- const seconds = args?.seconds ?? 5;
- seek(Math.min(duration, currentTime + seconds));
- },
- undefined
- );
-
- useActionHandler(
- "jump-backward",
- (args) => {
- const seconds = args?.seconds ?? 5;
- seek(Math.max(0, currentTime - seconds));
- },
- undefined
- );
-
- useActionHandler(
- "goto-start",
- () => {
- seek(0);
- },
- undefined
- );
-
- useActionHandler(
- "goto-end",
- () => {
- seek(duration);
- },
- undefined
- );
-
- // 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) {
- splitSelected(currentTime, trackId, elementId);
- } else {
- toast.error("Playhead must be within selected element");
- }
- }
- },
- undefined
- );
-
- useActionHandler(
- "delete-selected",
- () => {
- if (selectedElements.length === 0) {
- return;
- }
- deleteSelected();
- },
- undefined
- );
-
- useActionHandler(
- "select-all",
- () => {
- const allElements = tracks.flatMap((track: any) =>
- track.elements.map((element: any) => ({
- trackId: track.id,
- elementId: element.id,
- }))
- );
- setSelectedElements(allElements);
- },
- undefined
- );
-
- 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,
- });
- }
- },
- undefined
- );
-
- useActionHandler(
- "copy-selected",
- () => {
- if (selectedElements.length === 0) return;
- useTimelineStore.getState().copySelected();
- },
- undefined
- );
-
- useActionHandler(
- "paste-selected",
- () => {
- useTimelineStore.getState().pasteAtTime(currentTime);
- },
- undefined
- );
-
- useActionHandler(
- "toggle-snapping",
- () => {
- toggleSnapping();
- },
- undefined
- );
-
- // History actions
- useActionHandler(
- "undo",
- () => {
- undo();
- },
- undefined
- );
-
- useActionHandler(
- "redo",
- () => {
- redo();
- },
- undefined
- );
-}
diff --git a/apps/web/src/hooks/use-editor.ts b/apps/web/src/hooks/use-editor.ts
new file mode 100644
index 00000000..ff3e2db1
--- /dev/null
+++ b/apps/web/src/hooks/use-editor.ts
@@ -0,0 +1,39 @@
+import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
+import { EditorCore } from "@/core";
+
+export function useEditor(): EditorCore {
+ const editor = useMemo(() => EditorCore.getInstance(), []);
+ const versionRef = useRef(0);
+
+ const subscribe = useCallback(
+ (onStoreChange: () => void) => {
+ const handleStoreChange = () => {
+ versionRef.current += 1;
+ onStoreChange();
+ };
+
+ const unsubscribers = [
+ editor.playback.subscribe(handleStoreChange),
+ editor.timeline.subscribe(handleStoreChange),
+ editor.scenes.subscribe(handleStoreChange),
+ editor.project.subscribe(handleStoreChange),
+ editor.media.subscribe(handleStoreChange),
+ editor.renderer.subscribe(handleStoreChange),
+ editor.selection.subscribe(handleStoreChange),
+ ];
+
+ return () => {
+ for (const unsubscribe of unsubscribers) {
+ unsubscribe();
+ }
+ };
+ },
+ [editor],
+ );
+
+ const getSnapshot = useCallback(() => versionRef.current, []);
+
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+
+ return editor;
+}
diff --git a/apps/web/src/hooks/use-file-upload.ts b/apps/web/src/hooks/use-file-upload.ts
new file mode 100644
index 00000000..19badd98
--- /dev/null
+++ b/apps/web/src/hooks/use-file-upload.ts
@@ -0,0 +1,103 @@
+import { useState, useRef } from "react";
+import { hasDragData } from "@/lib/drag-data";
+
+interface UseFileUploadOptions {
+ accept?: string;
+ multiple?: boolean;
+ onFilesSelected?: (files: FileList) => void;
+}
+
+function containsFiles(dataTransfer: DataTransfer): boolean {
+ return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files");
+}
+
+export function useFileUpload({
+ accept,
+ multiple,
+ onFilesSelected,
+}: UseFileUploadOptions = {}) {
+ const [isDragOver, setIsDragOver] = useState(false);
+ const dragCounterRef = useRef(0);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ function openFilePicker() {
+ if (!inputRef.current) return;
+
+ inputRef.current.accept = accept || "*";
+ inputRef.current.multiple = multiple || false;
+ inputRef.current.click();
+ }
+
+ function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const files = event.target.files;
+ if (files && files.length > 0 && onFilesSelected) {
+ onFilesSelected(files);
+ }
+
+ if (event.target) {
+ event.target.value = "";
+ }
+ }
+
+ function handleDragEnter(e: React.DragEvent) {
+ e.preventDefault();
+
+ if (!containsFiles(e.dataTransfer)) return;
+
+ dragCounterRef.current += 1;
+ setIsDragOver(true);
+ }
+
+ function handleDragOver(e: React.DragEvent) {
+ e.preventDefault();
+
+ if (!containsFiles(e.dataTransfer)) return;
+ }
+
+ function handleDragLeave(e: React.DragEvent) {
+ e.preventDefault();
+
+ if (!containsFiles(e.dataTransfer)) return;
+
+ dragCounterRef.current -= 1;
+ if (dragCounterRef.current === 0) {
+ setIsDragOver(false);
+ }
+ }
+
+ function handleDrop(e: React.DragEvent) {
+ e.preventDefault();
+ setIsDragOver(false);
+ dragCounterRef.current = 0;
+
+ if (onFilesSelected && containsFiles(e.dataTransfer)) {
+ const files = e.dataTransfer.files;
+ const shouldUseMultiple = multiple ?? false;
+
+ if (shouldUseMultiple) {
+ onFilesSelected(files);
+ } else if (files.length > 0) {
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(files[0]);
+ onFilesSelected(dataTransfer.files);
+ }
+ }
+ }
+
+ return {
+ isDragOver,
+ openFilePicker,
+ fileInputProps: {
+ ref: inputRef,
+ type: "file",
+ style: { display: "none" },
+ onChange: handleFileChange,
+ },
+ dragProps: {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ },
+ };
+}
diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts
deleted file mode 100644
index cce187f1..00000000
--- a/apps/web/src/hooks/use-frame-cache.ts
+++ /dev/null
@@ -1,348 +0,0 @@
-import { useRef, useCallback } from "react";
-import {
- TimelineTrack,
- TimelineElement,
- MediaElement,
- TextElement,
-} from "@/types/timeline";
-import { MediaFile } from "@/types/media";
-import { TProject } from "@/types/project";
-
-interface CachedFrame {
- imageData: ImageData;
- timelineHash: string;
- timestamp: number;
-}
-
-interface FrameCacheOptions {
- maxCacheSize?: number; // Maximum number of cached frames
- cacheResolution?: number; // Frames per second to cache at
-}
-
-// Shared singleton cache across hook instances (HMR-safe)
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const __frameCacheGlobal: any = globalThis as any;
-const __sharedFrameCache: Map<number, CachedFrame> =
- __frameCacheGlobal.__sharedFrameCache ?? new Map<number, CachedFrame>();
-__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache;
-
-export function useFrameCache(options: FrameCacheOptions = {}) {
- const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps
-
- const frameCacheRef = useRef(__sharedFrameCache);
-
- // Generate a hash of the timeline state that affects rendering
- const getTimelineHash = useCallback(
- (
- time: number,
- tracks: TimelineTrack[],
- mediaFiles: MediaFile[],
- activeProject: TProject | null,
- sceneId?: string
- ): string => {
- // Get elements that are active at this time
- const activeElements: Array<{
- id: string;
- type: string;
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- mediaId?: string;
- // Text-specific properties
- content?: string;
- fontSize?: number;
- fontFamily?: string;
- color?: string;
- backgroundColor?: string;
- x?: number;
- y?: number;
- rotation?: number;
- opacity?: number;
- }> = [];
-
- for (const track of tracks) {
- if (track.muted) continue;
-
- for (const element of track.elements) {
- // Check if element has hidden property (some elements might not have it)
- const isHidden = "hidden" in element ? element.hidden : false;
- if (isHidden) continue;
-
- const elementStart = element.startTime;
- const elementEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (time >= elementStart && time < elementEnd) {
- if (element.type === "media") {
- const mediaElement = element as MediaElement;
- activeElements.push({
- id: element.id,
- type: element.type,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- mediaId: mediaElement.mediaId,
- });
- } else if (element.type === "text") {
- const textElement = element as TextElement;
- activeElements.push({
- id: element.id,
- type: element.type,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- content: textElement.content,
- fontSize: textElement.fontSize,
- fontFamily: textElement.fontFamily,
- color: textElement.color,
- backgroundColor: textElement.backgroundColor,
- x: textElement.x,
- y: textElement.y,
- rotation: textElement.rotation,
- opacity: textElement.opacity,
- });
- }
- }
- }
- }
-
- // Include project settings that affect rendering
- const projectState = {
- backgroundColor: activeProject?.backgroundColor,
- backgroundType: activeProject?.backgroundType,
- blurIntensity: activeProject?.blurIntensity,
- canvasSize: activeProject?.canvasSize,
- };
-
- const hash = {
- activeElements,
- projectState,
- sceneId,
- time: Math.floor(time * cacheResolution) / cacheResolution,
- };
- return JSON.stringify(hash);
- },
- [cacheResolution]
- );
-
- // Check if a frame is cached and valid
- const isFrameCached = useCallback(
- (
- time: number,
- tracks: TimelineTrack[],
- mediaFiles: MediaFile[],
- activeProject: TProject | null,
- sceneId?: string
- ): boolean => {
- const frameKey = Math.floor(time * cacheResolution);
- const cached = frameCacheRef.current.get(frameKey);
-
- if (!cached) return false;
-
- const currentHash = getTimelineHash(
- time,
- tracks,
- mediaFiles,
- activeProject,
- sceneId
- );
- return cached.timelineHash === currentHash;
- },
- [getTimelineHash, cacheResolution]
- );
-
- // Get cached frame if available and valid
- const getCachedFrame = useCallback(
- (
- time: number,
- tracks: TimelineTrack[],
- mediaFiles: MediaFile[],
- activeProject: TProject | null,
- sceneId?: string
- ): ImageData | null => {
- const frameKey = Math.floor(time * cacheResolution);
- const cached = frameCacheRef.current.get(frameKey);
-
- if (!cached) {
- return null;
- }
-
- const currentHash = getTimelineHash(
- time,
- tracks,
- mediaFiles,
- activeProject,
- sceneId
- );
- console.log(cached.timelineHash === currentHash);
- if (cached.timelineHash !== currentHash) {
- // Cache is stale, remove it
- console.log(
- "Cache miss - hash mismatch:",
- JSON.stringify({
- cachedHash: cached.timelineHash.slice(0, 100),
- currentHash: currentHash.slice(0, 100),
- })
- );
- frameCacheRef.current.delete(frameKey);
- return null;
- }
-
- return cached.imageData;
- },
- [getTimelineHash, cacheResolution]
- );
-
- // Cache a rendered frame
- const cacheFrame = useCallback(
- (
- time: number,
- imageData: ImageData,
- tracks: TimelineTrack[],
- mediaFiles: MediaFile[],
- activeProject: TProject | null,
- sceneId?: string
- ): void => {
- const frameKey = Math.floor(time * cacheResolution);
- const timelineHash = getTimelineHash(
- time,
- tracks,
- mediaFiles,
- activeProject,
- sceneId
- );
-
- // Enforce cache size limit (LRU eviction)
- if (frameCacheRef.current.size >= maxCacheSize) {
- // Remove oldest entries
- const entries = Array.from(frameCacheRef.current.entries());
- entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
-
- // Remove oldest 20% of entries
- const toRemove = Math.floor(entries.length * 0.2);
- for (let i = 0; i < toRemove; i++) {
- frameCacheRef.current.delete(entries[i][0]);
- }
- }
-
- frameCacheRef.current.set(frameKey, {
- imageData,
- timelineHash,
- timestamp: Date.now(),
- });
- },
- [getTimelineHash, cacheResolution, maxCacheSize]
- );
-
- // Clear cache when timeline changes significantly
- const invalidateCache = useCallback(() => {
- frameCacheRef.current.clear();
- }, []);
-
- // Get render status for timeline indicator
- const getRenderStatus = useCallback(
- (
- time: number,
- tracks: TimelineTrack[],
- mediaFiles: MediaFile[],
- activeProject: TProject | null,
- sceneId?: string
- ): "cached" | "not-cached" => {
- return isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)
- ? "cached"
- : "not-cached";
- },
- [isFrameCached]
- );
-
- // Pre-render frames around current time
- const preRenderNearbyFrames = useCallback(
- async (
- currentTime: number,
- tracks: TimelineTrack[],
- mediaFiles: MediaFile[],
- activeProject: TProject | null,
- renderFunction: (time: number) => Promise<ImageData>,
- sceneId?: string,
- range: number = 3 // seconds
- ) => {
- const framesToPreRender: number[] = [];
-
- // Calculate frames to pre-render (around current time)
- for (
- let offset = -range;
- offset <= range;
- offset += 1 / cacheResolution
- ) {
- const time = currentTime + offset;
- if (time < 0) continue;
-
- if (!isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)) {
- framesToPreRender.push(time);
- }
- }
-
- // Expand to full 1-second buckets to avoid fragmented tiny cache regions
- const secondsToPreRender = new Set<number>();
- for (const t of framesToPreRender) {
- secondsToPreRender.add(Math.floor(t));
- }
-
- const expandedTimes: number[] = [];
- for (const s of secondsToPreRender) {
- for (let k = 0; k < cacheResolution; k++) {
- const t = s + k / cacheResolution;
- if (t < 0) continue;
- if (!isFrameCached(t, tracks, mediaFiles, activeProject, sceneId)) {
- expandedTimes.push(t);
- }
- }
- }
-
- // Sort forward-first near currentTime to improve perceived responsiveness
- expandedTimes.sort((a, b) => {
- const da = a >= currentTime ? a - currentTime : currentTime - a + 1e6;
- const db = b >= currentTime ? b - currentTime : currentTime - b + 1e6;
- return da - db;
- });
-
- // Cap total scheduled renders to avoid jank (e.g., up to 90 frames)
- const CAP = Math.max(30, Math.min(90, cacheResolution * 3));
- const toSchedule = expandedTimes.slice(0, CAP);
-
- // Pre-render during idle time
- for (const time of toSchedule) {
- requestIdleCallback(async () => {
- try {
- const imageData = await renderFunction(time);
- cacheFrame(
- time,
- imageData,
- tracks,
- mediaFiles,
- activeProject,
- sceneId
- );
- } catch (error) {
- console.warn(`Pre-render failed for time ${time}:`, error);
- }
- });
- }
- },
- [isFrameCached, cacheFrame, cacheResolution]
- );
-
- return {
- isFrameCached,
- getCachedFrame,
- cacheFrame,
- invalidateCache,
- getRenderStatus,
- preRenderNearbyFrames,
- cacheSize: frameCacheRef.current.size,
- };
-}
diff --git a/apps/web/src/hooks/use-highlight-scroll.ts b/apps/web/src/hooks/use-highlight-scroll.ts
deleted file mode 100644
index 241bffb2..00000000
--- a/apps/web/src/hooks/use-highlight-scroll.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useEffect, useState, useRef } from "react";
-
-export function useHighlightScroll(
- highlightId: string | null,
- onClearHighlight: () => void,
- highlightDuration = 1000
-) {
- const [highlightedId, setHighlightedId] = useState<string | null>(null);
- const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
-
- const registerElement = (id: string, element: HTMLElement | null) => {
- if (element) {
- elementRefs.current.set(id, element);
- } else {
- elementRefs.current.delete(id);
- }
- };
-
- useEffect(() => {
- if (!highlightId) return;
-
- setHighlightedId(highlightId);
-
- const target = elementRefs.current.get(highlightId);
- target?.scrollIntoView({ block: "center" });
-
- const timeout = setTimeout(() => {
- setHighlightedId(null);
- onClearHighlight();
- }, highlightDuration);
-
- return () => clearTimeout(timeout);
- }, [highlightId, onClearHighlight, highlightDuration]);
-
- return { highlightedId, registerElement };
-}
diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts
index 0f8db198..4f823834 100644
--- a/apps/web/src/hooks/use-infinite-scroll.ts
+++ b/apps/web/src/hooks/use-infinite-scroll.ts
@@ -1,35 +1,35 @@
import { useRef, useCallback } from "react";
interface UseInfiniteScrollOptions {
- onLoadMore: () => void;
- hasMore: boolean;
- isLoading: boolean;
- threshold?: number;
- enabled?: boolean;
+ onLoadMore: () => void;
+ hasMore: boolean;
+ isLoading: boolean;
+ threshold?: number;
+ enabled?: boolean;
}
export function useInfiniteScroll({
- onLoadMore,
- hasMore,
- isLoading,
- threshold = 200,
- enabled = true,
+ onLoadMore,
+ hasMore,
+ isLoading,
+ threshold = 200,
+ enabled = true,
}: UseInfiniteScrollOptions) {
- const scrollAreaRef = useRef<HTMLDivElement>(null);
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
- const handleScroll = useCallback(
- (event: React.UIEvent<HTMLDivElement>) => {
- if (!enabled) return;
+ const handleScroll = useCallback(
+ (event: React.UIEvent<HTMLDivElement>) => {
+ if (!enabled) return;
- const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
- const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
- if (nearBottom && hasMore && !isLoading) {
- onLoadMore();
- }
- },
- [onLoadMore, hasMore, isLoading, threshold, enabled]
- );
+ if (nearBottom && hasMore && !isLoading) {
+ onLoadMore();
+ }
+ },
+ [onLoadMore, hasMore, isLoading, threshold, enabled],
+ );
- return { scrollAreaRef, handleScroll };
+ return { scrollAreaRef, handleScroll };
}
diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts
deleted file mode 100644
index cc5dd340..00000000
--- a/apps/web/src/hooks/use-keybinding-conflicts.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-"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
index 7f6505b0..e59d1eaf 100644
--- a/apps/web/src/hooks/use-keybindings.ts
+++ b/apps/web/src/hooks/use-keybindings.ts
@@ -1,75 +1,75 @@
import { useEffect } from "react";
-import { invokeAction } from "../constants/actions";
+import { invokeAction } from "@/lib/actions";
import { useKeybindingsStore } from "@/stores/keybindings-store";
/**
- * A composable that hooks to the caller component's
+ * 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, isRecording } =
- useKeybindingsStore();
+ const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
+ useKeybindingsStore();
- useEffect(() => {
- const handleKeyDown = (ev: KeyboardEvent) => {
- // Do not check keybinds if the mode is disabled
- if (!keybindingsEnabled) return;
- // ignore key events if user is changing keybindings
- if (isRecording) return;
+ useEffect(() => {
+ const eventOptions: AddEventListenerOptions = { capture: true };
+ const handleKeyDown = (ev: KeyboardEvent) => {
+ // do not check keybinds if the mode is disabled
+ if (!keybindingsEnabled) return;
+ // ignore key events if user is changing keybindings
+ if (isRecording) return;
- const binding = getKeybindingString(ev);
- if (!binding) return;
+ const binding = getKeybindingString(ev);
+ if (!binding) return;
- const boundAction = keybindings[binding];
- if (!boundAction) return;
+ const boundAction = keybindings[binding];
+ if (!boundAction) return;
- const activeElement = document.activeElement;
- const isTextInput =
- activeElement &&
- (activeElement.tagName === "INPUT" ||
- activeElement.tagName === "TEXTAREA" ||
- (activeElement as HTMLElement).isContentEditable);
+ const activeElement = document.activeElement;
+ const isTextInput =
+ activeElement &&
+ (activeElement.tagName === "INPUT" ||
+ activeElement.tagName === "TEXTAREA" ||
+ (activeElement as HTMLElement).isContentEditable);
- if (isTextInput) return;
+ if (isTextInput) return;
- ev.preventDefault();
+ ev.preventDefault();
- // Handle actions with default arguments
- let actionArgs: any;
+ switch (boundAction) {
+ case "seek-forward":
+ invokeAction("seek-forward", { seconds: 1 }, "keypress");
+ break;
+ case "seek-backward":
+ invokeAction("seek-backward", { seconds: 1 }, "keypress");
+ break;
+ case "jump-forward":
+ invokeAction("jump-forward", { seconds: 5 }, "keypress");
+ break;
+ case "jump-backward":
+ invokeAction("jump-backward", { seconds: 5 }, "keypress");
+ break;
+ default:
+ invokeAction(boundAction, undefined, "keypress");
+ }
+ };
- 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 };
- }
+ document.addEventListener("keydown", handleKeyDown, eventOptions);
- invokeAction(boundAction, actionArgs, "keypress");
- };
-
- document.addEventListener("keydown", handleKeyDown);
-
- return () => {
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown, eventOptions);
+ };
+ }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
}
/**
- * This composable allows for the UI component to be disabled if the component in question is mounted
+ * this composable allows for the UI component to be disabled if the component in question is mounted
*/
export function useKeybindingDisabler() {
- const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
+ const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
- return {
- disableKeybindings,
- enableKeybindings,
- };
+ 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
index 29e3c09b..80f7f07a 100644
--- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
+++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
@@ -2,138 +2,81 @@
import { useMemo } from "react";
import { useKeybindingsStore } from "@/stores/keybindings-store";
-import { Action } from "@/constants/actions";
-import { getPlatformAlternateKey, getPlatformSpecialKey } from "@/lib/utils";
+import { ACTIONS, type TAction } from "@/lib/actions";
+import {
+ getPlatformAlternateKey,
+ getPlatformSpecialKey,
+} from "@/utils/platform";
export interface KeyboardShortcut {
- id: string;
- keys: string[];
- description: string;
- category: string;
- action: Action;
- icon?: React.ReactNode;
+ id: string;
+ keys: string[];
+ description: string;
+ category: string;
+ action: TAction;
+ 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" },
- "copy-selected": {
- description: "Copy selected elements",
- category: "Editing",
- },
- "paste-selected": {
- description: "Paste elements at playhead",
- category: "Editing",
- },
-};
+function formatKey({ key }: { key: string }): string {
+ return key
+ .replace("ctrl", getPlatformSpecialKey())
+ .replace("alt", getPlatformAlternateKey())
+ .replace("shift", "Shift")
+ .replace("left", "←")
+ .replace("right", "→")
+ .replace("up", "↑")
+ .replace("down", "↓")
+ .replace("space", "Space")
+ .replace("home", "Home")
+ .replace("enter", "Enter")
+ .replace("end", "End")
+ .replace("delete", "Delete")
+ .replace("backspace", "Backspace")
+ .replace("-", "+");
+}
-// Convert key binding format to display format
-const formatKey = (key: string): string => {
- return key
- .replace("ctrl", getPlatformSpecialKey())
- .replace("alt", getPlatformAlternateKey())
- .replace("shift", "Shift")
- .replace("left", "←")
- .replace("right", "→")
- .replace("up", "↑")
- .replace("down", "↓")
- .replace("space", "Space")
- .replace("home", "Home")
- .replace("enter", "Enter")
- .replace("end", "End")
- .replace("delete", "Delete")
- .replace("backspace", "Backspace")
- .replace("-", "+");
-};
+export function useKeyboardShortcutsHelp() {
+ const { keybindings } = useKeybindingsStore();
-export const useKeyboardShortcutsHelp = () => {
- const { keybindings } = useKeybindingsStore();
+ const shortcuts = useMemo(() => {
+ const result: KeyboardShortcut[] = [];
+ const actionToKeys: Record<string, string[]> = {};
- const shortcuts = useMemo(() => {
- const result: KeyboardShortcut[] = [];
+ for (const [key, action] of Object.entries(keybindings)) {
+ if (action) {
+ if (!actionToKeys[action]) {
+ actionToKeys[action] = [];
+ }
+ actionToKeys[action].push(formatKey({ key }));
+ }
+ }
- // Group keybindings by action
- const actionToKeys: Record<Action, string[]> = {} as any;
+ for (const [actionId, keys] of Object.entries(actionToKeys)) {
+ if (!isAction(actionId)) continue;
- Object.entries(keybindings).forEach(([key, action]) => {
- if (action) {
- if (!actionToKeys[action]) {
- actionToKeys[action] = [];
- }
- actionToKeys[action].push(formatKey(key));
- }
- });
+ const actionDef = ACTIONS[actionId];
+ result.push({
+ id: actionId,
+ keys,
+ description: actionDef.description,
+ category: actionDef.category,
+ action: actionId,
+ });
+ }
- // 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,
- });
- }
- });
+ return result.sort((a, b) => {
+ if (a.category !== b.category) {
+ return a.category.localeCompare(b.category);
+ }
+ return a.description.localeCompare(b.description);
+ });
+ }, [keybindings]);
- // 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,
+ };
+}
- return {
- shortcuts,
- };
-};
+function isAction(id: string): id is TAction {
+ return id in ACTIONS;
+}
diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts
new file mode 100644
index 00000000..d6a67b93
--- /dev/null
+++ b/apps/web/src/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import { useEffect, useState } from "react";
+
+const MOBILE_BREAKPOINT = 768;
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
+
+ useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ };
+ mql.addEventListener("change", onChange);
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ return () => mql.removeEventListener("change", onChange);
+ }, []);
+
+ return !!isMobile;
+}
diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx
deleted file mode 100644
index 48fab93c..00000000
--- a/apps/web/src/hooks/use-mobile.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as React from "react";
-
-const MOBILE_BREAKPOINT = 768;
-
-export function useIsMobile() {
- const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
- undefined
- );
-
- React.useEffect(() => {
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
- const onChange = () => {
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
- };
- mql.addEventListener("change", onChange);
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
- return () => mql.removeEventListener("change", onChange);
- }, []);
-
- return !!isMobile;
-}
diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts
deleted file mode 100644
index c27911d2..00000000
--- a/apps/web/src/hooks/use-playback-controls.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { useEffect, useCallback } from "react";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { toast } from "sonner";
-
-export const usePlaybackControls = () => {
- const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
-
- const {
- selectedElements,
- tracks,
- splitSelected,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- } = useTimelineStore();
-
- const handleSplitSelectedElement = useCallback(() => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element to split");
- return;
- }
-
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
-
- if (!element) return;
-
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
-
- splitSelected(currentTime, trackId, elementId);
- }, [selectedElements, tracks, currentTime, splitSelected]);
-
- const handleSplitAndKeepLeftCallback = useCallback(() => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
-
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
-
- if (!element) return;
-
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
-
- splitAndKeepLeft(trackId, elementId, currentTime);
- }, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
-
- const handleSplitAndKeepRightCallback = useCallback(() => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
-
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
-
- if (!element) return;
-
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
-
- splitAndKeepRight(trackId, elementId, currentTime);
- }, [selectedElements, tracks, currentTime, splitAndKeepRight]);
-
- const handleSeparateAudioCallback = useCallback(() => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one media element to separate audio");
- return;
- }
-
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
-
- if (!track || track.type !== "media") {
- toast.error("Select a media element to separate audio");
- return;
- }
-
- separateAudio(trackId, elementId);
- }, [selectedElements, tracks, separateAudio]);
-};
diff --git a/apps/web/src/hooks/use-raf-loop.ts b/apps/web/src/hooks/use-raf-loop.ts
new file mode 100644
index 00000000..54b1e662
--- /dev/null
+++ b/apps/web/src/hooks/use-raf-loop.ts
@@ -0,0 +1,20 @@
+import { useEffect, useRef } from "react";
+
+export function useRafLoop(callback: ({ time }: { time: number }) => void) {
+ const requestRef = useRef<number>(0);
+ const previousTimeRef = useRef<number | null>(null);
+
+ useEffect(() => {
+ const loop = ({ time }: { time: number }) => {
+ if (previousTimeRef.current !== null) {
+ const deltaTime = time - previousTimeRef.current;
+ callback({ time: deltaTime });
+ }
+ previousTimeRef.current = time;
+ requestRef.current = requestAnimationFrame((time) => loop({ time }));
+ };
+
+ requestRef.current = requestAnimationFrame((time) => loop({ time }));
+ return () => cancelAnimationFrame(requestRef.current);
+ }, [callback]);
+}
diff --git a/apps/web/src/hooks/use-reveal-item.ts b/apps/web/src/hooks/use-reveal-item.ts
new file mode 100644
index 00000000..5004f60c
--- /dev/null
+++ b/apps/web/src/hooks/use-reveal-item.ts
@@ -0,0 +1,36 @@
+import { useEffect, useState, useRef } from "react";
+
+export function useRevealItem(
+ highlightId: string | null,
+ onClearHighlight: () => void,
+ highlightDuration = 1000,
+) {
+ const [highlightedId, setHighlightedId] = useState<string | null>(null);
+ const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
+
+ const registerElement = (id: string, element: HTMLElement | null) => {
+ if (element) {
+ elementRefs.current.set(id, element);
+ } else {
+ elementRefs.current.delete(id);
+ }
+ };
+
+ useEffect(() => {
+ if (!highlightId) return;
+
+ setHighlightedId(highlightId);
+
+ const target = elementRefs.current.get(highlightId);
+ target?.scrollIntoView({ block: "center" });
+
+ const timeout = setTimeout(() => {
+ setHighlightedId(null);
+ onClearHighlight();
+ }, highlightDuration);
+
+ return () => clearTimeout(timeout);
+ }, [highlightId, onClearHighlight, highlightDuration]);
+
+ return { highlightedId, registerElement };
+}
diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts
deleted file mode 100644
index 87026500..00000000
--- a/apps/web/src/hooks/use-selection-box.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-import { useState, useEffect, useCallback } from "react";
-
-interface UseSelectionBoxProps {
- containerRef: React.RefObject<HTMLElement>;
- playheadRef?: React.RefObject<HTMLElement>;
- onSelectionComplete: (
- elements: { trackId: string; elementId: string }[]
- ) => void;
- isEnabled?: boolean;
-}
-
-interface SelectionBoxState {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- isActive: boolean;
-}
-
-export function useSelectionBox({
- containerRef,
- playheadRef,
- onSelectionComplete,
- isEnabled = true,
-}: UseSelectionBoxProps) {
- const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
- null
- );
- const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
-
- // Mouse down handler to start selection
- const handleMouseDown = useCallback(
- (e: React.MouseEvent) => {
- if (!isEnabled) return;
-
- // Only start selection on empty space clicks
- if ((e.target as HTMLElement).closest(".timeline-element")) {
- return;
- }
- if (playheadRef?.current?.contains(e.target as Node)) {
- return;
- }
- if ((e.target as HTMLElement).closest("[data-track-labels]")) {
- return;
- }
- // Don't start selection when clicking in the ruler area - this interferes with playhead dragging
- if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
- return;
- }
-
- setSelectionBox({
- startPos: { x: e.clientX, y: e.clientY },
- currentPos: { x: e.clientX, y: e.clientY },
- isActive: false, // Will become active when mouse moves
- });
- },
- [isEnabled, playheadRef]
- );
-
- // Function to select elements within the selection box
- const selectElementsInBox = useCallback(
- (startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
- if (!containerRef.current) return;
-
- const container = containerRef.current;
- const containerRect = container.getBoundingClientRect();
-
- // Calculate selection rectangle in container coordinates
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const endX = endPos.x - containerRect.left;
- const endY = endPos.y - containerRect.top;
-
- const selectionRect = {
- left: Math.min(startX, endX),
- top: Math.min(startY, endY),
- right: Math.max(startX, endX),
- bottom: Math.max(startY, endY),
- };
-
- // Find all timeline elements within the selection rectangle
- const timelineElements = container.querySelectorAll(".timeline-element");
-
- const selectedElements: { trackId: string; elementId: string }[] = [];
-
- timelineElements.forEach((element) => {
- const elementRect = element.getBoundingClientRect();
- // Use absolute coordinates for more accurate intersection detection
- const elementAbsolute = {
- left: elementRect.left,
- top: elementRect.top,
- right: elementRect.right,
- bottom: elementRect.bottom,
- };
-
- const selectionAbsolute = {
- left: startPos.x,
- top: startPos.y,
- right: endPos.x,
- bottom: endPos.y,
- };
-
- // Normalize selection rectangle (handle dragging in any direction)
- const normalizedSelection = {
- left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
- top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
- right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
- bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
- };
-
- const elementId = element.getAttribute("data-element-id");
- const trackId = element.getAttribute("data-track-id");
-
- // Check if element intersects with selection rectangle (any overlap)
- // Using absolute coordinates for more precise detection
- const intersects = !(
- elementAbsolute.right < normalizedSelection.left ||
- elementAbsolute.left > normalizedSelection.right ||
- elementAbsolute.bottom < normalizedSelection.top ||
- elementAbsolute.top > normalizedSelection.bottom
- );
-
- if (intersects && elementId && trackId) {
- selectedElements.push({ trackId, elementId });
- }
- });
-
- // Always call the callback - with elements or empty array to clear selection
- console.log(
- JSON.stringify({ selectElementsInBox: selectedElements.length })
- );
- onSelectionComplete(selectedElements);
- },
- [containerRef, onSelectionComplete]
- );
-
- // Effect to track selection box movement
- useEffect(() => {
- if (!selectionBox) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
- const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
-
- // Start selection if mouse moved more than 5px
- const shouldActivate = deltaX > 5 || deltaY > 5;
-
- const newSelectionBox = {
- ...selectionBox,
- currentPos: { x: e.clientX, y: e.clientY },
- isActive: shouldActivate || selectionBox.isActive,
- };
-
- setSelectionBox(newSelectionBox);
-
- // Real-time visual feedback: update selection as we drag
- if (newSelectionBox.isActive) {
- selectElementsInBox(
- newSelectionBox.startPos,
- newSelectionBox.currentPos
- );
- }
- };
-
- const handleMouseUp = () => {
- console.log(
- JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
- );
-
- // If we had an active selection, mark that we just finished selecting
- if (selectionBox?.isActive) {
- console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
- setJustFinishedSelecting(true);
- // Clear the flag after a short delay to allow click events to check it
- setTimeout(() => {
- console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
- setJustFinishedSelecting(false);
- }, 50);
- }
-
- // Don't call selectElementsInBox again - real-time selection already handled it
- // Just clean up the selection box visual
- setSelectionBox(null);
- };
-
- window.addEventListener("mousemove", handleMouseMove);
- window.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- window.removeEventListener("mousemove", handleMouseMove);
- window.removeEventListener("mouseup", handleMouseUp);
- };
- }, [selectionBox, selectElementsInBox]);
-
- useEffect(() => {
- if (!selectionBox?.isActive) return;
-
- const previousBodyUserSelect = document.body.style.userSelect;
- const container = containerRef.current;
- const previousContainerUserSelect = container?.style.userSelect ?? "";
-
- document.body.style.userSelect = "none";
- if (container) container.style.userSelect = "none";
-
- return () => {
- document.body.style.userSelect = previousBodyUserSelect;
- if (container) container.style.userSelect = previousContainerUserSelect;
- };
- }, [selectionBox?.isActive, containerRef]);
-
- return {
- selectionBox,
- handleMouseDown,
- isSelecting: selectionBox?.isActive || false,
- justFinishedSelecting,
- };
-}
diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts
index db9a7e8e..263a01e2 100644
--- a/apps/web/src/hooks/use-sound-search.ts
+++ b/apps/web/src/hooks/use-sound-search.ts
@@ -1,156 +1,154 @@
import { useEffect } from "react";
import { useSoundsStore } from "@/stores/sounds-store";
-/**
- * Custom hook for searching sound effects with race condition protection.
- * Uses global Zustand store to persist search state across tab switches.
- * - Debounced search (300ms)
- * - Race condition protection with cleanup
- * - Proper error handling
- */
+export function useSoundSearch({
+ query,
+ commercialOnly,
+}: {
+ query: string;
+ commercialOnly: boolean;
+}) {
+ const {
+ searchResults,
+ isSearching,
+ searchError,
+ lastSearchQuery,
+ currentPage,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ setLoadingMore,
+ appendSearchResults,
+ appendTopSounds,
+ resetPagination,
+ } = useSoundsStore();
-export function useSoundSearch(query: string, commercialOnly: boolean) {
- const {
- searchResults,
- isSearching,
- searchError,
- lastSearchQuery,
- currentPage,
- hasNextPage,
- isLoadingMore,
- totalCount,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- setLoadingMore,
- appendSearchResults,
- appendTopSounds,
- resetPagination,
- } = useSoundsStore();
+ const loadMore = async () => {
+ if (isLoadingMore || !hasNextPage) return;
- // Load more function for infinite scroll
- const loadMore = async () => {
- if (isLoadingMore || !hasNextPage) return;
+ try {
+ setLoadingMore({ loading: true });
+ const nextPage = currentPage + 1;
- try {
- setLoadingMore(true);
- const nextPage = currentPage + 1;
+ const searchParams = new URLSearchParams({
+ page: nextPage.toString(),
+ type: "effects",
+ });
- const searchParams = new URLSearchParams({
- page: nextPage.toString(),
- type: "effects",
- });
+ if (query.trim()) {
+ searchParams.set("q", query);
+ }
- if (query.trim()) {
- searchParams.set("q", query);
- }
+ searchParams.set("commercial_only", commercialOnly.toString());
+ const response = await fetch(
+ `/api/sounds/search?${searchParams.toString()}`,
+ );
- searchParams.set("commercial_only", commercialOnly.toString());
- const response = await fetch(
- `/api/sounds/search?${searchParams.toString()}`
- );
+ if (response.ok) {
+ const data = await response.json();
- if (response.ok) {
- const data = await response.json();
+ if (query.trim()) {
+ appendSearchResults(data.results);
+ } else {
+ appendTopSounds(data.results);
+ }
- // Append to appropriate array based on whether we have a query
- if (query.trim()) {
- appendSearchResults(data.results);
- } else {
- appendTopSounds(data.results);
- }
+ setCurrentPage({ page: nextPage });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount(data.count);
+ } else {
+ setSearchError({ error: `Load more failed: ${response.status}` });
+ }
+ } catch (err) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Load more failed",
+ });
+ } finally {
+ setLoadingMore({ loading: false });
+ }
+ };
- setCurrentPage(nextPage);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- } else {
- setSearchError(`Load more failed: ${response.status}`);
- }
- } catch (err) {
- setSearchError(err instanceof Error ? err.message : "Load more failed");
- } finally {
- setLoadingMore(false);
- }
- };
+ useEffect(() => {
+ if (!query.trim()) {
+ setSearchResults({ results: [] });
+ setSearchError({ error: null });
+ setLastSearchQuery({ query: "" });
+ return;
+ }
- useEffect(() => {
- if (!query.trim()) {
- setSearchResults([]);
- setSearchError(null);
- setLastSearchQuery("");
- // Don't reset pagination here - top sounds pagination is managed by prefetcher
- return;
- }
+ if (query === lastSearchQuery && searchResults.length > 0) {
+ return;
+ }
- // If we already searched for this query and have results, don't search again
- if (query === lastSearchQuery && searchResults.length > 0) {
- return;
- }
+ let ignore = false;
- let ignore = false;
+ const timeoutId = setTimeout(async () => {
+ try {
+ setSearching({ searching: true });
+ setSearchError({ error: null });
+ resetPagination();
- const timeoutId = setTimeout(async () => {
- try {
- setSearching(true);
- setSearchError(null);
- resetPagination();
+ const response = await fetch(
+ `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
+ );
- const response = await fetch(
- `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`
- );
+ if (!ignore) {
+ if (response.ok) {
+ const data = await response.json();
+ setSearchResults({ results: data.results });
+ setLastSearchQuery({ query: query });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ setCurrentPage({ page: 1 });
+ } else {
+ setSearchError({ error: `Search failed: ${response.status}` });
+ }
+ }
+ } catch (err) {
+ if (!ignore) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Search failed",
+ });
+ }
+ } finally {
+ if (!ignore) {
+ setSearching({ searching: false });
+ }
+ }
+ }, 300);
- if (!ignore) {
- if (response.ok) {
- const data = await response.json();
- setSearchResults(data.results);
- setLastSearchQuery(query);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- setCurrentPage(1);
- } else {
- setSearchError(`Search failed: ${response.status}`);
- }
- }
- } catch (err) {
- if (!ignore) {
- setSearchError(err instanceof Error ? err.message : "Search failed");
- }
- } finally {
- if (!ignore) {
- setSearching(false);
- }
- }
- }, 300);
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }, [
+ query,
+ lastSearchQuery,
+ searchResults.length,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ resetPagination,
+ ]);
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }, [
- query,
- lastSearchQuery,
- searchResults.length,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- resetPagination,
- ]);
-
- return {
- results: searchResults,
- isLoading: isSearching,
- error: searchError,
- loadMore,
- hasNextPage,
- isLoadingMore,
- totalCount,
- };
+ return {
+ results: searchResults,
+ isLoading: isSearching,
+ error: searchError,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ };
}
diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts
deleted file mode 100644
index 4812459a..00000000
--- a/apps/web/src/hooks/use-timeline-element-resize.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-import { useState, useEffect } from "react";
-import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
-import { useMediaStore } from "@/stores/media-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
-import { snapTimeToFrame } from "@/constants/timeline-constants";
-
-interface UseTimelineElementResizeProps {
- element: TimelineElement;
- track: TimelineTrack;
- zoomLevel: number;
-}
-
-export function useTimelineElementResize({
- element,
- track,
- zoomLevel,
-}: UseTimelineElementResizeProps) {
- const [resizing, setResizing] = useState<ResizeState | null>(null);
- const { mediaFiles } = useMediaStore();
- const {
- updateElementStartTime,
- updateElementTrim,
- updateElementDuration,
- pushHistory,
- } = useTimelineStore();
-
- // Set up document-level mouse listeners during resize (like proper drag behavior)
- useEffect(() => {
- if (!resizing) return;
-
- const handleDocumentMouseMove = (e: MouseEvent) => {
- updateTrimFromMouseMove({ clientX: e.clientX });
- };
-
- const handleDocumentMouseUp = () => {
- handleResizeEnd();
- };
-
- // Add document-level listeners for proper drag behavior
- document.addEventListener("mousemove", handleDocumentMouseMove);
- document.addEventListener("mouseup", handleDocumentMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleDocumentMouseMove);
- document.removeEventListener("mouseup", handleDocumentMouseUp);
- };
- }, [resizing]); // Re-run when resizing state changes
-
- const handleResizeStart = (
- e: React.MouseEvent,
- elementId: string,
- side: "left" | "right"
- ) => {
- e.stopPropagation();
- e.preventDefault();
-
- // Push history once at the start of the resize operation
- pushHistory();
-
- setResizing({
- elementId,
- side,
- startX: e.clientX,
- initialTrimStart: element.trimStart,
- initialTrimEnd: element.trimEnd,
- });
- };
-
- const canExtendElementDuration = () => {
- // Text elements can always be extended
- if (element.type === "text") {
- return true;
- }
-
- // Media elements - check the media type
- if (element.type === "media") {
- const mediaFile = mediaFiles.find((file) => file.id === element.mediaId);
- if (!mediaFile) return false;
-
- // Images can be extended (static content)
- if (mediaFile.type === "image") {
- return true;
- }
-
- // Videos and audio cannot be extended beyond their natural duration
- // (no additional content exists)
- return false;
- }
-
- return false;
- };
-
- const updateTrimFromMouseMove = (e: { clientX: number }) => {
- if (!resizing) return;
-
- const deltaX = e.clientX - resizing.startX;
- // Reasonable sensitivity for resize operations - similar to timeline scale
- const deltaTime = deltaX / (50 * zoomLevel);
-
- // Get project FPS for frame snapping
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
-
- if (resizing.side === "left") {
- // Left resize - different behavior for media vs text/image elements
- const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
- const calculated = resizing.initialTrimStart + deltaTime;
-
- if (calculated >= 0) {
- // Normal trimming within available content
- const newTrimStart = snapTimeToFrame(
- Math.min(maxAllowed, calculated),
- projectFps
- );
- const trimDelta = newTrimStart - resizing.initialTrimStart;
- const newStartTime = snapTimeToFrame(
- element.startTime + trimDelta,
- projectFps
- );
-
- 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 maxExtension = element.startTime;
- const actualExtension = Math.min(extensionAmount, maxExtension);
- const newStartTime = snapTimeToFrame(
- element.startTime - actualExtension,
- projectFps
- );
- const newDuration = snapTimeToFrame(
- element.duration + actualExtension,
- projectFps
- );
-
- // 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 = snapTimeToFrame(
- element.startTime + trimDelta,
- projectFps
- );
-
- 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;
-
- if (calculated < 0) {
- // We're trying to extend beyond original duration
- if (canExtendElementDuration()) {
- // Extend the duration instead of reducing trimEnd further
- const extensionNeeded = Math.abs(calculated);
- const newDuration = snapTimeToFrame(
- element.duration + extensionNeeded,
- projectFps
- );
- const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
-
- // Update duration first, then trim
- updateElementDuration(track.id, element.id, newDuration, false);
- updateElementTrim(
- track.id,
- element.id,
- resizing.initialTrimStart,
- newTrimEnd,
- false
- );
- } else {
- // Can't extend - just set trimEnd to 0 (maximum possible extension)
- updateElementTrim(
- track.id,
- element.id,
- resizing.initialTrimStart,
- 0,
- false
- );
- }
- } else {
- // Normal trimming within original duration
- // Calculate the desired end time based on mouse movement
- const currentEndTime =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
- const desiredEndTime = currentEndTime + deltaTime;
-
- // Snap the desired end time to frame
- const snappedEndTime = snapTimeToFrame(desiredEndTime, projectFps);
-
- // Calculate what trimEnd should be to achieve this snapped end time
- const newTrimEnd = Math.max(
- 0,
- element.duration -
- element.trimStart -
- (snappedEndTime - element.startTime)
- );
-
- // Ensure we don't trim more than available content (leave at least 0.1s visible)
- const maxTrimEnd = element.duration - element.trimStart - 0.1;
- const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
-
- updateElementTrim(
- track.id,
- element.id,
- element.trimStart,
- finalTrimEnd,
- false
- );
- }
- }
- };
-
- const handleResizeEnd = () => {
- setResizing(null);
- };
-
- return {
- resizing,
- isResizing: resizing !== null,
- handleResizeStart,
- // Return empty handlers since we use document listeners now
- handleResizeMove: () => {}, // Not used anymore
- handleResizeEnd: () => {}, // Not used anymore
- };
-}
diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts
deleted file mode 100644
index 57ce085a..00000000
--- a/apps/web/src/hooks/use-timeline-playhead.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { snapTimeToFrame } from "@/constants/timeline-constants";
-import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useState, useEffect, useCallback, useRef } from "react";
-import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
-
-interface UseTimelinePlayheadProps {
- currentTime: number;
- duration: number;
- zoomLevel: number;
- seek: (time: number) => void;
- rulerRef: React.RefObject<HTMLDivElement>;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
- playheadRef?: React.RefObject<HTMLDivElement>;
-}
-
-export function useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
-}: UseTimelinePlayheadProps) {
- // Playhead scrubbing state
- const [isScrubbing, setIsScrubbing] = useState(false);
- const [scrubTime, setScrubTime] = useState<number | null>(null);
-
- // Ruler drag detection state
- const [isDraggingRuler, setIsDraggingRuler] = useState(false);
- const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
- const lastMouseXRef = useRef<number>(0);
-
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
- // --- Playhead Scrubbing Handlers ---
- const handlePlayheadMouseDown = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation(); // Prevent ruler drag from triggering
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- // Ruler mouse down handler
- const handleRulerMouseDown = useCallback(
- (e: React.MouseEvent) => {
- // Only handle left mouse button
- if (e.button !== 0) return;
-
- // Don't interfere if clicking on the playhead itself
- if (playheadRef?.current?.contains(e.target as Node)) return;
-
- e.preventDefault();
- setIsDraggingRuler(true);
- setHasDraggedRuler(false);
-
- // Start scrubbing immediately
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- const handleScrub = useCallback(
- (e: MouseEvent | React.MouseEvent) => {
- const ruler = rulerRef.current;
- if (!ruler) return;
- const rect = ruler.getBoundingClientRect();
- const rawX = e.clientX - rect.left;
-
- // Get the timeline content width based on duration and zoom
- const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
-
- // Constrain x to be within the timeline content bounds
- const x = Math.max(0, Math.min(timelineContentWidth, rawX));
-
- 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 || DEFAULT_FPS;
- const time = snapTimeToFrame(rawTime, projectFps);
-
- // Debug logging
- if (rawX < 0 || x !== rawX) {
- console.log(
- "PLAYHEAD DEBUG:",
- JSON.stringify({
- mouseX: e.clientX,
- rulerLeft: rect.left,
- rawX,
- constrainedX: x,
- timelineContentWidth,
- rawTime,
- finalTime: time,
- duration,
- zoomLevel,
- playheadPx: time * 50 * zoomLevel,
- })
- );
- }
-
- setScrubTime(time);
- seek(time); // update video preview in real time
-
- // Store mouse position for auto-scrolling
- lastMouseXRef.current = e.clientX;
- },
- [duration, zoomLevel, seek, rulerRef]
- );
-
- useEdgeAutoScroll({
- isActive: isScrubbing,
- getMouseClientX: () => lastMouseXRef.current,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * 50 * zoomLevel,
- });
-
- // 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);
-
- // Edge auto-scroll is handled by useEdgeAutoScroll
-
- return () => {
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- // nothing to cleanup for edge auto scroll
- };
- }, [
- isScrubbing,
- scrubTime,
- seek,
- handleScrub,
- isDraggingRuler,
- hasDraggedRuler,
- // edge auto scroll hook is independent
- ]);
-
- // --- Playhead auto-scroll effect (only during playback) ---
- useEffect(() => {
- const { isPlaying } = usePlaybackStore.getState();
-
- // Only auto-scroll during playback, not during manual interactions
- if (!isPlaying || isScrubbing) return;
-
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- 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;
-
- // Only auto-scroll if playhead is completely out of view (no buffer)
- const needsScroll =
- playheadPx < rulerViewport.scrollLeft ||
- playheadPx > rulerViewport.scrollLeft + viewportWidth;
-
- if (needsScroll) {
- // Center the playhead in the viewport
- const desiredScroll = Math.max(
- scrollMin,
- Math.min(scrollMax, playheadPx - viewportWidth / 2)
- );
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [
- playheadPosition,
- duration,
- zoomLevel,
- rulerScrollRef,
- tracksScrollRef,
- isScrubbing,
- ]);
-
- return {
- playheadPosition,
- handlePlayheadMouseDown,
- handleRulerMouseDown,
- isDraggingRuler,
- };
-}
diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts
deleted file mode 100644
index f7f96392..00000000
--- a/apps/web/src/hooks/use-timeline-snapping.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import { useCallback } from "react";
-import { TimelineTrack } from "@/types/timeline";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-
-export interface SnapPoint {
- time: number;
- type: "element-start" | "element-end" | "playhead";
- elementId?: string;
- trackId?: string;
-}
-
-export interface SnapResult {
- snappedTime: number;
- snapPoint: SnapPoint | null;
- snapDistance: number;
-}
-
-export interface UseTimelineSnappingOptions {
- snapThreshold?: number; // Distance in pixels to trigger snapping
- enableElementSnapping?: boolean;
- enablePlayheadSnapping?: boolean;
-}
-
-export function useTimelineSnapping({
- snapThreshold = 10,
- enableElementSnapping = true,
- enablePlayheadSnapping = true,
-}: UseTimelineSnappingOptions = {}) {
- const findSnapPoints = useCallback(
- (
- tracks: TimelineTrack[],
- currentTime: number,
- playheadTime: number,
- zoomLevel: number,
- excludeElementId?: string
- ): SnapPoint[] => {
- const snapPoints: SnapPoint[] = [];
-
- // Add element snap points
- if (enableElementSnapping) {
- tracks.forEach((track) => {
- track.elements.forEach((element) => {
- // Skip the element being dragged
- if (element.id === excludeElementId) return;
-
- const elementStart = element.startTime;
- const elementEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- snapPoints.push(
- {
- time: elementStart,
- type: "element-start",
- elementId: element.id,
- trackId: track.id,
- },
- {
- time: elementEnd,
- type: "element-end",
- elementId: element.id,
- trackId: track.id,
- }
- );
- });
- });
- }
-
- // Add playhead snap point
- if (enablePlayheadSnapping) {
- snapPoints.push({
- time: playheadTime,
- type: "playhead",
- });
- }
-
- return snapPoints;
- },
- [enableElementSnapping, enablePlayheadSnapping]
- );
-
- const snapToNearestPoint = useCallback(
- (
- targetTime: number,
- snapPoints: SnapPoint[],
- zoomLevel: number
- ): SnapResult => {
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const thresholdInSeconds = snapThreshold / pixelsPerSecond;
-
- let closestSnapPoint: SnapPoint | null = null;
- let closestDistance = Infinity;
-
- snapPoints.forEach((snapPoint) => {
- const distance = Math.abs(targetTime - snapPoint.time);
- if (distance < thresholdInSeconds && distance < closestDistance) {
- closestDistance = distance;
- closestSnapPoint = snapPoint;
- }
- });
-
- return {
- snappedTime: closestSnapPoint
- ? (closestSnapPoint as SnapPoint).time
- : targetTime,
- snapPoint: closestSnapPoint,
- snapDistance: closestDistance,
- };
- },
- [snapThreshold]
- );
-
- const snapElementPosition = useCallback(
- (
- targetTime: number,
- tracks: TimelineTrack[],
- playheadTime: number,
- zoomLevel: number,
- excludeElementId?: string
- ): SnapResult => {
- const snapPoints = findSnapPoints(
- tracks,
- targetTime,
- playheadTime,
- zoomLevel,
- excludeElementId
- );
-
- return snapToNearestPoint(targetTime, snapPoints, zoomLevel);
- },
- [findSnapPoints, snapToNearestPoint]
- );
-
- const snapElementEdge = useCallback(
- (
- targetTime: number,
- elementDuration: number,
- tracks: TimelineTrack[],
- playheadTime: number,
- zoomLevel: number,
- excludeElementId?: string,
- snapToStart = true // true for start edge, false for end edge
- ): SnapResult => {
- const snapPoints = findSnapPoints(
- tracks,
- targetTime,
- playheadTime,
- zoomLevel,
- excludeElementId
- );
-
- // For end edge snapping, we need to account for element duration
- const effectiveTargetTime = snapToStart
- ? targetTime
- : targetTime + elementDuration;
- const snapResult = snapToNearestPoint(
- effectiveTargetTime,
- snapPoints,
- zoomLevel
- );
-
- // Adjust the snapped time back for end edge
- if (!snapToStart && snapResult.snapPoint) {
- snapResult.snappedTime = snapResult.snappedTime - elementDuration;
- }
-
- return snapResult;
- },
- [findSnapPoints, snapToNearestPoint]
- );
-
- return {
- snapElementPosition,
- snapElementEdge,
- findSnapPoints,
- snapToNearestPoint,
- };
-}
diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts
deleted file mode 100644
index e764c550..00000000
--- a/apps/web/src/hooks/use-timeline-zoom.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useState, useCallback, useEffect, RefObject } from "react";
-
-interface UseTimelineZoomProps {
- containerRef: RefObject<HTMLDivElement>;
- isInTimeline?: boolean;
-}
-
-interface UseTimelineZoomReturn {
- zoomLevel: number;
- setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
- handleWheel: (e: React.WheelEvent) => void;
-}
-
-export function useTimelineZoom({
- containerRef,
- isInTimeline = false,
-}: UseTimelineZoomProps): UseTimelineZoomReturn {
- const [zoomLevel, setZoomLevel] = useState(1);
-
- 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 ? -0.15 : 0.15;
- setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
- }
- // For horizontal scrolling (when shift is held or horizontal wheel movement),
- // let the event bubble up to allow ScrollArea to handle it
- else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
- // Don't prevent default - let ScrollArea handle horizontal scrolling
- return;
- }
- // Otherwise, allow normal scrolling
- }, []);
-
- // 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,
- setZoomLevel,
- handleWheel,
- };
-}
diff --git a/apps/web/src/hooks/use-toast.ts b/apps/web/src/hooks/use-toast.ts
deleted file mode 100644
index dcc02dcd..00000000
--- a/apps/web/src/hooks/use-toast.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-"use client";
-
-// Inspired by react-hot-toast library
-import * as React from "react";
-
-import type { ToastActionElement, ToastProps } from "../components/ui/toast";
-
-const TOAST_LIMIT = 1;
-const TOAST_REMOVE_DELAY = 1_000_000;
-
-type ToasterToast = ToastProps & {
- id: string;
- title?: React.ReactNode;
- description?: React.ReactNode;
- action?: ToastActionElement;
-};
-
-const actionTypes = {
- ADD_TOAST: "ADD_TOAST",
- UPDATE_TOAST: "UPDATE_TOAST",
- DISMISS_TOAST: "DISMISS_TOAST",
- REMOVE_TOAST: "REMOVE_TOAST",
-} as const;
-
-let count = 0;
-
-function genId() {
- count = (count + 1) % Number.MAX_SAFE_INTEGER;
- return count.toString();
-}
-
-type ActionType = typeof actionTypes;
-
-type Action =
- | {
- type: ActionType["ADD_TOAST"];
- toast: ToasterToast;
- }
- | {
- type: ActionType["UPDATE_TOAST"];
- toast: Partial<ToasterToast>;
- }
- | {
- type: ActionType["DISMISS_TOAST"];
- toastId?: ToasterToast["id"];
- }
- | {
- type: ActionType["REMOVE_TOAST"];
- toastId?: ToasterToast["id"];
- };
-
-interface State {
- toasts: ToasterToast[];
-}
-
-const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
-
-const addToRemoveQueue = (toastId: string) => {
- if (toastTimeouts.has(toastId)) {
- return;
- }
-
- const timeout = setTimeout(() => {
- toastTimeouts.delete(toastId);
- dispatch({
- type: "REMOVE_TOAST",
- toastId,
- });
- }, TOAST_REMOVE_DELAY);
-
- toastTimeouts.set(toastId, timeout);
-};
-
-export const reducer = (state: State, action: Action): State => {
- switch (action.type) {
- case "ADD_TOAST":
- return {
- ...state,
- toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
- };
-
- case "UPDATE_TOAST":
- return {
- ...state,
- toasts: state.toasts.map((t) =>
- t.id === action.toast.id ? { ...t, ...action.toast } : t
- ),
- };
-
- case "DISMISS_TOAST": {
- const { toastId } = action;
-
- // ! Side effects ! - This could be extracted into a dismissToast() action,
- // but I'll keep it here for simplicity
- if (toastId) {
- addToRemoveQueue(toastId);
- } else {
- state.toasts.forEach((toast) => {
- addToRemoveQueue(toast.id);
- });
- }
-
- return {
- ...state,
- toasts: state.toasts.map((t) =>
- t.id === toastId || toastId === undefined
- ? {
- ...t,
- open: false,
- }
- : t
- ),
- };
- }
- case "REMOVE_TOAST":
- if (action.toastId === undefined) {
- return {
- ...state,
- toasts: [],
- };
- }
- return {
- ...state,
- toasts: state.toasts.filter((t) => t.id !== action.toastId),
- };
- }
-};
-
-const listeners: Array<(state: State) => void> = [];
-
-let memoryState: State = { toasts: [] };
-
-function dispatch(action: Action) {
- memoryState = reducer(memoryState, action);
- listeners.forEach((listener) => {
- listener(memoryState);
- });
-}
-
-type Toast = Omit<ToasterToast, "id">;
-
-function toast({ ...props }: Toast) {
- const id = genId();
-
- const update = (props: ToasterToast) =>
- dispatch({
- type: "UPDATE_TOAST",
- toast: { ...props, id },
- });
- const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
-
- dispatch({
- type: "ADD_TOAST",
- toast: {
- ...props,
- id,
- open: true,
- onOpenChange: (open) => {
- if (!open) dismiss();
- },
- },
- });
-
- return {
- id,
- dismiss,
- update,
- };
-}
-
-function useToast() {
- const [state, setState] = React.useState<State>(memoryState);
-
- React.useEffect(() => {
- listeners.push(setState);
- return () => {
- const index = listeners.indexOf(setState);
- if (index > -1) {
- listeners.splice(index, 1);
- }
- };
- }, [state]);
-
- return {
- ...state,
- toast,
- dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
- };
-}
-
-export { useToast, toast };