diff options
Diffstat (limited to 'apps/web/src/preview')
5 files changed, 1453 insertions, 1025 deletions
diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index 084c5b55..857b43e2 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -73,15 +73,13 @@ function TimecodeDisplay() { ); useEffect(() => { - const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time); - window.addEventListener("playback-update", handler); - window.addEventListener("playback-seek", handler); + const unsubscribeUpdate = editor.playback.onUpdate(setCurrentTime); + const unsubscribeSeek = editor.playback.onSeek(setCurrentTime); return () => { - window.removeEventListener("playback-update", handler); - window.removeEventListener("playback-seek", handler); + unsubscribeUpdate(); + unsubscribeSeek(); }; - }, []); + }, [editor.playback]); return ( <div className="flex items-center"> diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts new file mode 100644 index 00000000..00573662 --- /dev/null +++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts @@ -0,0 +1,583 @@ +import type { + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, +} from "react"; +import type { MediaAsset } from "@/media/types"; +import { + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/preview/element-bounds"; +import { + getHitElements, + hitTest, + resolvePreferredHit, +} from "@/preview/hit-test"; +import { + SNAP_THRESHOLD_SCREEN_PIXELS, + snapPosition, + type SnapLine, +} from "@/preview/preview-snap"; +import type { TCanvasSize } from "@/project/types"; +import type { Transform } from "@/rendering"; +import { isVisualElement } from "@/timeline/element-utils"; +import type { + ElementRef, + SceneTracks, + TextElement, + TimelineElement, + TimelineTrack, + VisualElement, +} from "@/timeline"; + +const MIN_DRAG_DISTANCE = 0.5; +const PRIMARY_POINTER_BUTTON = 0; + +type Point = { readonly x: number; readonly y: number }; + +interface CapturedPointerState { + readonly pointerId: number; + readonly captureTarget: HTMLElement; +} + +interface PendingGesture extends CapturedPointerState { + readonly kind: "pending"; + readonly origin: Point; + readonly topmostHit: ElementWithBounds | null; + readonly selectedHit: ElementWithBounds | null; + readonly selectedElements: readonly ElementRef[]; +} + +interface DragElementSnapshot { + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; +} + +interface DraggingGesture extends CapturedPointerState { + readonly kind: "dragging"; + readonly origin: Point; + readonly bounds: { + readonly width: number; + readonly height: number; + readonly rotation: number; + }; + readonly elements: readonly DragElementSnapshot[]; +} + +type GestureSession = + | { readonly kind: "idle" } + | PendingGesture + | DraggingGesture; + +const IDLE_GESTURE: GestureSession = { kind: "idle" }; + +export interface EditingTextState { + readonly trackId: string; + readonly elementId: string; + readonly element: TextElement; +} + +export interface PreviewViewportAdapter { + screenToCanvas: ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => Point | null; + screenPixelsToLogicalThreshold: ({ + screenPixels, + }: { + screenPixels: number; + }) => Point; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getTracks: () => SceneTracks; + getCurrentTime: () => number; + getMediaAssets: () => MediaAsset[]; + getCanvasSize: () => TCanvasSize; +} + +export interface SelectionApi { + getSelected: () => readonly ElementRef[]; + setSelected: (elements: readonly ElementRef[]) => void; + clearSelection: () => void; +} + +export interface TimelinePreviewUpdate { + readonly trackId: string; + readonly elementId: string; + readonly updates: Partial<TimelineElement>; +} + +export interface TimelineOps { + getElementsWithTracks: ({ + elements, + }: { + elements: readonly ElementRef[]; + }) => Array<{ track: TimelineTrack; element: TimelineElement }>; + previewElements: (updates: readonly TimelinePreviewUpdate[]) => void; + commitPreview: () => void; + discardPreview: () => void; +} + +export interface PlaybackApi { + getIsPlaying: () => boolean; + subscribe: (listener: () => void) => () => void; +} + +export interface PreviewOptions { + isMaskMode: () => boolean; + onSnapLinesChange?: (lines: SnapLine[]) => void; +} + +export interface PreviewInteractionDeps { + viewport: PreviewViewportAdapter; + input: InputAdapter; + scene: SceneReader; + selection: SelectionApi; + timeline: TimelineOps; + playback: PlaybackApi; + preview: PreviewOptions; +} + +export interface PreviewInteractionDepsRef { + readonly current: PreviewInteractionDeps; +} + +function isSameElementRef({ + left, + right, +}: { + left: ElementRef; + right: ElementRef; +}): boolean { + return left.trackId === right.trackId && left.elementId === right.elementId; +} + +function buildDragSelection({ + selectedElements, + dragTarget, +}: { + selectedElements: readonly 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 }), + ), + ]; +} + +function movedPastDragThreshold({ + current, + origin, +}: { + current: Point; + origin: Point; +}): boolean { + return ( + Math.abs(current.x - origin.x) > MIN_DRAG_DISTANCE || + Math.abs(current.y - origin.y) > MIN_DRAG_DISTANCE + ); +} + +function toDragElementSnapshots({ + elementsWithTracks, +}: { + elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>; +}): DragElementSnapshot[] { + const isVisualTrackedElement = (value: { + track: TimelineTrack; + element: TimelineElement; + }): value is { track: TimelineTrack; element: VisualElement } => + isVisualElement(value.element); + + return elementsWithTracks + .filter(isVisualTrackedElement) + .map(({ track, element }) => ({ + trackId: track.id, + elementId: element.id, + initialTransform: element.transform, + })); +} + +export class PreviewInteractionController { + private readonly depsRef: PreviewInteractionDepsRef; + private readonly subscribers = new Set<() => void>(); + + private gesture: GestureSession = IDLE_GESTURE; + private editingTextState: EditingTextState | null = null; + private wasPlaying: boolean; + private unsubscribePlayback: (() => void) | null = null; + + constructor({ depsRef }: { depsRef: PreviewInteractionDepsRef }) { + this.depsRef = depsRef; + this.wasPlaying = this.deps.playback.getIsPlaying(); + + this.onDoubleClick = this.onDoubleClick.bind(this); + this.onPointerDown = this.onPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + this.commitTextEdit = this.commitTextEdit.bind(this); + this.handlePlaybackChange = this.handlePlaybackChange.bind(this); + + this.unsubscribePlayback = this.deps.playback.subscribe( + this.handlePlaybackChange, + ); + } + + private get deps(): PreviewInteractionDeps { + return this.depsRef.current; + } + + get isDragging(): boolean { + return this.gesture.kind === "dragging"; + } + + get editingText(): EditingTextState | null { + return this.editingTextState; + } + + subscribe({ listener }: { listener: () => void }): () => void { + this.subscribers.add(listener); + return () => this.subscribers.delete(listener); + } + + destroy(): void { + this.unsubscribePlayback?.(); + this.unsubscribePlayback = null; + this.abortActiveGesture(); + this.editingTextState = null; + this.subscribers.clear(); + } + + cancel(): void { + if (this.gesture.kind === "idle") return; + this.abortActiveGesture(); + this.notify(); + } + + private abortActiveGesture(): void { + if (this.gesture.kind === "idle") return; + + if (this.gesture.kind === "dragging") { + this.deps.timeline.discardPreview(); + } + + this.releaseCapturedPointer({ pointerState: this.gesture }); + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + } + + commitTextEdit(): void { + if (!this.editingTextState) return; + + this.editingTextState = null; + this.deps.timeline.commitPreview(); + this.notify(); + } + + onDoubleClick({ clientX, clientY }: ReactMouseEvent): void { + if (this.editingTextState || this.deps.preview.isMaskMode()) return; + + const startPos = this.deps.viewport.screenToCanvas({ + clientX, + clientY, + }); + if (!startPos) return; + + const hit = hitTest({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + + if (!hit || hit.element.type !== "text") return; + + this.editingTextState = { + trackId: hit.trackId, + elementId: hit.elementId, + element: hit.element, + }; + this.notify(); + } + + onPointerDown({ + clientX, + clientY, + currentTarget, + pointerId, + button, + }: ReactPointerEvent): void { + if (this.editingTextState) return; + if (this.deps.preview.isMaskMode()) return; + if (button !== PRIMARY_POINTER_BUTTON) return; + + const startPos = this.deps.viewport.screenToCanvas({ + clientX, + clientY, + }); + if (!startPos) return; + + const hits = getHitElements({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + const selectedElements = this.deps.selection.getSelected(); + + this.gesture = { + kind: "pending", + origin: startPos, + pointerId, + captureTarget: currentTarget as HTMLElement, + topmostHit: hits[0] ?? null, + selectedHit: resolvePreferredHit({ + hits, + preferredElements: [...selectedElements], + }), + selectedElements, + }; + + currentTarget.setPointerCapture(pointerId); + } + + onPointerMove({ clientX, clientY }: ReactPointerEvent): void { + const currentPos = this.deps.viewport.screenToCanvas({ + clientX, + clientY, + }); + if (!currentPos) return; + + if (this.gesture.kind === "pending") { + const pending = this.gesture; + if ( + !movedPastDragThreshold({ + current: currentPos, + origin: pending.origin, + }) + ) { + this.clearSnapLines(); + return; + } + + this.beginDragFromPending({ pending }); + } + + if (this.gesture.kind !== "dragging") return; + + this.updateDragPreview({ + drag: this.gesture, + currentPos, + }); + } + + onPointerUp({ type }: ReactPointerEvent): void { + if (this.gesture.kind === "dragging") { + const drag = this.gesture; + + if (type === "pointercancel") { + this.deps.timeline.discardPreview(); + } else { + this.deps.timeline.commitPreview(); + } + + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: drag }); + this.notify(); + return; + } + + if (this.gesture.kind !== "pending") return; + + const pending = this.gesture; + + if (type !== "pointercancel") { + const clickTarget = pending.topmostHit; + if (!clickTarget) { + this.deps.selection.clearSelection(); + } else { + this.deps.selection.setSelected([ + { + trackId: clickTarget.trackId, + elementId: clickTarget.elementId, + }, + ]); + } + } + + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: pending }); + } + + private notify(): void { + for (const listener of this.subscribers) listener(); + } + + private clearSnapLines(): void { + this.deps.preview.onSnapLinesChange?.([]); + } + + private releaseCapturedPointer({ + pointerState, + }: { + pointerState: CapturedPointerState | null; + }): void { + if (!pointerState) return; + + if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) { + return; + } + + pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + } + + private getVisibleElementsWithBounds(): ElementWithBounds[] { + return getVisibleElementsWithBounds({ + tracks: this.deps.scene.getTracks(), + currentTime: this.deps.scene.getCurrentTime(), + canvasSize: this.deps.scene.getCanvasSize(), + mediaAssets: this.deps.scene.getMediaAssets(), + }); + } + + private handlePlaybackChange(): void { + const isPlaying = this.deps.playback.getIsPlaying(); + if (isPlaying && !this.wasPlaying && this.editingTextState) { + this.commitTextEdit(); + } + this.wasPlaying = isPlaying; + } + + private beginDragFromPending({ pending }: { pending: PendingGesture }): void { + const dragTarget = pending.selectedHit ?? pending.topmostHit; + if (!dragTarget) { + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: pending }); + return; + } + + const dragSelection = buildDragSelection({ + selectedElements: pending.selectedElements, + dragTarget, + }); + const draggableElements = toDragElementSnapshots({ + elementsWithTracks: this.deps.timeline.getElementsWithTracks({ + elements: dragSelection, + }), + }); + + if (draggableElements.length === 0) { + this.gesture = IDLE_GESTURE; + this.clearSnapLines(); + this.releaseCapturedPointer({ pointerState: pending }); + return; + } + + if (pending.selectedHit === null) { + this.deps.selection.setSelected([ + { + trackId: dragTarget.trackId, + elementId: dragTarget.elementId, + }, + ]); + } + + this.gesture = { + kind: "dragging", + origin: pending.origin, + pointerId: pending.pointerId, + captureTarget: pending.captureTarget, + bounds: { + width: dragTarget.bounds.width, + height: dragTarget.bounds.height, + rotation: dragTarget.bounds.rotation, + }, + elements: draggableElements, + }; + this.notify(); + } + + private updateDragPreview({ + drag, + currentPos, + }: { + drag: DraggingGesture; + currentPos: Point; + }): void { + const firstElement = drag.elements[0]; + if (!firstElement) return; + + const deltaX = currentPos.x - drag.origin.x; + const deltaY = currentPos.y - drag.origin.y; + + const proposedPosition = { + x: firstElement.initialTransform.position.x + deltaX, + y: firstElement.initialTransform.position.y + deltaY, + }; + + const shouldSnap = !this.deps.input.isShiftHeld(); + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedPosition, activeLines } = shouldSnap + ? snapPosition({ + proposedPosition, + canvasSize: this.deps.scene.getCanvasSize(), + elementSize: drag.bounds, + rotation: drag.bounds.rotation, + snapThreshold, + }) + : { + snappedPosition: proposedPosition, + activeLines: [] as SnapLine[], + }; + + this.deps.preview.onSnapLinesChange?.(activeLines); + + const deltaSnappedX = + snappedPosition.x - firstElement.initialTransform.position.x; + const deltaSnappedY = + snappedPosition.y - firstElement.initialTransform.position.y; + + this.deps.timeline.previewElements( + drag.elements.map(({ trackId, elementId, initialTransform }) => ({ + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + position: { + x: initialTransform.position.x + deltaSnappedX, + y: initialTransform.position.y + deltaSnappedY, + }, + }, + }, + })), + ); + } +} diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts new file mode 100644 index 00000000..e9d251e3 --- /dev/null +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -0,0 +1,753 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import type { MediaAsset } from "@/media/types"; +import { + getVisibleElementsWithBounds, + type Corner, + type Edge, + type ElementBounds, + type ElementWithBounds, +} from "@/preview/element-bounds"; +import { + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/preview/preview-snap"; +import { isVisualElement } from "@/timeline/element-utils"; +import { + getElementLocalTime, + hasKeyframesForPath, + resolveTransformAtTime, + setChannel, +} from "@/animation"; +import type { ElementAnimations } from "@/animation/types"; +import type { Transform } from "@/rendering"; +import type { + ElementRef, + SceneTracks, + TimelineElement, + VisualElement, +} from "@/timeline"; + +type Point = { readonly x: number; readonly y: number }; +type CanvasSize = { readonly width: number; readonly height: number }; +type HandleType = Corner | Edge | "rotation"; + +interface CapturedPointerState { + readonly pointerId: number; + readonly captureTarget: HTMLElement; +} + +interface CornerScaleSession extends CapturedPointerState { + readonly kind: "corner-scale"; + readonly corner: Corner; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialDistance: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface EdgeScaleSession extends CapturedPointerState { + readonly kind: "edge-scale"; + readonly edge: Edge; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly rotationRad: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface RotationSession extends CapturedPointerState { + readonly kind: "rotation"; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialAngle: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; +} + +type TransformSession = + | { readonly kind: "idle" } + | CornerScaleSession + | EdgeScaleSession + | RotationSession; + +const IDLE_SESSION: TransformSession = { kind: "idle" }; + +interface VisualSelectionContext { + readonly trackId: string; + readonly elementId: string; + readonly element: VisualElement; + readonly bounds: ElementBounds; + readonly resolvedTransform: Transform; +} + +export interface PreviewViewportAdapter { + screenToCanvas: ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => Point | null; + screenPixelsToLogicalThreshold: ({ + screenPixels, + }: { + screenPixels: number; + }) => Point; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getSelectedElements: () => readonly ElementRef[]; + getTracks: () => SceneTracks; + getCurrentTime: () => number; + getMediaAssets: () => MediaAsset[]; + getCanvasSize: () => CanvasSize; +} + +export interface TimelinePreviewUpdate { + readonly trackId: string; + readonly elementId: string; + readonly updates: Partial<TimelineElement>; +} + +export interface TimelineOps { + previewElements: (updates: readonly TimelinePreviewUpdate[]) => void; + commitPreview: () => void; + discardPreview: () => void; +} + +export interface PreviewOptions { + onSnapLinesChange?: (lines: SnapLine[]) => void; +} + +export interface TransformHandleDeps { + viewport: PreviewViewportAdapter; + input: InputAdapter; + scene: SceneReader; + timeline: TimelineOps; + preview: PreviewOptions; +} + +export interface TransformHandleDepsRef { + readonly current: TransformHandleDeps; +} + +function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; + } + return scale; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): number { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; + const localY = + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +function buildSelectedWithBounds({ + selectedElements, + elementsWithBounds, +}: { + selectedElements: readonly ElementRef[]; + elementsWithBounds: readonly ElementWithBounds[]; +}): ElementWithBounds | null { + if (selectedElements.length !== 1) return null; + + return ( + elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null + ); +} + +function buildCornerScaleAnimationReset({ + animations, +}: { + animations: ElementAnimations | undefined; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleX", + }) || + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleY", + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations: setChannel({ + animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", + channel: undefined, + }) + : animations, + }; +} + +function buildEdgeScaleAnimationReset({ + animations, + edge, +}: { + animations: ElementAnimations | undefined; + edge: Edge; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + + const shouldClearScaleAnimation = hasKeyframesForPath({ + animations, + propertyPath, + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations, + propertyPath, + channel: undefined, + }) + : animations, + }; +} + +export class TransformHandleController { + private readonly depsRef: TransformHandleDepsRef; + private readonly subscribers = new Set<() => void>(); + + private session: TransformSession = IDLE_SESSION; + + constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) { + this.depsRef = depsRef; + + this.onCornerPointerDown = this.onCornerPointerDown.bind(this); + this.onEdgePointerDown = this.onEdgePointerDown.bind(this); + this.onRotationPointerDown = this.onRotationPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + } + + private get deps(): TransformHandleDeps { + return this.depsRef.current; + } + + get selectedWithBounds(): ElementWithBounds | null { + return buildSelectedWithBounds({ + selectedElements: this.deps.scene.getSelectedElements(), + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + } + + get activeHandle(): HandleType | null { + switch (this.session.kind) { + case "corner-scale": + return this.session.corner; + case "edge-scale": + return this.session.edge; + case "rotation": + return "rotation"; + default: + return null; + } + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.session.kind !== "idle") { + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + } + + this.subscribers.clear(); + } + + cancel(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + onCornerPointerDown({ + event, + corner, + }: { + event: ReactPointerEvent; + corner: Corner; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildCornerScaleAnimationReset({ + animations: context.element.animations, + }); + + this.session = { + kind: "corner-scale", + corner, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialDistance: getCornerDistance({ + bounds: context.bounds, + corner, + }), + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onRotationPointerDown({ event }: { event: ReactPointerEvent }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + const deltaX = position.x - context.bounds.cx; + const deltaY = position.y - context.bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + + this.session = { + kind: "rotation", + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialAngle, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onEdgePointerDown({ + event, + edge, + }: { + event: ReactPointerEvent; + edge: Edge; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildEdgeScaleAnimationReset({ + animations: context.element.animations, + edge, + }); + + this.session = { + kind: "edge-scale", + edge, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + rotationRad: (context.bounds.rotation * Math.PI) / 180, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onPointerMove({ event }: { event: ReactPointerEvent }): void { + if (this.session.kind === "idle") return; + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + switch (this.session.kind) { + case "corner-scale": + this.previewCornerScale({ + session: this.session, + position, + }); + return; + case "edge-scale": + this.previewEdgeScale({ + session: this.session, + position, + }); + return; + case "rotation": + this.previewRotation({ + session: this.session, + position, + }); + return; + default: + return; + } + } + + onPointerUp(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.commitPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private clearSnapLines(): void { + this.deps.preview.onSnapLinesChange?.([]); + } + + private capturePointer({ + target, + pointerId, + }: { + target: HTMLElement; + pointerId: number; + }): HTMLElement { + target.setPointerCapture(pointerId); + return target; + } + + private releaseCapturedPointer(pointerState: CapturedPointerState): void { + if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) { + return; + } + + pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + } + + private getVisibleElementsWithBounds(): ElementWithBounds[] { + return getVisibleElementsWithBounds({ + tracks: this.deps.scene.getTracks(), + currentTime: this.deps.scene.getCurrentTime(), + canvasSize: this.deps.scene.getCanvasSize(), + mediaAssets: this.deps.scene.getMediaAssets(), + }); + } + + private getSelectedVisualContext(): VisualSelectionContext | null { + const selectedWithBounds = this.selectedWithBounds; + if (!selectedWithBounds) return null; + if (!isVisualElement(selectedWithBounds.element)) return null; + + const localTime = getElementLocalTime({ + timelineTime: this.deps.scene.getCurrentTime(), + elementStartTime: selectedWithBounds.element.startTime, + elementDuration: selectedWithBounds.element.duration, + }); + + return { + trackId: selectedWithBounds.trackId, + elementId: selectedWithBounds.elementId, + element: selectedWithBounds.element, + bounds: selectedWithBounds.bounds, + resolvedTransform: resolveTransformAtTime({ + baseTransform: selectedWithBounds.element.transform, + animations: selectedWithBounds.element.animations, + localTime, + }), + }; + } + + private previewCornerScale({ + session, + position, + }: { + session: CornerScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const scaleFactor = currentDistance / session.initialDistance; + + // Use actual element dimensions (base * current scale) so snapping is + // computed from the rendered geometry when scaleX != scaleY. + const effectiveWidth = session.baseWidth * session.initialTransform.scaleX; + const effectiveHeight = + session.baseHeight * session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedScale, activeLines } = this.deps.input.isShiftHeld() + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: session.initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + }); + + this.deps.preview.onSnapLinesChange?.(activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: clampScaleNonZero( + session.initialTransform.scaleX * snappedScale, + ), + scaleY: clampScaleNonZero( + session.initialTransform.scaleY * snappedScale, + ), + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewEdgeScale({ + session, + position, + }: { + session: EdgeScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const xProjection = + deltaX * Math.cos(session.rotationRad) + + deltaY * Math.sin(session.rotationRad); + const yProjection = + -deltaX * Math.sin(session.rotationRad) + + deltaY * Math.cos(session.rotationRad); + const projection = + session.edge === "right" + ? xProjection + : session.edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + session.edge === "right" || session.edge === "left" + ? session.baseWidth / 2 + : session.baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + session.edge === "right" || session.edge === "left" + ? proposedScale + : session.initialTransform.scaleX; + const proposedScaleY = + session.edge === "bottom" + ? proposedScale + : session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld() + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position: session.initialTransform.position, + baseWidth: session.baseWidth, + baseHeight: session.baseHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + preferredEdges: getPreferredEdge({ edge: session.edge }), + }); + + const relevantSnap = + session.edge === "right" || session.edge === "left" ? xSnap : ySnap; + this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: + session.edge === "right" || session.edge === "left" + ? xSnap.snappedScale + : session.initialTransform.scaleX, + scaleY: + session.edge === "bottom" + ? ySnap.snappedScale + : session.initialTransform.scaleY, + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewRotation({ + session, + position, + }: { + session: RotationSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + let deltaAngle = currentAngle - session.initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + const newRotate = session.initialTransform.rotate + deltaAngle; + const { snappedRotation } = this.deps.input.isShiftHeld() + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + rotate: snappedRotation, + }, + }, + }, + ]); + } +} diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts index 78212e74..d093dcf2 100644 --- a/apps/web/src/preview/hooks/use-preview-interaction.ts +++ b/apps/web/src/preview/hooks/use-preview-interaction.ts @@ -1,97 +1,17 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; -import type { Transform } from "@/rendering"; -import type { ElementRef, TextElement } from "@/timeline"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/preview/element-bounds"; -import { - getHitElements, - hitTest, - resolvePreferredHit, -} from "@/preview/hit-test"; -import { isVisualElement } from "@/timeline/element-utils"; -import { - SNAP_THRESHOLD_SCREEN_PIXELS, - snapPosition, - type SnapLine, -} from "@/preview/preview-snap"; +import type { SnapLine } from "@/preview/preview-snap"; import { registerCanceller } from "@/editor/cancel-interaction"; +import { + PreviewInteractionController, + type PreviewInteractionDeps, + type PreviewInteractionDepsRef, +} from "@/preview/controllers/preview-interaction-controller"; export type OnSnapLinesChange = (lines: SnapLine[]) => void; -const MIN_DRAG_DISTANCE = 0.5; - -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; - elementId: string; - initialTransform: Transform; - }>; -} - -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({ onSnapLinesChange, isMaskMode = false, @@ -102,355 +22,74 @@ export function usePreviewInteraction({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const viewport = usePreviewViewport(); - const [isDragging, setIsDragging] = useState(false); - const [editingText, setEditingText] = useState<{ - trackId: string; - elementId: string; - element: TextElement; - originalOpacity: number; - } | null>(null); - const dragStateRef = useRef<DragState | null>(null); - const pendingGestureRef = useRef<PendingGestureState | null>(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 deps: PreviewInteractionDeps = { + viewport: { + screenToCanvas: viewport.screenToCanvas, + screenPixelsToLogicalThreshold: viewport.screenPixelsToLogicalThreshold, }, - [], - ); - - const commitTextEdit = useCallback(() => { - const current = editingTextRef.current; - if (!current) return; - editingTextRef.current = null; - editor.timeline.commitPreview(); - setEditingText(null); - }, [editor.timeline]); - - useEffect(() => { - const unsubscribe = editor.playback.subscribe(() => { - const isPlaying = editor.playback.getIsPlaying(); - if (isPlaying && !wasPlayingRef.current && editingTextRef.current) { - commitTextEdit(); - } - wasPlayingRef.current = isPlaying; - }); - return unsubscribe; - }, [editor.playback, commitTextEdit]); - - 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 (editingText || isMaskMode) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hit = hitTest({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - - if (!hit || hit.element.type !== "text") return; - - const textElement = hit.element as TextElement; - setEditingText({ - trackId: hit.trackId, - elementId: hit.elementId, - element: textElement, - originalOpacity: textElement.opacity, - }); + input: { + isShiftHeld: () => isShiftHeldRef.current, }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerDown = useCallback( - ({ - clientX, - clientY, - currentTarget, - pointerId, - button, - }: React.PointerEvent) => { - if (editingText) return; - if (isMaskMode) return; - if (button !== 0) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hits = getHitElements({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - const selectedElements = editor.selection.getSelectedElements(); - const topmostHit = hits[0] ?? null; - - pendingGestureRef.current = { - startX: startPos.x, - startY: startPos.y, - pointerId, - captureTarget: currentTarget as HTMLElement, - topmostHit, - selectedHit: resolvePreferredHit({ - hits, - preferredElements: selectedElements, - }), - selectedElements, - }; - currentTarget.setPointerCapture(pointerId); + scene: { + getTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + getCanvasSize: () => editor.project.getActive().settings.canvasSize, }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerMove = useCallback( - ({ clientX, clientY }: React.PointerEvent) => { - const canvasSize = editor.project.getActive().settings.canvasSize; - - const currentPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - 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, - }, - ], - }); - } - - 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 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 = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedPosition, activeLines } = shouldSnap - ? snapPosition({ - proposedPosition, - canvasSize, - elementSize: dragState.bounds, - rotation: dragState.bounds.rotation, - snapThreshold, - }) - : { - snappedPosition: proposedPosition, - activeLines: [] as SnapLine[], - }; - - onSnapLinesChange?.(activeLines); - - const deltaSnappedX = - snappedPosition.x - firstElement.initialTransform.position.x; - const deltaSnappedY = - snappedPosition.y - firstElement.initialTransform.position.y; - - const updates = dragState.elements.map( - ({ trackId, elementId, initialTransform }) => ({ - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - position: { - x: initialTransform.position.x + deltaSnappedX, - y: initialTransform.position.y + deltaSnappedY, - }, - }, - }, - }), - ); - - editor.timeline.previewElements({ updates }); + selection: { + getSelected: () => editor.selection.getSelectedElements(), + setSelected: (elements) => + editor.selection.setSelectedElements({ elements: [...elements] }), + clearSelection: () => editor.selection.clearSelection(), }, - [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport], - ); + timeline: { + getElementsWithTracks: ({ elements }) => + editor.timeline.getElementsWithTracks({ elements: [...elements] }), + previewElements: (updates) => + editor.timeline.previewElements({ updates: [...updates] }), + commitPreview: () => editor.timeline.commitPreview(), + discardPreview: () => editor.timeline.discardPreview(), + }, + playback: { + getIsPlaying: () => editor.playback.getIsPlaying(), + subscribe: (listener) => editor.playback.subscribe(listener), + }, + preview: { + isMaskMode: () => isMaskMode, + onSnapLinesChange, + }, + }; - const handlePointerUp = useCallback( - ({ type }: React.PointerEvent) => { - const dragState = dragStateRef.current; - if (dragState) { - if (type === "pointercancel") { - editor.timeline.discardPreview(); - } else { - editor.timeline.commitPreview(); - } + const depsRef = useRef<PreviewInteractionDeps>(deps); + depsRef.current = deps; - dragStateRef.current = null; - pendingGestureRef.current = null; - setIsDragging(false); - onSnapLinesChange?.([]); - releaseCapturedPointer(dragState); - return; - } + const controllerRef = useRef<PreviewInteractionController | null>(null); + if (!controllerRef.current) { + controllerRef.current = new PreviewInteractionController({ + depsRef: depsRef as PreviewInteractionDepsRef, + }); + } + const controller = controllerRef.current; - const pendingGesture = pendingGestureRef.current; - if (!pendingGesture) return; + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect( + () => controller.subscribe({ listener: rerender }), + [controller], + ); - if (type !== "pointercancel") { - const clickTarget = pendingGesture.topmostHit; - if (!clickTarget) { - editor.selection.clearSelection(); - } else { - editor.selection.setSelectedElements({ - elements: [ - { - trackId: clickTarget.trackId, - elementId: clickTarget.elementId, - }, - ], - }); - } - } + useEffect(() => { + if (!controller.isDragging) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isDragging, controller]); - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - }, - [editor, onSnapLinesChange, releaseCapturedPointer], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - onPointerDown: handlePointerDown, - onPointerMove: handlePointerMove, - onPointerUp: handlePointerUp, - onDoubleClick: handleDoubleClick, - editingText, - commitTextEdit, + onPointerDown: controller.onPointerDown, + onPointerMove: controller.onPointerMove, + onPointerUp: controller.onPointerUp, + onDoubleClick: controller.onDoubleClick, + editingText: controller.editingText, + commitTextEdit: controller.commitTextEdit, }; } diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts index 030e026c..f4b7e306 100644 --- a/apps/web/src/preview/hooks/use-transform-handles.ts +++ b/apps/web/src/preview/hooks/use-transform-handles.ts @@ -1,629 +1,84 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/preview/element-bounds"; -import { - MIN_SCALE, - SNAP_THRESHOLD_SCREEN_PIXELS, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/preview/preview-snap"; -import { isVisualElement } from "@/timeline/element-utils"; -import { - getElementLocalTime, - hasKeyframesForPath, - resolveTransformAtTime, - setChannel, -} from "@/animation"; -import type { Transform } from "@/rendering"; -import type { ElementAnimations } from "@/animation/types"; import { registerCanceller } from "@/editor/cancel-interaction"; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -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; - elementId: string; - initialTransform: Transform; - initialDistance: number; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -interface RotationState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialAngle: number; - initialBoundsCx: number; - initialBoundsCy: number; -} - -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 scale; -} - -function getCornerDistance({ - bounds, - corner, -}: { - bounds: { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; - }; - corner: Corner; -}): number { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; - const localY = - corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; - - const rotatedX = localX * cos - localY * sin; - const rotatedY = localX * sin + localY * cos; - return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; -} +import { + TransformHandleController, + type TransformHandleDeps, +} from "@/preview/controllers/transform-handle-controller"; export function useTransformHandles({ onSnapLinesChange, }: { onSnapLinesChange?: OnSnapLinesChange; }) { + const viewport = usePreviewViewport(); const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandle, setActiveHandle] = useState<HandleType | null>(null); - const scaleStateRef = useRef<ScaleState | null>(null); - const rotationStateRef = useRef<RotationState | null>(null); - const edgeScaleStateRef = useRef<EdgeScaleState | null>(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); const tracks = useEditor( (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); - const currentTimeRef = useRef(currentTime); - currentTimeRef.current = currentTime; const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( (e) => e.project.getActive().settings.canvasSize, ); - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const selectedWithBounds: ElementWithBounds | null = - selectedElements.length === 1 - ? (elementsWithBounds.find( - (entry) => - entry.trackId === selectedElements[0].trackId && - entry.elementId === selectedElements[0].elementId, - ) ?? null) - : null; - - const hasVisualSelection = - selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); - - const 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; - 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 initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const shouldClearScaleAnimation = - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "transform.scaleX", - }) || - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "transform.scaleY", - }); - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: setChannel({ - animations: element.animations, - propertyPath: "transform.scaleX", - channel: undefined, - }), - propertyPath: "transform.scaleY", - channel: undefined, - }) - : element.animations; - - scaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialDistance, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(corner); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; + const deps: TransformHandleDeps = { + viewport, + input: { + isShiftHeld: () => isShiftHeldRef.current, }, - [selectedWithBounds], - ); - - const handleRotationPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - 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 position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - 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, - elementId, - initialTransform: resolvedTransform, - initialAngle, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - }; - setActiveHandle("rotation"); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; + scene: { + getSelectedElements: () => selectedElements, + getTracks: () => tracks, + getCurrentTime: () => currentTime, + getMediaAssets: () => mediaAssets, + getCanvasSize: () => canvasSize, }, - [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 = hasKeyframesForPath({ - animations: element.animations, - 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, - }; + timeline: { + previewElements: (updates) => + editor.timeline.previewElements({ updates }), + commitPreview: () => editor.timeline.commitPreview(), + discardPreview: () => editor.timeline.discardPreview(), }, - [selectedWithBounds], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if ( - !scaleStateRef.current && - !rotationStateRef.current && - !edgeScaleStateRef.current - ) - return; - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - - if ( - scaleStateRef.current && - activeHandle && - activeHandle !== "rotation" - ) { - const { - trackId, - elementId, - initialTransform, - initialDistance, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - } = scaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentDistance = - Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; - const scaleFactor = currentDistance / initialDistance; - - // 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 { 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, - preferredEdges: getPreferredEdge({ edge }), - }); + preview: { + onSnapLinesChange, + }, + }; - const relevantSnap = - edge === "right" || edge === "left" ? xSnap : ySnap; - onSnapLinesChange?.(relevantSnap.activeLines); + const depsRef = useRef<TransformHandleDeps>(deps); + depsRef.current = deps; - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: - edge === "right" || edge === "left" - ? xSnap.snappedScale - : initialTransform.scaleX, - scaleY: - edge === "bottom" - ? ySnap.snappedScale - : initialTransform.scaleY, - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } + const controllerRef = useRef<TransformHandleController | null>(null); + if (!controllerRef.current) { + controllerRef.current = new TransformHandleController({ depsRef }); + } + const controller = controllerRef.current; - if (rotationStateRef.current && activeHandle === "rotation") { - const { - trackId, - elementId, - initialTransform, - initialAngle, - initialBoundsCx, - initialBoundsCy, - } = rotationStateRef.current; + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); - 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 { snappedRotation } = isShiftHeldRef.current - ? { snappedRotation: newRotate } - : snapRotation({ proposedRotation: newRotate }); + useEffect(() => { + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller, controller.isActive]); - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { ...initialTransform, rotate: snappedRotation }, - }, - }, - ], - }); - } - }, - [ - activeHandle, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); + useEffect(() => () => controller.destroy(), [controller]); - const handlePointerUp = useCallback(() => { - if ( - scaleStateRef.current || - rotationStateRef.current || - edgeScaleStateRef.current - ) { - editor.timeline.commitPreview(); - clearActiveHandleState(); - } - releaseCapturedPointer(); - }, [clearActiveHandleState, editor, releaseCapturedPointer]); + const selectedWithBounds = controller.selectedWithBounds; + const hasVisualSelection = selectedWithBounds !== null; return { selectedWithBounds, hasVisualSelection, - activeHandle, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, + activeHandle: controller.activeHandle, + handleCornerPointerDown: controller.onCornerPointerDown, + handleEdgePointerDown: controller.onEdgePointerDown, + handleRotationPointerDown: controller.onRotationPointerDown, + handlePointerMove: controller.onPointerMove, + handlePointerUp: controller.onPointerUp, }; } |
