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
@@ -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,
},
},
},
]);
}
}