refactor: restructure files to their domains, new preview overlay system, and dep graph

This commit is contained in:
Maze Winther
2026-04-20 11:31:17 +02:00
parent 729d10592f
commit 3e89d29985
491 changed files with 3565 additions and 2372 deletions
@@ -0,0 +1,629 @@
import { useCallback, useEffect, useRef, useState } 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;
}
export function useTransformHandles({
onSnapLinesChange,
}: {
onSnapLinesChange?: OnSnapLinesChange;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const viewport = usePreviewViewport();
const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
const scaleStateRef = useRef<ScaleState | null>(null);
const rotationStateRef = useRef<RotationState | null>(null);
const edgeScaleStateRef = useRef<EdgeScaleState | null>(null);
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
null,
);
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
const tracks = useEditor(
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
);
const currentTime = useEditor((e) => e.playback.getCurrentTime());
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
const mediaAssets = useEditor((e) => e.media.getAssets());
const canvasSize = useEditor(
(e) => e.project.getActive().settings.canvasSize,
);
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
});
const selectedWithBounds: ElementWithBounds | null =
selectedElements.length === 1
? (elementsWithBounds.find(
(entry) =>
entry.trackId === selectedElements[0].trackId &&
entry.elementId === selectedElements[0].elementId,
) ?? null)
: null;
const hasVisualSelection =
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
const clearActiveHandleState = useCallback(() => {
scaleStateRef.current = null;
rotationStateRef.current = null;
edgeScaleStateRef.current = null;
setActiveHandle(null);
onSnapLinesChange?.([]);
}, [onSnapLinesChange]);
const releaseCapturedPointer = useCallback(() => {
const capture = captureRef.current;
if (!capture) return;
if (capture.element.hasPointerCapture(capture.pointerId)) {
capture.element.releasePointerCapture(capture.pointerId);
}
captureRef.current = null;
}, []);
useEffect(() => {
if (!activeHandle) return;
return registerCanceller({
fn: () => {
editor.timeline.discardPreview();
clearActiveHandleState();
releaseCapturedPointer();
},
});
}, [
activeHandle,
clearActiveHandleState,
editor.timeline,
releaseCapturedPointer,
]);
const handleCornerPointerDown = useCallback(
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
if (!selectedWithBounds) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
const localTime = getElementLocalTime({
timelineTime: currentTimeRef.current,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const initialDistance = getCornerDistance({ bounds, corner });
const baseWidth = bounds.width / resolvedTransform.scaleX;
const baseHeight = bounds.height / resolvedTransform.scaleY;
const shouldClearScaleAnimation =
hasKeyframesForPath({
animations: element.animations,
propertyPath: "transform.scaleX",
}) ||
hasKeyframesForPath({
animations: element.animations,
propertyPath: "transform.scaleY",
});
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
animations: setChannel({
animations: element.animations,
propertyPath: "transform.scaleX",
channel: undefined,
}),
propertyPath: "transform.scaleY",
channel: undefined,
})
: element.animations;
scaleStateRef.current = {
trackId,
elementId,
initialTransform: resolvedTransform,
initialDistance,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
baseWidth,
baseHeight,
shouldClearScaleAnimation,
animationsWithoutScale,
};
setActiveHandle(corner);
const captureTarget = event.currentTarget as HTMLElement;
captureTarget.setPointerCapture(event.pointerId);
captureRef.current = {
element: captureTarget,
pointerId: event.pointerId,
};
},
[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]);
return {
selectedWithBounds,
hasVisualSelection,
activeHandle,
handleCornerPointerDown,
handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
};
}