diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-02 13:13:07 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-02 13:13:07 +0100 |
| commit | e7dcb586c0408255a0bf788e355fdb6d50d9b923 (patch) | |
| tree | 74ff0fc0f1ba501a075342db9b4e454450926154 /apps/web/src/hooks/use-transform-handles.ts | |
| parent | 93bea01c9ed3fcbebc64c9cef382a51d24c925ed (diff) | |
| download | opencut-e7dcb586c0408255a0bf788e355fdb6d50d9b923.tar.gz | |
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).
Diffstat (limited to 'apps/web/src/hooks/use-transform-handles.ts')
| -rw-r--r-- | apps/web/src/hooks/use-transform-handles.ts | 96 |
1 files changed, 74 insertions, 22 deletions
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; |
