From 93d1e3383ce47e45157d70f9ba6251239dae6bc2 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:24:02 +0100 Subject: feat: major editor overhaul (assets, properties, timeline, fonts) (#709) * feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env --- .../timeline/element/use-element-interaction.ts | 40 ++- .../hooks/timeline/element/use-element-resize.ts | 322 +++++++++++--------- .../timeline/element/use-element-selection.ts | 4 +- apps/web/src/hooks/timeline/use-bookmark-drag.ts | 289 ++++++++++++++++++ .../src/hooks/timeline/use-timeline-drag-drop.ts | 172 +++++------ .../src/hooks/timeline/use-timeline-playhead.ts | 40 ++- .../src/hooks/timeline/use-timeline-snapping.ts | 32 +- apps/web/src/hooks/timeline/use-timeline-zoom.ts | 50 +++- apps/web/src/hooks/use-focus-lock.ts | 79 +++++ apps/web/src/hooks/use-infinite-scroll.ts | 70 ++--- apps/web/src/hooks/use-paste-media.ts | 99 ++++++ apps/web/src/hooks/use-preview-interaction.ts | 330 +++++++++++++------- apps/web/src/hooks/use-shift-key.ts | 35 +++ apps/web/src/hooks/use-sound-search.ts | 308 +++++++++---------- apps/web/src/hooks/use-transform-handles.ts | 331 +++++++++++++++++++++ 15 files changed, 1618 insertions(+), 583 deletions(-) create mode 100644 apps/web/src/hooks/timeline/use-bookmark-drag.ts create mode 100644 apps/web/src/hooks/use-focus-lock.ts create mode 100644 apps/web/src/hooks/use-paste-media.ts create mode 100644 apps/web/src/hooks/use-shift-key.ts create mode 100644 apps/web/src/hooks/use-transform-handles.ts (limited to 'apps/web/src/hooks') 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; @@ -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 deltaX = clientX - resizing.startX; - let deltaTime = deltaX / (50 * zoomLevel); - let resizeSnapPoint: SnapPoint | null = null; - - const projectFps = activeProject.settings.fps; - const minDurationSeconds = 1 / projectFps; - const canSnap = snappingEnabled; - if (canSnap) { - const tracks = editor.timeline.getTracks(); - const playheadTime = editor.playback.getCurrentTime(); - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - excludeElementId: element.id, - }); - if (resizing.side === "left") { - const targetStartTime = resizing.initialStartTime + deltaTime; - const snapResult = snapToNearestPoint({ - targetTime: targetStartTime, - snapPoints, - zoomLevel, + const updateTrimFromMouseMove = useCallback( + ({ clientX }: { clientX: number }) => { + if (!resizing) return; + + 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 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 - 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; + if (resizing.side === "left") { + const targetStartTime = resizing.initialStartTime + deltaTime; + const snapResult = snapToNearestPoint({ + targetTime: targetStartTime, + snapPoints, + zoomLevel, + }); + resizeSnapPoint = snapResult.snapPoint; + if (snapResult.snapPoint) { + deltaTime = snapResult.snappedTime - resizing.initialStartTime; + } + } else { + const baseEndTime = + resizing.initialStartTime + resizing.initialDuration; + const targetEndTime = baseEndTime + deltaTime; + const snapResult = snapToNearestPoint({ + targetTime: targetEndTime, + snapPoints, + zoomLevel, + }); + resizeSnapPoint = snapResult.snapPoint; + if (snapResult.snapPoint) { + deltaTime = snapResult.snappedTime - baseEndTime; + } } } - } - onSnapPointChange?.(resizeSnapPoint); - - if (resizing.side === "left") { - const sourceDuration = - resizing.initialTrimStart + - resizing.initialDuration + - resizing.initialTrimEnd; - const maxAllowed = - sourceDuration - resizing.initialTrimEnd - minDurationSeconds; - const calculated = resizing.initialTrimStart + deltaTime; - - if (calculated >= 0 && calculated <= maxAllowed) { - const newTrimStart = snapTimeToFrame({ - time: Math.min(maxAllowed, calculated), - fps: projectFps, - }); - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime + trimDelta, - fps: projectFps, - }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, - fps: projectFps, - }); + onSnapPointChange?.(resizeSnapPoint); - setCurrentTrimStart(newTrimStart); - setCurrentStartTime(newStartTime); - setCurrentDuration(newDuration); - currentTrimStartRef.current = newTrimStart; - currentStartTimeRef.current = newStartTime; - currentDurationRef.current = newDuration; - } else if (calculated < 0) { - if (canExtendElementDuration()) { - const extensionAmount = Math.abs(calculated); - const maxExtension = resizing.initialStartTime; - const actualExtension = Math.min(extensionAmount, maxExtension); - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime - actualExtension, - fps: projectFps, - }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration + actualExtension, + 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, }); - - 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; - - if (newTrimEnd < 0) { - if (canExtendElementDuration()) { - const extensionNeeded = Math.abs(newTrimEnd); - const baseDuration = - resizing.initialDuration + resizing.initialTrimEnd; - const newDuration = snapTimeToFrame({ - time: baseDuration + extensionNeeded, + } else { + const sourceDuration = + resizing.initialTrimStart + + resizing.initialDuration + + resizing.initialTrimEnd; + const newTrimEnd = resizing.initialTrimEnd - deltaTime; + + if (newTrimEnd < 0) { + if (canExtendElementDuration()) { + const extensionNeeded = Math.abs(newTrimEnd); + const baseDuration = + resizing.initialDuration + resizing.initialTrimEnd; + const newDuration = snapTimeToFrame({ + time: baseDuration + extensionNeeded, + fps: projectFps, + }); + + setCurrentDuration(newDuration); + setCurrentTrimEnd(0); + currentDurationRef.current = newDuration; + currentTrimEndRef.current = 0; + } else { + const extensionToLimit = resizing.initialTrimEnd; + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration + extensionToLimit, + fps: projectFps, + }); + + setCurrentDuration(newDuration); + setCurrentTrimEnd(0); + currentDurationRef.current = newDuration; + currentTrimEndRef.current = 0; + } + } else { + const maxTrimEnd = + sourceDuration - resizing.initialTrimStart - minDurationSeconds; + const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd)); + const finalTrimEnd = snapTimeToFrame({ + time: clampedTrimEnd, fps: projectFps, }); - - setCurrentDuration(newDuration); - setCurrentTrimEnd(0); - currentDurationRef.current = newDuration; - currentTrimEndRef.current = 0; - } else { - const extensionToLimit = resizing.initialTrimEnd; + 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; + 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({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef(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(); + + const commands: Command[] = [addMediaCmd]; + + 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 (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, - }); + if (!trackId) return; + + 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 trackType: TrackType = - added.type === "audio" ? "audio" : "video"; - const trackId = dropTarget.isNewTrack - ? editor.timeline.addTrack({ - type: trackType, - index: dropTarget.trackIndex, - }) - : currentTracks[dropTarget.trackIndex]?.id; - - if (!trackId) return; - - if (added.type === "audio") { - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element: buildUploadAudioElement({ - mediaId: added.id, - name: added.name, - duration, - startTime: dropTarget.xPosition, - buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }), - }), - }); - } else if (added.type === "video") { - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element: buildVideoElement({ - mediaId: added.id, - name: added.name, - duration, - startTime: dropTarget.xPosition, - }), - }); - } else { - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element: buildImageElement({ - mediaId: added.id, - name: added.name, - duration, - startTime: dropTarget.xPosition, - }), - }); - } - } + 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; playheadTime: number; excludeElementId?: string; + bookmarks?: Array; + 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; }): 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({ + isActive, + onDismiss, + cursor = "default", + allowSelector, +}: { + isActive: boolean; + onDismiss: () => void; + cursor?: FocusLockCursor; + allowSelector?: string; +}) { + const containerRef = useRef(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(null); - - const handleScroll = useCallback( - (event: React.UIEvent) => { - 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(null); + + const handleScroll = useCallback( + (event: React.UIEvent) => { + 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; }) { const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); const [isDragging, setIsDragging] = useState(false); + const [snapLines, setSnapLines] = useState([]); + const [editingText, setEditingText] = useState<{ + trackId: string; + elementId: string; + element: TextElement; + originalOpacity: number; + } | null>(null); const dragStateRef = useRef(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 currentPos = getCanvasCoordinates({ - clientX: event.clientX, - clientY: event.clientY, + const canvasSize = editor.project.getActive().settings.canvasSize; + + 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; + } + + const firstElement = dragStateRef.current.elements[0]; + const proposedPosition = { + x: firstElement.initialTransform.position.x + deltaX, + y: firstElement.initialTransform.position.y + deltaY, + }; - for (const { trackId, elementId, initialTransform } of dragStateRef - .current.elements) { - const newPosition = { - x: initialTransform.position.x + deltaX, - y: 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 { + 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; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const [activeHandle, setActiveHandle] = useState(null); + const [snapLines, setSnapLines] = useState([]); + const snapLinesRef = useRef([]); + const scaleStateRef = useRef(null); + const rotationStateRef = useRef(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, + }; +} -- cgit v1.3.1