summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks/timeline')
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-selection.ts274
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts458
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts562
-rw-r--r--apps/web/src/hooks/timeline/use-edge-auto-scroll.ts194
-rw-r--r--apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts84
-rw-r--r--apps/web/src/hooks/timeline/use-scroll-sync.ts346
-rw-r--r--apps/web/src/hooks/timeline/use-snap-indicator-position.ts108
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts1218
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-seek.ts394
9 files changed, 1819 insertions, 1819 deletions
diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts
index dcfb5720..42187b7f 100644
--- a/apps/web/src/hooks/timeline/element/use-element-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts
@@ -1,137 +1,137 @@
-import { useCallback } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import type { ElementRef } from "@/lib/timeline/types";
-
-export function useElementSelection() {
- const editor = useEditor();
- const selectedElements = useEditor((e) => e.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],
- );
-
-
- /**
- * Merges elements into the current selection, deduplicating by identity.
- * Used for additive box-select where the pre-drag selection is preserved.
- */
- const mergeElementsIntoSelection = useCallback(
- ({ elements }: { elements: ElementRef[] }) => {
- const merged = [
- ...selectedElements.filter(
- (selectedElement) =>
- !elements.some(
- (element) =>
- element.trackId === selectedElement.trackId &&
- element.elementId === selectedElement.elementId,
- ),
- ),
- ...elements,
- ];
- editor.selection.setSelectedElements({ elements: merged });
- },
- [selectedElements, 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,
- mergeElementsIntoSelection,
- addElementToSelection,
- removeElementFromSelection,
- toggleElementSelection,
- clearElementSelection,
- handleElementClick,
- };
-}
+import { useCallback } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import type { ElementRef } from "@/lib/timeline/types";
+
+export function useElementSelection() {
+ const editor = useEditor();
+ const selectedElements = useEditor((e) => e.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],
+ );
+
+
+ /**
+ * Merges elements into the current selection, deduplicating by identity.
+ * Used for additive box-select where the pre-drag selection is preserved.
+ */
+ const mergeElementsIntoSelection = useCallback(
+ ({ elements }: { elements: ElementRef[] }) => {
+ const merged = [
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !elements.some(
+ (element) =>
+ element.trackId === selectedElement.trackId &&
+ element.elementId === selectedElement.elementId,
+ ),
+ ),
+ ...elements,
+ ];
+ editor.selection.setSelectedElements({ elements: merged });
+ },
+ [selectedElements, 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,
+ mergeElementsIntoSelection,
+ addElementToSelection,
+ removeElementFromSelection,
+ toggleElementSelection,
+ clearElementSelection,
+ handleElementClick,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
index 8634cb48..1a219d1f 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
@@ -1,229 +1,229 @@
-import { useCallback, useSyncExternalStore } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import type { SelectedKeyframeRef } from "@/lib/animation/types";
-
-function getSelectedKeyframeId({
- keyframe,
-}: {
- keyframe: SelectedKeyframeRef;
-}): string {
- return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
-}
-
-function mergeUniqueKeyframes({
- keyframes,
-}: {
- keyframes: SelectedKeyframeRef[];
-}): SelectedKeyframeRef[] {
- const keyframesById = new Map<string, SelectedKeyframeRef>();
- for (const keyframe of keyframes) {
- keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
- }
- return [...keyframesById.values()];
-}
-
-export function useKeyframeSelection() {
- const editor = useEditor();
- const selectedKeyframes = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedKeyframes(),
- );
- const keyframeSelectionAnchor = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getKeyframeSelectionAnchor(),
- );
-
- const isKeyframeSelected = useCallback(
- ({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
- const keyframeId = getSelectedKeyframeId({ keyframe });
- return selectedKeyframes.some(
- (selectedKeyframe) =>
- getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
- );
- },
- [selectedKeyframes],
- );
-
- const setKeyframeSelection = useCallback(
- ({
- keyframes,
- anchorKeyframe,
- }: {
- keyframes: SelectedKeyframeRef[];
- anchorKeyframe?: SelectedKeyframeRef;
- }) => {
- const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
- editor.selection.setSelectedKeyframes({
- keyframes: uniqueKeyframes,
- anchorKeyframe:
- anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
- });
- },
- [editor],
- );
-
- const addKeyframesToSelection = useCallback(
- ({
- keyframes,
- anchorKeyframe,
- }: {
- keyframes: SelectedKeyframeRef[];
- anchorKeyframe?: SelectedKeyframeRef;
- }) => {
- const mergedKeyframes = mergeUniqueKeyframes({
- keyframes: [...selectedKeyframes, ...keyframes],
- });
- editor.selection.setSelectedKeyframes({
- keyframes: mergedKeyframes,
- anchorKeyframe:
- anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
- });
- },
- [selectedKeyframes, editor],
- );
-
- const removeKeyframesFromSelection = useCallback(
- ({
- keyframes,
- anchorKeyframe,
- }: {
- keyframes: SelectedKeyframeRef[];
- anchorKeyframe?: SelectedKeyframeRef;
- }) => {
- const keyframeIdsToRemove = new Set(
- keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
- );
- const nextKeyframes = selectedKeyframes.filter(
- (selectedKeyframe) =>
- !keyframeIdsToRemove.has(
- getSelectedKeyframeId({ keyframe: selectedKeyframe }),
- ),
- );
- editor.selection.setSelectedKeyframes({
- keyframes: nextKeyframes,
- anchorKeyframe:
- anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
- });
- },
- [selectedKeyframes, editor],
- );
-
- const clearKeyframeSelection = useCallback(() => {
- editor.selection.clearKeyframeSelection();
- }, [editor]);
-
- const toggleKeyframeSelection = useCallback(
- ({
- keyframes,
- isMultiKey,
- }: {
- keyframes: SelectedKeyframeRef[];
- isMultiKey: boolean;
- }) => {
- const anchorKeyframe = keyframes[0];
- const areAllKeyframesSelected = keyframes.every((keyframe) =>
- isKeyframeSelected({ keyframe }),
- );
- if (!isMultiKey) {
- setKeyframeSelection({ keyframes, anchorKeyframe });
- return;
- }
-
- if (areAllKeyframesSelected) {
- removeKeyframesFromSelection({ keyframes, anchorKeyframe });
- return;
- }
-
- addKeyframesToSelection({ keyframes, anchorKeyframe });
- },
- [
- setKeyframeSelection,
- isKeyframeSelected,
- removeKeyframesFromSelection,
- addKeyframesToSelection,
- ],
- );
-
- const selectKeyframeRange = useCallback(
- ({
- orderedKeyframes,
- targetKeyframes,
- isAdditive,
- }: {
- orderedKeyframes: SelectedKeyframeRef[];
- targetKeyframes: SelectedKeyframeRef[];
- isAdditive: boolean;
- }) => {
- if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
- return;
- }
-
- const anchorKeyframe =
- keyframeSelectionAnchor ??
- selectedKeyframes[selectedKeyframes.length - 1] ??
- targetKeyframes[0];
- if (!anchorKeyframe) {
- return;
- }
-
- const targetKeyframeIds = new Set(
- targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
- );
- const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
- const anchorIndex = orderedKeyframes.findIndex(
- (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
- );
- if (anchorIndex === -1) {
- if (isAdditive) {
- addKeyframesToSelection({
- keyframes: targetKeyframes,
- anchorKeyframe,
- });
- return;
- }
- setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
- return;
- }
-
- const targetIndexes = orderedKeyframes
- .map((keyframe, index) => ({
- keyframeId: getSelectedKeyframeId({ keyframe }),
- index,
- }))
- .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
- .map(({ index }) => index);
- if (targetIndexes.length === 0) {
- return;
- }
-
- const rangeStart = Math.min(anchorIndex, ...targetIndexes);
- const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
- const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
-
- if (isAdditive) {
- addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
- return;
- }
-
- setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
- },
- [
- keyframeSelectionAnchor,
- selectedKeyframes,
- addKeyframesToSelection,
- setKeyframeSelection,
- ],
- );
-
- return {
- selectedKeyframes,
- keyframeSelectionAnchor,
- isKeyframeSelected,
- setKeyframeSelection,
- addKeyframesToSelection,
- removeKeyframesFromSelection,
- clearKeyframeSelection,
- toggleKeyframeSelection,
- selectKeyframeRange,
- };
-}
+import { useCallback, useSyncExternalStore } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
+
+function getSelectedKeyframeId({
+ keyframe,
+}: {
+ keyframe: SelectedKeyframeRef;
+}): string {
+ return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
+}
+
+function mergeUniqueKeyframes({
+ keyframes,
+}: {
+ keyframes: SelectedKeyframeRef[];
+}): SelectedKeyframeRef[] {
+ const keyframesById = new Map<string, SelectedKeyframeRef>();
+ for (const keyframe of keyframes) {
+ keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
+ }
+ return [...keyframesById.values()];
+}
+
+export function useKeyframeSelection() {
+ const editor = useEditor();
+ const selectedKeyframes = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getSelectedKeyframes(),
+ );
+ const keyframeSelectionAnchor = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getKeyframeSelectionAnchor(),
+ );
+
+ const isKeyframeSelected = useCallback(
+ ({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
+ const keyframeId = getSelectedKeyframeId({ keyframe });
+ return selectedKeyframes.some(
+ (selectedKeyframe) =>
+ getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
+ );
+ },
+ [selectedKeyframes],
+ );
+
+ const setKeyframeSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
+ editor.selection.setSelectedKeyframes({
+ keyframes: uniqueKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
+ });
+ },
+ [editor],
+ );
+
+ const addKeyframesToSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const mergedKeyframes = mergeUniqueKeyframes({
+ keyframes: [...selectedKeyframes, ...keyframes],
+ });
+ editor.selection.setSelectedKeyframes({
+ keyframes: mergedKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
+ });
+ },
+ [selectedKeyframes, editor],
+ );
+
+ const removeKeyframesFromSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const keyframeIdsToRemove = new Set(
+ keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
+ );
+ const nextKeyframes = selectedKeyframes.filter(
+ (selectedKeyframe) =>
+ !keyframeIdsToRemove.has(
+ getSelectedKeyframeId({ keyframe: selectedKeyframe }),
+ ),
+ );
+ editor.selection.setSelectedKeyframes({
+ keyframes: nextKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
+ });
+ },
+ [selectedKeyframes, editor],
+ );
+
+ const clearKeyframeSelection = useCallback(() => {
+ editor.selection.clearKeyframeSelection();
+ }, [editor]);
+
+ const toggleKeyframeSelection = useCallback(
+ ({
+ keyframes,
+ isMultiKey,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ isMultiKey: boolean;
+ }) => {
+ const anchorKeyframe = keyframes[0];
+ const areAllKeyframesSelected = keyframes.every((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ if (!isMultiKey) {
+ setKeyframeSelection({ keyframes, anchorKeyframe });
+ return;
+ }
+
+ if (areAllKeyframesSelected) {
+ removeKeyframesFromSelection({ keyframes, anchorKeyframe });
+ return;
+ }
+
+ addKeyframesToSelection({ keyframes, anchorKeyframe });
+ },
+ [
+ setKeyframeSelection,
+ isKeyframeSelected,
+ removeKeyframesFromSelection,
+ addKeyframesToSelection,
+ ],
+ );
+
+ const selectKeyframeRange = useCallback(
+ ({
+ orderedKeyframes,
+ targetKeyframes,
+ isAdditive,
+ }: {
+ orderedKeyframes: SelectedKeyframeRef[];
+ targetKeyframes: SelectedKeyframeRef[];
+ isAdditive: boolean;
+ }) => {
+ if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
+ return;
+ }
+
+ const anchorKeyframe =
+ keyframeSelectionAnchor ??
+ selectedKeyframes[selectedKeyframes.length - 1] ??
+ targetKeyframes[0];
+ if (!anchorKeyframe) {
+ return;
+ }
+
+ const targetKeyframeIds = new Set(
+ targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
+ );
+ const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
+ const anchorIndex = orderedKeyframes.findIndex(
+ (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
+ );
+ if (anchorIndex === -1) {
+ if (isAdditive) {
+ addKeyframesToSelection({
+ keyframes: targetKeyframes,
+ anchorKeyframe,
+ });
+ return;
+ }
+ setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
+ return;
+ }
+
+ const targetIndexes = orderedKeyframes
+ .map((keyframe, index) => ({
+ keyframeId: getSelectedKeyframeId({ keyframe }),
+ index,
+ }))
+ .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
+ .map(({ index }) => index);
+ if (targetIndexes.length === 0) {
+ return;
+ }
+
+ const rangeStart = Math.min(anchorIndex, ...targetIndexes);
+ const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
+ const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
+
+ if (isAdditive) {
+ addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
+ return;
+ }
+
+ setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
+ },
+ [
+ keyframeSelectionAnchor,
+ selectedKeyframes,
+ addKeyframesToSelection,
+ setKeyframeSelection,
+ ],
+ );
+
+ return {
+ selectedKeyframes,
+ keyframeSelectionAnchor,
+ isKeyframeSelected,
+ setKeyframeSelection,
+ addKeyframesToSelection,
+ removeKeyframesFromSelection,
+ clearKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
index 512db821..e7e70495 100644
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -1,281 +1,281 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type RefObject,
-} from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
-import { snapTimeToFrame } from "@/lib/time";
-import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
-import {
- findSnapPoints,
- snapToNearestPoint,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
-import type { Bookmark } from "@/lib/timeline";
-
-export interface BookmarkDragState {
- isDragging: boolean;
- bookmarkTime: number | null;
- currentTime: number;
-}
-
-interface PendingBookmarkDrag {
- bookmarkTime: number;
- startMouseX: number;
- startMouseY: number;
-}
-
-interface UseBookmarkDragProps {
- zoomLevel: number;
- scrollRef: RefObject<HTMLElement | null>;
- snappingEnabled: boolean;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}
-
-export function useBookmarkDrag({
- zoomLevel,
- scrollRef,
- snappingEnabled,
- onSnapPointChange,
-}: UseBookmarkDragProps) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const tracks = editor.timeline.getTracks();
- const activeScene = editor.scenes.getActiveScene();
- const bookmarks = activeScene?.bookmarks ?? [];
- const playheadTime = editor.playback.getCurrentTime();
- const duration = editor.timeline.getTotalDuration();
-
- const [dragState, setDragState] = useState<BookmarkDragState>({
- isDragging: false,
- bookmarkTime: null,
- currentTime: 0,
- });
- const [isPendingDrag, setIsPendingDrag] = useState(false);
- const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
- const lastMouseXRef = useRef(0);
-
- const startDrag = useCallback(
- ({
- bookmarkTime,
- initialCurrentTime,
- }: {
- bookmarkTime: number;
- initialCurrentTime: number;
- }) => {
- setDragState({
- isDragging: true,
- bookmarkTime,
- currentTime: initialCurrentTime,
- });
- },
- [],
- );
-
- const endDrag = useCallback(() => {
- setDragState({
- isDragging: false,
- bookmarkTime: null,
- currentTime: 0,
- });
- }, []);
-
- const getSnapResult = useCallback(
- ({
- rawTime,
- excludeBookmarkTime,
- }: {
- rawTime: number;
- excludeBookmarkTime: number;
- }): { snappedTime: number; snapPoint: SnapPoint | null } => {
- const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
- if (!shouldSnap) {
- return { snappedTime: rawTime, snapPoint: null };
- }
-
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- bookmarks,
- excludeBookmarkTime,
- });
- const result = snapToNearestPoint({
- targetTime: rawTime,
- snapPoints,
- zoomLevel,
- });
- return {
- snappedTime: result.snappedTime,
- snapPoint: result.snapPoint,
- };
- },
- [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- const handleMouseMove = (event: MouseEvent) => {
- lastMouseXRef.current = event.clientX;
-
- const scrollContainer = scrollRef.current;
- if (!scrollContainer) return;
-
- if (isPendingDrag && pendingDragRef.current) {
- const { startMouseX, startMouseY, bookmarkTime } =
- pendingDragRef.current;
- const deltaX = Math.abs(event.clientX - startMouseX);
- const deltaY = Math.abs(event.clientY - startMouseY);
-
- if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
- return;
- }
-
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- const scrollLeft = scrollContainer.scrollLeft;
- const mouseTime = getMouseTimeFromClientX({
- clientX: event.clientX,
- containerRect: scrollContainer.getBoundingClientRect(),
- zoomLevel,
- scrollLeft,
- });
- const frameSnappedTime = snapTimeToFrame({
- time: Math.max(0, Math.min(mouseTime, duration)),
- fps: activeProject.settings.fps,
- });
- const { snappedTime: initialTime } = getSnapResult({
- rawTime: frameSnappedTime,
- excludeBookmarkTime: bookmarkTime,
- });
-
- startDrag({
- bookmarkTime,
- initialCurrentTime: initialTime,
- });
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- return;
- }
-
- if (!dragState.isDragging || dragState.bookmarkTime === null) return;
-
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- const scrollLeft = scrollContainer.scrollLeft;
- const mouseTime = getMouseTimeFromClientX({
- clientX: event.clientX,
- containerRect: scrollContainer.getBoundingClientRect(),
- zoomLevel,
- scrollLeft,
- });
- const clampedTime = Math.max(0, Math.min(mouseTime, duration));
- const frameSnappedTime = snapTimeToFrame({
- time: clampedTime,
- fps: activeProject.settings.fps,
- });
- const snapResult = getSnapResult({
- rawTime: frameSnappedTime,
- excludeBookmarkTime: dragState.bookmarkTime,
- });
-
- setDragState((previousDragState) => ({
- ...previousDragState,
- currentTime: snapResult.snappedTime,
- }));
- onSnapPointChange?.(snapResult.snapPoint);
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- return () => document.removeEventListener("mousemove", handleMouseMove);
- }, [
- dragState.isDragging,
- dragState.bookmarkTime,
- zoomLevel,
- duration,
- editor.project,
- scrollRef,
- isPendingDrag,
- startDrag,
- getSnapResult,
- onSnapPointChange,
- ]);
-
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseUp = () => {
- if (dragState.bookmarkTime === null) {
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
-
- const clampedTime = Math.max(
- 0,
- Math.min(dragState.currentTime, duration),
- );
-
- editor.scenes.moveBookmark({
- fromTime: dragState.bookmarkTime,
- toTime: clampedTime,
- });
-
- endDrag();
- onSnapPointChange?.(null);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [
- dragState.isDragging,
- dragState.bookmarkTime,
- dragState.currentTime,
- duration,
- endDrag,
- onSnapPointChange,
- editor.scenes,
- ]);
-
- 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 handleBookmarkMouseDown = useCallback(
- ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
- if (event.button !== 0) return;
-
- event.preventDefault();
- event.stopPropagation();
-
- pendingDragRef.current = {
- bookmarkTime: bookmark.time,
- startMouseX: event.clientX,
- startMouseY: event.clientY,
- };
- setIsPendingDrag(true);
- },
- [],
- );
-
- return {
- dragState,
- handleBookmarkMouseDown,
- lastMouseXRef,
- };
-}
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type RefObject,
+} from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
+import { snapTimeToFrame } from "@/lib/time";
+import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
+import {
+ findSnapPoints,
+ snapToNearestPoint,
+ type SnapPoint,
+} from "@/lib/timeline/snap-utils";
+import type { Bookmark } from "@/lib/timeline";
+
+export interface BookmarkDragState {
+ isDragging: boolean;
+ bookmarkTime: number | null;
+ currentTime: number;
+}
+
+interface PendingBookmarkDrag {
+ bookmarkTime: number;
+ startMouseX: number;
+ startMouseY: number;
+}
+
+interface UseBookmarkDragProps {
+ zoomLevel: number;
+ scrollRef: RefObject<HTMLElement | null>;
+ snappingEnabled: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}
+
+export function useBookmarkDrag({
+ zoomLevel,
+ scrollRef,
+ snappingEnabled,
+ onSnapPointChange,
+}: UseBookmarkDragProps) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const tracks = editor.timeline.getTracks();
+ const activeScene = editor.scenes.getActiveScene();
+ const bookmarks = activeScene?.bookmarks ?? [];
+ const playheadTime = editor.playback.getCurrentTime();
+ const duration = editor.timeline.getTotalDuration();
+
+ const [dragState, setDragState] = useState<BookmarkDragState>({
+ isDragging: false,
+ bookmarkTime: null,
+ currentTime: 0,
+ });
+ const [isPendingDrag, setIsPendingDrag] = useState(false);
+ const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
+ const lastMouseXRef = useRef(0);
+
+ const startDrag = useCallback(
+ ({
+ bookmarkTime,
+ initialCurrentTime,
+ }: {
+ bookmarkTime: number;
+ initialCurrentTime: number;
+ }) => {
+ setDragState({
+ isDragging: true,
+ bookmarkTime,
+ currentTime: initialCurrentTime,
+ });
+ },
+ [],
+ );
+
+ const endDrag = useCallback(() => {
+ setDragState({
+ isDragging: false,
+ bookmarkTime: null,
+ currentTime: 0,
+ });
+ }, []);
+
+ const getSnapResult = useCallback(
+ ({
+ rawTime,
+ excludeBookmarkTime,
+ }: {
+ rawTime: number;
+ excludeBookmarkTime: number;
+ }): { snappedTime: number; snapPoint: SnapPoint | null } => {
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ if (!shouldSnap) {
+ return { snappedTime: rawTime, snapPoint: null };
+ }
+
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ bookmarks,
+ excludeBookmarkTime,
+ });
+ const result = snapToNearestPoint({
+ targetTime: rawTime,
+ snapPoints,
+ zoomLevel,
+ });
+ return {
+ snappedTime: result.snappedTime,
+ snapPoint: result.snapPoint,
+ };
+ },
+ [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ const handleMouseMove = (event: MouseEvent) => {
+ lastMouseXRef.current = event.clientX;
+
+ const scrollContainer = scrollRef.current;
+ if (!scrollContainer) return;
+
+ if (isPendingDrag && pendingDragRef.current) {
+ const { startMouseX, startMouseY, bookmarkTime } =
+ pendingDragRef.current;
+ const deltaX = Math.abs(event.clientX - startMouseX);
+ const deltaY = Math.abs(event.clientY - startMouseY);
+
+ if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
+ return;
+ }
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX: event.clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const frameSnappedTime = snapTimeToFrame({
+ time: Math.max(0, Math.min(mouseTime, duration)),
+ fps: activeProject.settings.fps,
+ });
+ const { snappedTime: initialTime } = getSnapResult({
+ rawTime: frameSnappedTime,
+ excludeBookmarkTime: bookmarkTime,
+ });
+
+ startDrag({
+ bookmarkTime,
+ initialCurrentTime: initialTime,
+ });
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ return;
+ }
+
+ if (!dragState.isDragging || dragState.bookmarkTime === null) return;
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX: event.clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const clampedTime = Math.max(0, Math.min(mouseTime, duration));
+ const frameSnappedTime = snapTimeToFrame({
+ time: clampedTime,
+ fps: activeProject.settings.fps,
+ });
+ const snapResult = getSnapResult({
+ rawTime: frameSnappedTime,
+ excludeBookmarkTime: dragState.bookmarkTime,
+ });
+
+ setDragState((previousDragState) => ({
+ ...previousDragState,
+ currentTime: snapResult.snappedTime,
+ }));
+ onSnapPointChange?.(snapResult.snapPoint);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ return () => document.removeEventListener("mousemove", handleMouseMove);
+ }, [
+ dragState.isDragging,
+ dragState.bookmarkTime,
+ zoomLevel,
+ duration,
+ editor.project,
+ scrollRef,
+ isPendingDrag,
+ startDrag,
+ getSnapResult,
+ onSnapPointChange,
+ ]);
+
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseUp = () => {
+ if (dragState.bookmarkTime === null) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+
+ const clampedTime = Math.max(
+ 0,
+ Math.min(dragState.currentTime, duration),
+ );
+
+ editor.scenes.moveBookmark({
+ fromTime: dragState.bookmarkTime,
+ toTime: clampedTime,
+ });
+
+ endDrag();
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ dragState.isDragging,
+ dragState.bookmarkTime,
+ dragState.currentTime,
+ duration,
+ endDrag,
+ onSnapPointChange,
+ editor.scenes,
+ ]);
+
+ 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 handleBookmarkMouseDown = useCallback(
+ ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
+ if (event.button !== 0) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ pendingDragRef.current = {
+ bookmarkTime: bookmark.time,
+ startMouseX: event.clientX,
+ startMouseY: event.clientY,
+ };
+ setIsPendingDrag(true);
+ },
+ [],
+ );
+
+ return {
+ dragState,
+ handleBookmarkMouseDown,
+ lastMouseXRef,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts
index fc5df4d9..509031bb 100644
--- a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts
+++ b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts
@@ -1,97 +1,97 @@
-import { useEffect, useRef } from "react";
-
-interface UseEdgeAutoScrollParams {
- isActive: boolean;
- getMouseClientX: () => number;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- 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 - 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,
- ]);
-}
+import { useEffect, useRef } from "react";
+
+interface UseEdgeAutoScrollParams {
+ isActive: boolean;
+ getMouseClientX: () => number;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ 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 - 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-initial-scroll-bottom.ts b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
index 50833f14..0b1cf92a 100644
--- a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
+++ b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
@@ -1,42 +1,42 @@
-import { type RefObject, useLayoutEffect, useRef } from "react";
-
-interface UseInitialScrollBottomProps {
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef: RefObject<HTMLDivElement | null>;
- onAfterScroll?: () => void;
- /** Defers the scroll until there is at least one track to measure against. */
- isReady: boolean;
-}
-
-/**
- * Scrolls the timeline tracks viewport to the bottom once on mount.
- * useLayoutEffect runs synchronously after React commits the DOM but before
- * the browser paints, so the initial scroll position is never visible.
- */
-export function useInitialScrollBottom({
- tracksScrollRef,
- trackLabelsScrollRef,
- onAfterScroll,
- isReady,
-}: UseInitialScrollBottomProps): void {
- const hasScrolledRef = useRef(false);
-
- useLayoutEffect(() => {
- if (!isReady || hasScrolledRef.current) return;
-
- const viewport = tracksScrollRef.current;
- if (!viewport) return;
-
- const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
- if (maxScrollTop <= 0) return;
-
- viewport.scrollTop = maxScrollTop;
-
- if (trackLabelsScrollRef.current) {
- trackLabelsScrollRef.current.scrollTop = maxScrollTop;
- }
-
- onAfterScroll?.();
- hasScrolledRef.current = true;
- }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]);
-}
+import { type RefObject, useLayoutEffect, useRef } from "react";
+
+interface UseInitialScrollBottomProps {
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef: RefObject<HTMLDivElement | null>;
+ onAfterScroll?: () => void;
+ /** Defers the scroll until there is at least one track to measure against. */
+ isReady: boolean;
+}
+
+/**
+ * Scrolls the timeline tracks viewport to the bottom once on mount.
+ * useLayoutEffect runs synchronously after React commits the DOM but before
+ * the browser paints, so the initial scroll position is never visible.
+ */
+export function useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll,
+ isReady,
+}: UseInitialScrollBottomProps): void {
+ const hasScrolledRef = useRef(false);
+
+ useLayoutEffect(() => {
+ if (!isReady || hasScrolledRef.current) return;
+
+ const viewport = tracksScrollRef.current;
+ if (!viewport) return;
+
+ const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
+ if (maxScrollTop <= 0) return;
+
+ viewport.scrollTop = maxScrollTop;
+
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = maxScrollTop;
+ }
+
+ onAfterScroll?.();
+ hasScrolledRef.current = true;
+ }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]);
+}
diff --git a/apps/web/src/hooks/timeline/use-scroll-sync.ts b/apps/web/src/hooks/timeline/use-scroll-sync.ts
index 61465a3d..e147c106 100644
--- a/apps/web/src/hooks/timeline/use-scroll-sync.ts
+++ b/apps/web/src/hooks/timeline/use-scroll-sync.ts
@@ -1,173 +1,173 @@
-import { useEffect, useRef } from "react";
-
-interface UseScrollSyncProps {
- rulerScrollRef?: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef?: React.RefObject<HTMLDivElement | null>;
- bookmarksScrollRef?: React.RefObject<HTMLDivElement | null>;
-}
-
-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,
- ]);
-}
+import { useEffect, useRef } from "react";
+
+interface UseScrollSyncProps {
+ rulerScrollRef?: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef?: React.RefObject<HTMLDivElement | null>;
+ bookmarksScrollRef?: React.RefObject<HTMLDivElement | null>;
+}
+
+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-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
index 1a8f3c5d..3dc34b7f 100644
--- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
+++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
@@ -1,54 +1,54 @@
-import { useEffect, useState } from "react";
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants";
-interface UseSnapIndicatorPositionParams {
- snapPoint: { time: number } | null;
- zoomLevel: number;
- timelineRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
-}
-
-interface SnapIndicatorPosition {
- leftPosition: number;
- topPosition: number;
- height: number;
-}
-
-export function useSnapIndicatorPosition({
- snapPoint,
- zoomLevel,
- timelineRef,
- 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 timelinePosition = timelineTimeToSnappedPixels({
- time: snapPoint?.time ?? 0,
- zoomLevel,
- });
- const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft;
-
- return {
- leftPosition,
- topPosition: 0,
- height: totalHeight,
- };
-}
+import { useEffect, useState } from "react";
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
+import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants";
+interface UseSnapIndicatorPositionParams {
+ snapPoint: { time: number } | null;
+ zoomLevel: number;
+ timelineRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+}
+
+interface SnapIndicatorPosition {
+ leftPosition: number;
+ topPosition: number;
+ height: number;
+}
+
+export function useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ timelineRef,
+ 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 timelinePosition = timelineTimeToSnappedPixels({
+ time: snapPoint?.time ?? 0,
+ zoomLevel,
+ });
+ const leftPosition = TRACK_LABELS_WIDTH_PX + 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
index e8f6e49d..7cd9f140 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -1,609 +1,609 @@
-import { useState, useCallback, type RefObject } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { processMediaAssets } from "@/lib/media/processing";
-import { toast } from "sonner";
-import { showMediaUploadToast } from "@/lib/media/upload-toast";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { snapTimeToFrame } from "@/lib/time";
-import {
- buildTextElement,
- buildGraphicElement,
- buildStickerElement,
- buildElementFromMedia,
- buildEffectElement,
-} from "@/lib/timeline/element-utils";
-import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
-import { BatchCommand } from "@/lib/commands";
-import { computeDropTarget } from "@/lib/timeline/drop-utils";
-import { getDragData, hasDragData } from "@/lib/drag-data";
-import { isMainTrack } from "@/lib/timeline/track-utils";
-import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
-import type {
- MediaDragData,
- GraphicDragData,
- StickerDragData,
- EffectDragData,
-} from "@/lib/timeline/drag";
-
-interface UseTimelineDragDropProps {
- containerRef: RefObject<HTMLDivElement | null>;
- headerRef?: RefObject<HTMLElement | null>;
- tracksScrollRef?: RefObject<HTMLDivElement | null>;
- zoomLevel: number;
-}
-
-export function useTimelineDragDrop({
- containerRef,
- headerRef,
- tracksScrollRef,
- 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 getSnappedTime = useCallback(
- ({ time }: { time: number }) => {
- const projectFps = editor.project.getActive().settings.fps;
- return snapTimeToFrame({ time, fps: projectFps });
- },
- [editor],
- );
-
- 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 === "graphic") return "graphic";
- if (dragData.type === "sticker") return "sticker";
- if (dragData.type === "effect") return "effect";
- if (dragData.type === "media") {
- return dragData.mediaType;
- }
- return null;
- },
- [],
- );
-
- const getElementDuration = useCallback(
- ({
- elementType,
- mediaId,
- }: {
- elementType: ElementType;
- mediaId?: string;
- }): number => {
- if (
- elementType === "text" ||
- elementType === "graphic" ||
- elementType === "sticker" ||
- elementType === "effect"
- ) {
- return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- }
- if (mediaId) {
- const mediaAssets = editor.media.getAssets();
- const media = mediaAssets.find((m) => m.id === mediaId);
- return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- }
- return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- },
- [editor],
- );
-
- 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 scrollContainer = tracksScrollRef?.current;
- const referenceRect =
- scrollContainer?.getBoundingClientRect() ??
- containerRef.current?.getBoundingClientRect();
- if (!referenceRect) return;
-
- const headerHeight =
- headerRef?.current?.getBoundingClientRect().height ?? 0;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 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 - referenceRect.left + scrollLeft;
- const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
-
- const targetElementTypes =
- dragData?.type === "effect"
- ? (dragData as EffectDragData).targetElementTypes
- : dragData?.type === "media"
- ? (dragData as MediaDragData).targetElementTypes
- : undefined;
-
- const tracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
- const target = computeDropTarget({
- elementType,
- mouseX,
- mouseY,
- tracks,
- playheadTime: currentTime,
- isExternalDrop: isExternal,
- elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
- zoomLevel,
- targetElementTypes,
- });
-
- target.xPosition = getSnappedTime({ time: target.xPosition });
-
- setDropTarget(target);
- e.dataTransfer.dropEffect = "copy";
- },
- [
- containerRef,
- headerRef,
- tracksScrollRef,
- zoomLevel,
- getElementType,
- getElementDuration,
- getSnappedTime,
- editor,
- ],
- );
-
- 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 };
- }) => {
- const element = buildTextElement({
- raw: {
- name: dragData.name ?? "",
- content: dragData.content ?? "",
- },
- startTime: target.xPosition,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeStickerDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: StickerDragData;
- }) => {
- const element = buildStickerElement({
- stickerId: dragData.stickerId,
- name: dragData.name,
- startTime: target.xPosition,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeGraphicDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: GraphicDragData;
- }) => {
- const element = buildGraphicElement({
- definitionId: dragData.definitionId,
- name: dragData.name,
- startTime: target.xPosition,
- params: dragData.params,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeMediaDrop = useCallback(
- ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
- if (target.targetElement) {
- toast.info("Replace media source is coming soon!");
- return;
- }
-
- const mediaAssets = editor.media.getAssets();
- const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
- if (!mediaAsset) return;
-
- const trackType: TrackType =
- dragData.mediaType === "audio" ? "audio" : "video";
-
- const duration =
- mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const element = buildElementFromMedia({
- mediaId: mediaAsset.id,
- mediaType: mediaAsset.type,
- name: mediaAsset.name,
- duration,
- startTime: target.xPosition,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeEffectDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: EffectDragData;
- }) => {
- if (target.targetElement) {
- editor.timeline.addClipEffect({
- trackId: target.targetElement.trackId,
- elementId: target.targetElement.elementId,
- effectType: dragData.effectType,
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const effectTrack = tracks.find((t) => t.type === "effect");
- let trackId: string;
-
- if (effectTrack) {
- trackId = effectTrack.id;
- } else if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element: buildEffectElement({
- effectType: dragData.effectType,
- startTime: target.xPosition,
- }),
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- } else {
- const track = tracks[target.trackIndex];
- if (!track || track.type !== "effect") return;
- trackId = track.id;
- }
-
- const element = buildEffectElement({
- effectType: dragData.effectType,
- startTime: target.xPosition,
- });
-
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element,
- });
- },
- [editor],
- );
-
- const executeFileDrop = useCallback(
- async ({
- files,
- mouseX,
- mouseY,
- }: {
- files: File[];
- mouseX: number;
- mouseY: number;
- }) => {
- const activeProject = editor.project.getActiveOrNull();
- if (!activeProject) return;
-
- await showMediaUploadToast({
- filesCount: files.length,
- promise: async () => {
- const processedAssets = await processMediaAssets({ files });
- const projectId = activeProject.metadata.id;
-
- for (const asset of processedAssets) {
- const createdAsset = await editor.media.addMediaAsset({
- projectId,
- asset,
- });
- if (!createdAsset) continue;
-
- const duration =
- createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const currentTracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
- const onlyTrack = currentTracks[0];
- const reuseMainTrackId =
- createdAsset.type !== "audio" &&
- currentTracks.length === 1 &&
- !!onlyTrack &&
- isMainTrack(onlyTrack) &&
- onlyTrack.elements.length === 0
- ? onlyTrack.id
- : null;
- const dropTarget = reuseMainTrackId
- ? null
- : computeDropTarget({
- elementType: createdAsset.type,
- mouseX,
- mouseY,
- tracks: currentTracks,
- playheadTime: currentTime,
- isExternalDrop: true,
- elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
- zoomLevel,
- });
-
- const trackType: TrackType =
- createdAsset.type === "audio" ? "audio" : "video";
-
- let trackId: string | undefined;
- if (reuseMainTrackId) {
- trackId = reuseMainTrackId;
- } else {
- if (!dropTarget) continue;
- if (dropTarget.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(
- trackType,
- dropTarget.trackIndex,
- );
- trackId = addTrackCmd.getTrackId();
- editor.command.execute({ command: addTrackCmd });
- } else {
- trackId = currentTracks[dropTarget.trackIndex]?.id;
- }
- }
-
- if (!trackId) continue;
-
- const element = buildElementFromMedia({
- mediaId: createdAsset.id,
- mediaType: createdAsset.type,
- name: createdAsset.name,
- duration,
- startTime: dropTarget?.xPosition ?? currentTime,
- buffer:
- createdAsset.type === "audio"
- ? new AudioBuffer({ length: 1, sampleRate: 44100 })
- : undefined,
- });
-
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId },
- });
- editor.command.execute({ command: insertCmd });
- }
-
- return {
- uploadedCount: processedAssets.length,
- assetNames: processedAssets.map((asset) => asset.name),
- };
- },
- });
- },
- [editor, 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 === "graphic") {
- executeGraphicDrop({
- target: currentTarget,
- dragData: dragData as GraphicDragData,
- });
- } else if (dragData.type === "sticker") {
- executeStickerDrop({ target: currentTarget, dragData });
- } else if (dragData.type === "effect") {
- executeEffectDrop({
- target: currentTarget,
- dragData: dragData as EffectDragData,
- });
- } else {
- executeMediaDrop({ target: currentTarget, dragData });
- }
- } else if (hasFiles) {
- const scrollContainer = tracksScrollRef?.current;
- const referenceRect =
- scrollContainer?.getBoundingClientRect() ??
- containerRef.current?.getBoundingClientRect();
- if (!referenceRect) return;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
- const mouseX = e.clientX - referenceRect.left + scrollLeft;
- const headerHeight =
- headerRef?.current?.getBoundingClientRect().height ?? 0;
- const mouseY =
- e.clientY - referenceRect.top + scrollTop - headerHeight;
- await executeFileDrop({
- files: Array.from(e.dataTransfer.files),
- mouseX,
- mouseY,
- });
- }
- } catch (err) {
- console.error("Failed to process drop:", err);
- }
- },
- [
- dropTarget,
- executeTextDrop,
- executeGraphicDrop,
- executeStickerDrop,
- executeMediaDrop,
- executeEffectDrop,
- executeFileDrop,
- containerRef,
- headerRef,
- tracksScrollRef,
- ],
- );
-
- return {
- isDragOver,
- dropTarget,
- dragElementType,
- dragProps: {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- },
- };
-}
+import { useState, useCallback, type RefObject } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { processMediaAssets } from "@/lib/media/processing";
+import { toast } from "sonner";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { snapTimeToFrame } from "@/lib/time";
+import {
+ buildTextElement,
+ buildGraphicElement,
+ buildStickerElement,
+ buildElementFromMedia,
+ buildEffectElement,
+} from "@/lib/timeline/element-utils";
+import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
+import { BatchCommand } from "@/lib/commands";
+import { computeDropTarget } from "@/lib/timeline/drop-utils";
+import { getDragData, hasDragData } from "@/lib/drag-data";
+import { isMainTrack } from "@/lib/timeline/track-utils";
+import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
+import type {
+ MediaDragData,
+ GraphicDragData,
+ StickerDragData,
+ EffectDragData,
+} from "@/lib/timeline/drag";
+
+interface UseTimelineDragDropProps {
+ containerRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ tracksScrollRef?: RefObject<HTMLDivElement | null>;
+ zoomLevel: number;
+}
+
+export function useTimelineDragDrop({
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ 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 getSnappedTime = useCallback(
+ ({ time }: { time: number }) => {
+ const projectFps = editor.project.getActive().settings.fps;
+ return snapTimeToFrame({ time, fps: projectFps });
+ },
+ [editor],
+ );
+
+ 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 === "graphic") return "graphic";
+ if (dragData.type === "sticker") return "sticker";
+ if (dragData.type === "effect") return "effect";
+ if (dragData.type === "media") {
+ return dragData.mediaType;
+ }
+ return null;
+ },
+ [],
+ );
+
+ const getElementDuration = useCallback(
+ ({
+ elementType,
+ mediaId,
+ }: {
+ elementType: ElementType;
+ mediaId?: string;
+ }): number => {
+ if (
+ elementType === "text" ||
+ elementType === "graphic" ||
+ elementType === "sticker" ||
+ elementType === "effect"
+ ) {
+ return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ }
+ if (mediaId) {
+ const mediaAssets = editor.media.getAssets();
+ const media = mediaAssets.find((m) => m.id === mediaId);
+ return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ }
+ return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ },
+ [editor],
+ );
+
+ 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 scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
+
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 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 - referenceRect.left + scrollLeft;
+ const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
+
+ const targetElementTypes =
+ dragData?.type === "effect"
+ ? (dragData as EffectDragData).targetElementTypes
+ : dragData?.type === "media"
+ ? (dragData as MediaDragData).targetElementTypes
+ : undefined;
+
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const target = computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime: currentTime,
+ isExternalDrop: isExternal,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ targetElementTypes,
+ });
+
+ target.xPosition = getSnappedTime({ time: target.xPosition });
+
+ setDropTarget(target);
+ e.dataTransfer.dropEffect = "copy";
+ },
+ [
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ zoomLevel,
+ getElementType,
+ getElementDuration,
+ getSnappedTime,
+ editor,
+ ],
+ );
+
+ 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 };
+ }) => {
+ const element = buildTextElement({
+ raw: {
+ name: dragData.name ?? "",
+ content: dragData.content ?? "",
+ },
+ startTime: target.xPosition,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeStickerDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: StickerDragData;
+ }) => {
+ const element = buildStickerElement({
+ stickerId: dragData.stickerId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeGraphicDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: GraphicDragData;
+ }) => {
+ const element = buildGraphicElement({
+ definitionId: dragData.definitionId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ params: dragData.params,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeMediaDrop = useCallback(
+ ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
+ if (target.targetElement) {
+ toast.info("Replace media source is coming soon!");
+ return;
+ }
+
+ const mediaAssets = editor.media.getAssets();
+ const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
+ if (!mediaAsset) return;
+
+ const trackType: TrackType =
+ dragData.mediaType === "audio" ? "audio" : "video";
+
+ const duration =
+ mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const element = buildElementFromMedia({
+ mediaId: mediaAsset.id,
+ mediaType: mediaAsset.type,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeEffectDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: EffectDragData;
+ }) => {
+ if (target.targetElement) {
+ editor.timeline.addClipEffect({
+ trackId: target.targetElement.trackId,
+ elementId: target.targetElement.elementId,
+ effectType: dragData.effectType,
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const effectTrack = tracks.find((t) => t.type === "effect");
+ let trackId: string;
+
+ if (effectTrack) {
+ trackId = effectTrack.id;
+ } else if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element: buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ }),
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track || track.type !== "effect") return;
+ trackId = track.id;
+ }
+
+ const element = buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeFileDrop = useCallback(
+ async ({
+ files,
+ mouseX,
+ mouseY,
+ }: {
+ files: File[];
+ mouseX: number;
+ mouseY: number;
+ }) => {
+ const activeProject = editor.project.getActiveOrNull();
+ if (!activeProject) return;
+
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const projectId = activeProject.metadata.id;
+
+ for (const asset of processedAssets) {
+ const createdAsset = await editor.media.addMediaAsset({
+ projectId,
+ asset,
+ });
+ if (!createdAsset) continue;
+
+ const duration =
+ createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const currentTracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const onlyTrack = currentTracks[0];
+ const reuseMainTrackId =
+ createdAsset.type !== "audio" &&
+ currentTracks.length === 1 &&
+ !!onlyTrack &&
+ isMainTrack(onlyTrack) &&
+ onlyTrack.elements.length === 0
+ ? onlyTrack.id
+ : null;
+ const dropTarget = reuseMainTrackId
+ ? null
+ : computeDropTarget({
+ elementType: createdAsset.type,
+ mouseX,
+ mouseY,
+ tracks: currentTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ });
+
+ const trackType: TrackType =
+ createdAsset.type === "audio" ? "audio" : "video";
+
+ let trackId: string | undefined;
+ if (reuseMainTrackId) {
+ trackId = reuseMainTrackId;
+ } else {
+ if (!dropTarget) continue;
+ if (dropTarget.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(
+ trackType,
+ dropTarget.trackIndex,
+ );
+ trackId = addTrackCmd.getTrackId();
+ editor.command.execute({ command: addTrackCmd });
+ } else {
+ trackId = currentTracks[dropTarget.trackIndex]?.id;
+ }
+ }
+
+ if (!trackId) continue;
+
+ const element = buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: dropTarget?.xPosition ?? currentTime,
+ buffer:
+ createdAsset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
+
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId },
+ });
+ editor.command.execute({ command: insertCmd });
+ }
+
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
+ },
+ [editor, 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 === "graphic") {
+ executeGraphicDrop({
+ target: currentTarget,
+ dragData: dragData as GraphicDragData,
+ });
+ } else if (dragData.type === "sticker") {
+ executeStickerDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "effect") {
+ executeEffectDrop({
+ target: currentTarget,
+ dragData: dragData as EffectDragData,
+ });
+ } else {
+ executeMediaDrop({ target: currentTarget, dragData });
+ }
+ } else if (hasFiles) {
+ const scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const mouseY =
+ e.clientY - referenceRect.top + scrollTop - headerHeight;
+ await executeFileDrop({
+ files: Array.from(e.dataTransfer.files),
+ mouseX,
+ mouseY,
+ });
+ }
+ } catch (err) {
+ console.error("Failed to process drop:", err);
+ }
+ },
+ [
+ dropTarget,
+ executeTextDrop,
+ executeGraphicDrop,
+ executeStickerDrop,
+ executeMediaDrop,
+ executeEffectDrop,
+ executeFileDrop,
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ ],
+ );
+
+ return {
+ isDragOver,
+ dropTarget,
+ dragElementType,
+ dragProps: {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ },
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts
index 127ebcbb..4abbf023 100644
--- a/apps/web/src/hooks/timeline/use-timeline-seek.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts
@@ -1,197 +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 | null>;
- trackLabelsRef: RefObject<HTMLDivElement | null>;
- rulerScrollRef: RefObject<HTMLDivElement | null>;
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- 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,
- };
-}
+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 | null>;
+ trackLabelsRef: RefObject<HTMLDivElement | null>;
+ rulerScrollRef: RefObject<HTMLDivElement | null>;
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ 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,
+ };
+}