diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-26 17:31:28 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-26 17:31:28 +0200 |
| commit | 56ca09692a308b22455420d11376ff8d41244c47 (patch) | |
| tree | 4eca5ae032451c5db954801008aa9f164b2e2b1d /apps/web/src/preview/controllers | |
| parent | e6c6193638e4247ff1e6f2df40de2a057112c773 (diff) | |
| download | opencut-56ca09692a308b22455420d11376ff8d41244c47.tar.gz | |
refactor: thinner hooks
Diffstat (limited to 'apps/web/src/preview/controllers')
| -rw-r--r-- | apps/web/src/preview/controllers/preview-interaction-controller.ts | 583 | ||||
| -rw-r--r-- | apps/web/src/preview/controllers/transform-handle-controller.ts | 753 |
2 files changed, 1336 insertions, 0 deletions
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, + }, + }, + }, + ]); + } +} |
