diff options
Diffstat (limited to 'apps/web/src/hooks')
| -rw-r--r-- | apps/web/src/hooks/actions/use-editor-actions.ts | 49 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/element/use-element-interaction.ts | 70 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/element/use-element-resize.ts | 112 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/element/use-keyframe-drag.ts | 302 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/element/use-keyframe-selection.ts | 229 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-selection-box.ts | 18 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-snap-indicator-position.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/hooks/timeline/use-timeline-drag-drop.ts | 208 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-effect-preview.ts | 47 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-transform-handles.ts | 96 |
10 files changed, 993 insertions, 146 deletions
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 1ce890cb..e4f378d6 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -4,13 +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 } = 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", @@ -158,11 +164,21 @@ export function useEditorActions() { if (elementsToSplit.length === 0) return; - editor.timeline.splitElements({ + const rightSideElements = editor.timeline.splitElements({ elements: elementsToSplit, splitTime: currentTime, retainSide: "right", + rippleEnabled: rippleEditingEnabled, }); + + if (rippleEditingEnabled && rightSideElements.length > 0) { + const firstRightElement = editor.timeline.getElementsWithTracks({ + elements: [rightSideElements[0]], + })[0]; + if (firstRightElement) { + editor.playback.seek({ time: firstRightElement.element.startTime }); + } + } }, undefined, ); @@ -193,11 +209,17 @@ export function useEditorActions() { useActionHandler( "delete-selected", () => { + if (selectedKeyframes.length > 0) { + editor.timeline.removeKeyframes({ keyframes: selectedKeyframes }); + clearKeyframeSelection(); + return; + } if (selectedElements.length === 0) { return; } editor.timeline.deleteElements({ elements: selectedElements, + rippleEnabled: rippleEditingEnabled, }); editor.selection.clearSelection(); }, @@ -219,6 +241,19 @@ export function useEditorActions() { ); useActionHandler( + "deselect-all", + () => { + setElementSelection({ elements: [] }); + clearKeyframeSelection(); + const activeElement = document.activeElement; + if (activeElement instanceof HTMLButtonElement) { + activeElement.blur(); + } + }, + undefined, + ); + + useActionHandler( "duplicate-selected", () => { editor.timeline.duplicateElements({ @@ -261,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, @@ -296,6 +331,14 @@ export function useEditorActions() { ); useActionHandler( + "toggle-ripple-editing", + () => { + toggleRippleEditing(); + }, + undefined, + ); + + useActionHandler( "undo", () => { editor.command.undo(); 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 e7b93813..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,16 +593,18 @@ 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, }); if (!alreadySelected) { selectElement({ trackId: track.id, elementId: element.id }); + return; } + + editor.selection.clearKeyframeSelection(); }, - [isElementSelected, selectElement], + [editor.selection, isElementSelected, selectElement], ); return { 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 2b303ad9..3f49e798 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -40,7 +40,9 @@ export function useTimelineElementResize({ const activeProject = editor.project.getActive(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); - + const rippleEditingEnabled = useTimelineStore( + (state) => state.rippleEditingEnabled, + ); const [resizing, setResizing] = useState<ResizeState | null>(null); const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart); @@ -86,12 +88,8 @@ export function useTimelineElementResize({ }; const canExtendElementDuration = useCallback(() => { - if (element.type === "text" || element.type === "image") { - return true; - } - - return false; - }, [element.type]); + return element.sourceDuration == null; + }, [element.sourceDuration]); const updateTrimFromMouseMove = useCallback( ({ clientX }: { clientX: number }) => { @@ -141,18 +139,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; @@ -175,7 +207,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, @@ -192,7 +233,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, @@ -202,10 +244,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; } @@ -216,6 +258,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()) { @@ -223,7 +268,7 @@ export function useTimelineElementResize({ const baseDuration = resizing.initialDuration + resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: baseDuration + extensionNeeded, + time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps, }); @@ -234,7 +279,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, }); @@ -244,9 +292,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, @@ -271,6 +330,7 @@ export function useTimelineElementResize({ snappingEnabled, editor, element.id, + track.elements, onSnapPointChange, canExtendElementDuration, isShiftHeldRef, @@ -289,26 +349,14 @@ 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, + rippleEnabled: rippleEditingEnabled, }); } @@ -319,9 +367,9 @@ export function useTimelineElementResize({ resizing, editor.timeline, element.id, - track.id, onResizeStateChange, onSnapPointChange, + rippleEditingEnabled, ]); useEffect(() => { 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..b18db6eb --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts @@ -0,0 +1,302 @@ +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, getSnappedSeekTime } 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<string>; + deltaTime: number; +} + +const initialDragState: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTime: 0, +}; + +interface PendingKeyframeDrag { + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +export function useKeyframeDrag({ + zoomLevel, + element, + displayedStartTime, +}: { + zoomLevel: number; + element: TimelineElement; + displayedStartTime: number; +}) { + const editor = useEditor(); + const { + selectedKeyframes, + isKeyframeSelected, + setKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + } = useKeyframeSelection(); + + const [dragState, setDragState] = + useState<KeyframeDragState>(initialDragState); + const [isPendingDrag, setIsPendingDrag] = useState(false); + + const pendingDragRef = useRef<PendingKeyframeDrag | null>(null); + const mouseDownXRef = useRef<number | null>(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 isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; + if (!anySelected && !isModifierKey) { + setKeyframeSelection({ keyframes }); + } + + const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; + + pendingDragRef.current = { + keyframeRefs: keyframeRefsToTrack, + startMouseX: event.clientX, + }; + setIsPendingDrag(true); + }, + [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], +); + + const handleKeyframeClick = useCallback( + ({ + event, + keyframes, + orderedKeyframes, + indicatorTime, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }) => { + event.stopPropagation(); + + const wasDrag = + mouseDownXRef.current !== null && + Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX; + mouseDownXRef.current = null; + + if (wasDrag) return; + + const duration = editor.timeline.getTotalDuration(); + const seekTime = getSnappedSeekTime({ + rawTime: displayedStartTime + indicatorTime, + duration, + fps, + }); + editor.playback.seek({ time: seekTime }); + + if (event.shiftKey) { + selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + }, + [toggleKeyframeSelection, selectKeyframeRange, editor, displayedStartTime, fps], + ); + + 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/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts new file mode 100644 index 00000000..0ab05fe3 --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts @@ -0,0 +1,229 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import type { SelectedKeyframeRef } from "@/types/animation"; + +function getSelectedKeyframeId({ + keyframe, +}: { + keyframe: SelectedKeyframeRef; +}): string { + return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`; +} + +function mergeUniqueKeyframes({ + keyframes, +}: { + keyframes: SelectedKeyframeRef[]; +}): SelectedKeyframeRef[] { + const keyframesById = new Map<string, SelectedKeyframeRef>(); + for (const keyframe of keyframes) { + keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe); + } + return [...keyframesById.values()]; +} + +export function useKeyframeSelection() { + const editor = useEditor(); + const selectedKeyframes = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getSelectedKeyframes(), + ); + const keyframeSelectionAnchor = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getKeyframeSelectionAnchor(), + ); + + const isKeyframeSelected = useCallback( + ({ keyframe }: { keyframe: SelectedKeyframeRef }) => { + const keyframeId = getSelectedKeyframeId({ keyframe }); + return selectedKeyframes.some( + (selectedKeyframe) => + getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId, + ); + }, + [selectedKeyframes], + ); + + const setKeyframeSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const uniqueKeyframes = mergeUniqueKeyframes({ keyframes }); + editor.selection.setSelectedKeyframes({ + keyframes: uniqueKeyframes, + anchorKeyframe: + anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null, + }); + }, + [editor], + ); + + const addKeyframesToSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const mergedKeyframes = mergeUniqueKeyframes({ + keyframes: [...selectedKeyframes, ...keyframes], + }); + editor.selection.setSelectedKeyframes({ + keyframes: mergedKeyframes, + anchorKeyframe: + anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const removeKeyframesFromSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const keyframeIdsToRemove = new Set( + keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const nextKeyframes = selectedKeyframes.filter( + (selectedKeyframe) => + !keyframeIdsToRemove.has( + getSelectedKeyframeId({ keyframe: selectedKeyframe }), + ), + ); + editor.selection.setSelectedKeyframes({ + keyframes: nextKeyframes, + anchorKeyframe: + anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const clearKeyframeSelection = useCallback(() => { + editor.selection.clearKeyframeSelection(); + }, [editor]); + + const toggleKeyframeSelection = useCallback( + ({ + keyframes, + isMultiKey, + }: { + keyframes: SelectedKeyframeRef[]; + isMultiKey: boolean; + }) => { + const anchorKeyframe = keyframes[0]; + const areAllKeyframesSelected = keyframes.every((keyframe) => + isKeyframeSelected({ keyframe }), + ); + if (!isMultiKey) { + setKeyframeSelection({ keyframes, anchorKeyframe }); + return; + } + + if (areAllKeyframesSelected) { + removeKeyframesFromSelection({ keyframes, anchorKeyframe }); + return; + } + + addKeyframesToSelection({ keyframes, anchorKeyframe }); + }, + [ + setKeyframeSelection, + isKeyframeSelected, + removeKeyframesFromSelection, + addKeyframesToSelection, + ], + ); + + const selectKeyframeRange = useCallback( + ({ + orderedKeyframes, + targetKeyframes, + isAdditive, + }: { + orderedKeyframes: SelectedKeyframeRef[]; + targetKeyframes: SelectedKeyframeRef[]; + isAdditive: boolean; + }) => { + if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) { + return; + } + + const anchorKeyframe = + keyframeSelectionAnchor ?? + selectedKeyframes[selectedKeyframes.length - 1] ?? + targetKeyframes[0]; + if (!anchorKeyframe) { + return; + } + + const targetKeyframeIds = new Set( + targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe }); + const anchorIndex = orderedKeyframes.findIndex( + (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId, + ); + if (anchorIndex === -1) { + if (isAdditive) { + addKeyframesToSelection({ + keyframes: targetKeyframes, + anchorKeyframe, + }); + return; + } + setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe }); + return; + } + + const targetIndexes = orderedKeyframes + .map((keyframe, index) => ({ + keyframeId: getSelectedKeyframeId({ keyframe }), + index, + })) + .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId)) + .map(({ index }) => index); + if (targetIndexes.length === 0) { + return; + } + + const rangeStart = Math.min(anchorIndex, ...targetIndexes); + const rangeEnd = Math.max(anchorIndex, ...targetIndexes); + const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1); + + if (isAdditive) { + addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + return; + } + + setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + }, + [ + keyframeSelectionAnchor, + selectedKeyframes, + addKeyframesToSelection, + setKeyframeSelection, + ], + ); + + return { + selectedKeyframes, + keyframeSelectionAnchor, + isKeyframeSelected, + setKeyframeSelection, + addKeyframesToSelection, + removeKeyframesFromSelection, + clearKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + }; +} diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts index 12c6bf8a..c4f8d6ce 100644 --- a/apps/web/src/hooks/timeline/use-selection-box.ts +++ b/apps/web/src/hooks/timeline/use-selection-box.ts @@ -5,6 +5,7 @@ import { useEditor } from "../use-editor"; interface UseSelectionBoxProps { containerRef: React.RefObject<HTMLElement | null>; + headerRef: React.RefObject<HTMLElement | null>; onSelectionComplete: ( elements: { trackId: string; elementId: string }[], ) => void; @@ -88,6 +89,7 @@ function isRectangleIntersecting({ export function useSelectionBox({ containerRef, + headerRef, onSelectionComplete, isEnabled = true, tracksScrollRef, @@ -131,6 +133,8 @@ export function useSelectionBox({ endPos, }); const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const timelineHeaderHeight = + headerRef.current?.getBoundingClientRect().height ?? 0; const selectedElements: { trackId: string; elementId: string }[] = []; for (const [trackIndex, track] of tracks.entries()) { @@ -139,8 +143,9 @@ export function useSelectionBox({ trackIndex, }); const trackHeight = getTrackHeight({ type: track.type }); - const elementTop = trackTop; - const elementBottom = trackTop + trackHeight; + const elementTop = + timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop; + const elementBottom = elementTop + trackHeight; for (const element of track.elements) { const elementLeft = element.startTime * pixelsPerSecond; @@ -168,7 +173,14 @@ export function useSelectionBox({ } onSelectionComplete(selectedElements); }, - [containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel], + [ + containerRef, + headerRef, + onSelectionComplete, + tracks, + tracksScrollRef, + zoomLevel, + ], ); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts index d78eb04d..f3e63894 100644 --- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts +++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import type { TimelineTrack } from "@/types/timeline"; interface UseSnapIndicatorPositionParams { @@ -50,8 +50,10 @@ export function useSnapIndicatorPosition({ ? trackLabelsRef.current.offsetWidth : 0; - const timelinePosition = - (snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const timelinePosition = timelineTimeToSnappedPixels({ + time: snapPoint?.time ?? 0, + zoomLevel, + }); const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft; return { 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 632d2c78..8a30785e 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -8,6 +8,7 @@ import { buildTextElement, buildStickerElement, buildElementFromMedia, + buildEffectElement, } from "@/lib/timeline/element-utils"; import type { Command } from "@/lib/commands/base-command"; import { AddMediaAssetCommand } from "@/lib/commands/media"; @@ -16,17 +17,23 @@ 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"; -import type { MediaDragData, StickerDragData } from "@/types/drag"; +import type { + MediaDragData, + StickerDragData, + EffectDragData, +} from "@/types/drag"; interface UseTimelineDragDropProps { containerRef: RefObject<HTMLDivElement | null>; headerRef?: RefObject<HTMLElement | null>; + tracksScrollRef?: RefObject<HTMLDivElement | null>; zoomLevel: number; } export function useTimelineDragDrop({ containerRef, headerRef, + tracksScrollRef, zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); @@ -54,6 +61,7 @@ export function useTimelineDragDrop({ if (dragData.type === "text") return "text"; if (dragData.type === "sticker") return "sticker"; + if (dragData.type === "effect") return "effect"; if (dragData.type === "media") { return dragData.mediaType; } @@ -70,7 +78,11 @@ export function useTimelineDragDrop({ elementType: ElementType; mediaId?: string; }): number => { - if (elementType === "text" || elementType === "sticker") { + if ( + elementType === "text" || + elementType === "sticker" || + elementType === "effect" + ) { return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; } if (mediaId) { @@ -94,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 }); @@ -121,8 +138,15 @@ 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" + ? (dragData as EffectDragData).targetElementTypes + : dragData?.type === "media" + ? (dragData as MediaDragData).targetElementTypes + : undefined; const target = computeDropTarget({ elementType, @@ -134,6 +158,7 @@ export function useTimelineDragDrop({ elementDuration: duration, pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, zoomLevel, + targetElementTypes, }); target.xPosition = getSnappedTime({ time: target.xPosition }); @@ -144,6 +169,7 @@ export function useTimelineDragDrop({ [ containerRef, headerRef, + tracksScrollRef, tracks, currentTime, zoomLevel, @@ -182,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 ?? "", @@ -203,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( @@ -219,52 +246,46 @@ 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( ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { + if (target.targetElement) { + toast.info("Replace media source is coming soon!"); + return; + } + const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); if (!mediaAsset) return; const trackType: TrackType = dragData.mediaType === "audio" ? "audio" : "video"; - let trackId: string; - - if (target.isNewTrack) { - trackId = editor.timeline.addTrack({ - type: trackType, - index: target.trackIndex, - }); - } else { - const track = tracks[target.trackIndex]; - if (!track) return; - trackId = track.id; - } const duration = mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; @@ -276,12 +297,80 @@ 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: track.id }, + element, + }); + }, + [editor.command, editor.timeline, mediaAssets, tracks], + ); + + const executeEffectDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: EffectDragData; + }) => { + if (target.targetElement) { + editor.timeline.addClipEffect({ + trackId: target.targetElement.trackId, + elementId: target.targetElement.elementId, + effectType: dragData.effectType, + }); + return; + } + + const effectTrack = tracks.find((t) => t.type === "effect"); + let trackId: string; + + if (effectTrack) { + trackId = effectTrack.id; + } else if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element: buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }), + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } else { + const track = tracks[target.trackIndex]; + if (!track || track.type !== "effect") return; + trackId = track.id; + } + + const element = buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }); + editor.timeline.insertElement({ placement: { mode: "explicit", trackId }, element, }); }, - [editor.timeline, mediaAssets, tracks], + [editor.command, editor.timeline, tracks], ); const executeFileDrop = useCallback( @@ -384,16 +473,27 @@ export function useTimelineDragDrop({ executeTextDrop({ target: currentTarget, dragData }); } else if (dragData.type === "sticker") { executeStickerDrop({ target: currentTarget, dragData }); + } else if (dragData.type === "effect") { + executeEffectDrop({ + target: currentTarget, + dragData: dragData as EffectDragData, + }); } else { executeMediaDrop({ target: currentTarget, dragData }); } } else if (hasFiles) { - const 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, @@ -410,9 +510,11 @@ export function useTimelineDragDrop({ executeTextDrop, executeStickerDrop, executeMediaDrop, + executeEffectDrop, executeFileDrop, containerRef, headerRef, + tracksScrollRef, ], ); diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts new file mode 100644 index 00000000..a84781e0 --- /dev/null +++ b/apps/web/src/hooks/use-effect-preview.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import { effectPreviewService } from "@/services/renderer/effect-preview"; +import type { EffectParamValues } from "@/types/effects"; + +export function useEffectPreview({ + effectType, + params, + canvasRef, + isActive, +}: { + effectType: string; + params: EffectParamValues; + canvasRef: React.RefObject<HTMLCanvasElement | null>; + isActive: boolean; +}): void { + const requestRef = useRef<number>(0); + + useEffect(() => { + if (!isActive) { + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + requestRef.current = 0; + } + return; + } + + const loop = (): void => { + const canvas = canvasRef.current; + if (canvas) { + effectPreviewService.renderPreview({ + effectType, + params, + targetCanvas: canvas, + }); + } + requestRef.current = requestAnimationFrame(loop); + }; + + requestRef.current = requestAnimationFrame(loop); + + return () => { + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + } + }; + }, [effectType, params, canvasRef, isActive]); +} 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; |
