From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- apps/web/src/hooks/actions/use-editor-actions.ts | 89 +++- .../timeline/element/use-element-interaction.ts | 83 ++-- .../hooks/timeline/element/use-element-resize.ts | 231 +++++++++-- .../timeline/element/use-element-selection.ts | 35 +- .../hooks/timeline/element/use-keyframe-drag.ts | 18 +- .../timeline/element/use-keyframe-selection.ts | 2 +- apps/web/src/hooks/timeline/use-bookmark-drag.ts | 2 +- .../hooks/timeline/use-initial-scroll-bottom.ts | 42 ++ apps/web/src/hooks/timeline/use-selection-box.ts | 255 ------------ .../hooks/timeline/use-snap-indicator-position.ts | 14 +- .../src/hooks/timeline/use-timeline-drag-drop.ts | 233 +++++++---- .../src/hooks/timeline/use-timeline-playhead.ts | 186 +++++---- apps/web/src/hooks/timeline/use-timeline-zoom.ts | 28 +- apps/web/src/hooks/use-container-size.ts | 24 +- apps/web/src/hooks/use-editor.ts | 63 ++- apps/web/src/hooks/use-effect-preview.ts | 47 --- apps/web/src/hooks/use-element-preview.ts | 38 ++ apps/web/src/hooks/use-file-upload.ts | 12 +- apps/web/src/hooks/use-font-atlas.ts | 53 +++ apps/web/src/hooks/use-keybindings.ts | 163 ++++++-- apps/web/src/hooks/use-mask-handles.ts | 258 ++++++++++++ apps/web/src/hooks/use-menu-preview.ts | 26 ++ apps/web/src/hooks/use-paste-media.ts | 87 ++-- apps/web/src/hooks/use-preview-interaction.ts | 349 +++++++++++----- apps/web/src/hooks/use-resize-observer.ts | 26 ++ apps/web/src/hooks/use-reveal-item.ts | 36 -- apps/web/src/hooks/use-storage-persistence.ts | 45 ++ apps/web/src/hooks/use-transform-handles.ts | 457 +++++++++++++++------ 28 files changed, 1962 insertions(+), 940 deletions(-) create mode 100644 apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts delete mode 100644 apps/web/src/hooks/timeline/use-selection-box.ts delete mode 100644 apps/web/src/hooks/use-effect-preview.ts create mode 100644 apps/web/src/hooks/use-element-preview.ts create mode 100644 apps/web/src/hooks/use-font-atlas.ts create mode 100644 apps/web/src/hooks/use-mask-handles.ts create mode 100644 apps/web/src/hooks/use-menu-preview.ts create mode 100644 apps/web/src/hooks/use-resize-observer.ts delete mode 100644 apps/web/src/hooks/use-reveal-item.ts create mode 100644 apps/web/src/hooks/use-storage-persistence.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 e4f378d6..bfac931b 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -1,15 +1,22 @@ "use client"; +import { useEffect, useRef } from "react"; 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"; +import { cancelInteraction } from "@/lib/cancel-interaction"; +import { invokeAction } from "@/lib/actions"; +import { + activateScope, + clearActiveScope, + type ScopeEntry, +} from "@/lib/selection/scope"; export function useEditorActions() { const editor = useEditor(); - const activeProject = editor.project.getActive(); const { selectedElements, setElementSelection } = useElementSelection(); const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection(); const clipboard = useTimelineStore((s) => s.clipboard); @@ -17,6 +24,39 @@ export function useEditorActions() { const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); + const hasTimelineSelectionRef = useRef(false); + const clearTimelineSelectionRef = useRef(() => {}); + const timelineScopeRef = useRef(null); + const hasTimelineSelection = + selectedElements.length > 0 || selectedKeyframes.length > 0; + + hasTimelineSelectionRef.current = hasTimelineSelection; + clearTimelineSelectionRef.current = () => { + setElementSelection({ elements: [] }); + clearKeyframeSelection(); + }; + + if (!timelineScopeRef.current) { + timelineScopeRef.current = { + hasSelection: () => hasTimelineSelectionRef.current, + clear: () => { + clearTimelineSelectionRef.current(); + }, + }; + } + + useEffect(() => { + if (!hasTimelineSelection) { + return; + } + + const timelineScope = timelineScopeRef.current; + if (!timelineScope) { + return; + } + + return activateScope({ entry: timelineScope }); + }, [hasTimelineSelection]); useActionHandler( "toggle-play", @@ -65,7 +105,7 @@ export function useEditorActions() { useActionHandler( "frame-step-forward", () => { - const fps = activeProject.settings.fps; + const fps = editor.project.getActive().settings.fps; editor.playback.seek({ time: Math.min( editor.timeline.getTotalDuration(), @@ -79,7 +119,7 @@ export function useEditorActions() { useActionHandler( "frame-step-backward", () => { - const fps = activeProject.settings.fps; + const fps = editor.project.getActive().settings.fps; editor.playback.seek({ time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps), }); @@ -240,14 +280,22 @@ export function useEditorActions() { undefined, ); + useActionHandler( + "cancel-interaction", + () => { + if (!cancelInteraction()) { + invokeAction("deselect-all"); + } + }, + undefined, + ); + useActionHandler( "deselect-all", () => { - setElementSelection({ elements: [] }); - clearKeyframeSelection(); - const activeElement = document.activeElement; - if (activeElement instanceof HTMLButtonElement) { - activeElement.blur(); + if (!clearActiveScope()) { + setElementSelection({ elements: [] }); + clearKeyframeSelection(); } }, undefined, @@ -353,4 +401,29 @@ export function useEditorActions() { }, undefined, ); + + // todo: potnetially unify these two actions: + useActionHandler( + "remove-media-asset", + (args) => { + if (!args) return; + editor.media.removeMediaAsset({ + projectId: args.projectId, + id: args.assetId, + }); + }, + undefined, + ); + + useActionHandler( + "remove-media-assets", + (args) => { + if (!args) return; + editor.media.removeMediaAssets({ + projectId: args.projectId, + ids: args.assetIds, + }); + }, + undefined, + ); } 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 ea01c718..fae8c527 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -18,16 +18,14 @@ import { snapTimeToFrame } from "@/lib/time"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; -import { - snapElementEdge, - type SnapPoint, -} from "@/lib/timeline/snap-utils"; +import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils"; +import { registerCanceller } from "@/lib/cancel-interaction"; import type { DropTarget, ElementDragState, TimelineElement, TimelineTrack, -} from "@/types/timeline"; +} from "@/lib/timeline"; interface UseElementInteractionProps { zoomLevel: number; @@ -213,6 +211,20 @@ export function useElementInteraction({ setDragDropTarget(null); }, []); + const cancelCurrentDrag = useCallback(() => { + pendingDragRef.current = null; + mouseDownLocationRef.current = null; + setIsPendingDrag(false); + endDrag(); + onSnapPointChange?.(null); + }, [endDrag, onSnapPointChange]); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + return registerCanceller({ fn: cancelCurrentDrag }); + }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); + const getDragSnapResult = useCallback( ({ frameSnappedTime, @@ -445,33 +457,36 @@ 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: targetTrack.id, + targetTrackId: newTrackId, elementId: dragState.elementId, newStartTime: snappedTime, + createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, rippleEnabled: rippleEditingEnabled, }); - if (targetTrack.id !== dragState.trackId) { - selectElement({ trackId: targetTrack.id, elementId: dragState.elementId }); + selectElement({ trackId: newTrackId, elementId: dragState.elementId }); + } else { + const targetTrack = tracks[dropTarget.trackIndex]; + if (targetTrack) { + editor.timeline.moveElement({ + sourceTrackId: dragState.trackId, + targetTrackId: targetTrack.id, + elementId: dragState.elementId, + newStartTime: snappedTime, + rippleEnabled: rippleEditingEnabled, + }); + if (targetTrack.id !== dragState.trackId) { + selectElement({ + trackId: targetTrack.id, + elementId: dragState.elementId, + }); + } } } - } endDrag(); onSnapPointChange?.(null); @@ -520,10 +535,10 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - const isRightClick = event.button === MOUSE_BUTTON_RIGHT; + 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, @@ -538,12 +553,12 @@ export function useElementInteraction({ return; } - 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; - if (isMultiSelect) { + if (isMultiSelect) { handleSelectionClick({ trackId: track.id, elementId: element.id, @@ -551,7 +566,7 @@ export function useElementInteraction({ }); } - const clickOffsetTime = getClickOffsetTime({ + const clickOffsetTime = getClickOffsetTime({ clientX: event.clientX, elementRect: event.currentTarget.getBoundingClientRect(), zoomLevel, @@ -579,9 +594,9 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - event.stopPropagation(); + event.stopPropagation(); - 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) { @@ -593,7 +608,7 @@ export function useElementInteraction({ // modifier keys already handled in mousedown if (event.metaKey || event.ctrlKey || event.shiftKey) return; - 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 3f49e798..b41f82f9 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { @@ -9,7 +9,13 @@ import { snapToNearestPoint, type SnapPoint, } from "@/lib/timeline/snap-utils"; +import { isRetimableElement } from "@/lib/timeline"; +import { + getSourceSpanAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/lib/retime"; import { useTimelineStore } from "@/stores/timeline-store"; +import { registerCanceller } from "@/lib/cancel-interaction"; export interface ResizeState { elementId: string; @@ -37,7 +43,6 @@ export function useTimelineElementResize({ onResizeStateChange, }: UseTimelineElementResizeProps) { const editor = useEditor(); - const activeProject = editor.project.getActive(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const rippleEditingEnabled = useTimelineStore( @@ -91,6 +96,94 @@ export function useTimelineElementResize({ return element.sourceDuration == null; }, [element.sourceDuration]); + const getSourceDeltaForClipDelta = useCallback( + (clipDelta: number) => { + if (!isRetimableElement(element)) { + return clipDelta; + } + + return clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: element.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: element.retime, + }); + }, + [element], + ); + + const getVisibleSourceSpanForDuration = useCallback( + (duration: number) => { + if (!isRetimableElement(element)) { + return duration; + } + + return getSourceSpanAtClipTime({ + clipTime: duration, + retime: element.retime, + }); + }, + [element], + ); + + const getDurationForVisibleSourceSpan = useCallback( + (sourceSpan: number) => { + if (!isRetimableElement(element)) { + return sourceSpan; + } + + return getTimelineDurationForSourceSpan({ + sourceSpan, + retime: element.retime, + }); + }, + [element], + ); + + const getSourceDuration = useCallback( + ({ + trimStart, + duration, + trimEnd, + }: { + trimStart: number; + duration: number; + trimEnd: number; + }) => { + if (typeof element.sourceDuration === "number") { + return element.sourceDuration; + } + + return trimStart + getVisibleSourceSpanForDuration(duration) + trimEnd; + }, + [element.sourceDuration, getVisibleSourceSpanForDuration], + ); + + const cancelResize = useCallback(() => { + if (!resizing) return; + + setCurrentTrimStart(resizing.initialTrimStart); + setCurrentTrimEnd(resizing.initialTrimEnd); + setCurrentStartTime(resizing.initialStartTime); + setCurrentDuration(resizing.initialDuration); + currentTrimStartRef.current = resizing.initialTrimStart; + currentTrimEndRef.current = resizing.initialTrimEnd; + currentStartTimeRef.current = resizing.initialStartTime; + currentDurationRef.current = resizing.initialDuration; + setResizing(null); + onResizeStateChange?.({ isResizing: false }); + onSnapPointChange?.(null); + }, [resizing, onResizeStateChange, onSnapPointChange]); + + useEffect(() => { + if (!resizing) return; + + return registerCanceller({ fn: cancelResize }); + }, [resizing, cancelResize]); + const updateTrimFromMouseMove = useCallback( ({ clientX }: { clientX: number }) => { if (!resizing) return; @@ -100,7 +193,7 @@ export function useTimelineElementResize({ deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); let resizeSnapPoint: SnapPoint | null = null; - const projectFps = activeProject.settings.fps; + const projectFps = editor.project.getActive().settings.fps; const minDurationSeconds = 1 / projectFps; const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (shouldSnap) { @@ -139,14 +232,20 @@ export function useTimelineElementResize({ } onSnapPointChange?.(resizeSnapPoint); - const otherElements = track.elements.filter(({ id }) => id !== element.id); - const initialEndTime = resizing.initialStartTime + resizing.initialDuration; + 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) + .reduce( + (min, { startTime }) => Math.min(min, startTime), + Infinity, + ) : Infinity; const leftNeighborBound = @@ -164,20 +263,26 @@ export function useTimelineElementResize({ : -Infinity; if (resizing.side === "left") { - const sourceDuration = - resizing.initialTrimStart + - resizing.initialDuration + - resizing.initialTrimEnd; + const sourceDuration = getSourceDuration({ + trimStart: resizing.initialTrimStart, + duration: resizing.initialDuration, + trimEnd: resizing.initialTrimEnd, + }); const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound) ? Math.max( 0, resizing.initialTrimStart + - (leftNeighborBound - resizing.initialStartTime), + getSourceDeltaForClipDelta( + leftNeighborBound - resizing.initialStartTime, + ), ) : 0; const maxAllowed = - sourceDuration - resizing.initialTrimEnd - minDurationSeconds; - const calculated = resizing.initialTrimStart + deltaTime; + sourceDuration - + resizing.initialTrimEnd - + getVisibleSourceSpanForDuration(minDurationSeconds); + const calculated = + resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime); if (calculated >= 0 && calculated <= maxAllowed) { const newTrimStart = snapTimeToFrame({ @@ -187,13 +292,17 @@ export function useTimelineElementResize({ ), fps: projectFps, }); - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime + trimDelta, + const visibleSourceSpan = Math.max( + 0, + sourceDuration - newTrimStart - resizing.initialTrimEnd, + ); + const newDuration = snapTimeToFrame({ + time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps, }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, + const trimDelta = resizing.initialDuration - newDuration; + const newStartTime = snapTimeToFrame({ + time: resizing.initialStartTime + trimDelta, fps: projectFps, }); @@ -233,31 +342,50 @@ export function useTimelineElementResize({ currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } else { - const trimDelta = + const leftBound = Number.isFinite(leftNeighborBound) + ? leftNeighborBound + : 0; + const trimDeltaFromTrimStart = minTrimStartForNeighbor - resizing.initialTrimStart; - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime + trimDelta, + const trimDeltaFromStartTime = getSourceDeltaForClipDelta( + leftBound - resizing.initialStartTime, + ); + const trimDelta = Math.max( + trimDeltaFromTrimStart, + trimDeltaFromStartTime, + ); + const newTrimStart = resizing.initialTrimStart + trimDelta; + const visibleSourceSpan = Math.max( + 0, + sourceDuration - newTrimStart - resizing.initialTrimEnd, + ); + const newDuration = snapTimeToFrame({ + time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps, }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, + const newStartTime = snapTimeToFrame({ + time: + resizing.initialStartTime + + (resizing.initialDuration - newDuration), fps: projectFps, }); - setCurrentTrimStart(minTrimStartForNeighbor); + setCurrentTrimStart(newTrimStart); setCurrentStartTime(newStartTime); setCurrentDuration(newDuration); - currentTrimStartRef.current = minTrimStartForNeighbor; + currentTrimStartRef.current = newTrimStart; currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } } } else { - const sourceDuration = - resizing.initialTrimStart + - resizing.initialDuration + - resizing.initialTrimEnd; - const newTrimEnd = resizing.initialTrimEnd - deltaTime; + const sourceDuration = getSourceDuration({ + trimStart: resizing.initialTrimStart, + duration: resizing.initialDuration, + trimEnd: resizing.initialTrimEnd, + }); + const newTrimEnd = + resizing.initialTrimEnd - getSourceDeltaForClipDelta(deltaTime); const maxAllowedDuration = Number.isFinite(rightNeighborBound) ? rightNeighborBound - resizing.initialStartTime : Infinity; @@ -268,7 +396,10 @@ export function useTimelineElementResize({ const baseDuration = resizing.initialDuration + resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), + time: Math.min( + baseDuration + extensionNeeded, + maxAllowedDuration, + ), fps: projectFps, }); @@ -277,12 +408,11 @@ export function useTimelineElementResize({ currentDurationRef.current = newDuration; currentTrimEndRef.current = 0; } else { - const extensionToLimit = resizing.initialTrimEnd; + const unclampedDuration = getDurationForVisibleSourceSpan( + Math.max(0, sourceDuration - resizing.initialTrimStart), + ); const newDuration = snapTimeToFrame({ - time: Math.min( - resizing.initialDuration + extensionToLimit, - maxAllowedDuration, - ), + time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps, }); @@ -295,13 +425,15 @@ export function useTimelineElementResize({ const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration) ? Math.max( 0, - resizing.initialDuration + - resizing.initialTrimEnd - - maxAllowedDuration, + sourceDuration - + resizing.initialTrimStart - + getVisibleSourceSpanForDuration(maxAllowedDuration), ) : 0; const maxTrimEnd = - sourceDuration - resizing.initialTrimStart - minDurationSeconds; + sourceDuration - + resizing.initialTrimStart - + getVisibleSourceSpanForDuration(minDurationSeconds); const clampedTrimEnd = Math.min( maxTrimEnd, Math.max(minTrimEndForNeighbor, newTrimEnd), @@ -310,9 +442,12 @@ export function useTimelineElementResize({ time: clampedTrimEnd, fps: projectFps, }); - const trimDelta = finalTrimEnd - resizing.initialTrimEnd; + const visibleSourceSpan = Math.max( + 0, + sourceDuration - resizing.initialTrimStart - finalTrimEnd, + ); const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, + time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps, }); @@ -326,13 +461,16 @@ export function useTimelineElementResize({ [ resizing, zoomLevel, - activeProject.settings.fps, snappingEnabled, editor, element.id, track.elements, onSnapPointChange, canExtendElementDuration, + getDurationForVisibleSourceSpan, + getSourceDeltaForClipDelta, + getSourceDuration, + getVisibleSourceSpanForDuration, isShiftHeldRef, ], ); @@ -349,7 +487,12 @@ export function useTimelineElementResize({ const startTimeChanged = finalStartTime !== resizing.initialStartTime; const durationChanged = finalDuration !== resizing.initialDuration; - if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) { + if ( + trimStartChanged || + trimEndChanged || + startTimeChanged || + durationChanged + ) { editor.timeline.updateElementTrim({ elementId: element.id, trimStart: finalTrimStart, diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts index c1e5b53f..dcfb5720 100644 --- a/apps/web/src/hooks/timeline/element/use-element-selection.ts +++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts @@ -1,14 +1,10 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback } from "react"; import { useEditor } from "@/hooks/use-editor"; - -type ElementRef = { trackId: string; elementId: string }; +import type { ElementRef } from "@/lib/timeline/types"; export function useElementSelection() { const editor = useEditor(); - const selectedElements = useSyncExternalStore( - (listener) => editor.selection.subscribe(listener), - () => editor.selection.getSelectedElements(), - ); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); const isElementSelected = useCallback( ({ trackId, elementId }: ElementRef) => @@ -82,6 +78,30 @@ export function useElementSelection() { [editor], ); + + /** + * Merges elements into the current selection, deduplicating by identity. + * Used for additive box-select where the pre-drag selection is preserved. + */ + const mergeElementsIntoSelection = useCallback( + ({ elements }: { elements: ElementRef[] }) => { + const merged = [ + ...selectedElements.filter( + (selectedElement) => + !elements.some( + (element) => + element.trackId === selectedElement.trackId && + element.elementId === selectedElement.elementId, + ), + ), + ...elements, + ]; + editor.selection.setSelectedElements({ elements: merged }); + }, + [selectedElements, editor], + ); + + /** * Handles click interaction on an element. * - Regular click: select only this element @@ -107,6 +127,7 @@ export function useElementSelection() { isElementSelected, selectElement, setElementSelection, + mergeElementsIntoSelection, addElementToSelection, removeElementFromSelection, toggleElementSelection, diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts index b18db6eb..4f961a0a 100644 --- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts +++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts @@ -15,9 +15,10 @@ import { } 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 { SelectedKeyframeRef } from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; import type { Command } from "@/lib/commands/base-command"; +import { registerCanceller } from "@/lib/cancel-interaction"; export interface KeyframeDragState { isDragging: boolean; draggingKeyframeIds: Set; @@ -69,6 +70,13 @@ export function useKeyframeDrag({ setDragState(initialDragState); }, []); + const cancelDrag = useCallback(() => { + pendingDragRef.current = null; + mouseDownXRef.current = null; + setIsPendingDrag(false); + endDrag(); + }, [endDrag]); + const commitDrag = useCallback( ({ keyframeRefs, @@ -107,6 +115,12 @@ export function useKeyframeDrag({ [editor.command, element], ); + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + return registerCanceller({ fn: cancelDrag }); + }, [dragState.isDragging, isPendingDrag, cancelDrag]); + useEffect(() => { if (!dragState.isDragging && !isPendingDrag) return; diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts index 0ab05fe3..8634cb48 100644 --- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts +++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts @@ -1,6 +1,6 @@ import { useCallback, useSyncExternalStore } from "react"; import { useEditor } from "@/hooks/use-editor"; -import type { SelectedKeyframeRef } from "@/types/animation"; +import type { SelectedKeyframeRef } from "@/lib/animation/types"; function getSelectedKeyframeId({ keyframe, diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts index 0e7fabd5..512db821 100644 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts @@ -15,7 +15,7 @@ import { snapToNearestPoint, type SnapPoint, } from "@/lib/timeline/snap-utils"; -import type { Bookmark } from "@/types/timeline"; +import type { Bookmark } from "@/lib/timeline"; export interface BookmarkDragState { isDragging: boolean; diff --git a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts new file mode 100644 index 00000000..50833f14 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts @@ -0,0 +1,42 @@ +import { type RefObject, useLayoutEffect, useRef } from "react"; + +interface UseInitialScrollBottomProps { + tracksScrollRef: RefObject; + trackLabelsScrollRef: RefObject; + onAfterScroll?: () => void; + /** Defers the scroll until there is at least one track to measure against. */ + isReady: boolean; +} + +/** + * Scrolls the timeline tracks viewport to the bottom once on mount. + * useLayoutEffect runs synchronously after React commits the DOM but before + * the browser paints, so the initial scroll position is never visible. + */ +export function useInitialScrollBottom({ + tracksScrollRef, + trackLabelsScrollRef, + onAfterScroll, + isReady, +}: UseInitialScrollBottomProps): void { + const hasScrolledRef = useRef(false); + + useLayoutEffect(() => { + if (!isReady || hasScrolledRef.current) return; + + const viewport = tracksScrollRef.current; + if (!viewport) return; + + const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; + if (maxScrollTop <= 0) return; + + viewport.scrollTop = maxScrollTop; + + if (trackLabelsScrollRef.current) { + trackLabelsScrollRef.current.scrollTop = maxScrollTop; + } + + onAfterScroll?.(); + hasScrolledRef.current = true; + }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]); +} diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts deleted file mode 100644 index c4f8d6ce..00000000 --- a/apps/web/src/hooks/timeline/use-selection-box.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline"; -import { useEditor } from "../use-editor"; - -interface UseSelectionBoxProps { - containerRef: React.RefObject; - headerRef: React.RefObject; - onSelectionComplete: ( - elements: { trackId: string; elementId: string }[], - ) => void; - isEnabled?: boolean; - tracksScrollRef: React.RefObject; - zoomLevel: number; -} - -interface SelectionBoxState { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; -} - -interface SelectionRectangle { - left: number; - top: number; - right: number; - bottom: number; -} - -function getNormalizedRectangle({ - startPos, - endPos, -}: { - startPos: { x: number; y: number }; - endPos: { x: number; y: number }; -}): SelectionRectangle { - return { - left: Math.min(startPos.x, endPos.x), - top: Math.min(startPos.y, endPos.y), - right: Math.max(startPos.x, endPos.x), - bottom: Math.max(startPos.y, endPos.y), - }; -} - -function getSelectionRectangleInContent({ - container, - scrollContainer, - startPos, - endPos, -}: { - container: HTMLElement; - scrollContainer: HTMLDivElement | null; - startPos: { x: number; y: number }; - endPos: { x: number; y: number }; -}): SelectionRectangle { - const containerRect = container.getBoundingClientRect(); - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - - const adjustedStart = { - x: startPos.x - containerRect.left + scrollLeft, - y: startPos.y - containerRect.top + scrollTop, - }; - const adjustedEnd = { - x: endPos.x - containerRect.left + scrollLeft, - y: endPos.y - containerRect.top + scrollTop, - }; - - return getNormalizedRectangle({ - startPos: adjustedStart, - endPos: adjustedEnd, - }); -} - -function isRectangleIntersecting({ - elementRectangle, - selectionRectangle, -}: { - elementRectangle: SelectionRectangle; - selectionRectangle: SelectionRectangle; -}): boolean { - return !( - elementRectangle.right < selectionRectangle.left || - elementRectangle.left > selectionRectangle.right || - elementRectangle.bottom < selectionRectangle.top || - elementRectangle.top > selectionRectangle.bottom - ); -} - -export function useSelectionBox({ - containerRef, - headerRef, - onSelectionComplete, - isEnabled = true, - tracksScrollRef, - zoomLevel, -}: UseSelectionBoxProps) { - const editor = useEditor(); - const tracks = editor.timeline.getTracks(); - const [selectionBox, setSelectionBox] = useState( - null, - ); - const justFinishedSelectingRef = useRef(false); - - const handleMouseDown = useCallback( - ({ clientX, clientY }: React.MouseEvent) => { - if (!isEnabled) return; - - setSelectionBox({ - startPos: { x: clientX, y: clientY }, - currentPos: { x: clientX, y: clientY }, - isActive: false, - }); - }, - [isEnabled], - ); - - const selectElementsInBox = useCallback( - ({ - startPos, - endPos, - }: { - startPos: { x: number; y: number }; - endPos: { x: number; y: number }; - }) => { - if (!containerRef.current) return; - - const container = containerRef.current; - const selectionRectangle = getSelectionRectangleInContent({ - container, - scrollContainer: tracksScrollRef.current, - startPos, - 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()) { - const trackTop = getCumulativeHeightBefore({ - tracks, - trackIndex, - }); - const trackHeight = getTrackHeight({ type: track.type }); - const elementTop = - timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop; - const elementBottom = elementTop + trackHeight; - - for (const element of track.elements) { - const elementLeft = element.startTime * pixelsPerSecond; - const elementRight = elementLeft + element.duration * pixelsPerSecond; - - const elementRectangle = { - left: elementLeft, - top: elementTop, - right: elementRight, - bottom: elementBottom, - }; - - const intersects = isRectangleIntersecting({ - elementRectangle, - selectionRectangle, - }); - - if (intersects) { - selectedElements.push({ - trackId: track.id, - elementId: element.id, - }); - } - } - } - onSelectionComplete(selectedElements); - }, - [ - containerRef, - headerRef, - onSelectionComplete, - tracks, - tracksScrollRef, - zoomLevel, - ], - ); - - useEffect(() => { - if (!selectionBox) return; - - const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { - const deltaX = Math.abs(clientX - selectionBox.startPos.x); - const deltaY = Math.abs(clientY - selectionBox.startPos.y); - const shouldActivate = deltaX > 5 || deltaY > 5; - - const newSelectionBox = { - ...selectionBox, - currentPos: { x: clientX, y: clientY }, - isActive: shouldActivate || selectionBox.isActive, - }; - - setSelectionBox(newSelectionBox); - - if (newSelectionBox.isActive) { - selectElementsInBox({ - startPos: newSelectionBox.startPos, - endPos: newSelectionBox.currentPos, - }); - } - }; - - const handleMouseUp = () => { - if (selectionBox?.isActive) { - justFinishedSelectingRef.current = true; - requestAnimationFrame(() => { - justFinishedSelectingRef.current = false; - }); - } - setSelectionBox(null); - }; - - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - }, [selectionBox, selectElementsInBox]); - - useEffect(() => { - if (!selectionBox) return; - - const previousBodyUserSelect = document.body.style.userSelect; - const container = containerRef.current; - const previousContainerUserSelect = container?.style.userSelect ?? ""; - - document.body.style.userSelect = "none"; - if (container) container.style.userSelect = "none"; - - return () => { - document.body.style.userSelect = previousBodyUserSelect; - if (container) container.style.userSelect = previousContainerUserSelect; - }; - }, [selectionBox, containerRef]); - - const shouldIgnoreClick = useCallback(() => { - return justFinishedSelectingRef.current; - }, []); - - return { - selectionBox, - handleMouseDown, - isSelecting: selectionBox?.isActive || false, - shouldIgnoreClick, - }; -} 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 f3e63894..1a8f3c5d 100644 --- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts +++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts @@ -1,13 +1,10 @@ import { useEffect, useState } from "react"; import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -import type { TimelineTrack } from "@/types/timeline"; - +import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants"; interface UseSnapIndicatorPositionParams { snapPoint: { time: number } | null; zoomLevel: number; - tracks: TimelineTrack[]; timelineRef: React.RefObject; - trackLabelsRef?: React.RefObject; tracksScrollRef: React.RefObject; } @@ -20,9 +17,7 @@ interface SnapIndicatorPosition { export function useSnapIndicatorPosition({ snapPoint, zoomLevel, - tracks, timelineRef, - trackLabelsRef, tracksScrollRef, }: UseSnapIndicatorPositionParams): SnapIndicatorPosition { const [scrollLeft, setScrollLeft] = useState(0); @@ -45,16 +40,11 @@ export function useSnapIndicatorPosition({ const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; const totalHeight = timelineContainerHeight - 8; // 8px padding from edges - const trackLabelsWidth = - tracks.length > 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; - const timelinePosition = timelineTimeToSnappedPixels({ time: snapPoint?.time ?? 0, zoomLevel, }); - const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft; + const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft; return { leftPosition, 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 8a30785e..e8f6e49d 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -2,26 +2,28 @@ import { useState, useCallback, type RefObject } from "react"; import { useEditor } from "@/hooks/use-editor"; import { processMediaAssets } from "@/lib/media/processing"; import { toast } from "sonner"; +import { showMediaUploadToast } from "@/lib/media/upload-toast"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; import { buildTextElement, + buildGraphicElement, buildStickerElement, buildElementFromMedia, buildEffectElement, } from "@/lib/timeline/element-utils"; -import type { Command } from "@/lib/commands/base-command"; -import { AddMediaAssetCommand } from "@/lib/commands/media"; import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; import { BatchCommand } from "@/lib/commands"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getDragData, hasDragData } from "@/lib/drag-data"; -import type { TrackType, DropTarget, ElementType } from "@/types/timeline"; +import { isMainTrack } from "@/lib/timeline/track-utils"; +import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; import type { MediaDragData, + GraphicDragData, StickerDragData, EffectDragData, -} from "@/types/drag"; +} from "@/lib/timeline/drag"; interface UseTimelineDragDropProps { containerRef: RefObject; @@ -41,17 +43,12 @@ export function useTimelineDragDrop({ const [dropTarget, setDropTarget] = useState(null); const [dragElementType, setElementType] = useState(null); - const tracks = editor.timeline.getTracks(); - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const activeProject = editor.project.getActive(); - const getSnappedTime = useCallback( ({ time }: { time: number }) => { - const projectFps = activeProject.settings.fps; + const projectFps = editor.project.getActive().settings.fps; return snapTimeToFrame({ time, fps: projectFps }); }, - [activeProject.settings.fps], + [editor], ); const getElementType = useCallback( @@ -60,6 +57,7 @@ export function useTimelineDragDrop({ if (!dragData) return null; if (dragData.type === "text") return "text"; + if (dragData.type === "graphic") return "graphic"; if (dragData.type === "sticker") return "sticker"; if (dragData.type === "effect") return "effect"; if (dragData.type === "media") { @@ -80,18 +78,20 @@ export function useTimelineDragDrop({ }): number => { if ( elementType === "text" || + elementType === "graphic" || elementType === "sticker" || elementType === "effect" ) { return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; } if (mediaId) { + const mediaAssets = editor.media.getAssets(); const media = mediaAssets.find((m) => m.id === mediaId); return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; } return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; }, - [mediaAssets], + [editor], ); const handleDragEnter = useCallback((e: React.DragEvent) => { @@ -148,6 +148,8 @@ export function useTimelineDragDrop({ ? (dragData as MediaDragData).targetElementTypes : undefined; + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); const target = computeDropTarget({ elementType, mouseX, @@ -170,12 +172,11 @@ export function useTimelineDragDrop({ containerRef, headerRef, tracksScrollRef, - tracks, - currentTime, zoomLevel, getElementType, getElementDuration, getSnappedTime, + editor, ], ); @@ -228,6 +229,7 @@ export function useTimelineDragDrop({ return; } + const tracks = editor.timeline.getTracks(); const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -235,7 +237,7 @@ export function useTimelineDragDrop({ element, }); }, - [editor.command, editor.timeline, tracks], + [editor], ); const executeStickerDrop = useCallback( @@ -253,7 +255,7 @@ export function useTimelineDragDrop({ }); if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex); + const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); const insertCmd = new InsertElementCommand({ element, placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, @@ -264,6 +266,7 @@ export function useTimelineDragDrop({ return; } + const tracks = editor.timeline.getTracks(); const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -271,7 +274,45 @@ export function useTimelineDragDrop({ element, }); }, - [editor.command, editor.timeline, tracks], + [editor], + ); + + const executeGraphicDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: GraphicDragData; + }) => { + const element = buildGraphicElement({ + definitionId: dragData.definitionId, + name: dragData.name, + startTime: target.xPosition, + params: dragData.params, + }); + + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const tracks = editor.timeline.getTracks(); + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor], ); const executeMediaDrop = useCallback( @@ -281,6 +322,7 @@ export function useTimelineDragDrop({ return; } + const mediaAssets = editor.media.getAssets(); const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); if (!mediaAsset) return; @@ -309,6 +351,7 @@ export function useTimelineDragDrop({ return; } + const tracks = editor.timeline.getTracks(); const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -316,7 +359,7 @@ export function useTimelineDragDrop({ element, }); }, - [editor.command, editor.timeline, mediaAssets, tracks], + [editor], ); const executeEffectDrop = useCallback( @@ -336,6 +379,7 @@ export function useTimelineDragDrop({ return; } + const tracks = editor.timeline.getTracks(); const effectTrack = tracks.find((t) => t.type === "effect"); let trackId: string; @@ -370,7 +414,7 @@ export function useTimelineDragDrop({ element, }); }, - [editor.command, editor.timeline, tracks], + [editor], ); const executeFileDrop = useCallback( @@ -383,70 +427,98 @@ export function useTimelineDragDrop({ mouseX: number; mouseY: number; }) => { + const activeProject = editor.project.getActiveOrNull(); if (!activeProject) return; - const processedAssets = await processMediaAssets({ files }); - const projectId = activeProject.metadata.id; - - for (const asset of processedAssets) { - const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - const currentTracks = editor.timeline.getTracks(); - const dropTarget = computeDropTarget({ - elementType: asset.type, - mouseX, - mouseY, - tracks: currentTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, - zoomLevel, - }); - - const trackType: TrackType = asset.type === "audio" ? "audio" : "video"; - const addMediaCmd = new AddMediaAssetCommand(projectId, asset); - const assetId = addMediaCmd.getAssetId(); - - const commands: Command[] = [addMediaCmd]; - - let trackId: string | undefined; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - trackId = addTrackCmd.getTrackId(); - commands.unshift(addTrackCmd); - } else { - trackId = currentTracks[dropTarget.trackIndex]?.id; - } + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + const projectId = activeProject.metadata.id; - if (!trackId) return; - - const element = buildElementFromMedia({ - mediaId: assetId, - mediaType: asset.type, - name: asset.name, - duration, - startTime: dropTarget.xPosition, - buffer: - asset.type === "audio" - ? new AudioBuffer({ length: 1, sampleRate: 44100 }) - : undefined, - }); + for (const asset of processedAssets) { + const createdAsset = await editor.media.addMediaAsset({ + projectId, + asset, + }); + if (!createdAsset) continue; + + const duration = + createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const currentTracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const onlyTrack = currentTracks[0]; + const reuseMainTrackId = + createdAsset.type !== "audio" && + currentTracks.length === 1 && + !!onlyTrack && + isMainTrack(onlyTrack) && + onlyTrack.elements.length === 0 + ? onlyTrack.id + : null; + const dropTarget = reuseMainTrackId + ? null + : computeDropTarget({ + elementType: createdAsset.type, + mouseX, + mouseY, + tracks: currentTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, + }); + + const trackType: TrackType = + createdAsset.type === "audio" ? "audio" : "video"; + + let trackId: string | undefined; + if (reuseMainTrackId) { + trackId = reuseMainTrackId; + } else { + if (!dropTarget) continue; + if (dropTarget.isNewTrack) { + const addTrackCmd = new AddTrackCommand( + trackType, + dropTarget.trackIndex, + ); + trackId = addTrackCmd.getTrackId(); + editor.command.execute({ command: addTrackCmd }); + } else { + trackId = currentTracks[dropTarget.trackIndex]?.id; + } + } + + if (!trackId) continue; + + const element = buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: dropTarget?.xPosition ?? currentTime, + buffer: + createdAsset.type === "audio" + ? new AudioBuffer({ length: 1, sampleRate: 44100 }) + : undefined, + }); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }); - commands.push(insertCmd); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId }, + }); + editor.command.execute({ command: insertCmd }); + } - const batchCmd = new BatchCommand(commands); - editor.command.execute({ command: batchCmd }); - } + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); }, - [activeProject, editor.command, editor.timeline, currentTime, zoomLevel], + [editor, zoomLevel], ); const handleDrop = useCallback( @@ -471,6 +543,11 @@ export function useTimelineDragDrop({ if (dragData.type === "text") { executeTextDrop({ target: currentTarget, dragData }); + } else if (dragData.type === "graphic") { + executeGraphicDrop({ + target: currentTarget, + dragData: dragData as GraphicDragData, + }); } else if (dragData.type === "sticker") { executeStickerDrop({ target: currentTarget, dragData }); } else if (dragData.type === "effect") { @@ -502,12 +579,12 @@ export function useTimelineDragDrop({ } } catch (err) { console.error("Failed to process drop:", err); - toast.error("Failed to process drop"); } }, [ dropTarget, executeTextDrop, + executeGraphicDrop, executeStickerDrop, executeMediaDrop, executeEffectDrop, diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index 1426a003..e6204f23 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -1,12 +1,13 @@ import { getSnappedSeekTime } from "@/lib/time"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef } from "react"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useEditor } from "../use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils"; import { - findSnapPoints, - snapToNearestPoint, -} from "@/lib/timeline/snap-utils"; + getCenteredLineLeft, + timelineTimeToSnappedPixels, +} from "@/lib/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; interface UseTimelinePlayheadProps { @@ -25,27 +26,33 @@ export function useTimelinePlayhead({ playheadRef, }: UseTimelinePlayheadProps) { const editor = useEditor(); + const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); const activeProject = editor.project.getActive(); - const currentTime = editor.playback.getCurrentTime(); const duration = editor.timeline.getTotalDuration(); - const isPlaying = editor.playback.getIsPlaying(); - const isScrubbing = editor.playback.getIsScrubbing(); const isShiftHeldRef = useShiftKey(); + const zoomLevelRef = useRef(zoomLevel); + const durationRef = useRef(duration); + const isScrubbingRef = useRef(isScrubbing); + const isPlayingRef = useRef(false); + + useEffect(() => { + zoomLevelRef.current = zoomLevel; + durationRef.current = duration; + isScrubbingRef.current = isScrubbing; + isPlayingRef.current = editor.playback.getIsPlaying(); + }, [zoomLevel, duration, isScrubbing, editor.playback]); + const seek = useCallback( ({ time }: { time: number }) => editor.playback.seek({ time }), [editor.playback], ); - const [scrubTime, setScrubTime] = useState(null); - - const [isDraggingRuler, setIsDraggingRuler] = useState(false); - const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + const scrubTimeRef = useRef(null); + const isDraggingRulerRef = useRef(false); + const hasDraggedRulerRef = useRef(false); const lastMouseXRef = useRef(0); - const playheadPosition = - isScrubbing && scrubTime !== null ? scrubTime : currentTime; - const handleScrub = useCallback( ({ event, @@ -86,8 +93,7 @@ export function useTimelinePlayhead({ const time = (() => { if (!shouldSnap) return frameTime; const tracks = editor.timeline.getTracks(); - const bookmarks = - editor.scenes.getActiveScene()?.bookmarks ?? []; + const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; const snapPoints = findSnapPoints({ tracks, playheadTime: frameTime, @@ -102,7 +108,7 @@ export function useTimelinePlayhead({ return snapResult.snapPoint ? snapResult.snappedTime : frameTime; })(); - setScrubTime(time); + scrubTimeRef.current = time; seek({ time }); lastMouseXRef.current = event.clientX; @@ -132,17 +138,16 @@ export function useTimelinePlayhead({ const handleRulerMouseDown = useCallback( ({ event }: { event: React.MouseEvent }) => { if (event.button !== 0) return; - if (playheadRef?.current?.contains(event.target as Node)) return; event.preventDefault(); - setIsDraggingRuler(true); - setHasDraggedRuler(false); + isDraggingRulerRef.current = true; + hasDraggedRulerRef.current = false; - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event, snappingEnabled: false }); - }, - [handleScrub, playheadRef, editor.playback], + editor.playback.setScrubbing({ isScrubbing: true }); + handleScrub({ event, snappingEnabled: false }); + }, + [handleScrub, playheadRef, editor.playback], ); const handlePlayheadMouseDownEvent = useCallback( @@ -168,31 +173,32 @@ export function useTimelinePlayhead({ const handleMouseMove = ({ event }: { event: MouseEvent }) => { handleScrub({ event }); - if (isDraggingRuler) { - setHasDraggedRuler(true); + if (isDraggingRulerRef.current) { + hasDraggedRulerRef.current = true; } }; const handleMouseUp = ({ event }: { event: MouseEvent }) => { editor.playback.setScrubbing({ isScrubbing: false }); - if (scrubTime !== null) { - seek({ time: scrubTime }); + const finalTime = scrubTimeRef.current; + if (finalTime !== null) { + seek({ time: finalTime }); editor.project.setTimelineViewState({ viewState: { zoomLevel, scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, - playheadTime: scrubTime, + playheadTime: finalTime, }, }); } - setScrubTime(null); + scrubTimeRef.current = null; - if (isDraggingRuler) { - setIsDraggingRuler(false); - if (!hasDraggedRuler) { + if (isDraggingRulerRef.current) { + isDraggingRulerRef.current = false; + if (!hasDraggedRulerRef.current) { handleScrub({ event, snappingEnabled: false }); } - setHasDraggedRuler(false); + hasDraggedRulerRef.current = false; } }; @@ -206,55 +212,79 @@ export function useTimelinePlayhead({ window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; - }, [ - isScrubbing, - scrubTime, - seek, - handleScrub, - isDraggingRuler, - hasDraggedRuler, - editor, - tracksScrollRef, - zoomLevel, - ]); + }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); + + const updatePlayheadLeft = useCallback( + (time: number) => { + const playheadEl = playheadRef?.current; + if (!playheadEl) return; + const centerPosition = timelineTimeToSnappedPixels({ + time, + zoomLevel: zoomLevelRef.current, + }); + const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); + const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0; + playheadEl.style.left = `${leftPosition - scrollLeft}px`; + }, + [playheadRef, rulerScrollRef], + ); useEffect(() => { - if (!isPlaying || isScrubbing) return; - - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; - - const playheadPixels = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const viewportWidth = rulerViewport.clientWidth; - const scrollMinimum = 0; - const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; - - const needsScroll = - playheadPixels < rulerViewport.scrollLeft || - playheadPixels > rulerViewport.scrollLeft + viewportWidth; - - if (needsScroll) { - const desiredScroll = Math.max( - scrollMinimum, - Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }, [ - playheadPosition, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - isScrubbing, - isPlaying, - ]); + const scrollEl = rulerScrollRef.current; + if (!scrollEl) return; + + const handleScroll = () => { + updatePlayheadLeft(editor.playback.getCurrentTime()); + }; + + scrollEl.addEventListener("scroll", handleScroll, { passive: true }); + return () => scrollEl.removeEventListener("scroll", handleScroll); + }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); + + useEffect(() => { + const handlePlaybackUpdate = (e: Event) => { + const time = (e as CustomEvent<{ time: number }>).detail.time; + updatePlayheadLeft(time); + + if (!isPlayingRef.current || isScrubbingRef.current) return; + const rulerViewport = rulerScrollRef.current; + const tracksViewport = tracksScrollRef.current; + if (!rulerViewport || !tracksViewport) return; + + const playheadPixels = + time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current; + const viewportWidth = rulerViewport.clientWidth; + const scrollMinimum = 0; + const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; + + const needsScroll = + playheadPixels < rulerViewport.scrollLeft || + playheadPixels > rulerViewport.scrollLeft + viewportWidth; + + if (needsScroll) { + const desiredScroll = Math.max( + scrollMinimum, + Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), + ); + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + }; + + const initialTime = editor.playback.getCurrentTime(); + handlePlaybackUpdate({ + detail: { time: initialTime }, + } as CustomEvent<{ time: number }>); + + window.addEventListener("playback-update", handlePlaybackUpdate); + window.addEventListener("playback-seek", handlePlaybackUpdate); + return () => { + window.removeEventListener("playback-update", handlePlaybackUpdate); + window.removeEventListener("playback-seek", handlePlaybackUpdate); + }; + }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]); return { - playheadPosition, handlePlayheadMouseDown: handlePlayheadMouseDownEvent, handleRulerMouseDown: handleRulerMouseDownEvent, - isDraggingRuler, }; } diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts index 095c64dc..64a0fb58 100644 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -81,18 +81,22 @@ export function useTimelineZoom({ return; } - // pinch-zoom (ctrl/meta + wheel) - if (isZoomGesture) { - const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1; - setZoomLevel((prev) => { - const nextZoom = Math.max( - minZoom, - Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier), - ); - return nextZoom; - }); - return; - } + // pinch-zoom (ctrl/meta + wheel) + if (isZoomGesture) { + const normalizedDelta = + event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; + const cappedDelta = + Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + setZoomLevel((prev) => { + const nextZoom = Math.max( + minZoom, + Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomFactor), + ); + return nextZoom; + }); + return; + } }, [minZoom, setZoomLevel], ); diff --git a/apps/web/src/hooks/use-container-size.ts b/apps/web/src/hooks/use-container-size.ts index 8b46f20e..6f6f89be 100644 --- a/apps/web/src/hooks/use-container-size.ts +++ b/apps/web/src/hooks/use-container-size.ts @@ -1,4 +1,5 @@ -import { useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { useResizeObserver } from "./use-resize-observer"; export function useContainerSize({ containerRef, @@ -7,23 +8,12 @@ export function useContainerSize({ }) { const [size, setSize] = useState({ width: 0, height: 0 }); - useEffect(() => { - const container = containerRef.current; - if (!container) return; + const onResize = useCallback((entry: ResizeObserverEntry) => { + const { width, height } = entry.contentRect; + setSize({ width, height }); + }, []); - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width, height } = entry.contentRect; - setSize({ width, height }); - } - }); - - observer.observe(container); - - return () => { - observer.disconnect(); - }; - }, [containerRef]); + useResizeObserver({ ref: containerRef, onResize }); return size; } diff --git a/apps/web/src/hooks/use-editor.ts b/apps/web/src/hooks/use-editor.ts index ff3e2db1..e211a85b 100644 --- a/apps/web/src/hooks/use-editor.ts +++ b/apps/web/src/hooks/use-editor.ts @@ -1,39 +1,58 @@ import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import { EditorCore } from "@/core"; -export function useEditor(): EditorCore { +function isShallowEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + return a.every((item, i) => Object.is(item, b[i])); +} + +const subscribeNone = () => () => {}; + +export function useEditor(): EditorCore; +export function useEditor(selector: (editor: EditorCore) => T): T; +export function useEditor( + selector?: (editor: EditorCore) => T, +): EditorCore | T { const editor = useMemo(() => EditorCore.getInstance(), []); - const versionRef = useRef(0); + const selectorRef = useRef(selector); + selectorRef.current = selector; - const subscribe = useCallback( - (onStoreChange: () => void) => { - const handleStoreChange = () => { - versionRef.current += 1; - onStoreChange(); - }; + const snapshotCacheRef = useRef(undefined); + const subscribeAll = useCallback( + (onChange: () => void) => { const unsubscribers = [ - editor.playback.subscribe(handleStoreChange), - editor.timeline.subscribe(handleStoreChange), - editor.scenes.subscribe(handleStoreChange), - editor.project.subscribe(handleStoreChange), - editor.media.subscribe(handleStoreChange), - editor.renderer.subscribe(handleStoreChange), - editor.selection.subscribe(handleStoreChange), + editor.playback.subscribe(onChange), + editor.timeline.subscribe(onChange), + editor.scenes.subscribe(onChange), + editor.project.subscribe(onChange), + editor.media.subscribe(onChange), + editor.renderer.subscribe(onChange), + editor.selection.subscribe(onChange), ]; - return () => { - for (const unsubscribe of unsubscribers) { + unsubscribers.forEach((unsubscribe) => { unsubscribe(); - } + }); }; }, [editor], ); - const getSnapshot = useCallback(() => versionRef.current, []); - - useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const getSnapshot = useCallback(() => { + const next = selectorRef.current ? selectorRef.current(editor) : editor; + if (isShallowEqual(snapshotCacheRef.current, next)) { + return snapshotCacheRef.current; + } + snapshotCacheRef.current = next; + return next; + }, [editor]); - return editor; + return useSyncExternalStore( + selector ? subscribeAll : subscribeNone, + getSnapshot, + getSnapshot, + ) as EditorCore | T; } diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts deleted file mode 100644 index a84781e0..00000000 --- a/apps/web/src/hooks/use-effect-preview.ts +++ /dev/null @@ -1,47 +0,0 @@ -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; - isActive: boolean; -}): void { - const requestRef = useRef(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-element-preview.ts b/apps/web/src/hooks/use-element-preview.ts new file mode 100644 index 00000000..8aa16501 --- /dev/null +++ b/apps/web/src/hooks/use-element-preview.ts @@ -0,0 +1,38 @@ +import { useEditor } from "@/hooks/use-editor"; +import type { TimelineElement } from "@/lib/timeline"; + +/** + * Subscribes to render tracks and returns the live (preview-aware) version of + * an element alongside helpers for previewing and committing updates. + * + * Use this wherever property fields need to reflect in-progress preview state + * (e.g. a slider being dragged) rather than the last committed value. + */ +export function useElementPreview({ + trackId, + elementId, + fallback, +}: { + trackId: string; + elementId: string; + fallback: T; +}) { + const editor = useEditor(); + useEditor((e) => e.timeline.getRenderTracks()); + + const renderElement = + (editor.timeline + .getRenderTracks() + .find((t) => t.id === trackId) + ?.elements.find((el) => el.id === elementId) as T | undefined) ?? + fallback; + + const previewUpdates = (updates: Partial) => + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates }], + }); + + const commit = () => editor.timeline.commitPreview(); + + return { renderElement, previewUpdates, commit }; +} diff --git a/apps/web/src/hooks/use-file-upload.ts b/apps/web/src/hooks/use-file-upload.ts index 19badd98..d174e002 100644 --- a/apps/web/src/hooks/use-file-upload.ts +++ b/apps/web/src/hooks/use-file-upload.ts @@ -4,7 +4,7 @@ import { hasDragData } from "@/lib/drag-data"; interface UseFileUploadOptions { accept?: string; multiple?: boolean; - onFilesSelected?: (files: FileList) => void; + onFilesSelected?: (files: File[]) => void; } function containsFiles(dataTransfer: DataTransfer): boolean { @@ -29,8 +29,8 @@ export function useFileUpload({ } function handleFileChange(event: React.ChangeEvent) { - const files = event.target.files; - if (files && files.length > 0 && onFilesSelected) { + const files = Array.from(event.target.files ?? []); + if (files.length > 0 && onFilesSelected) { onFilesSelected(files); } @@ -71,15 +71,13 @@ export function useFileUpload({ dragCounterRef.current = 0; if (onFilesSelected && containsFiles(e.dataTransfer)) { - const files = e.dataTransfer.files; + const files = Array.from(e.dataTransfer.files); const shouldUseMultiple = multiple ?? false; if (shouldUseMultiple) { onFilesSelected(files); } else if (files.length > 0) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(files[0]); - onFilesSelected(dataTransfer.files); + onFilesSelected([files[0]]); } } } diff --git a/apps/web/src/hooks/use-font-atlas.ts b/apps/web/src/hooks/use-font-atlas.ts new file mode 100644 index 00000000..7d6b87e9 --- /dev/null +++ b/apps/web/src/hooks/use-font-atlas.ts @@ -0,0 +1,53 @@ +import { useState, useMemo, useCallback, useEffect } from "react"; +import { + getCachedFontAtlas, + loadFontAtlas, + clearFontAtlasCache, +} from "@/lib/fonts/google-fonts"; +import type { FontAtlas } from "@/lib/fonts/types"; +import { SYSTEM_FONTS } from "@/constants/font-constants"; + +type Status = "idle" | "loading" | "error"; + +export function useFontAtlas({ open }: { open: boolean }) { + const [atlas, setAtlas] = useState(() => + getCachedFontAtlas(), + ); + const [status, setStatus] = useState(() => + getCachedFontAtlas() ? "idle" : "loading", + ); + + useEffect(() => { + if (!open || atlas) return; + + setStatus("loading"); + loadFontAtlas().then((data) => { + if (data) { + setAtlas(data); + setStatus("idle"); + } else { + setStatus("error"); + } + }); + }, [open, atlas]); + + const retry = useCallback(() => { + clearFontAtlasCache(); + setStatus("loading"); + loadFontAtlas().then((data) => { + if (data) { + setAtlas(data); + setStatus("idle"); + } else { + setStatus("error"); + } + }); + }, []); + + const fontNames = useMemo(() => { + if (!atlas) return []; + return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort(); + }, [atlas]); + + return { atlas, status, fontNames, retry }; +} diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts index e59d1eaf..69a4e847 100644 --- a/apps/web/src/hooks/use-keybindings.ts +++ b/apps/web/src/hooks/use-keybindings.ts @@ -1,6 +1,8 @@ import { useEffect } from "react"; import { invokeAction } from "@/lib/actions"; import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { isTypableDOMElement } from "@/utils/browser"; /** * a composable that hooks to the caller component's @@ -8,31 +10,141 @@ import { useKeybindingsStore } from "@/stores/keybindings-store"; * the appropriate actions based on keybindings */ export function useKeybindingsListener() { - const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } = - useKeybindingsStore(); + const { + keybindings, + getKeybindingString, + overlayDepth, + isLoadingProject, + isRecording, + } = useKeybindingsStore(); + const clipboard = useTimelineStore((state) => state.clipboard); useEffect(() => { const eventOptions: AddEventListenerOptions = { capture: true }; + // #region agent log + fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "3997d9", + }, + body: JSON.stringify({ + sessionId: "3997d9", + runId: "initial", + hypothesisId: "H1", + location: "use-keybindings.ts:effect", + message: "Keybindings listener mounted", + data: { + overlayDepth, + isLoadingProject, + isRecording, + keybindingCount: Object.keys(keybindings).length, + }, + timestamp: Date.now(), + }), + }).catch(() => {}); + // #endregion const handleKeyDown = (ev: KeyboardEvent) => { - // do not check keybinds if the mode is disabled - if (!keybindingsEnabled) return; - // ignore key events if user is changing keybindings - if (isRecording) return; + const normalizedKey = (ev.key ?? "").toLowerCase(); + const shouldLogKey = + ev.code === "Space" || + ev.code.startsWith("Key") || + ["escape", "delete", "backspace", "enter"].includes(normalizedKey); - const binding = getKeybindingString(ev); - if (!binding) return; - - const boundAction = keybindings[binding]; - if (!boundAction) return; + if (overlayDepth > 0 || isLoadingProject || isRecording) { + if (shouldLogKey) { + // #region agent log + fetch( + "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "3997d9", + }, + body: JSON.stringify({ + sessionId: "3997d9", + runId: "initial", + hypothesisId: "H2", + location: "use-keybindings.ts:blocked", + message: "Shortcut blocked by runtime gate", + data: { + key: ev.key, + code: ev.code, + overlayDepth, + isLoadingProject, + isRecording, + targetTag: + ev.target instanceof HTMLElement ? ev.target.tagName : null, + }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion + } + return; + } + const binding = getKeybindingString(ev); const activeElement = document.activeElement; const isTextInput = - activeElement && - (activeElement.tagName === "INPUT" || - activeElement.tagName === "TEXTAREA" || - (activeElement as HTMLElement).isContentEditable); + activeElement instanceof HTMLElement && + isTypableDOMElement({ element: activeElement }); + const boundAction = binding ? keybindings[binding] : undefined; + + if (shouldLogKey || binding || boundAction) { + // #region agent log + fetch( + "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "3997d9", + }, + body: JSON.stringify({ + sessionId: "3997d9", + runId: "initial", + hypothesisId: !binding ? "H3" : isTextInput ? "H5" : "H4", + location: "use-keybindings.ts:keydown", + message: "Shortcut keydown observed", + data: { + key: ev.key, + code: ev.code, + binding, + boundAction: boundAction ?? null, + isTextInput, + keybindingCount: Object.keys(keybindings).length, + activeTag: + activeElement instanceof HTMLElement + ? activeElement.tagName + : null, + targetTag: + ev.target instanceof HTMLElement ? ev.target.tagName : null, + }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion + } + + if (normalizedKey === "escape" && isTextInput) { + activeElement.blur(); + return; + } + + if (!binding) return; + if (!boundAction) return; if (isTextInput) return; + if (boundAction === "paste-copied") { + if (!clipboard?.items.length) return; + ev.preventDefault(); + invokeAction("paste-copied", undefined, "keypress"); + return; + } ev.preventDefault(); @@ -59,17 +171,12 @@ export function useKeybindingsListener() { return () => { document.removeEventListener("keydown", handleKeyDown, eventOptions); }; - }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]); -} - -/** - * this composable allows for the UI component to be disabled if the component in question is mounted - */ -export function useKeybindingDisabler() { - const { disableKeybindings, enableKeybindings } = useKeybindingsStore(); - - return { - disableKeybindings, - enableKeybindings, - }; + }, [ + keybindings, + getKeybindingString, + overlayDepth, + isLoadingProject, + isRecording, + clipboard, + ]); } diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts new file mode 100644 index 00000000..c3e83304 --- /dev/null +++ b/apps/web/src/hooks/use-mask-handles.ts @@ -0,0 +1,258 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { masksRegistry } from "@/lib/masks"; +import { + getMaskHandlePositions, + getLineMaskLinePoints, +} from "@/lib/masks/handle-positions"; +import { snapMaskInteraction } from "@/lib/masks/snap"; +import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; +import { + SNAP_THRESHOLD_SCREEN_PIXELS, + type SnapLine, +} from "@/lib/preview/preview-snap"; +import type { ParamValues } from "@/lib/params"; +import type { + BaseMaskParams, + MaskHandlePosition, + MaskLinePoints, +} from "@/lib/masks/types"; +import type { MaskableElement } from "@/lib/timeline"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +interface DragState { + trackId: string; + elementId: string; + handleId: string; + startCanvasX: number; + startCanvasY: number; + startParams: BaseMaskParams & ParamValues; +} + +export function useMaskHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: (lines: SnapLine[]) => void; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); + const [activeHandleId, setActiveHandleId] = useState(null); + const dragStateRef = useRef(null); + const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + null, + ); + + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const selectedWithMask = + selectedElements.length === 1 + ? (() => { + const sel = selectedElements[0]; + const entry = elementsWithBounds.find( + (item) => + item.trackId === sel.trackId && item.elementId === sel.elementId, + ); + if (!entry) return null; + const element = entry.element as MaskableElement; + if (!element.masks?.length) return null; + return { ...entry, element, mask: element.masks[0] }; + })() + : null; + + const handlePositions: MaskHandlePosition[] = selectedWithMask + ? (() => { + const def = masksRegistry.get(selectedWithMask.mask.type); + const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); + const displayScale = (scaleX + scaleY) / 2; + return getMaskHandlePositions({ + overlayShape: def.overlayShape, + features: def.features, + params: selectedWithMask.mask.params, + bounds: selectedWithMask.bounds, + displayScale, + }); + })() + : []; + + const linePoints: MaskLinePoints | null = + selectedWithMask?.mask.type === "split" + ? getLineMaskLinePoints({ + centerX: selectedWithMask.mask.params.centerX, + centerY: selectedWithMask.mask.params.centerY, + rotation: selectedWithMask.mask.params.rotation, + bounds: selectedWithMask.bounds, + }) + : null; + + const clearMaskHandleState = useCallback(() => { + dragStateRef.current = null; + setActiveHandleId(null); + onSnapLinesChange?.([]); + }, [onSnapLinesChange]); + + const releaseCapturedPointer = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + + if (capture.element.hasPointerCapture(capture.pointerId)) { + capture.element.releasePointerCapture(capture.pointerId); + } + + captureRef.current = null; + }, []); + + useEffect(() => { + if (!activeHandleId) return; + + return registerCanceller({ + fn: () => { + editor.timeline.discardPreview(); + clearMaskHandleState(); + releaseCapturedPointer(); + }, + }); + }, [ + activeHandleId, + clearMaskHandleState, + editor.timeline, + releaseCapturedPointer, + ]); + + const handlePointerDown = useCallback( + ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { + if (!selectedWithMask) return; + event.stopPropagation(); + + const pos = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!pos) return; + + dragStateRef.current = { + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + handleId, + startCanvasX: pos.x, + startCanvasY: pos.y, + startParams: { ...selectedWithMask.mask.params }, + }; + setActiveHandleId(handleId); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithMask, viewport], + ); + + const handlePointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + const drag = dragStateRef.current; + if (!drag || !selectedWithMask) return; + + const pos = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!pos) return; + + const deltaX = pos.x - drag.startCanvasX; + const deltaY = pos.y - drag.startCanvasY; + const def = masksRegistry.get(selectedWithMask.mask.type); + + const rawParams = def.computeParamUpdate({ + handleId: drag.handleId, + startParams: drag.startParams, + deltaX, + deltaY, + startCanvasX: drag.startCanvasX, + startCanvasY: drag.startCanvasY, + bounds: selectedWithMask.bounds, + canvasSize, + }); + const proposedParams = { ...drag.startParams, ...rawParams }; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { params: nextParams, activeLines } = isShiftHeldRef.current + ? { params: proposedParams, activeLines: [] as SnapLine[] } + : snapMaskInteraction({ + handleId: drag.handleId, + startParams: drag.startParams, + proposedParams, + bounds: selectedWithMask.bounds, + canvasSize, + snapThreshold, + }); + + onSnapLinesChange?.(activeLines); + + const updatedMask = { + ...selectedWithMask.mask, + params: nextParams, + }; + editor.timeline.previewElements({ + updates: [ + { + trackId: drag.trackId, + elementId: drag.elementId, + updates: { + masks: [ + updatedMask, + ...(selectedWithMask.element.masks?.slice(1) ?? []), + ], + } as Partial, + }, + ], + }); + }, + [ + selectedWithMask, + canvasSize, + editor, + isShiftHeldRef, + onSnapLinesChange, + viewport, + ], + ); + + const handlePointerUp = useCallback(() => { + if (dragStateRef.current) { + editor.timeline.commitPreview(); + clearMaskHandleState(); + } + releaseCapturedPointer(); + }, + [clearMaskHandleState, editor, releaseCapturedPointer], + ); + + return { + selectedWithMask, + handlePositions, + linePoints, + activeHandleId, + handlePointerDown, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/apps/web/src/hooks/use-menu-preview.ts b/apps/web/src/hooks/use-menu-preview.ts new file mode 100644 index 00000000..a17ee278 --- /dev/null +++ b/apps/web/src/hooks/use-menu-preview.ts @@ -0,0 +1,26 @@ +import { useRef } from "react"; +import { useEditor } from "@/hooks/use-editor"; + +export function useMenuPreview() { + const editor = useEditor(); + const didCommitRef = useRef(false); + + const discard = () => { + if (!didCommitRef.current && editor.timeline.isPreviewActive()) { + editor.timeline.discardPreview(); + } + }; + + const markCommitted = () => { + didCommitRef.current = true; + }; + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + discard(); + didCommitRef.current = false; + } + }; + + return { onPointerLeave: discard, onOpenChange, markCommitted }; +} diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts index 4533ab28..8f362a12 100644 --- a/apps/web/src/hooks/use-paste-media.ts +++ b/apps/web/src/hooks/use-paste-media.ts @@ -1,14 +1,15 @@ import { useEffect } from "react"; -import { toast } from "sonner"; import { useEditor } from "@/hooks/use-editor"; import { processMediaAssets } from "@/lib/media/processing"; +import { showMediaUploadToast } from "@/lib/media/upload-toast"; +import { invokeAction } from "@/lib/actions"; import { buildElementFromMedia } from "@/lib/timeline/element-utils"; import { AddMediaAssetCommand } from "@/lib/commands/media"; import { InsertElementCommand } from "@/lib/commands/timeline"; import { BatchCommand } from "@/lib/commands"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { isTypableDOMElement } from "@/utils/browser"; -import type { MediaType } from "@/types/assets"; +import type { MediaType } from "@/lib/media/types"; const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"]; @@ -40,6 +41,7 @@ export function usePasteMedia() { useEffect(() => { const handlePaste = async (event: ClipboardEvent) => { const activeElement = document.activeElement as HTMLElement; + if (activeElement && isTypableDOMElement({ element: activeElement })) { return; } @@ -47,7 +49,11 @@ export function usePasteMedia() { const files = extractMediaFilesFromClipboard({ clipboardData: event.clipboardData, }); - if (files.length === 0) return; + if (files.length === 0) { + event.preventDefault(); + invokeAction("paste-copied"); + return; + } event.preventDefault(); @@ -55,41 +61,50 @@ export function usePasteMedia() { if (!activeProject) return; try { - const processedAssets = await processMediaAssets({ files }); - const startTime = editor.playback.getCurrentTime(); - - for (const asset of processedAssets) { - const addMediaCmd = new AddMediaAssetCommand( - activeProject.metadata.id, - asset, - ); - const assetId = addMediaCmd.getAssetId(); - const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - const trackType = asset.type === "audio" ? "audio" : "video"; - - const element = buildElementFromMedia({ - mediaId: assetId, - mediaType: asset.type, - name: asset.name, - duration, - startTime, - buffer: - asset.type === "audio" - ? new AudioBuffer({ length: 1, sampleRate: 44100 }) - : undefined, - }); - - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "auto", trackType }, - }); - const batchCmd = new BatchCommand([addMediaCmd, insertCmd]); - editor.command.execute({ command: batchCmd }); - } + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + const startTime = editor.playback.getCurrentTime(); + + for (const asset of processedAssets) { + const addMediaCmd = new AddMediaAssetCommand( + activeProject.metadata.id, + asset, + ); + const assetId = addMediaCmd.getAssetId(); + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const trackType = asset.type === "audio" ? "audio" : "video"; + + const element = buildElementFromMedia({ + mediaId: assetId, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + buffer: + asset.type === "audio" + ? new AudioBuffer({ length: 1, sampleRate: 44100 }) + : undefined, + }); + + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "auto", trackType }, + }); + const batchCmd = new BatchCommand([addMediaCmd, insertCmd]); + editor.command.execute({ command: batchCmd }); + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); } catch (error) { console.error("Failed to paste media:", error); - toast.error("Failed to paste media"); } }; diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index d29f2a4b..90fa0450 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -1,28 +1,50 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import type { TextElement, Transform } from "@/types/timeline"; -import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; -import { hitTest } from "@/lib/preview/hit-test"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import type { Transform } from "@/lib/rendering"; +import type { ElementRef, TextElement } from "@/lib/timeline"; import { - screenPixelsToLogicalThreshold, - screenToCanvas, -} from "@/lib/preview/preview-coords"; + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/lib/preview/element-bounds"; +import { + getHitElements, + hitTest, + resolvePreferredHit, +} from "@/lib/preview/hit-test"; import { isVisualElement } from "@/lib/timeline/element-utils"; import { SNAP_THRESHOLD_SCREEN_PIXELS, snapPosition, type SnapLine, } from "@/lib/preview/preview-snap"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +export type OnSnapLinesChange = (lines: SnapLine[]) => void; const MIN_DRAG_DISTANCE = 0.5; -interface DragState { +interface CapturedPointerState { + pointerId: number; + captureTarget: HTMLElement; +} + +interface PendingGestureState extends CapturedPointerState { + startX: number; + startY: number; + topmostHit: ElementWithBounds | null; + selectedHit: ElementWithBounds | null; + selectedElements: ElementRef[]; +} + +interface DragState extends CapturedPointerState { startX: number; startY: number; bounds: { width: number; height: number; + rotation: number; }; elements: Array<{ trackId: string; @@ -31,15 +53,56 @@ interface DragState { }>; } +function isSameElementRef({ + left, + right, +}: { + left: ElementRef; + right: ElementRef; +}): boolean { + return left.trackId === right.trackId && left.elementId === right.elementId; +} + +function buildDragSelection({ + selectedElements, + dragTarget, +}: { + selectedElements: ElementRef[]; + dragTarget: ElementWithBounds; +}): ElementRef[] { + const dragTargetRef = { + trackId: dragTarget.trackId, + elementId: dragTarget.elementId, + }; + + if ( + !selectedElements.some((selectedElement) => + isSameElementRef({ left: selectedElement, right: dragTargetRef }), + ) + ) { + return [dragTargetRef]; + } + + return [ + dragTargetRef, + ...selectedElements.filter( + (selectedElement) => + !isSameElementRef({ left: selectedElement, right: dragTargetRef }), + ), + ]; +} + export function usePreviewInteraction({ - canvasRef, + onSnapLinesChange, + isMaskMode = false, }: { - canvasRef: React.RefObject; + onSnapLinesChange?: OnSnapLinesChange; + isMaskMode?: boolean; }) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); const [isDragging, setIsDragging] = useState(false); - const [snapLines, setSnapLines] = useState([]); const [editingText, setEditingText] = useState<{ trackId: string; elementId: string; @@ -47,13 +110,30 @@ export function usePreviewInteraction({ originalOpacity: number; } | null>(null); const dragStateRef = useRef(null); + const pendingGestureRef = useRef(null); const wasPlayingRef = useRef(editor.playback.getIsPlaying()); const editingTextRef = useRef(editingText); editingTextRef.current = editingText; + const releaseCapturedPointer = useCallback( + (pointerState: CapturedPointerState | null) => { + if (!pointerState) return; + + if ( + !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId) + ) { + return; + } + + pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + }, + [], + ); + const commitTextEdit = useCallback(() => { const current = editingTextRef.current; if (!current) return; + editingTextRef.current = null; editor.timeline.previewElements({ updates: [ { @@ -67,11 +147,6 @@ export function usePreviewInteraction({ setEditingText(null); }, [editor.timeline]); - const cancelTextEdit = useCallback(() => { - editor.timeline.discardPreview(); - setEditingText(null); - }, [editor.timeline]); - useEffect(() => { const unsubscribe = editor.playback.subscribe(() => { const isPlaying = editor.playback.getIsPlaying(); @@ -83,20 +158,38 @@ export function usePreviewInteraction({ return unsubscribe; }, [editor.playback, commitTextEdit]); + useEffect(() => { + if (!isDragging) return; + + return registerCanceller({ + fn: () => { + const dragState = dragStateRef.current; + if (!dragState) return; + + editor.timeline.discardPreview(); + dragStateRef.current = null; + pendingGestureRef.current = null; + setIsDragging(false); + onSnapLinesChange?.([]); + releaseCapturedPointer(dragState); + }, + }); + }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]); + const handleDoubleClick = useCallback( ({ clientX, clientY }: React.MouseEvent) => { - if (!canvasRef.current || editingText) return; + if (editingText || isMaskMode) return; const tracks = editor.timeline.getTracks(); const currentTime = editor.playback.getCurrentTime(); const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; - const startPos = screenToCanvas({ + const startPos = viewport.screenToCanvas({ clientX, clientY, - canvas: canvasRef.current, }); + if (!startPos) return; const elementsWithBounds = getVisibleElementsWithBounds({ tracks, @@ -130,7 +223,7 @@ export function usePreviewInteraction({ originalOpacity: textElement.opacity, }); }, - [canvasRef, editor, editingText], + [editor, editingText, isMaskMode, viewport], ); const handlePointerDown = useCallback( @@ -141,8 +234,8 @@ export function usePreviewInteraction({ pointerId, button, }: React.PointerEvent) => { - if (!canvasRef.current) return; if (editingText) return; + if (isMaskMode) return; if (button !== 0) return; const tracks = editor.timeline.getTracks(); @@ -150,11 +243,11 @@ export function usePreviewInteraction({ const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; - const startPos = screenToCanvas({ + const startPos = viewport.screenToCanvas({ clientX, clientY, - canvas: canvasRef.current, }); + if (!startPos) return; const elementsWithBounds = getVisibleElementsWithBounds({ tracks, @@ -163,89 +256,134 @@ export function usePreviewInteraction({ mediaAssets, }); - const hit = hitTest({ + const hits = getHitElements({ canvasX: startPos.x, canvasY: startPos.y, elementsWithBounds, }); + const selectedElements = editor.selection.getSelectedElements(); + const topmostHit = hits[0] ?? null; - if (!hit) { - editor.selection.clearSelection(); - return; - } - - editor.selection.setSelectedElements({ - elements: [{ trackId: hit.trackId, elementId: hit.elementId }], - }); - - const elementsWithTracks = editor.timeline.getElementsWithTracks({ - elements: [{ trackId: hit.trackId, elementId: hit.elementId }], - }); - - const draggableElements = elementsWithTracks.filter(({ element }) => - isVisualElement(element), - ); - - if (draggableElements.length === 0) return; - - dragStateRef.current = { + pendingGestureRef.current = { startX: startPos.x, startY: startPos.y, - bounds: { - width: hit.bounds.width, - height: hit.bounds.height, - }, - elements: draggableElements.map(({ track, element }) => ({ - trackId: track.id, - elementId: element.id, - initialTransform: (element as { transform: Transform }).transform, - })), + pointerId, + captureTarget: currentTarget as HTMLElement, + topmostHit, + selectedHit: resolvePreferredHit({ + hits, + preferredElements: selectedElements, + }), + selectedElements, }; - - setIsDragging(true); currentTarget.setPointerCapture(pointerId); }, - [editor, canvasRef, editingText], + [editor, editingText, isMaskMode, viewport], ); const handlePointerMove = useCallback( ({ clientX, clientY }: React.PointerEvent) => { - if (!dragStateRef.current || !isDragging || !canvasRef.current) return; - const canvasSize = editor.project.getActive().settings.canvasSize; - const currentPos = screenToCanvas({ + const currentPos = viewport.screenToCanvas({ clientX, clientY, - canvas: canvasRef.current, }); + if (!currentPos) return; + + let dragState = dragStateRef.current; + + if (!dragState) { + const pendingGesture = pendingGestureRef.current; + if (!pendingGesture) return; + + const deltaX = currentPos.x - pendingGesture.startX; + const deltaY = currentPos.y - pendingGesture.startY; + const hasMovement = + Math.abs(deltaX) > MIN_DRAG_DISTANCE || + Math.abs(deltaY) > MIN_DRAG_DISTANCE; + + if (!hasMovement) { + onSnapLinesChange?.([]); + return; + } + + const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit; + if (!dragTarget) { + pendingGestureRef.current = null; + onSnapLinesChange?.([]); + releaseCapturedPointer(pendingGesture); + return; + } + + const dragSelection = buildDragSelection({ + selectedElements: pendingGesture.selectedElements, + dragTarget, + }); + const elementsWithTracks = editor.timeline.getElementsWithTracks({ + elements: dragSelection, + }); + const draggableElements = elementsWithTracks.filter(({ element }) => + isVisualElement(element), + ); + + if (draggableElements.length === 0) { + pendingGestureRef.current = null; + onSnapLinesChange?.([]); + releaseCapturedPointer(pendingGesture); + return; + } + + if (pendingGesture.selectedHit === null) { + editor.selection.setSelectedElements({ + elements: [ + { + trackId: dragTarget.trackId, + elementId: dragTarget.elementId, + }, + ], + }); + } - const deltaX = currentPos.x - dragStateRef.current.startX; - const deltaY = currentPos.y - dragStateRef.current.startY; - const hasMovement = - Math.abs(deltaX) > MIN_DRAG_DISTANCE || - Math.abs(deltaY) > MIN_DRAG_DISTANCE; - if (!hasMovement) { - setSnapLines([]); - return; + dragState = { + startX: pendingGesture.startX, + startY: pendingGesture.startY, + pointerId: pendingGesture.pointerId, + captureTarget: pendingGesture.captureTarget, + bounds: { + width: dragTarget.bounds.width, + height: dragTarget.bounds.height, + rotation: dragTarget.bounds.rotation, + }, + elements: draggableElements.map(({ track, element }) => ({ + trackId: track.id, + elementId: element.id, + initialTransform: (element as { transform: Transform }).transform, + })), + }; + dragStateRef.current = dragState; + pendingGestureRef.current = null; + setIsDragging(true); } - const firstElement = dragStateRef.current.elements[0]; + const deltaX = currentPos.x - dragState.startX; + const deltaY = currentPos.y - dragState.startY; + const firstElement = dragState.elements[0]; const proposedPosition = { x: firstElement.initialTransform.position.x + deltaX, y: firstElement.initialTransform.position.y + deltaY, }; const shouldSnap = !isShiftHeldRef.current; - const snapThreshold = screenPixelsToLogicalThreshold({ - canvas: canvasRef.current, + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, }); const { snappedPosition, activeLines } = shouldSnap ? snapPosition({ proposedPosition, canvasSize, - elementSize: dragStateRef.current.bounds, + elementSize: dragState.bounds, + rotation: dragState.bounds.rotation, snapThreshold, }) : { @@ -253,14 +391,14 @@ export function usePreviewInteraction({ activeLines: [] as SnapLine[], }; - setSnapLines(activeLines); + onSnapLinesChange?.(activeLines); const deltaSnappedX = snappedPosition.x - firstElement.initialTransform.position.x; const deltaSnappedY = snappedPosition.y - firstElement.initialTransform.position.y; - const updates = dragStateRef.current.elements.map( + const updates = dragState.elements.map( ({ trackId, elementId, initialTransform }) => ({ trackId, elementId, @@ -278,38 +416,51 @@ export function usePreviewInteraction({ editor.timeline.previewElements({ updates }); }, - [isDragging, canvasRef, editor, isShiftHeldRef], + [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport], ); const handlePointerUp = useCallback( - ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => { - if (!dragStateRef.current || !isDragging || !canvasRef.current) return; - - const currentPos = screenToCanvas({ - clientX, - clientY, - canvas: canvasRef.current, - }); - - const deltaX = currentPos.x - dragStateRef.current.startX; - const deltaY = currentPos.y - dragStateRef.current.startY; - - const hasMovement = - Math.abs(deltaX) > MIN_DRAG_DISTANCE || - Math.abs(deltaY) > MIN_DRAG_DISTANCE; + ({ type }: React.PointerEvent) => { + const dragState = dragStateRef.current; + if (dragState) { + if (type === "pointercancel") { + editor.timeline.discardPreview(); + } else { + editor.timeline.commitPreview(); + } + + dragStateRef.current = null; + pendingGestureRef.current = null; + setIsDragging(false); + onSnapLinesChange?.([]); + releaseCapturedPointer(dragState); + return; + } - if (!hasMovement) { - editor.timeline.discardPreview(); - } else { - editor.timeline.commitPreview(); + const pendingGesture = pendingGestureRef.current; + if (!pendingGesture) return; + + if (type !== "pointercancel") { + const clickTarget = pendingGesture.topmostHit; + if (!clickTarget) { + editor.selection.clearSelection(); + } else { + editor.selection.setSelectedElements({ + elements: [ + { + trackId: clickTarget.trackId, + elementId: clickTarget.elementId, + }, + ], + }); + } } - dragStateRef.current = null; - setIsDragging(false); - setSnapLines([]); - currentTarget.releasePointerCapture(pointerId); + pendingGestureRef.current = null; + onSnapLinesChange?.([]); + releaseCapturedPointer(pendingGesture); }, - [isDragging, canvasRef, editor], + [editor, onSnapLinesChange, releaseCapturedPointer], ); return { @@ -317,9 +468,7 @@ export function usePreviewInteraction({ onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onDoubleClick: handleDoubleClick, - snapLines, editingText, commitTextEdit, - cancelTextEdit, }; } diff --git a/apps/web/src/hooks/use-resize-observer.ts b/apps/web/src/hooks/use-resize-observer.ts new file mode 100644 index 00000000..a4b3a603 --- /dev/null +++ b/apps/web/src/hooks/use-resize-observer.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; + +export function useResizeObserver({ + ref, + onResize, +}: { + ref: React.RefObject; + onResize: (entry: ResizeObserverEntry) => void; +}) { + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + onResize(entry); + } + }); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [ref, onResize]); +} diff --git a/apps/web/src/hooks/use-reveal-item.ts b/apps/web/src/hooks/use-reveal-item.ts deleted file mode 100644 index 5004f60c..00000000 --- a/apps/web/src/hooks/use-reveal-item.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useState, useRef } from "react"; - -export function useRevealItem( - highlightId: string | null, - onClearHighlight: () => void, - highlightDuration = 1000, -) { - const [highlightedId, setHighlightedId] = useState(null); - const elementRefs = useRef>(new Map()); - - const registerElement = (id: string, element: HTMLElement | null) => { - if (element) { - elementRefs.current.set(id, element); - } else { - elementRefs.current.delete(id); - } - }; - - useEffect(() => { - if (!highlightId) return; - - setHighlightedId(highlightId); - - const target = elementRefs.current.get(highlightId); - target?.scrollIntoView({ block: "center" }); - - const timeout = setTimeout(() => { - setHighlightedId(null); - onClearHighlight(); - }, highlightDuration); - - return () => clearTimeout(timeout); - }, [highlightId, onClearHighlight, highlightDuration]); - - return { highlightedId, registerElement }; -} diff --git a/apps/web/src/hooks/use-storage-persistence.ts b/apps/web/src/hooks/use-storage-persistence.ts new file mode 100644 index 00000000..79e05069 --- /dev/null +++ b/apps/web/src/hooks/use-storage-persistence.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const DISMISSED_KEY = "opencut-storage-persist-dismissed"; + +function isFirefox(): boolean { + return navigator.userAgent.toLowerCase().includes("firefox"); +} + +export function useStoragePersistence() { + const [showDialog, setShowDialog] = useState(false); + + useEffect(() => { + if (!navigator.storage?.persist) return; + + const run = async () => { + const alreadyPersisted = await navigator.storage.persisted(); + if (alreadyPersisted) return; + + const dismissed = localStorage.getItem(DISMISSED_KEY) === "true"; + if (dismissed) return; + + if (isFirefox()) { + setShowDialog(true); + } else { + await navigator.storage.persist(); + } + }; + + run(); + }, []); + + const onConfirm = async () => { + setShowDialog(false); + await navigator.storage.persist(); + }; + + const onDismiss = () => { + setShowDialog(false); + localStorage.setItem(DISMISSED_KEY, "true"); + }; + + return { showDialog, onConfirm, onDismiss }; +} diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index fb561396..a01fb361 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -1,19 +1,19 @@ -import { useCallback, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { getVisibleElementsWithBounds, type ElementWithBounds, } from "@/lib/preview/element-bounds"; -import { - screenPixelsToLogicalThreshold, - screenToCanvas, -} from "@/lib/preview/preview-coords"; import { MIN_SCALE, SNAP_THRESHOLD_SCREEN_PIXELS, snapRotation, snapScale, + snapScaleAxes, + type ScaleEdgePreference, type SnapLine, } from "@/lib/preview/preview-snap"; import { isVisualElement } from "@/lib/timeline/element-utils"; @@ -22,11 +22,25 @@ import { resolveTransformAtTime, setChannel, } from "@/lib/animation"; -import type { Transform } from "@/types/timeline"; -import type { ElementAnimations } from "@/types/animation"; +import type { Transform } from "@/lib/rendering"; +import type { ElementAnimations } from "@/lib/animation/types"; +import { registerCanceller } from "@/lib/cancel-interaction"; type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -type HandleType = Corner | "rotation"; +type Edge = "right" | "left" | "bottom"; +type HandleType = Corner | Edge | "rotation"; + +function getPreferredEdge({ + edge, +}: { + edge: Edge; +}): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} interface ScaleState { trackId: string; @@ -50,26 +64,25 @@ interface RotationState { initialBoundsCy: number; } -function areSnapLinesEqual({ - previousLines, - nextLines, -}: { - previousLines: SnapLine[]; - nextLines: SnapLine[]; -}): boolean { - if (previousLines.length !== nextLines.length) { - return false; - } - for (const [index, line] of previousLines.entries()) { - const nextLine = nextLines[index]; - if (!nextLine) { - return false; - } - if (line.type !== nextLine.type || line.position !== nextLine.position) { - return false; - } +interface EdgeScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; + edge: Edge; + rotationRad: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; } - return true; + return scale; } function getCornerDistance({ @@ -102,29 +115,30 @@ function getCornerDistance({ } export function useTransformHandles({ - canvasRef, + onSnapLinesChange, }: { - canvasRef: React.RefObject; + onSnapLinesChange?: OnSnapLinesChange; }) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); const [activeHandle, setActiveHandle] = useState(null); - const [snapLines, setSnapLines] = useState([]); - const snapLinesRef = useRef([]); const scaleStateRef = useRef(null); const rotationStateRef = useRef(null); - - const selectedElements = useSyncExternalStore( - (listener) => editor.selection.subscribe(listener), - () => editor.selection.getSelectedElements(), + const edgeScaleStateRef = useRef(null); + const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + null, ); - const tracks = editor.timeline.getTracks(); - const currentTime = editor.playback.getCurrentTime(); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); const currentTimeRef = useRef(currentTime); currentTimeRef.current = currentTime; - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); const elementsWithBounds = getVisibleElementsWithBounds({ tracks, @@ -145,6 +159,37 @@ export function useTransformHandles({ const hasVisualSelection = selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); + const clearActiveHandleState = useCallback(() => { + scaleStateRef.current = null; + rotationStateRef.current = null; + edgeScaleStateRef.current = null; + setActiveHandle(null); + onSnapLinesChange?.([]); + }, [onSnapLinesChange]); + + const releaseCapturedPointer = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + + if (capture.element.hasPointerCapture(capture.pointerId)) { + capture.element.releasePointerCapture(capture.pointerId); + } + + captureRef.current = null; + }, []); + + useEffect(() => { + if (!activeHandle) return; + + return registerCanceller({ + fn: () => { + editor.timeline.discardPreview(); + clearActiveHandleState(); + releaseCapturedPointer(); + }, + }); + }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]); + const handleCornerPointerDown = useCallback( ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { if (!selectedWithBounds) return; @@ -165,14 +210,19 @@ export function useTransformHandles({ }); const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / resolvedTransform.scale; - const baseHeight = bounds.height / resolvedTransform.scale; + const baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; const shouldClearScaleAnimation = - !!element.animations?.channels["transform.scale"]; + !!element.animations?.channels["transform.scaleX"] || + !!element.animations?.channels["transform.scaleY"]; const animationsWithoutScale = shouldClearScaleAnimation ? setChannel({ - animations: element.animations, - propertyPath: "transform.scale", + animations: setChannel({ + animations: element.animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", channel: undefined, }) : element.animations; @@ -190,14 +240,19 @@ export function useTransformHandles({ animationsWithoutScale, }; setActiveHandle(corner); - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; }, [selectedWithBounds], ); const handleRotationPointerDown = useCallback( ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithBounds || !canvasRef.current) return; + if (!selectedWithBounds) return; event.stopPropagation(); const { bounds, trackId, elementId, element } = selectedWithBounds; @@ -214,14 +269,14 @@ export function useTransformHandles({ localTime, }); - const position = screenToCanvas({ + const position = viewport.screenToCanvas({ clientX: event.clientX, clientY: event.clientY, - canvas: canvasRef.current, }); - const deltaX = position.x - bounds.cx; - const deltaY = position.y - bounds.cy; - const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + if (!position) return; + const deltaX = position.x - bounds.cx; + const deltaY = position.y - bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; rotationStateRef.current = { trackId, @@ -232,21 +287,91 @@ export function useTransformHandles({ initialBoundsCy: bounds.cy, }; setActiveHandle("rotation"); - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds, viewport], + ); + + const handleEdgePointerDown = useCallback( + ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + 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 baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; + const rotationRad = (bounds.rotation * Math.PI) / 180; + + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + const shouldClearScaleAnimation = + !!element.animations?.channels[propertyPath]; + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: element.animations, + propertyPath, + channel: undefined, + }) + : element.animations; + + edgeScaleStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + }; + setActiveHandle(edge); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; }, - [selectedWithBounds, canvasRef], + [selectedWithBounds], ); const handlePointerMove = useCallback( ({ event }: { event: React.PointerEvent }) => { - if (!canvasRef.current) return; - if (!scaleStateRef.current && !rotationStateRef.current) return; + if ( + !scaleStateRef.current && + !rotationStateRef.current && + !edgeScaleStateRef.current + ) + return; - const position = screenToCanvas({ + const position = viewport.screenToCanvas({ clientX: event.clientX, clientY: event.clientY, - canvas: canvasRef.current, }); + if (!position) return; if ( scaleStateRef.current && @@ -266,58 +391,157 @@ export function useTransformHandles({ animationsWithoutScale, } = scaleStateRef.current; - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 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, - initialTransform.scale * scaleFactor, - ); - - const canvasSize = editor.project.getActive().settings.canvasSize; - const snapThreshold = screenPixelsToLogicalThreshold({ - canvas: canvasRef.current, + + // Use actual element dimensions (base * current scale) so snap + // computes the correct edges when scaleX ≠ scaleY + const effectiveWidth = baseWidth * initialTransform.scaleX; + const effectiveHeight = baseHeight * initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, }); - const shouldSnap = !isShiftHeldRef.current; - const { snappedScale, activeLines } = shouldSnap - ? snapScale({ - proposedScale, + const { snappedScale: snappedFactor, activeLines } = + isShiftHeldRef.current + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: initialTransform.rotate, + canvasSize, + snapThreshold, + }); + + onSnapLinesChange?.(activeLines); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + scaleX: clampScaleNonZero( + initialTransform.scaleX * snappedFactor, + ), + scaleY: clampScaleNonZero( + initialTransform.scaleY * snappedFactor, + ), + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, + }, + ], + }); + return; + } + + if ( + edgeScaleStateRef.current && + (activeHandle === "right" || + activeHandle === "left" || + activeHandle === "bottom") + ) { + const { + trackId, + elementId, + initialTransform, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + } = edgeScaleStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const xProjection = + deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); + const yProjection = + -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); + const projection = + edge === "right" + ? xProjection + : edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + edge === "right" || edge === "left" + ? proposedScale + : initialTransform.scaleX; + const proposedScaleY = + edge === "bottom" ? proposedScale : initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = isShiftHeldRef.current + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, position: initialTransform.position, baseWidth, baseHeight, + rotation: initialTransform.rotate, canvasSize, snapThreshold, - }) - : { snappedScale: proposedScale, activeLines: [] as SnapLine[] }; + preferredEdges: getPreferredEdge({ edge }), + }); - const isSameLines = areSnapLinesEqual({ - previousLines: snapLinesRef.current, - nextLines: activeLines, - }); - - if (!isSameLines) { - snapLinesRef.current = activeLines; - setSnapLines(activeLines); - } - - const updates: { - transform: Transform; - animations?: ElementAnimations; - } = { - transform: { ...initialTransform, scale: snappedScale }, - }; - if (shouldClearScaleAnimation) { - updates.animations = animationsWithoutScale; - } + const relevantSnap = + edge === "right" || edge === "left" ? xSnap : ySnap; + onSnapLinesChange?.(relevantSnap.activeLines); editor.timeline.previewElements({ updates: [ { trackId, elementId, - updates, + updates: { + transform: { + ...initialTransform, + scaleX: + edge === "right" || edge === "left" + ? xSnap.snappedScale + : initialTransform.scaleX, + scaleY: + edge === "bottom" + ? ySnap.snappedScale + : initialTransform.scaleY, + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, }, ], }); @@ -334,17 +558,16 @@ export function useTransformHandles({ initialBoundsCy, } = rotationStateRef.current; - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(deltaY, deltaX) * 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; const newRotate = initialTransform.rotate + deltaAngle; - const shouldSnapRotation = !isShiftHeldRef.current; - const { snappedRotation } = shouldSnapRotation - ? snapRotation({ proposedRotation: newRotate }) - : { snappedRotation: newRotate }; + const { snappedRotation } = isShiftHeldRef.current + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); editor.timeline.previewElements({ updates: [ @@ -359,32 +582,36 @@ export function useTransformHandles({ }); } }, - [activeHandle, canvasRef, editor, isShiftHeldRef], + [ + activeHandle, + canvasSize, + editor, + isShiftHeldRef, + onSnapLinesChange, + viewport, + ], ); - const handlePointerUp = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (scaleStateRef.current || rotationStateRef.current) { + const handlePointerUp = useCallback(() => { + if ( + scaleStateRef.current || + rotationStateRef.current || + edgeScaleStateRef.current + ) { editor.timeline.commitPreview(); - scaleStateRef.current = null; - rotationStateRef.current = null; - setActiveHandle(null); - snapLinesRef.current = []; - setSnapLines([]); + clearActiveHandleState(); } - (event.currentTarget as HTMLElement).releasePointerCapture( - event.pointerId, - ); + releaseCapturedPointer(); }, - [editor], + [clearActiveHandleState, editor, releaseCapturedPointer], ); return { selectedWithBounds, hasVisualSelection, activeHandle, - snapLines, handleCornerPointerDown, + handleEdgePointerDown, handleRotationPointerDown, handlePointerMove, handlePointerUp, -- cgit v1.3.1