refactor: thinner hooks

This commit is contained in:
Maze Winther
2026-04-26 17:31:28 +02:00
parent e6c6193638
commit 56ca09692a
48 changed files with 5355 additions and 4264 deletions
+5 -7
View File
@@ -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">
@@ -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,
},
},
},
})),
);
}
}
@@ -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,
},
},
},
]);
}
}
@@ -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,
},
[],
);
input: {
isShiftHeld: () => isShiftHeldRef.current,
},
scene: {
getTracks: () => editor.scenes.getActiveScene().tracks,
getCurrentTime: () => editor.playback.getCurrentTime(),
getMediaAssets: () => editor.media.getAssets(),
getCanvasSize: () => editor.project.getActive().settings.canvasSize,
},
selection: {
getSelected: () => editor.selection.getSelectedElements(),
setSelected: (elements) =>
editor.selection.setSelectedElements({ elements: [...elements] }),
clearSelection: () => editor.selection.clearSelection(),
},
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 commitTextEdit = useCallback(() => {
const current = editingTextRef.current;
if (!current) return;
editingTextRef.current = null;
editor.timeline.commitPreview();
setEditingText(null);
}, [editor.timeline]);
const depsRef = useRef<PreviewInteractionDeps>(deps);
depsRef.current = deps;
const controllerRef = useRef<PreviewInteractionController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new PreviewInteractionController({
depsRef: depsRef as PreviewInteractionDepsRef,
});
}
const controller = controllerRef.current;
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(
() => controller.subscribe({ listener: rerender }),
[controller],
);
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]);
if (!controller.isDragging) return;
return registerCanceller({ fn: () => controller.cancel() });
}, [controller.isDragging, controller]);
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,
});
},
[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);
},
[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 });
},
[editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
);
const handlePointerUp = useCallback(
({ type }: React.PointerEvent) => {
const dragState = dragStateRef.current;
if (dragState) {
if (type === "pointercancel") {
editor.timeline.discardPreview();
} else {
editor.timeline.commitPreview();
}
dragStateRef.current = null;
pendingGestureRef.current = null;
setIsDragging(false);
onSnapLinesChange?.([]);
releaseCapturedPointer(dragState);
return;
}
const pendingGesture = pendingGestureRef.current;
if (!pendingGesture) return;
if (type !== "pointercancel") {
const clickTarget = pendingGesture.topmostHit;
if (!clickTarget) {
editor.selection.clearSelection();
} else {
editor.selection.setSelectedElements({
elements: [
{
trackId: clickTarget.trackId,
elementId: clickTarget.elementId,
},
],
});
}
}
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,
};
}
@@ -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 deps: TransformHandleDeps = {
viewport,
input: {
isShiftHeld: () => isShiftHeldRef.current,
},
scene: {
getSelectedElements: () => selectedElements,
getTracks: () => tracks,
getCurrentTime: () => currentTime,
getMediaAssets: () => mediaAssets,
getCanvasSize: () => canvasSize,
},
timeline: {
previewElements: (updates) =>
editor.timeline.previewElements({ updates }),
commitPreview: () => editor.timeline.commitPreview(),
discardPreview: () => editor.timeline.discardPreview(),
},
preview: {
onSnapLinesChange,
},
};
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
});
const depsRef = useRef<TransformHandleDeps>(deps);
depsRef.current = deps;
const selectedWithBounds: ElementWithBounds | null =
selectedElements.length === 1
? (elementsWithBounds.find(
(entry) =>
entry.trackId === selectedElements[0].trackId &&
entry.elementId === selectedElements[0].elementId,
) ?? null)
: null;
const controllerRef = useRef<TransformHandleController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new TransformHandleController({ depsRef });
}
const controller = controllerRef.current;
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;
}, []);
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => controller.subscribe(rerender), [controller]);
useEffect(() => {
if (!activeHandle) return;
if (!controller.isActive) return;
return registerCanceller({ fn: () => controller.cancel() });
}, [controller, controller.isActive]);
return registerCanceller({
fn: () => {
editor.timeline.discardPreview();
clearActiveHandleState();
releaseCapturedPointer();
},
});
}, [
activeHandle,
clearActiveHandleState,
editor.timeline,
releaseCapturedPointer,
]);
useEffect(() => () => controller.destroy(), [controller]);
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,
};
},
[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,
};
},
[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,
};
},
[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 }),
});
const relevantSnap =
edge === "right" || edge === "left" ? xSnap : ySnap;
onSnapLinesChange?.(relevantSnap.activeLines);
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;
}
if (rotationStateRef.current && activeHandle === "rotation") {
const {
trackId,
elementId,
initialTransform,
initialAngle,
initialBoundsCx,
initialBoundsCy,
} = rotationStateRef.current;
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 });
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
transform: { ...initialTransform, rotate: snappedRotation },
},
},
],
});
}
},
[
activeHandle,
canvasSize,
editor,
isShiftHeldRef,
onSnapLinesChange,
viewport,
],
);
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,
};
}