summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks')
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts40
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts314
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-selection.ts4
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts289
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts168
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts40
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-snapping.ts32
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts50
-rw-r--r--apps/web/src/hooks/use-focus-lock.ts79
-rw-r--r--apps/web/src/hooks/use-infinite-scroll.ts70
-rw-r--r--apps/web/src/hooks/use-paste-media.ts99
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts328
-rw-r--r--apps/web/src/hooks/use-shift-key.ts35
-rw-r--r--apps/web/src/hooks/use-sound-search.ts308
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts331
15 files changed, 1611 insertions, 576 deletions
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index c92d6722..79f1272c 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -7,10 +7,15 @@ import {
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ DRAG_THRESHOLD_PX,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
+import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import type {
@@ -21,8 +26,6 @@ import type {
} from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
-const DRAG_THRESHOLD_PX = 5;
-
interface UseElementInteractionProps {
zoomLevel: number;
timelineRef: RefObject<HTMLDivElement | null>;
@@ -54,24 +57,6 @@ interface PendingDragState {
clickOffsetTime: number;
}
-function getMouseTimeFromClientX({
- clientX,
- containerRect,
- zoomLevel,
- scrollLeft,
-}: {
- clientX: number;
- containerRect: DOMRect;
- zoomLevel: number;
- scrollLeft: number;
-}): number {
- const mouseX = clientX - containerRect.left + scrollLeft;
- return Math.max(
- 0,
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
- );
-}
-
function getClickOffsetTime({
clientX,
elementRect,
@@ -175,6 +160,7 @@ export function useElementInteraction({
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const { snapElementEdge } = useTimelineSnapping();
const {
@@ -230,7 +216,8 @@ export function useElementInteraction({
frameSnappedTime: number;
movingElement: TimelineElement | null | undefined;
}) => {
- if (!snappingEnabled || !movingElement) {
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ if (!shouldSnap || !movingElement) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
@@ -268,7 +255,14 @@ export function useElementInteraction({
snapPoint: snapResult.snapPoint,
};
},
- [snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel],
+ [
+ snappingEnabled,
+ editor.playback,
+ snapElementEdge,
+ tracks,
+ zoomLevel,
+ isShiftHeldRef,
+ ],
);
useEffect(() => {
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 803d9788..14693fff 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -1,7 +1,9 @@
import { useState, useEffect, useRef, useCallback } from "react";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
-import { EditorCore } from "@/core";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
import {
useTimelineSnapping,
type SnapPoint,
@@ -33,8 +35,9 @@ export function useTimelineElementResize({
onSnapPointChange,
onResizeStateChange,
}: UseTimelineElementResizeProps) {
- const editor = EditorCore.getInstance();
+ const editor = useEditor();
const activeProject = editor.project.getActive();
+ const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
@@ -49,21 +52,21 @@ export function useTimelineElementResize({
const currentDurationRef = useRef(element.duration);
const handleResizeStart = ({
- e,
+ event,
elementId,
side,
}: {
- e: React.MouseEvent;
+ event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => {
- e.stopPropagation();
- e.preventDefault();
+ event.stopPropagation();
+ event.preventDefault();
setResizing({
elementId,
side,
- startX: e.clientX,
+ startX: event.clientX,
initialTrimStart: element.trimStart,
initialTrimEnd: element.trimEnd,
initialStartTime: element.startTime,
@@ -89,104 +92,69 @@ export function useTimelineElementResize({
return false;
}, [element.type]);
- const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => {
- if (!resizing) return;
+ const updateTrimFromMouseMove = useCallback(
+ ({ clientX }: { clientX: number }) => {
+ if (!resizing) return;
- const deltaX = clientX - resizing.startX;
- let deltaTime = deltaX / (50 * zoomLevel);
- let resizeSnapPoint: SnapPoint | null = null;
+ const deltaX = clientX - resizing.startX;
+ let deltaTime =
+ deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ let resizeSnapPoint: SnapPoint | null = null;
- const projectFps = activeProject.settings.fps;
- const minDurationSeconds = 1 / projectFps;
- const canSnap = snappingEnabled;
- if (canSnap) {
- const tracks = editor.timeline.getTracks();
- const playheadTime = editor.playback.getCurrentTime();
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- excludeElementId: element.id,
- });
- if (resizing.side === "left") {
- const targetStartTime = resizing.initialStartTime + deltaTime;
- const snapResult = snapToNearestPoint({
- targetTime: targetStartTime,
- snapPoints,
- zoomLevel,
- });
- resizeSnapPoint = snapResult.snapPoint;
- if (snapResult.snapPoint) {
- deltaTime = snapResult.snappedTime - resizing.initialStartTime;
- }
- } else {
- const baseEndTime =
- resizing.initialStartTime + resizing.initialDuration;
- const targetEndTime = baseEndTime + deltaTime;
- const snapResult = snapToNearestPoint({
- targetTime: targetEndTime,
- snapPoints,
- zoomLevel,
+ const projectFps = activeProject.settings.fps;
+ const minDurationSeconds = 1 / projectFps;
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ if (shouldSnap) {
+ const tracks = editor.timeline.getTracks();
+ const playheadTime = editor.playback.getCurrentTime();
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId: element.id,
});
- resizeSnapPoint = snapResult.snapPoint;
- if (snapResult.snapPoint) {
- deltaTime = snapResult.snappedTime - baseEndTime;
+ if (resizing.side === "left") {
+ const targetStartTime = resizing.initialStartTime + deltaTime;
+ const snapResult = snapToNearestPoint({
+ targetTime: targetStartTime,
+ snapPoints,
+ zoomLevel,
+ });
+ resizeSnapPoint = snapResult.snapPoint;
+ if (snapResult.snapPoint) {
+ deltaTime = snapResult.snappedTime - resizing.initialStartTime;
+ }
+ } else {
+ const baseEndTime =
+ resizing.initialStartTime + resizing.initialDuration;
+ const targetEndTime = baseEndTime + deltaTime;
+ const snapResult = snapToNearestPoint({
+ targetTime: targetEndTime,
+ snapPoints,
+ zoomLevel,
+ });
+ resizeSnapPoint = snapResult.snapPoint;
+ if (snapResult.snapPoint) {
+ deltaTime = snapResult.snappedTime - baseEndTime;
+ }
}
}
- }
- onSnapPointChange?.(resizeSnapPoint);
+ onSnapPointChange?.(resizeSnapPoint);
- if (resizing.side === "left") {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
- const maxAllowed =
- sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
- const calculated = resizing.initialTrimStart + deltaTime;
-
- if (calculated >= 0 && calculated <= maxAllowed) {
- const newTrimStart = snapTimeToFrame({
- time: Math.min(maxAllowed, calculated),
- fps: projectFps,
- });
- const trimDelta = newTrimStart - resizing.initialTrimStart;
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime + trimDelta,
- fps: projectFps,
- });
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
- fps: projectFps,
- });
+ if (resizing.side === "left") {
+ const sourceDuration =
+ resizing.initialTrimStart +
+ resizing.initialDuration +
+ resizing.initialTrimEnd;
+ const maxAllowed =
+ sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
+ const calculated = resizing.initialTrimStart + deltaTime;
- setCurrentTrimStart(newTrimStart);
- setCurrentStartTime(newStartTime);
- setCurrentDuration(newDuration);
- currentTrimStartRef.current = newTrimStart;
- currentStartTimeRef.current = newStartTime;
- currentDurationRef.current = newDuration;
- } else if (calculated < 0) {
- if (canExtendElementDuration()) {
- const extensionAmount = Math.abs(calculated);
- const maxExtension = resizing.initialStartTime;
- const actualExtension = Math.min(extensionAmount, maxExtension);
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime - actualExtension,
+ if (calculated >= 0 && calculated <= maxAllowed) {
+ const newTrimStart = snapTimeToFrame({
+ time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration + actualExtension,
- fps: projectFps,
- });
-
- setCurrentTrimStart(0);
- setCurrentStartTime(newStartTime);
- setCurrentDuration(newDuration);
- currentTrimStartRef.current = 0;
- currentStartTimeRef.current = newStartTime;
- currentDurationRef.current = newDuration;
- } else {
- const trimDelta = 0 - resizing.initialTrimStart;
+ const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
@@ -196,68 +164,119 @@ export function useTimelineElementResize({
fps: projectFps,
});
- setCurrentTrimStart(0);
+ setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
- currentTrimStartRef.current = 0;
+ currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
+ } else if (calculated < 0) {
+ if (canExtendElementDuration()) {
+ const extensionAmount = Math.abs(calculated);
+ const maxExtension = resizing.initialStartTime;
+ const actualExtension = Math.min(extensionAmount, maxExtension);
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime - actualExtension,
+ fps: projectFps,
+ });
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration + actualExtension,
+ fps: projectFps,
+ });
+
+ setCurrentTrimStart(0);
+ setCurrentStartTime(newStartTime);
+ setCurrentDuration(newDuration);
+ currentTrimStartRef.current = 0;
+ currentStartTimeRef.current = newStartTime;
+ currentDurationRef.current = newDuration;
+ } else {
+ const trimDelta = 0 - resizing.initialTrimStart;
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime + trimDelta,
+ fps: projectFps,
+ });
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration - trimDelta,
+ fps: projectFps,
+ });
+
+ setCurrentTrimStart(0);
+ setCurrentStartTime(newStartTime);
+ setCurrentDuration(newDuration);
+ currentTrimStartRef.current = 0;
+ currentStartTimeRef.current = newStartTime;
+ currentDurationRef.current = newDuration;
+ }
}
- }
- } else {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
- const newTrimEnd = resizing.initialTrimEnd - deltaTime;
+ } else {
+ const sourceDuration =
+ resizing.initialTrimStart +
+ resizing.initialDuration +
+ resizing.initialTrimEnd;
+ const newTrimEnd = resizing.initialTrimEnd - deltaTime;
- if (newTrimEnd < 0) {
- if (canExtendElementDuration()) {
- const extensionNeeded = Math.abs(newTrimEnd);
- const baseDuration =
- resizing.initialDuration + resizing.initialTrimEnd;
- const newDuration = snapTimeToFrame({
- time: baseDuration + extensionNeeded,
- fps: projectFps,
- });
+ if (newTrimEnd < 0) {
+ if (canExtendElementDuration()) {
+ const extensionNeeded = Math.abs(newTrimEnd);
+ const baseDuration =
+ resizing.initialDuration + resizing.initialTrimEnd;
+ const newDuration = snapTimeToFrame({
+ time: baseDuration + extensionNeeded,
+ fps: projectFps,
+ });
- setCurrentDuration(newDuration);
- setCurrentTrimEnd(0);
- currentDurationRef.current = newDuration;
- currentTrimEndRef.current = 0;
+ setCurrentDuration(newDuration);
+ setCurrentTrimEnd(0);
+ currentDurationRef.current = newDuration;
+ currentTrimEndRef.current = 0;
+ } else {
+ const extensionToLimit = resizing.initialTrimEnd;
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration + extensionToLimit,
+ fps: projectFps,
+ });
+
+ setCurrentDuration(newDuration);
+ setCurrentTrimEnd(0);
+ currentDurationRef.current = newDuration;
+ currentTrimEndRef.current = 0;
+ }
} else {
- const extensionToLimit = resizing.initialTrimEnd;
+ const maxTrimEnd =
+ sourceDuration - resizing.initialTrimStart - minDurationSeconds;
+ const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
+ const finalTrimEnd = snapTimeToFrame({
+ time: clampedTrimEnd,
+ fps: projectFps,
+ });
+ const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
- time: resizing.initialDuration + extensionToLimit,
+ time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
+ setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
- setCurrentTrimEnd(0);
+ currentTrimEndRef.current = finalTrimEnd;
currentDurationRef.current = newDuration;
- currentTrimEndRef.current = 0;
}
- } else {
- const maxTrimEnd =
- sourceDuration - resizing.initialTrimStart - minDurationSeconds;
- const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
- const finalTrimEnd = snapTimeToFrame({
- time: clampedTrimEnd,
- fps: projectFps,
- });
- const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
- fps: projectFps,
- });
-
- setCurrentTrimEnd(finalTrimEnd);
- setCurrentDuration(newDuration);
- currentTrimEndRef.current = finalTrimEnd;
- currentDurationRef.current = newDuration;
}
- }
- }, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]);
+ },
+ [
+ resizing,
+ zoomLevel,
+ activeProject.settings.fps,
+ snappingEnabled,
+ editor,
+ findSnapPoints,
+ snapToNearestPoint,
+ element.id,
+ onSnapPointChange,
+ canExtendElementDuration,
+ isShiftHeldRef,
+ ],
+ );
const handleResizeEnd = useCallback(() => {
if (!resizing) return;
@@ -297,7 +316,14 @@ export function useTimelineElementResize({
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
- }, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]);
+ }, [
+ resizing,
+ editor.timeline,
+ element.id,
+ track.id,
+ onResizeStateChange,
+ onSnapPointChange,
+ ]);
useEffect(() => {
if (!resizing) return;
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 38694c8b..c1e5b53f 100644
--- a/apps/web/src/hooks/timeline/element/use-element-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts
@@ -21,7 +21,9 @@ export function useElementSelection() {
const selectElement = useCallback(
({ trackId, elementId }: ElementRef) => {
- editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
+ editor.selection.setSelectedElements({
+ elements: [{ trackId, elementId }],
+ });
},
[editor],
);
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
new file mode 100644
index 00000000..faf28e40
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -0,0 +1,289 @@
+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 { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
+import type { Bookmark } from "@/types/timeline";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+
+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 { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
+
+ 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,
+ findSnapPoints,
+ snapToNearestPoint,
+ 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-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 01af9362..632d2c78 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -7,10 +7,12 @@ import { snapTimeToFrame } from "@/lib/time";
import {
buildTextElement,
buildStickerElement,
- buildUploadAudioElement,
- buildVideoElement,
- buildImageElement,
+ buildElementFromMedia,
} from "@/lib/timeline/element-utils";
+import type { Command } from "@/lib/commands/base-command";
+import { AddMediaAssetCommand } from "@/lib/commands/media";
+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 type { TrackType, DropTarget, ElementType } from "@/types/timeline";
@@ -231,7 +233,8 @@ export function useTimelineDragDrop({
}
const element = buildStickerElement({
- iconName: dragData.iconName,
+ stickerId: dragData.stickerId,
+ name: dragData.name,
startTime: target.xPosition,
});
@@ -265,38 +268,18 @@ export function useTimelineDragDrop({
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 (dragData.mediaType === "audio") {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildUploadAudioElement({
- mediaId: mediaAsset.id,
- name: mediaAsset.name,
- duration,
- startTime: target.xPosition,
- }),
- });
- } else if (dragData.mediaType === "video") {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildVideoElement({
- mediaId: mediaAsset.id,
- name: mediaAsset.name,
- duration,
- startTime: target.xPosition,
- }),
- });
- } else {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildImageElement({
- mediaId: mediaAsset.id,
- name: mediaAsset.name,
- duration,
- startTime: target.xPosition,
- }),
- });
- }
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element,
+ });
},
[editor.timeline, mediaAssets, tracks],
);
@@ -314,80 +297,67 @@ export function useTimelineDragDrop({
if (!activeProject) return;
const processedAssets = await processMediaAssets({ files });
+ const projectId = activeProject.metadata.id;
for (const asset of processedAssets) {
- await editor.media.addMediaAsset({
- projectId: activeProject.metadata.id,
- asset,
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const currentTracks = editor.timeline.getTracks();
+ const dropTarget = computeDropTarget({
+ elementType: asset.type,
+ mouseX,
+ mouseY,
+ tracks: currentTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
});
- const added = editor.media
- .getAssets()
- .find((m) => m.name === asset.name && m.url === asset.url);
+ const trackType: TrackType = asset.type === "audio" ? "audio" : "video";
+ const addMediaCmd = new AddMediaAssetCommand(projectId, asset);
+ const assetId = addMediaCmd.getAssetId();
- if (added) {
- const duration =
- added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const currentTracks = editor.timeline.getTracks();
- const dropTarget = computeDropTarget({
- elementType: added.type,
- mouseX,
- mouseY,
- tracks: currentTracks,
- playheadTime: currentTime,
- isExternalDrop: true,
- elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
- zoomLevel,
- });
+ const commands: Command[] = [addMediaCmd];
- const trackType: TrackType =
- added.type === "audio" ? "audio" : "video";
- const trackId = dropTarget.isNewTrack
- ? editor.timeline.addTrack({
- type: trackType,
- index: dropTarget.trackIndex,
- })
- : currentTracks[dropTarget.trackIndex]?.id;
+ let trackId: string | undefined;
+ if (dropTarget.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(
+ trackType,
+ dropTarget.trackIndex,
+ );
+ trackId = addTrackCmd.getTrackId();
+ commands.unshift(addTrackCmd);
+ } else {
+ trackId = currentTracks[dropTarget.trackIndex]?.id;
+ }
- if (!trackId) return;
+ if (!trackId) return;
- if (added.type === "audio") {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildUploadAudioElement({
- mediaId: added.id,
- name: added.name,
- duration,
- startTime: dropTarget.xPosition,
- buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
- }),
- });
- } else if (added.type === "video") {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildVideoElement({
- mediaId: added.id,
- name: added.name,
- duration,
- startTime: dropTarget.xPosition,
- }),
- });
- } else {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildImageElement({
- mediaId: added.id,
- name: added.name,
- duration,
- startTime: dropTarget.xPosition,
- }),
- });
- }
- }
+ const element = buildElementFromMedia({
+ mediaId: assetId,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ buffer:
+ asset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
+
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId },
+ });
+ commands.push(insertCmd);
+
+ const batchCmd = new BatchCommand(commands);
+ editor.command.execute({ command: batchCmd });
}
},
- [activeProject, editor.media, editor.timeline, currentTime, zoomLevel],
+ [activeProject, editor.command, editor.timeline, currentTime, zoomLevel],
);
const handleDrop = useCallback(
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index addef433..44e12ab1 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -2,6 +2,11 @@ import { getSnappedSeekTime } from "@/lib/time";
import { useState, useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import {
+ useTimelineSnapping,
+ type SnapPoint,
+} from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@@ -25,6 +30,11 @@ export function useTimelinePlayhead({
const duration = editor.timeline.getTotalDuration();
const isPlaying = editor.playback.getIsPlaying();
const isScrubbing = editor.playback.getIsScrubbing();
+ const isShiftHeldRef = useShiftKey();
+ const { snapToNearestPoint } = useTimelineSnapping({
+ enableElementSnapping: false,
+ enablePlayheadSnapping: false,
+ });
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
@@ -62,19 +72,45 @@ export function useTimelinePlayhead({
clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
),
);
+
const framesPerSecond = activeProject.settings.fps;
- const time = getSnappedSeekTime({
+ const frameTime = getSnappedSeekTime({
rawTime,
duration,
fps: framesPerSecond,
});
+ const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
+ const bookmarkSnapPoints: SnapPoint[] = bookmarks.map((bookmark) => ({
+ time: bookmark.time,
+ type: "bookmark",
+ }));
+ const shouldSnapToBookmark =
+ !isShiftHeldRef.current && bookmarkSnapPoints.length > 0;
+ const snapResult = shouldSnapToBookmark
+ ? snapToNearestPoint({
+ targetTime: frameTime,
+ snapPoints: bookmarkSnapPoints,
+ zoomLevel,
+ })
+ : null;
+ const time = snapResult?.snapPoint ? snapResult.snappedTime : frameTime;
+
setScrubTime(time);
seek({ time });
lastMouseXRef.current = event.clientX;
},
- [duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
+ [
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ activeProject.settings.fps,
+ isShiftHeldRef,
+ editor.scenes,
+ snapToNearestPoint,
+ ],
);
const handlePlayheadMouseDown = useCallback(
diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts
index 4cd6f775..5de75448 100644
--- a/apps/web/src/hooks/timeline/use-timeline-snapping.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-snapping.ts
@@ -1,10 +1,11 @@
import { useCallback } from "react";
-import type { TimelineTrack } from "@/types/timeline";
+import type { Bookmark, TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
export interface SnapPoint {
time: number;
- type: "element-start" | "element-end" | "playhead";
+ type: "element-start" | "element-end" | "playhead" | "bookmark";
elementId?: string;
trackId?: string;
}
@@ -19,22 +20,28 @@ export interface UseTimelineSnappingOptions {
snapThreshold?: number;
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
+ enableBookmarkSnapping?: boolean;
}
export function useTimelineSnapping({
snapThreshold = 10,
enableElementSnapping = true,
enablePlayheadSnapping = true,
+ enableBookmarkSnapping = true,
}: UseTimelineSnappingOptions = {}) {
const findSnapPoints = useCallback(
({
tracks,
playheadTime,
excludeElementId,
+ bookmarks = [],
+ excludeBookmarkTime,
}: {
tracks: Array<TimelineTrack>;
playheadTime: number;
excludeElementId?: string;
+ bookmarks?: Array<Bookmark>;
+ excludeBookmarkTime?: number;
}): SnapPoint[] => {
const snapPoints: SnapPoint[] = [];
@@ -71,9 +78,25 @@ export function useTimelineSnapping({
});
}
+ if (enableBookmarkSnapping) {
+ for (const bookmark of bookmarks) {
+ if (
+ excludeBookmarkTime != null &&
+ Math.abs(bookmark.time - excludeBookmarkTime) <
+ BOOKMARK_TIME_EPSILON
+ ) {
+ continue;
+ }
+ snapPoints.push({
+ time: bookmark.time,
+ type: "bookmark",
+ });
+ }
+ }
+
return snapPoints;
},
- [enableElementSnapping, enablePlayheadSnapping],
+ [enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping],
);
const snapToNearestPoint = useCallback(
@@ -118,6 +141,7 @@ export function useTimelineSnapping({
zoomLevel,
excludeElementId,
snapToStart = true,
+ bookmarks = [],
}: {
targetTime: number;
elementDuration: number;
@@ -126,11 +150,13 @@ export function useTimelineSnapping({
zoomLevel: number;
excludeElementId?: string;
snapToStart?: boolean;
+ bookmarks?: Array<Bookmark>;
}): SnapResult => {
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId,
+ bookmarks,
});
const effectiveTargetTime = snapToStart
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
index 10563a05..095c64dc 100644
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
@@ -57,6 +57,8 @@ export function useTimelineZoom({
const previousZoomRef = useRef(zoomLevel);
const hasRestoredScrollRef = useRef(false);
const preZoomScrollLeftRef = useRef(0);
+ const prePlayheadAnchorScrollLeftRef = useRef(0);
+ const isInPlayheadAnchorModeRef = useRef(false);
const setZoomLevel = useCallback(
(updater: number | ((prev: number) => number)) => {
@@ -89,8 +91,6 @@ export function useTimelineZoom({
);
return nextZoom;
});
- // for horizontal scrolling (when shift is held or horizontal wheel movement),
- // let the event bubble up to allow ScrollArea to handle it
return;
}
},
@@ -143,6 +143,36 @@ export function useTimelineZoom({
const currentScrollLeft = preZoomScrollLeftRef.current;
const playheadTime = editor.playback.getCurrentTime();
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
+ const previousSliderPercent = zoomToSlider({
+ zoomLevel: previousZoom,
+ minZoom,
+ });
+ const isCrossingThresholdUp =
+ previousSliderPercent <
+ TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+ const isCrossingThresholdDown =
+ previousSliderPercent >=
+ TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+
+ const syncScroll = (scrollLeft: number) => {
+ scrollElement.scrollLeft = scrollLeft;
+ if (rulerScrollRef.current) {
+ rulerScrollRef.current.scrollLeft = scrollLeft;
+ }
+ };
+
+ const clampScrollLeft = (scrollLeft: number) => {
+ const maxScrollLeft =
+ scrollElement.scrollWidth - scrollElement.clientWidth;
+ return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
+ };
+
+ if (isCrossingThresholdUp) {
+ prePlayheadAnchorScrollLeftRef.current = currentScrollLeft;
+ isInPlayheadAnchorModeRef.current = true;
+ }
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
@@ -153,17 +183,10 @@ export function useTimelineZoom({
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;
- const maxScrollLeft =
- scrollElement.scrollWidth - scrollElement.clientWidth;
- const clampedScrollLeft = Math.max(
- 0,
- Math.min(maxScrollLeft, newScrollLeft),
- );
-
- scrollElement.scrollLeft = clampedScrollLeft;
- if (rulerScrollRef.current) {
- rulerScrollRef.current.scrollLeft = clampedScrollLeft;
- }
+ syncScroll(clampScrollLeft(newScrollLeft));
+ } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
+ syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
+ isInPlayheadAnchorModeRef.current = false;
}
previousZoomRef.current = zoomLevel;
@@ -217,7 +240,6 @@ export function useTimelineZoom({
const observer = new ResizeObserver(() => {
if (scrollElement.scrollWidth > 0) {
restoreScroll();
- hasRestoredScrollRef.current = true;
observer.disconnect();
}
});
diff --git a/apps/web/src/hooks/use-focus-lock.ts b/apps/web/src/hooks/use-focus-lock.ts
new file mode 100644
index 00000000..80baebdb
--- /dev/null
+++ b/apps/web/src/hooks/use-focus-lock.ts
@@ -0,0 +1,79 @@
+import { useEffect, useRef } from "react";
+
+type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
+
+const DATA_ATTR = "data-focus-locked";
+
+function buildFocusLockCSS({
+ cursor,
+ allowSelector,
+}: {
+ cursor: FocusLockCursor;
+ allowSelector?: string;
+}) {
+ const rules = [
+ `*, *::before, *::after { pointer-events: none !important; cursor: ${cursor} !important; }`,
+ `[${DATA_ATTR}], [${DATA_ATTR}] * { pointer-events: auto !important; cursor: auto !important; }`,
+ ];
+
+ if (allowSelector) {
+ rules.push(
+ `${allowSelector} { pointer-events: auto !important; cursor: auto !important; }`,
+ );
+ }
+
+ return rules.join("\n");
+}
+
+export function useFocusLock<T extends HTMLElement = HTMLElement>({
+ isActive,
+ onDismiss,
+ cursor = "default",
+ allowSelector,
+}: {
+ isActive: boolean;
+ onDismiss: () => void;
+ cursor?: FocusLockCursor;
+ allowSelector?: string;
+}) {
+ const containerRef = useRef<T>(null);
+ const onDismissRef = useRef(onDismiss);
+ onDismissRef.current = onDismiss;
+
+ useEffect(() => {
+ if (!isActive) return;
+ const container = containerRef.current;
+ if (!container) return;
+
+ container.setAttribute(DATA_ATTR, "");
+
+ const focusLockStyle = document.createElement("style");
+ focusLockStyle.textContent = buildFocusLockCSS({ cursor, allowSelector });
+ document.head.appendChild(focusLockStyle);
+
+ const handleOutsidePointerDown = (event: PointerEvent) => {
+ if (event.button !== 0) return;
+ if (container.contains(event.target as Node)) return;
+
+ const target = event.target as Element | null;
+ const isAllowedTarget = allowSelector && target?.closest(allowSelector);
+ if (isAllowedTarget) return;
+
+ onDismissRef.current();
+ };
+
+ document.addEventListener("pointerdown", handleOutsidePointerDown, true);
+
+ return () => {
+ document.removeEventListener(
+ "pointerdown",
+ handleOutsidePointerDown,
+ true,
+ );
+ container.removeAttribute(DATA_ATTR);
+ focusLockStyle.remove();
+ };
+ }, [isActive, cursor, allowSelector]);
+
+ return { containerRef };
+}
diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts
index 4f823834..8d6e5fcc 100644
--- a/apps/web/src/hooks/use-infinite-scroll.ts
+++ b/apps/web/src/hooks/use-infinite-scroll.ts
@@ -1,35 +1,35 @@
-import { useRef, useCallback } from "react";
-
-interface UseInfiniteScrollOptions {
- onLoadMore: () => void;
- hasMore: boolean;
- isLoading: boolean;
- threshold?: number;
- enabled?: boolean;
-}
-
-export function useInfiniteScroll({
- onLoadMore,
- hasMore,
- isLoading,
- threshold = 200,
- enabled = true,
-}: UseInfiniteScrollOptions) {
- const scrollAreaRef = useRef<HTMLDivElement>(null);
-
- const handleScroll = useCallback(
- (event: React.UIEvent<HTMLDivElement>) => {
- if (!enabled) return;
-
- const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
- const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
-
- if (nearBottom && hasMore && !isLoading) {
- onLoadMore();
- }
- },
- [onLoadMore, hasMore, isLoading, threshold, enabled],
- );
-
- return { scrollAreaRef, handleScroll };
-}
+import { useRef, useCallback } from "react";
+
+interface UseInfiniteScrollOptions {
+ onLoadMore: () => void;
+ hasMore: boolean;
+ isLoading: boolean;
+ threshold?: number;
+ enabled?: boolean;
+}
+
+export function useInfiniteScroll({
+ onLoadMore,
+ hasMore,
+ isLoading,
+ threshold = 200,
+ enabled = true,
+}: UseInfiniteScrollOptions) {
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
+
+ const handleScroll = useCallback(
+ (event: React.UIEvent<HTMLDivElement>) => {
+ if (!enabled) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
+
+ if (nearBottom && hasMore && !isLoading) {
+ onLoadMore();
+ }
+ },
+ [onLoadMore, hasMore, isLoading, threshold, enabled],
+ );
+
+ return { scrollAreaRef, handleScroll };
+}
diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts
new file mode 100644
index 00000000..4533ab28
--- /dev/null
+++ b/apps/web/src/hooks/use-paste-media.ts
@@ -0,0 +1,99 @@
+import { useEffect } from "react";
+import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
+import { processMediaAssets } from "@/lib/media/processing";
+import { buildElementFromMedia } from "@/lib/timeline/element-utils";
+import { AddMediaAssetCommand } from "@/lib/commands/media";
+import { InsertElementCommand } from "@/lib/commands/timeline";
+import { BatchCommand } from "@/lib/commands";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { isTypableDOMElement } from "@/utils/browser";
+import type { MediaType } from "@/types/assets";
+
+const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
+
+function isMediaMimeType({ type }: { type: string }): boolean {
+ return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
+}
+
+function extractMediaFilesFromClipboard({
+ clipboardData,
+}: {
+ clipboardData: DataTransfer | null;
+}): File[] {
+ if (!clipboardData?.items) return [];
+
+ const files: File[] = [];
+ for (const item of clipboardData.items) {
+ if (item.kind !== "file") continue;
+ if (!isMediaMimeType({ type: item.type })) continue;
+
+ const file = item.getAsFile();
+ if (file) files.push(file);
+ }
+ return files;
+}
+
+export function usePasteMedia() {
+ const editor = useEditor();
+
+ useEffect(() => {
+ const handlePaste = async (event: ClipboardEvent) => {
+ const activeElement = document.activeElement as HTMLElement;
+ if (activeElement && isTypableDOMElement({ element: activeElement })) {
+ return;
+ }
+
+ const files = extractMediaFilesFromClipboard({
+ clipboardData: event.clipboardData,
+ });
+ if (files.length === 0) return;
+
+ event.preventDefault();
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ try {
+ const processedAssets = await processMediaAssets({ files });
+ const startTime = editor.playback.getCurrentTime();
+
+ for (const asset of processedAssets) {
+ const addMediaCmd = new AddMediaAssetCommand(
+ activeProject.metadata.id,
+ asset,
+ );
+ const assetId = addMediaCmd.getAssetId();
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const trackType = asset.type === "audio" ? "audio" : "video";
+
+ const element = buildElementFromMedia({
+ mediaId: assetId,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ buffer:
+ asset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
+
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "auto", trackType },
+ });
+ const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
+ editor.command.execute({ command: batchCmd });
+ }
+ } catch (error) {
+ console.error("Failed to paste media:", error);
+ toast.error("Failed to paste media");
+ }
+ };
+
+ window.addEventListener("paste", handlePaste);
+ return () => window.removeEventListener("paste", handlePaste);
+ }, [editor]);
+}
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
index 31b33504..2bb33f21 100644
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -1,12 +1,22 @@
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
-import { useSyncExternalStore } from "react";
-import type { Transform, TimelineTrack } from "@/types/timeline";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import type { TextElement, Transform } from "@/types/timeline";
+import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
+import { hitTest } from "@/lib/preview/hit-test";
+import { screenToCanvas } from "@/lib/preview/preview-coords";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap";
+
+const MIN_DRAG_DISTANCE = 0.5;
interface DragState {
startX: number;
startY: number;
- tracksSnapshot: TimelineTrack[];
+ bounds: {
+ width: number;
+ height: number;
+ };
elements: Array<{
trackId: string;
elementId: string;
@@ -20,59 +30,164 @@ export function usePreviewInteraction({
canvasRef: React.RefObject<HTMLCanvasElement | null>;
}) {
const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
const [isDragging, setIsDragging] = useState(false);
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const [editingText, setEditingText] = useState<{
+ trackId: string;
+ elementId: string;
+ element: TextElement;
+ originalOpacity: number;
+ } | null>(null);
const dragStateRef = useRef<DragState | null>(null);
+ const wasPlayingRef = useRef(editor.playback.getIsPlaying());
+ const editingTextRef = useRef(editingText);
+ editingTextRef.current = editingText;
- const selectedElements = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedElements(),
- );
+ const commitTextEdit = useCallback(() => {
+ const current = editingTextRef.current;
+ if (!current) return;
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: current.trackId,
+ elementId: current.elementId,
+ updates: { opacity: current.originalOpacity },
+ },
+ ],
+ });
+ editor.timeline.commitPreview();
+ setEditingText(null);
+ }, [editor.timeline]);
- const getCanvasCoordinates = useCallback(
- ({ clientX, clientY }: { clientX: number; clientY: number }) => {
- if (!canvasRef.current) return { x: 0, y: 0 };
+ const cancelTextEdit = useCallback(() => {
+ editor.timeline.discardPreview();
+ setEditingText(null);
+ }, [editor.timeline]);
+
+ useEffect(() => {
+ const unsubscribe = editor.playback.subscribe(() => {
+ const isPlaying = editor.playback.getIsPlaying();
+ if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
+ commitTextEdit();
+ }
+ wasPlayingRef.current = isPlaying;
+ });
+ return unsubscribe;
+ }, [editor.playback, commitTextEdit]);
- const rect = canvasRef.current.getBoundingClientRect();
- const logicalWidth = canvasRef.current.width;
- const logicalHeight = canvasRef.current.height;
- const scaleX = logicalWidth / rect.width;
- const scaleY = logicalHeight / rect.height;
+ const handleDoubleClick = useCallback(
+ ({ clientX, clientY }: React.MouseEvent) => {
+ if (!canvasRef.current || editingText) return;
+
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const mediaAssets = editor.media.getAssets();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ const startPos = screenToCanvas({
+ clientX,
+ clientY,
+ canvas: canvasRef.current,
+ });
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const hit = hitTest({
+ canvasX: startPos.x,
+ canvasY: startPos.y,
+ elementsWithBounds,
+ });
- const canvasX = (clientX - rect.left) * scaleX;
- const canvasY = (clientY - rect.top) * scaleY;
+ if (!hit || hit.element.type !== "text") return;
- return { x: canvasX, y: canvasY };
+ const textElement = hit.element as TextElement;
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: hit.trackId,
+ elementId: hit.elementId,
+ updates: { opacity: 0 },
+ },
+ ],
+ });
+ setEditingText({
+ trackId: hit.trackId,
+ elementId: hit.elementId,
+ element: textElement,
+ originalOpacity: textElement.opacity,
+ });
},
- [canvasRef],
+ [canvasRef, editor, editingText],
);
const handlePointerDown = useCallback(
- (event: React.PointerEvent) => {
- if (selectedElements.length === 0) return;
+ ({
+ clientX,
+ clientY,
+ currentTarget,
+ pointerId,
+ button,
+ }: React.PointerEvent) => {
+ if (!canvasRef.current) return;
+ if (editingText) return;
+ if (button !== 0) return;
+
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const mediaAssets = editor.media.getAssets();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ const startPos = screenToCanvas({
+ clientX,
+ clientY,
+ canvas: canvasRef.current,
+ });
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const hit = hitTest({
+ canvasX: startPos.x,
+ canvasY: startPos.y,
+ elementsWithBounds,
+ });
+
+ if (!hit) {
+ editor.selection.clearSelection();
+ return;
+ }
+
+ editor.selection.setSelectedElements({
+ elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
+ });
const elementsWithTracks = editor.timeline.getElementsWithTracks({
- elements: selectedElements,
+ elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
});
- const draggableElements = elementsWithTracks.filter(
- ({ element }) =>
- element.type === "video" ||
- element.type === "image" ||
- element.type === "text" ||
- element.type === "sticker",
+ const draggableElements = elementsWithTracks.filter(({ element }) =>
+ isVisualElement(element),
);
if (draggableElements.length === 0) return;
- const startPos = getCanvasCoordinates({
- clientX: event.clientX,
- clientY: event.clientY,
- });
-
dragStateRef.current = {
startX: startPos.x,
startY: startPos.y,
- tracksSnapshot: editor.timeline.getTracks(),
+ bounds: {
+ width: hit.bounds.width,
+ height: hit.bounds.height,
+ },
elements: draggableElements.map(({ track, element }) => ({
trackId: track.id,
elementId: element.id,
@@ -81,107 +196,118 @@ export function usePreviewInteraction({
};
setIsDragging(true);
- event.currentTarget.setPointerCapture(event.pointerId);
+ currentTarget.setPointerCapture(pointerId);
},
- [selectedElements, editor, getCanvasCoordinates],
+ [editor, canvasRef, editingText],
);
const handlePointerMove = useCallback(
- (event: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging) return;
+ ({ clientX, clientY }: React.PointerEvent) => {
+ if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
+
+ const canvasSize = editor.project.getActive().settings.canvasSize;
- const currentPos = getCanvasCoordinates({
- clientX: event.clientX,
- clientY: event.clientY,
+ const currentPos = screenToCanvas({
+ clientX,
+ clientY,
+ canvas: canvasRef.current,
});
const deltaX = currentPos.x - dragStateRef.current.startX;
const deltaY = currentPos.y - dragStateRef.current.startY;
+ const hasMovement =
+ Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
+ Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+ if (!hasMovement) {
+ setSnapLines([]);
+ return;
+ }
- for (const { trackId, elementId, initialTransform } of dragStateRef
- .current.elements) {
- const newPosition = {
- x: initialTransform.position.x + deltaX,
- y: initialTransform.position.y + deltaY,
- };
+ const firstElement = dragStateRef.current.elements[0];
+ const proposedPosition = {
+ x: firstElement.initialTransform.position.x + deltaX,
+ y: firstElement.initialTransform.position.y + deltaY,
+ };
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- position: newPosition,
- },
+ const shouldSnap = !isShiftHeldRef.current;
+ const { snappedPosition, activeLines } = shouldSnap
+ ? snapPosition({
+ proposedPosition,
+ canvasSize,
+ elementSize: dragStateRef.current.bounds,
+ })
+ : {
+ snappedPosition: proposedPosition,
+ activeLines: [] as SnapLine[],
+ };
+
+ setSnapLines(activeLines);
+
+ const deltaSnappedX =
+ snappedPosition.x - firstElement.initialTransform.position.x;
+ const deltaSnappedY =
+ snappedPosition.y - firstElement.initialTransform.position.y;
+
+ const updates = dragStateRef.current.elements.map(
+ ({ trackId, elementId, initialTransform }) => ({
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ position: {
+ x: initialTransform.position.x + deltaSnappedX,
+ y: initialTransform.position.y + deltaSnappedY,
},
},
- ],
- pushHistory: false,
- });
- }
+ },
+ }),
+ );
+
+ editor.timeline.previewElements({ updates });
},
- [isDragging, getCanvasCoordinates, editor],
+ [isDragging, canvasRef, editor, isShiftHeldRef],
);
const handlePointerUp = useCallback(
- (event: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging) return;
+ ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => {
+ if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
- const currentPos = getCanvasCoordinates({
- clientX: event.clientX,
- clientY: event.clientY,
+ const currentPos = screenToCanvas({
+ clientX,
+ clientY,
+ canvas: canvasRef.current,
});
const deltaX = currentPos.x - dragStateRef.current.startX;
const deltaY = currentPos.y - dragStateRef.current.startY;
- const hasMovement = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
-
+ const hasMovement =
+ Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
+ Math.abs(deltaY) > MIN_DRAG_DISTANCE;
if (!hasMovement) {
- dragStateRef.current = null;
- setIsDragging(false);
- event.currentTarget.releasePointerCapture(event.pointerId);
- return;
+ editor.timeline.discardPreview();
+ } else {
+ editor.timeline.commitPreview();
}
- // revert to pre-drag state so the command captures the correct undo snapshot
- editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
-
- const updates = dragStateRef.current.elements.map(
- ({ trackId, elementId, initialTransform }) => {
- const newPosition = {
- x: initialTransform.position.x + deltaX,
- y: initialTransform.position.y + deltaY,
- };
-
- return {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- position: newPosition,
- },
- },
- };
- },
- );
-
- editor.timeline.updateElements({ updates });
-
dragStateRef.current = null;
setIsDragging(false);
- event.currentTarget.releasePointerCapture(event.pointerId);
+ setSnapLines([]);
+ currentTarget.releasePointerCapture(pointerId);
},
- [isDragging, getCanvasCoordinates, editor],
+ [isDragging, canvasRef, editor],
);
return {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
+ onDoubleClick: handleDoubleClick,
+ snapLines,
+ editingText,
+ commitTextEdit,
+ cancelTextEdit,
};
}
diff --git a/apps/web/src/hooks/use-shift-key.ts b/apps/web/src/hooks/use-shift-key.ts
new file mode 100644
index 00000000..4f9fe67c
--- /dev/null
+++ b/apps/web/src/hooks/use-shift-key.ts
@@ -0,0 +1,35 @@
+import { useEffect, useRef, type RefObject } from "react";
+
+export function useShiftKey(): RefObject<boolean> {
+ const isShiftHeldRef = useRef(false);
+
+ useEffect(() => {
+ const handleKeyDown = ({ key }: KeyboardEvent) => {
+ if (key === "Shift") {
+ isShiftHeldRef.current = true;
+ }
+ };
+
+ const handleKeyUp = ({ key }: KeyboardEvent) => {
+ if (key === "Shift") {
+ isShiftHeldRef.current = false;
+ }
+ };
+
+ const handleBlur = () => {
+ isShiftHeldRef.current = false;
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("keyup", handleKeyUp);
+ window.addEventListener("blur", handleBlur);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("keyup", handleKeyUp);
+ window.removeEventListener("blur", handleBlur);
+ };
+ }, []);
+
+ return isShiftHeldRef;
+}
diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts
index 263a01e2..f3c0aab4 100644
--- a/apps/web/src/hooks/use-sound-search.ts
+++ b/apps/web/src/hooks/use-sound-search.ts
@@ -1,154 +1,154 @@
-import { useEffect } from "react";
-import { useSoundsStore } from "@/stores/sounds-store";
-
-export function useSoundSearch({
- query,
- commercialOnly,
-}: {
- query: string;
- commercialOnly: boolean;
-}) {
- const {
- searchResults,
- isSearching,
- searchError,
- lastSearchQuery,
- currentPage,
- hasNextPage,
- isLoadingMore,
- totalCount,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- setLoadingMore,
- appendSearchResults,
- appendTopSounds,
- resetPagination,
- } = useSoundsStore();
-
- const loadMore = async () => {
- if (isLoadingMore || !hasNextPage) return;
-
- try {
- setLoadingMore({ loading: true });
- const nextPage = currentPage + 1;
-
- const searchParams = new URLSearchParams({
- page: nextPage.toString(),
- type: "effects",
- });
-
- if (query.trim()) {
- searchParams.set("q", query);
- }
-
- searchParams.set("commercial_only", commercialOnly.toString());
- const response = await fetch(
- `/api/sounds/search?${searchParams.toString()}`,
- );
-
- if (response.ok) {
- const data = await response.json();
-
- if (query.trim()) {
- appendSearchResults(data.results);
- } else {
- appendTopSounds(data.results);
- }
-
- setCurrentPage({ page: nextPage });
- setHasNextPage({ hasNext: !!data.next });
- setTotalCount(data.count);
- } else {
- setSearchError({ error: `Load more failed: ${response.status}` });
- }
- } catch (err) {
- setSearchError({
- error: err instanceof Error ? err.message : "Load more failed",
- });
- } finally {
- setLoadingMore({ loading: false });
- }
- };
-
- useEffect(() => {
- if (!query.trim()) {
- setSearchResults({ results: [] });
- setSearchError({ error: null });
- setLastSearchQuery({ query: "" });
- return;
- }
-
- if (query === lastSearchQuery && searchResults.length > 0) {
- return;
- }
-
- let ignore = false;
-
- const timeoutId = setTimeout(async () => {
- try {
- setSearching({ searching: true });
- setSearchError({ error: null });
- resetPagination();
-
- const response = await fetch(
- `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
- );
-
- if (!ignore) {
- if (response.ok) {
- const data = await response.json();
- setSearchResults({ results: data.results });
- setLastSearchQuery({ query: query });
- setHasNextPage({ hasNext: !!data.next });
- setTotalCount({ count: data.count });
- setCurrentPage({ page: 1 });
- } else {
- setSearchError({ error: `Search failed: ${response.status}` });
- }
- }
- } catch (err) {
- if (!ignore) {
- setSearchError({
- error: err instanceof Error ? err.message : "Search failed",
- });
- }
- } finally {
- if (!ignore) {
- setSearching({ searching: false });
- }
- }
- }, 300);
-
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }, [
- query,
- lastSearchQuery,
- searchResults.length,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- resetPagination,
- ]);
-
- return {
- results: searchResults,
- isLoading: isSearching,
- error: searchError,
- loadMore,
- hasNextPage,
- isLoadingMore,
- totalCount,
- };
-}
+import { useEffect } from "react";
+import { useSoundsStore } from "@/stores/sounds-store";
+
+export function useSoundSearch({
+ query,
+ commercialOnly,
+}: {
+ query: string;
+ commercialOnly: boolean;
+}) {
+ const {
+ searchResults,
+ isSearching,
+ searchError,
+ lastSearchQuery,
+ currentPage,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ setLoadingMore,
+ appendSearchResults,
+ appendTopSounds,
+ resetPagination,
+ } = useSoundsStore();
+
+ const loadMore = async () => {
+ if (isLoadingMore || !hasNextPage) return;
+
+ try {
+ setLoadingMore({ loading: true });
+ const nextPage = currentPage + 1;
+
+ const searchParams = new URLSearchParams({
+ page: nextPage.toString(),
+ type: "effects",
+ });
+
+ if (query.trim()) {
+ searchParams.set("q", query);
+ }
+
+ searchParams.set("commercial_only", commercialOnly.toString());
+ const response = await fetch(
+ `/api/sounds/search?${searchParams.toString()}`,
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+
+ if (query.trim()) {
+ appendSearchResults(data.results);
+ } else {
+ appendTopSounds(data.results);
+ }
+
+ setCurrentPage({ page: nextPage });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount(data.count);
+ } else {
+ setSearchError({ error: `Load more failed: ${response.status}` });
+ }
+ } catch (err) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Load more failed",
+ });
+ } finally {
+ setLoadingMore({ loading: false });
+ }
+ };
+
+ useEffect(() => {
+ if (!query.trim()) {
+ setSearchResults({ results: [] });
+ setSearchError({ error: null });
+ setLastSearchQuery({ query: "" });
+ return;
+ }
+
+ if (query === lastSearchQuery && searchResults.length > 0) {
+ return;
+ }
+
+ let ignore = false;
+
+ const timeoutId = setTimeout(async () => {
+ try {
+ setSearching({ searching: true });
+ setSearchError({ error: null });
+ resetPagination();
+
+ const response = await fetch(
+ `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
+ );
+
+ if (!ignore) {
+ if (response.ok) {
+ const data = await response.json();
+ setSearchResults({ results: data.results });
+ setLastSearchQuery({ query: query });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ setCurrentPage({ page: 1 });
+ } else {
+ setSearchError({ error: `Search failed: ${response.status}` });
+ }
+ }
+ } catch (err) {
+ if (!ignore) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Search failed",
+ });
+ }
+ } finally {
+ if (!ignore) {
+ setSearching({ searching: false });
+ }
+ }
+ }, 300);
+
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }, [
+ query,
+ lastSearchQuery,
+ searchResults.length,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ resetPagination,
+ ]);
+
+ return {
+ results: searchResults,
+ isLoading: isSearching,
+ error: searchError,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ };
+}
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
new file mode 100644
index 00000000..37ef6d5f
--- /dev/null
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -0,0 +1,331 @@
+import { useCallback, useRef, useState } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { useSyncExternalStore } from "react";
+import {
+ getVisibleElementsWithBounds,
+ type ElementWithBounds,
+} from "@/lib/preview/element-bounds";
+import { screenToCanvas } from "@/lib/preview/preview-coords";
+import {
+ MIN_SCALE,
+ snapRotation,
+ snapScale,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import type { Transform } from "@/types/timeline";
+
+type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+type HandleType = Corner | "rotation";
+
+interface ScaleState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialDistance: number;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+ baseWidth: number;
+ baseHeight: number;
+}
+
+interface RotationState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialAngle: number;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+}
+
+function areSnapLinesEqual({
+ previousLines,
+ nextLines,
+}: {
+ previousLines: SnapLine[];
+ nextLines: SnapLine[];
+}): boolean {
+ if (previousLines.length !== nextLines.length) {
+ return false;
+ }
+ for (const [index, line] of previousLines.entries()) {
+ const nextLine = nextLines[index];
+ if (!nextLine) {
+ return false;
+ }
+ if (line.type !== nextLine.type || line.position !== nextLine.position) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function getCornerDistance({
+ bounds,
+ corner,
+}: {
+ bounds: {
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+ };
+ corner: Corner;
+}): number {
+ const halfW = bounds.width / 2;
+ const halfH = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+
+ const localX =
+ corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+
+ const rotatedX = localX * cos - localY * sin;
+ const rotatedY = localX * sin + localY * cos;
+ return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
+}
+
+export function useTransformHandles({
+ canvasRef,
+}: {
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const snapLinesRef = useRef<SnapLine[]>([]);
+ const scaleStateRef = useRef<ScaleState | null>(null);
+ const rotationStateRef = useRef<RotationState | null>(null);
+
+ const selectedElements = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getSelectedElements(),
+ );
+
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const mediaAssets = editor.media.getAssets();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const selectedWithBounds: ElementWithBounds | null =
+ selectedElements.length === 1
+ ? (elementsWithBounds.find(
+ (entry) =>
+ entry.trackId === selectedElements[0].trackId &&
+ entry.elementId === selectedElements[0].elementId,
+ ) ?? null)
+ : null;
+
+ const hasVisualSelection =
+ selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
+
+ const handleCornerPointerDown = useCallback(
+ ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const initialDistance = getCornerDistance({ bounds, corner });
+ const baseWidth = bounds.width / element.transform.scale;
+ const baseHeight = bounds.height / element.transform.scale;
+
+ scaleStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: element.transform,
+ initialDistance,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ baseWidth,
+ baseHeight,
+ };
+ setActiveHandle(corner);
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ },
+ [selectedWithBounds],
+ );
+
+ const handleRotationPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (!selectedWithBounds || !canvasRef.current) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const position = screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ canvas: canvasRef.current,
+ });
+ const dx = position.x - bounds.cx;
+ const dy = position.y - bounds.cy;
+ const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
+
+ rotationStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: element.transform,
+ initialAngle,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ };
+ setActiveHandle("rotation");
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ },
+ [selectedWithBounds, canvasRef],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (!canvasRef.current) return;
+ if (!scaleStateRef.current && !rotationStateRef.current) return;
+
+ const position = screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ canvas: canvasRef.current,
+ });
+
+ if (
+ scaleStateRef.current &&
+ activeHandle &&
+ activeHandle !== "rotation"
+ ) {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialDistance,
+ initialBoundsCx,
+ initialBoundsCy,
+ baseWidth,
+ baseHeight,
+ } = scaleStateRef.current;
+
+ const dx = position.x - initialBoundsCx;
+ const dy = position.y - initialBoundsCy;
+ const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1;
+ const scaleFactor = currentDistance / initialDistance;
+ const proposedScale = Math.max(
+ MIN_SCALE,
+ initialTransform.scale * scaleFactor,
+ );
+
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+ const shouldSnap = !isShiftHeldRef.current;
+ const { snappedScale, activeLines } = shouldSnap
+ ? snapScale({
+ proposedScale,
+ position: initialTransform.position,
+ baseWidth,
+ baseHeight,
+ canvasSize,
+ })
+ : { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
+
+ const isSameLines = areSnapLinesEqual({
+ previousLines: snapLinesRef.current,
+ nextLines: activeLines,
+ });
+
+ if (!isSameLines) {
+ snapLinesRef.current = activeLines;
+ setSnapLines(activeLines);
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: { ...initialTransform, scale: snappedScale },
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (rotationStateRef.current && activeHandle === "rotation") {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialAngle,
+ initialBoundsCx,
+ initialBoundsCy,
+ } = rotationStateRef.current;
+
+ const dx = position.x - initialBoundsCx;
+ const dy = position.y - initialBoundsCy;
+ const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
+ let deltaAngle = currentAngle - initialAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+ const newRotate = initialTransform.rotate + deltaAngle;
+ const shouldSnapRotation = !isShiftHeldRef.current;
+ const { snappedRotation } = shouldSnapRotation
+ ? snapRotation({ proposedRotation: newRotate })
+ : { snappedRotation: newRotate };
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: { ...initialTransform, rotate: snappedRotation },
+ },
+ },
+ ],
+ });
+ }
+ },
+ [activeHandle, canvasRef, editor, isShiftHeldRef],
+ );
+
+ const handlePointerUp = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (scaleStateRef.current || rotationStateRef.current) {
+ editor.timeline.commitPreview();
+ scaleStateRef.current = null;
+ rotationStateRef.current = null;
+ setActiveHandle(null);
+ snapLinesRef.current = [];
+ setSnapLines([]);
+ }
+ (event.currentTarget as HTMLElement).releasePointerCapture(
+ event.pointerId,
+ );
+ },
+ [editor],
+ );
+
+ return {
+ selectedWithBounds,
+ hasVisualSelection,
+ activeHandle,
+ snapLines,
+ handleCornerPointerDown,
+ handleRotationPointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ };
+}