refactor: centralize editor interaction state

Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events.

This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync.

Made-with: Cursor
This commit is contained in:
Maze Winther
2026-04-25 03:56:22 +02:00
parent 0dffefdd59
commit 6a22a3ecbd
38 changed files with 5692 additions and 4773 deletions
+10 -15
View File
@@ -8,12 +8,10 @@ import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
GridTableIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { getGuideById } from "@/guides";
import { Separator } from "@/components/ui/separator";
import {
Select,
@@ -24,17 +22,12 @@ import {
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/preview/preview-store";
export function PreviewToolbar({
onToggleFullscreen,
}: {
onToggleFullscreen: () => void;
}) {
const activeGuide = usePreviewStore((state) => state.activeGuide);
const activeGuideDefinition = getGuideById(activeGuide);
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<TimecodeDisplay />
@@ -72,15 +65,13 @@ function TimecodeDisplay() {
);
useEffect(() => {
const handler = (e: Event) =>
setCurrentTime((e as CustomEvent<{ time: number }>).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">
@@ -94,7 +85,11 @@ function TimecodeDisplay() {
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
{formatTimecode({
time: totalDuration,
format: "HH:MM:SS:FF",
rate: fps,
})}
</span>
</div>
);
@@ -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,
};
}