From e7dcb586c0408255a0bf788e355fdb6d50d9b923 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 2 Mar 2026 13:13:07 +0100 Subject: feat: Clip effects, asset sorting, and timeline improvements Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders). --- apps/web/src/hooks/actions/use-editor-actions.ts | 34 ++- .../timeline/element/use-element-interaction.ts | 65 ++--- .../hooks/timeline/element/use-element-resize.ts | 98 +++++-- .../hooks/timeline/element/use-keyframe-drag.ts | 281 +++++++++++++++++++++ .../src/hooks/timeline/use-timeline-drag-drop.ts | 163 +++++++----- apps/web/src/hooks/use-transform-handles.ts | 96 +++++-- 6 files changed, 590 insertions(+), 147 deletions(-) create mode 100644 apps/web/src/hooks/timeline/element/use-keyframe-drag.ts (limited to 'apps/web/src/hooks') diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 5f48fc8f..e4f378d6 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -4,19 +4,19 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { useActionHandler } from "@/hooks/actions/use-action-handler"; import { useEditor } from "../use-editor"; import { useElementSelection } from "../timeline/element/use-element-selection"; +import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection"; import { getElementsAtTime } from "@/lib/timeline"; export function useEditorActions() { const editor = useEditor(); const activeProject = editor.project.getActive(); const { selectedElements, setElementSelection } = useElementSelection(); - const { - clipboard, - setClipboard, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - } = useTimelineStore(); + const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection(); + const clipboard = useTimelineStore((s) => s.clipboard); + const setClipboard = useTimelineStore((s) => s.setClipboard); + const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); + const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); useActionHandler( "toggle-play", @@ -209,6 +209,11 @@ export function useEditorActions() { useActionHandler( "delete-selected", () => { + if (selectedKeyframes.length > 0) { + editor.timeline.removeKeyframes({ keyframes: selectedKeyframes }); + clearKeyframeSelection(); + return; + } if (selectedElements.length === 0) { return; } @@ -235,6 +240,19 @@ export function useEditorActions() { undefined, ); + useActionHandler( + "deselect-all", + () => { + setElementSelection({ elements: [] }); + clearKeyframeSelection(); + const activeElement = document.activeElement; + if (activeElement instanceof HTMLButtonElement) { + activeElement.blur(); + } + }, + undefined, + ); + useActionHandler( "duplicate-selected", () => { @@ -278,7 +296,7 @@ export function useEditorActions() { elements: selectedElements, }); const items = results.map(({ track, element }) => { - const { ...elementWithoutId } = element; + const { id: _, ...elementWithoutId } = element; return { trackId: track.id, trackType: track.type, 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 3831a86f..ea01c718 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -8,6 +8,7 @@ import { } from "react"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useTimelineStore } from "@/stores/timeline-store"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { DRAG_THRESHOLD_PX, @@ -38,6 +39,8 @@ interface UseElementInteractionProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } +const MOUSE_BUTTON_RIGHT = 2; + const initialDragState: ElementDragState = { isDragging: false, elementId: null, @@ -162,6 +165,7 @@ export function useElementInteraction({ onSnapPointChange, }: UseElementInteractionProps) { const editor = useEditor(); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const isShiftHeldRef = useShiftKey(); const tracks = editor.timeline.getTracks(); const { @@ -441,27 +445,33 @@ export function useElementInteraction({ return; } - if (dropTarget.isNewTrack) { - const newTrackId = generateUUID(); + if (dropTarget.isNewTrack) { + const newTrackId = generateUUID(); + editor.timeline.moveElement({ + sourceTrackId: dragState.trackId, + targetTrackId: newTrackId, + elementId: dragState.elementId, + newStartTime: snappedTime, + createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, + rippleEnabled: rippleEditingEnabled, + }); + selectElement({ trackId: newTrackId, elementId: dragState.elementId }); + } else { + const targetTrack = tracks[dropTarget.trackIndex]; + if (targetTrack) { editor.timeline.moveElement({ sourceTrackId: dragState.trackId, - targetTrackId: newTrackId, + targetTrackId: targetTrack.id, elementId: dragState.elementId, newStartTime: snappedTime, - createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, + rippleEnabled: rippleEditingEnabled, }); - } else { - const targetTrack = tracks[dropTarget.trackIndex]; - if (targetTrack) { - editor.timeline.moveElement({ - sourceTrackId: dragState.trackId, - targetTrackId: targetTrack.id, - elementId: dragState.elementId, - newStartTime: snappedTime, - }); + if (targetTrack.id !== dragState.trackId) { + selectElement({ trackId: targetTrack.id, elementId: dragState.elementId }); } } + } endDrag(); onSnapPointChange?.(null); @@ -483,6 +493,8 @@ export function useElementInteraction({ tracksContainerRef, tracksScrollRef, headerRef, + rippleEditingEnabled, + selectElement, ]); useEffect(() => { @@ -508,10 +520,10 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - const isRightClick = event.button === 2; + const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { + // right-click: don't stop propagation so ContextMenu can open + if (isRightClick) { const alreadySelected = isElementSelected({ trackId: track.id, elementId: element.id, @@ -526,14 +538,12 @@ export function useElementInteraction({ return; } - // left-click: stop propagation for drag operations - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; + event.stopPropagation(); + mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; + const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - // multi-select: toggle selection - if (isMultiSelect) { + if (isMultiSelect) { handleSelectionClick({ trackId: track.id, elementId: element.id, @@ -541,8 +551,7 @@ export function useElementInteraction({ }); } - // start drag - const clickOffsetTime = getClickOffsetTime({ + const clickOffsetTime = getClickOffsetTime({ clientX: event.clientX, elementRect: event.currentTarget.getBoundingClientRect(), zoomLevel, @@ -570,10 +579,9 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - event.stopPropagation(); + event.stopPropagation(); - // was it a drag or a click? - if (mouseDownLocationRef.current) { + if (mouseDownLocationRef.current) { const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) { @@ -585,8 +593,7 @@ export function useElementInteraction({ // modifier keys already handled in mousedown if (event.metaKey || event.ctrlKey || event.shiftKey) return; - // single click: select if not selected - const alreadySelected = isElementSelected({ + const alreadySelected = isElementSelected({ trackId: track.id, elementId: element.id, }); 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 2340c9b2..0ae17cb6 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -137,18 +137,52 @@ export function useTimelineElementResize({ } onSnapPointChange?.(resizeSnapPoint); + const otherElements = track.elements.filter(({ id }) => id !== element.id); + const initialEndTime = resizing.initialStartTime + resizing.initialDuration; + + const rightNeighborBound = + resizing.side === "right" + ? otherElements + .filter(({ startTime }) => startTime >= initialEndTime) + .reduce((min, { startTime }) => Math.min(min, startTime), Infinity) + : Infinity; + + const leftNeighborBound = + resizing.side === "left" + ? otherElements + .filter( + ({ startTime, duration }) => + startTime + duration <= resizing.initialStartTime, + ) + .reduce( + (max, { startTime, duration }) => + Math.max(max, startTime + duration), + -Infinity, + ) + : -Infinity; + if (resizing.side === "left") { const sourceDuration = resizing.initialTrimStart + resizing.initialDuration + resizing.initialTrimEnd; + const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound) + ? Math.max( + 0, + resizing.initialTrimStart + + (leftNeighborBound - resizing.initialStartTime), + ) + : 0; const maxAllowed = sourceDuration - resizing.initialTrimEnd - minDurationSeconds; const calculated = resizing.initialTrimStart + deltaTime; if (calculated >= 0 && calculated <= maxAllowed) { const newTrimStart = snapTimeToFrame({ - time: Math.min(maxAllowed, calculated), + time: Math.min( + maxAllowed, + Math.max(minTrimStartForNeighbor, calculated), + ), fps: projectFps, }); const trimDelta = newTrimStart - resizing.initialTrimStart; @@ -171,7 +205,16 @@ export function useTimelineElementResize({ if (canExtendElementDuration()) { const extensionAmount = Math.abs(calculated); const maxExtension = resizing.initialStartTime; - const actualExtension = Math.min(extensionAmount, maxExtension); + const actualExtension = Math.max( + 0, + Number.isFinite(leftNeighborBound) + ? Math.min( + extensionAmount, + maxExtension, + resizing.initialStartTime - leftNeighborBound, + ) + : Math.min(extensionAmount, maxExtension), + ); const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps, @@ -188,7 +231,8 @@ export function useTimelineElementResize({ currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } else { - const trimDelta = 0 - resizing.initialTrimStart; + const trimDelta = + minTrimStartForNeighbor - resizing.initialTrimStart; const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps, @@ -198,10 +242,10 @@ export function useTimelineElementResize({ fps: projectFps, }); - setCurrentTrimStart(0); + setCurrentTrimStart(minTrimStartForNeighbor); setCurrentStartTime(newStartTime); setCurrentDuration(newDuration); - currentTrimStartRef.current = 0; + currentTrimStartRef.current = minTrimStartForNeighbor; currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } @@ -212,6 +256,9 @@ export function useTimelineElementResize({ resizing.initialDuration + resizing.initialTrimEnd; const newTrimEnd = resizing.initialTrimEnd - deltaTime; + const maxAllowedDuration = Number.isFinite(rightNeighborBound) + ? rightNeighborBound - resizing.initialStartTime + : Infinity; if (newTrimEnd < 0) { if (canExtendElementDuration()) { @@ -219,7 +266,7 @@ export function useTimelineElementResize({ const baseDuration = resizing.initialDuration + resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: baseDuration + extensionNeeded, + time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps, }); @@ -230,7 +277,10 @@ export function useTimelineElementResize({ } else { const extensionToLimit = resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: resizing.initialDuration + extensionToLimit, + time: Math.min( + resizing.initialDuration + extensionToLimit, + maxAllowedDuration, + ), fps: projectFps, }); @@ -240,9 +290,20 @@ export function useTimelineElementResize({ currentTrimEndRef.current = 0; } } else { + const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration) + ? Math.max( + 0, + resizing.initialDuration + + resizing.initialTrimEnd - + maxAllowedDuration, + ) + : 0; const maxTrimEnd = sourceDuration - resizing.initialTrimStart - minDurationSeconds; - const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd)); + const clampedTrimEnd = Math.min( + maxTrimEnd, + Math.max(minTrimEndForNeighbor, newTrimEnd), + ); const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps, @@ -267,6 +328,7 @@ export function useTimelineElementResize({ snappingEnabled, editor, element.id, + track.elements, onSnapPointChange, canExtendElementDuration, isShiftHeldRef, @@ -285,26 +347,13 @@ export function useTimelineElementResize({ const startTimeChanged = finalStartTime !== resizing.initialStartTime; const durationChanged = finalDuration !== resizing.initialDuration; - if (trimStartChanged || trimEndChanged) { + if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) { editor.timeline.updateElementTrim({ elementId: element.id, trimStart: finalTrimStart, trimEnd: finalTrimEnd, - }); - } - - if (startTimeChanged) { - editor.timeline.updateElementStartTime({ - elements: [{ trackId: track.id, elementId: element.id }], - startTime: finalStartTime, - }); - } - - if (durationChanged) { - editor.timeline.updateElementDuration({ - trackId: track.id, - elementId: element.id, - duration: finalDuration, + startTime: startTimeChanged ? finalStartTime : undefined, + duration: durationChanged ? finalDuration : undefined, }); } @@ -315,7 +364,6 @@ export function useTimelineElementResize({ resizing, editor.timeline, element.id, - track.id, onResizeStateChange, onSnapPointChange, ]); diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts new file mode 100644 index 00000000..ad4bfe4f --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts @@ -0,0 +1,281 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useKeyframeSelection } from "./use-keyframe-selection"; +import { snapTimeToFrame } from "@/lib/time"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; +import { DRAG_THRESHOLD_PX, TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; +import { BatchCommand } from "@/lib/commands"; +import type { SelectedKeyframeRef } from "@/types/animation"; +import type { TimelineElement } from "@/types/timeline"; +import type { Command } from "@/lib/commands/base-command"; + +export interface KeyframeDragState { + isDragging: boolean; + draggingKeyframeIds: Set; + deltaTime: number; +} + +const initialDragState: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTime: 0, +}; + +interface PendingKeyframeDrag { + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +export function useKeyframeDrag({ + zoomLevel, + element, +}: { + zoomLevel: number; + element: TimelineElement; +}) { + const editor = useEditor(); + const { + selectedKeyframes, + isKeyframeSelected, + toggleKeyframeSelection, + selectKeyframeRange, + } = useKeyframeSelection(); + + const [dragState, setDragState] = + useState(initialDragState); + const [isPendingDrag, setIsPendingDrag] = useState(false); + + const pendingDragRef = useRef(null); + const mouseDownXRef = useRef(null); + + const activeProject = editor.project.getActive(); + const fps = activeProject.settings.fps; + + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + const endDrag = useCallback(() => { + setDragState(initialDragState); + }, []); + + const commitDrag = useCallback( + ({ + keyframeRefs, + deltaTime, + }: { + keyframeRefs: SelectedKeyframeRef[]; + deltaTime: number; + }) => { + const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { + const channel = element.animations?.channels[keyframeRef.propertyPath]; + const keyframe = channel?.keyframes.find( + (keyframe) => keyframe.id === keyframeRef.keyframeId, + ); + if (!keyframe) return []; + const nextTime = Math.max( + 0, + Math.min(element.duration, keyframe.time + deltaTime), + ); + return [ + new RetimeKeyframeCommand({ + trackId: keyframeRef.trackId, + elementId: keyframeRef.elementId, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + nextTime, + }), + ]; + }); + + if (commands.length === 1) { + editor.command.execute({ command: commands[0] }); + } else if (commands.length > 1) { + editor.command.execute({ command: new BatchCommand(commands) }); + } + }, + [editor.command, element], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = ({ clientX }: MouseEvent) => { + if (isPendingDrag && pendingDragRef.current) { + const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); + if (deltaX <= DRAG_THRESHOLD_PX) return; + + const pending = pendingDragRef.current; + pendingDragRef.current = null; + setIsPendingDrag(false); + setDragState({ + isDragging: true, + draggingKeyframeIds: new Set( + pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), + ), + deltaTime: 0, + }); + return; + } + + if (!dragState.isDragging) return; + + const startX = mouseDownXRef.current ?? clientX; + const rawDelta = (clientX - startX) / pixelsPerSecond; + const snappedDelta = snapTimeToFrame({ time: rawDelta, fps }); + + setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = () => { + const draggingRefs = selectedKeyframes.filter( + (keyframe) => + keyframe.elementId === element.id && + dragState.draggingKeyframeIds.has(keyframe.keyframeId), + ); + + if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { + commitDrag({ + keyframeRefs: draggingRefs, + deltaTime: dragState.deltaTime, + }); + } + + endDrag(); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.draggingKeyframeIds, + dragState.deltaTime, + selectedKeyframes, + element.id, + commitDrag, + endDrag, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag]); + + const handleKeyframeMouseDown = useCallback( + ({ + event, + keyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => { + event.preventDefault(); + event.stopPropagation(); + + mouseDownXRef.current = event.clientX; + + const anySelected = keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; + + pendingDragRef.current = { + keyframeRefs: keyframeRefsToTrack, + startMouseX: event.clientX, + }; + setIsPendingDrag(true); + }, + [isKeyframeSelected, selectedKeyframes], + ); + + const handleKeyframeClick = useCallback( + ({ + event, + keyframes, + orderedKeyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + }) => { + event.stopPropagation(); + + const wasDrag = + mouseDownXRef.current !== null && + Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX; + mouseDownXRef.current = null; + + if (wasDrag) return; + + if (event.shiftKey) { + selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + }, + [toggleKeyframeSelection, selectKeyframeRange], + ); + + const getVisualOffsetPx = useCallback( + ({ + indicatorTime, + indicatorOffsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }): number => { + if (!isBeingDragged) return indicatorOffsetPx; + const clampedTime = Math.max( + 0, + Math.min(element.duration, indicatorTime + dragState.deltaTime), + ); + return ( + timelineTimeToSnappedPixels({ + time: displayedStartTime + clampedTime, + zoomLevel, + }) - elementLeft + ); + }, + [dragState.deltaTime, element.duration, zoomLevel], + ); + + return { + keyframeDragState: dragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + }; +} 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 ee739298..8a30785e 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -26,12 +26,14 @@ import type { interface UseTimelineDragDropProps { containerRef: RefObject; headerRef?: RefObject; + tracksScrollRef?: RefObject; zoomLevel: number; } export function useTimelineDragDrop({ containerRef, headerRef, + tracksScrollRef, zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); @@ -104,11 +106,16 @@ export function useTimelineDragDrop({ (e: React.DragEvent) => { e.preventDefault(); - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; + const scrollContainer = tracksScrollRef?.current; + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + containerRef.current?.getBoundingClientRect(); + if (!referenceRect) return; const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; const hasFiles = e.dataTransfer.types.includes("Files"); const isExternal = hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); @@ -131,8 +138,8 @@ export function useTimelineDragDrop({ mediaId: dragData?.type === "media" ? dragData.id : undefined, }); - const mouseX = e.clientX - rect.left; - const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + const mouseX = e.clientX - referenceRect.left + scrollLeft; + const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; const targetElementTypes = dragData?.type === "effect" @@ -162,6 +169,7 @@ export function useTimelineDragDrop({ [ containerRef, headerRef, + tracksScrollRef, tracks, currentTime, zoomLevel, @@ -200,19 +208,6 @@ export function useTimelineDragDrop({ target: DropTarget; dragData: { name?: string; content?: string }; }) => { - let trackId: string; - - if (target.isNewTrack) { - trackId = editor.timeline.addTrack({ - type: "text", - index: target.trackIndex, - }); - } else { - const track = tracks[target.trackIndex]; - if (!track) return; - trackId = track.id; - } - const element = buildTextElement({ raw: { name: dragData.name ?? "", @@ -221,12 +216,26 @@ export function useTimelineDragDrop({ startTime: target.xPosition, }); + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("text", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const track = tracks[target.trackIndex]; + if (!track) return; editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, + placement: { mode: "explicit", trackId: track.id }, element, }); }, - [editor.timeline, tracks], + [editor.command, editor.timeline, tracks], ); const executeStickerDrop = useCallback( @@ -237,31 +246,32 @@ export function useTimelineDragDrop({ target: DropTarget; dragData: StickerDragData; }) => { - let trackId: string; - - if (target.isNewTrack) { - trackId = editor.timeline.addTrack({ - type: "sticker", - index: target.trackIndex, - }); - } else { - const track = tracks[target.trackIndex]; - if (!track) return; - trackId = track.id; - } - const element = buildStickerElement({ stickerId: dragData.stickerId, name: dragData.name, startTime: target.xPosition, }); + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const track = tracks[target.trackIndex]; + if (!track) return; editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, + placement: { mode: "explicit", trackId: track.id }, element, }); }, - [editor.timeline, tracks], + [editor.command, editor.timeline, tracks], ); const executeMediaDrop = useCallback( @@ -276,18 +286,6 @@ export function useTimelineDragDrop({ const trackType: TrackType = dragData.mediaType === "audio" ? "audio" : "video"; - let trackId: string; - - if (target.isNewTrack) { - trackId = editor.timeline.addTrack({ - type: trackType, - index: target.trackIndex, - }); - } else { - const track = tracks[target.trackIndex]; - if (!track) return; - trackId = track.id; - } const duration = mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; @@ -299,31 +297,63 @@ export function useTimelineDragDrop({ startTime: target.xPosition, }); + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const track = tracks[target.trackIndex]; + if (!track) return; editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, + placement: { mode: "explicit", trackId: track.id }, element, }); }, - [editor.timeline, mediaAssets, tracks], + [editor.command, editor.timeline, mediaAssets, tracks], ); const executeEffectDrop = useCallback( - ({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => { + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: EffectDragData; + }) => { + if (target.targetElement) { + editor.timeline.addClipEffect({ + trackId: target.targetElement.trackId, + elementId: target.targetElement.elementId, + effectType: dragData.effectType, + }); + return; + } + const effectTrack = tracks.find((t) => t.type === "effect"); let trackId: string; - if (effectTrack && !target.targetElement) { + if (effectTrack) { trackId = effectTrack.id; - } else if (target.targetElement) { - trackId = effectTrack?.id ?? editor.timeline.addTrack({ - type: "effect", - index: 0, - }); } else if (target.isNewTrack) { - trackId = editor.timeline.addTrack({ - type: "effect", - index: target.trackIndex, + const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element: buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }), + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; } else { const track = tracks[target.trackIndex]; if (!track || track.type !== "effect") return; @@ -340,7 +370,7 @@ export function useTimelineDragDrop({ element, }); }, - [editor.timeline, tracks], + [editor.command, editor.timeline, tracks], ); const executeFileDrop = useCallback( @@ -452,12 +482,18 @@ export function useTimelineDragDrop({ executeMediaDrop({ target: currentTarget, dragData }); } } else if (hasFiles) { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - const mouseX = e.clientX - rect.left; + const scrollContainer = tracksScrollRef?.current; + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + containerRef.current?.getBoundingClientRect(); + if (!referenceRect) return; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + const mouseX = e.clientX - referenceRect.left + scrollLeft; const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + const mouseY = + e.clientY - referenceRect.top + scrollTop - headerHeight; await executeFileDrop({ files: Array.from(e.dataTransfer.files), mouseX, @@ -478,6 +514,7 @@ export function useTimelineDragDrop({ executeFileDrop, containerRef, headerRef, + tracksScrollRef, ], ); diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index 8be6420c..fb561396 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -1,7 +1,6 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useRef, useState, useSyncExternalStore } from "react"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { useSyncExternalStore } from "react"; import { getVisibleElementsWithBounds, type ElementWithBounds, @@ -18,7 +17,13 @@ import { type SnapLine, } from "@/lib/preview/preview-snap"; import { isVisualElement } from "@/lib/timeline/element-utils"; +import { + getElementLocalTime, + resolveTransformAtTime, + setChannel, +} from "@/lib/animation"; import type { Transform } from "@/types/timeline"; +import type { ElementAnimations } from "@/types/animation"; type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; type HandleType = Corner | "rotation"; @@ -32,6 +37,8 @@ interface ScaleState { initialBoundsCy: number; baseWidth: number; baseHeight: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; } interface RotationState { @@ -78,16 +85,16 @@ function getCornerDistance({ }; corner: Corner; }): number { - const halfW = bounds.width / 2; - const halfH = bounds.height / 2; + const halfWidth = bounds.width / 2; + const halfHeight = 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; + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; const localY = - corner === "top-left" || corner === "top-right" ? -halfH : halfH; + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; const rotatedX = localX * cos - localY * sin; const rotatedY = localX * sin + localY * cos; @@ -114,6 +121,8 @@ export function useTransformHandles({ const tracks = editor.timeline.getTracks(); const currentTime = editor.playback.getCurrentTime(); + const currentTimeRef = useRef(currentTime); + currentTimeRef.current = currentTime; const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; @@ -144,19 +153,41 @@ export function useTransformHandles({ const { bounds, trackId, elementId, element } = selectedWithBounds; if (!isVisualElement(element)) return; + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / element.transform.scale; - const baseHeight = bounds.height / element.transform.scale; + const baseWidth = bounds.width / resolvedTransform.scale; + const baseHeight = bounds.height / resolvedTransform.scale; + const shouldClearScaleAnimation = + !!element.animations?.channels["transform.scale"]; + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: element.animations, + propertyPath: "transform.scale", + channel: undefined, + }) + : element.animations; scaleStateRef.current = { trackId, elementId, - initialTransform: element.transform, + initialTransform: resolvedTransform, initialDistance, initialBoundsCx: bounds.cx, initialBoundsCy: bounds.cy, baseWidth, baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, }; setActiveHandle(corner); (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); @@ -172,19 +203,30 @@ export function useTransformHandles({ const { bounds, trackId, elementId, element } = selectedWithBounds; if (!isVisualElement(element)) return; + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + 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; + const deltaX = position.x - bounds.cx; + const deltaY = position.y - bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; rotationStateRef.current = { trackId, elementId, - initialTransform: element.transform, + initialTransform: resolvedTransform, initialAngle, initialBoundsCx: bounds.cx, initialBoundsCy: bounds.cy, @@ -220,11 +262,13 @@ export function useTransformHandles({ initialBoundsCy, baseWidth, baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, } = scaleStateRef.current; - const dx = position.x - initialBoundsCx; - const dy = position.y - initialBoundsCy; - const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1; + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; const scaleFactor = currentDistance / initialDistance; const proposedScale = Math.max( MIN_SCALE, @@ -258,14 +302,22 @@ export function useTransformHandles({ setSnapLines(activeLines); } + const updates: { + transform: Transform; + animations?: ElementAnimations; + } = { + transform: { ...initialTransform, scale: snappedScale }, + }; + if (shouldClearScaleAnimation) { + updates.animations = animationsWithoutScale; + } + editor.timeline.previewElements({ updates: [ { trackId, elementId, - updates: { - transform: { ...initialTransform, scale: snappedScale }, - }, + updates, }, ], }); @@ -282,9 +334,9 @@ export function useTransformHandles({ initialBoundsCy, } = rotationStateRef.current; - const dx = position.x - initialBoundsCx; - const dy = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI; + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; let deltaAngle = currentAngle - initialAngle; if (deltaAngle > 180) deltaAngle -= 360; if (deltaAngle < -180) deltaAngle += 360; -- cgit v1.3.1