summaryrefslogtreecommitdiff
path: root/apps/web/src/preview
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/preview')
-rw-r--r--apps/web/src/preview/components/context-menu.tsx85
-rw-r--r--apps/web/src/preview/components/cursors.ts21
-rw-r--r--apps/web/src/preview/components/guide-popover.tsx100
-rw-r--r--apps/web/src/preview/components/handle-primitives.tsx427
-rw-r--r--apps/web/src/preview/components/index.tsx328
-rw-r--r--apps/web/src/preview/components/mask-handles.tsx308
-rw-r--r--apps/web/src/preview/components/overlay-layer.tsx197
-rw-r--r--apps/web/src/preview/components/preview-interaction-overlay.tsx103
-rw-r--r--apps/web/src/preview/components/preview-viewport.tsx576
-rw-r--r--apps/web/src/preview/components/snap-guides.tsx47
-rw-r--r--apps/web/src/preview/components/text-edit-overlay.tsx158
-rw-r--r--apps/web/src/preview/components/toolbar.tsx148
-rw-r--r--apps/web/src/preview/components/transform-handles.tsx145
-rw-r--r--apps/web/src/preview/element-bounds.ts308
-rw-r--r--apps/web/src/preview/hit-test.ts102
-rw-r--r--apps/web/src/preview/hooks/use-preview-interaction.ts456
-rw-r--r--apps/web/src/preview/hooks/use-transform-handles.ts629
-rw-r--r--apps/web/src/preview/overlays.ts108
-rw-r--r--apps/web/src/preview/preview-coords.ts101
-rw-r--r--apps/web/src/preview/preview-snap.ts539
-rw-r--r--apps/web/src/preview/preview-store.ts104
-rw-r--r--apps/web/src/preview/zoom.ts7
22 files changed, 4997 insertions, 0 deletions
diff --git a/apps/web/src/preview/components/context-menu.tsx b/apps/web/src/preview/components/context-menu.tsx
new file mode 100644
index 00000000..fca55d71
--- /dev/null
+++ b/apps/web/src/preview/components/context-menu.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import {
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+} from "@/components/ui/context-menu";
+import { usePreviewViewport } from "@/preview/components/preview-viewport";
+import { useEditor } from "@/editor/use-editor";
+import type { PreviewOverlayControl } from "@/preview/overlays";
+import { toast } from "sonner";
+
+export function PreviewContextMenu({
+ onToggleFullscreen,
+ containerRef,
+ overlayControls,
+ onOverlayVisibilityChange,
+}: {
+ onToggleFullscreen: () => void;
+ containerRef: React.RefObject<HTMLElement | null>;
+ overlayControls: PreviewOverlayControl[];
+ onOverlayVisibilityChange: (params: {
+ overlayId: string;
+ isVisible: boolean;
+ }) => void;
+}) {
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+
+ const handleCopySnapshot = async () => {
+ const result = await editor.renderer.copySnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to copy snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
+ const handleSaveSnapshot = async () => {
+ const result = await editor.renderer.saveSnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to save snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
+ return (
+ <ContextMenuContent className="w-56" container={containerRef.current}>
+ <ContextMenuItem onClick={viewport.fitToScreen} inset>
+ Fit to screen
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuItem onClick={onToggleFullscreen} inset>
+ Full screen
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handleSaveSnapshot} inset>
+ Save snapshot
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handleCopySnapshot} inset>
+ Copy snapshot
+ </ContextMenuItem>
+ {overlayControls.length > 0 ? <ContextMenuSeparator /> : null}
+ {overlayControls.map((overlayControl) => (
+ <ContextMenuCheckboxItem
+ key={overlayControl.id}
+ checked={overlayControl.isVisible}
+ onCheckedChange={(checked) =>
+ onOverlayVisibilityChange({
+ overlayId: overlayControl.id,
+ isVisible: !!checked,
+ })
+ }
+ >
+ {overlayControl.label}
+ </ContextMenuCheckboxItem>
+ ))}
+ </ContextMenuContent>
+ );
+}
diff --git a/apps/web/src/preview/components/cursors.ts b/apps/web/src/preview/components/cursors.ts
new file mode 100644
index 00000000..f04dbef0
--- /dev/null
+++ b/apps/web/src/preview/components/cursors.ts
@@ -0,0 +1,21 @@
+function svgCursor({
+ svg,
+ hotspotX,
+ hotspotY,
+}: {
+ svg: string;
+ hotspotX: number;
+ hotspotY: number;
+}): string {
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${hotspotX} ${hotspotY}, crosshair`;
+}
+
+/** Hotspot is at the nib tip, which is where anchor points land. */
+export const PEN_CURSOR = svgCursor({
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
+ <path d="M 1 1 L 5 2 L 13 10 L 10 13 L 2 5 Z" fill="white" stroke="#111" stroke-width="1" stroke-linejoin="round"/>
+ <path d="M 1 1 L 5 2 L 2 5 Z" fill="#111"/>
+</svg>`,
+ hotspotX: 1,
+ hotspotY: 1,
+});
diff --git a/apps/web/src/preview/components/guide-popover.tsx b/apps/web/src/preview/components/guide-popover.tsx
new file mode 100644
index 00000000..5bfa92ce
--- /dev/null
+++ b/apps/web/src/preview/components/guide-popover.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { AnimatePresence, motion } from "motion/react";
+import { GUIDE_REGISTRY, getGuideById } from "@/guides";
+import { usePreviewStore } from "@/preview/preview-store";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/utils/ui";
+
+export function GridPopover({ children }: { children: React.ReactNode }) {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const toggleGuide = usePreviewStore((state) => state.toggleGuide);
+ const activeGuideDef = getGuideById(activeGuide);
+ const options = activeGuideDef?.renderOptions?.();
+
+ return (
+ <Popover>
+ <PopoverTrigger>{children}</PopoverTrigger>
+ <PopoverContent sideOffset={8} className="w-60 px-0">
+ <div className="flex flex-col gap-2 px-4">
+ <Label>Guides</Label>
+ <div className="grid grid-cols-3 gap-1">
+ {GUIDE_REGISTRY.map((guide) => (
+ <GridItem
+ key={guide.id}
+ label={guide.label}
+ preview={guide.renderPreview()}
+ isSelected={activeGuide === guide.id}
+ onClick={() => toggleGuide(guide.id)}
+ />
+ ))}
+ </div>
+ </div>
+ <AnimatePresence>
+ {options && (
+ <motion.div
+ initial={{ height: 0, opacity: 0 }}
+ animate={{ height: "auto", opacity: 1 }}
+ exit={{ height: 0, opacity: 0 }}
+ transition={{
+ height: { type: "spring", duration: 0.2, bounce: 0 },
+ opacity: { duration: 0.2 },
+ }}
+ className="overflow-hidden"
+ >
+ <Separator className="my-3" />
+ <div className="px-4">{options}</div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function GridItem({
+ label,
+ preview,
+ isSelected,
+ onClick,
+}: {
+ label: string;
+ preview: React.ReactNode;
+ isSelected?: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ aria-pressed={isSelected}
+ className={cn(
+ "flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5",
+ isSelected && "bg-primary/5! text-primary",
+ )}
+ >
+ <div
+ className={cn(
+ "aspect-video bg-foreground/5 flex items-center justify-center rounded-sm",
+ isSelected && "bg-primary/5!",
+ )}
+ >
+ {preview}
+ </div>
+ <span
+ className={cn(
+ "text-xs",
+ isSelected ? "text-primary" : "text-muted-foreground",
+ )}
+ >
+ {label}
+ </span>
+ </button>
+ );
+}
diff --git a/apps/web/src/preview/components/handle-primitives.tsx b/apps/web/src/preview/components/handle-primitives.tsx
new file mode 100644
index 00000000..33baf9fc
--- /dev/null
+++ b/apps/web/src/preview/components/handle-primitives.tsx
@@ -0,0 +1,427 @@
+"use client";
+
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { IconSvgElement } from "@hugeicons/react";
+
+export const HANDLE_SIZE = 10;
+export const HANDLE_HIT_AREA_SIZE = 18;
+export const ICON_HANDLE_RADIUS = 10;
+export const EDGE_HANDLE_THIN_SIZE = 6;
+export const EDGE_HANDLE_THICK_SIZE = 14;
+export const LINE_HIT_AREA_SIZE = 48;
+
+export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
+ const normalized = ((angleDeg % 180) + 180) % 180;
+ if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
+ if (normalized < 67.5) return "nwse-resize";
+ if (normalized < 112.5) return "ns-resize";
+ return "nesw-resize";
+}
+
+export function HandleButton({
+ screen,
+ cursor,
+ hitAreaSize,
+ className,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ children,
+}: {
+ screen: { x: number; y: number };
+ cursor?: string;
+ hitAreaSize: number;
+ className?: string;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+ children: React.ReactNode;
+}) {
+ return (
+ <button
+ type="button"
+ className={cn(
+ "absolute flex items-center justify-center outline-none",
+ className,
+ )}
+ style={{
+ left: screen.x - hitAreaSize / 2,
+ top: screen.y - hitAreaSize / 2,
+ width: hitAreaSize,
+ height: hitAreaSize,
+ pointerEvents: "auto",
+ cursor,
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ {children}
+ </button>
+ );
+}
+
+export function CornerHandle({
+ cursor,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ cursor?: string;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
+ />
+ </HandleButton>
+ );
+}
+
+export function CircleHandle({
+ cursor,
+ screen,
+ size = HANDLE_SIZE,
+ isSelected = false,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ cursor?: string;
+ screen: { x: number; y: number };
+ size?: number;
+ isSelected?: boolean;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
+ style={{ width: size, height: size }}
+ />
+ </HandleButton>
+ );
+}
+
+export function EdgeHandle({
+ edge,
+ screen,
+ rotation,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ edge: "right" | "left" | "bottom";
+ screen: { x: number; y: number };
+ rotation: number;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ const isHorizontalEdge = edge === "right" || edge === "left";
+ const width = isHorizontalEdge
+ ? EDGE_HANDLE_THIN_SIZE
+ : EDGE_HANDLE_THICK_SIZE;
+ const height = isHorizontalEdge
+ ? EDGE_HANDLE_THICK_SIZE
+ : EDGE_HANDLE_THIN_SIZE;
+ const cursor = getResizeCursor({
+ angleDeg: isHorizontalEdge ? rotation : rotation + 90,
+ });
+
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width, height, transform: `rotate(${rotation}deg)` }}
+ />
+ </HandleButton>
+ );
+}
+
+export function IconHandle({
+ icon,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ icon: IconSvgElement;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ hitAreaSize={ICON_HANDLE_RADIUS * 2}
+ className="rounded-full bg-white text-black shadow-sm"
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
+ </HandleButton>
+ );
+}
+
+export function BoundingBoxOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ cursor,
+ dashed = false,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ cursor?: string;
+ dashed?: boolean;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className="absolute overflow-visible"
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ cursor,
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <rect
+ x={0.5}
+ y={0.5}
+ width={Math.max(outlineWidth - 1, 0)}
+ height={Math.max(outlineHeight - 1, 0)}
+ fill="transparent"
+ stroke="white"
+ strokeDasharray={dashed ? "4 4" : undefined}
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function ShapeOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ pathData,
+ cursor,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ pathData: string;
+ cursor?: string;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className="absolute overflow-visible"
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ cursor,
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <path
+ d={pathData}
+ fill="transparent"
+ stroke="white"
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function CanvasPathOutline({
+ pathData,
+ translateX = 0,
+ translateY = 0,
+ scaleX = 1,
+ scaleY = 1,
+ cursor,
+ strokeWidth = 1,
+ strokeOpacity = 0.75,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ pathData: string;
+ translateX?: number;
+ translateY?: number;
+ scaleX?: number;
+ scaleY?: number;
+ cursor?: string;
+ strokeWidth?: number;
+ strokeOpacity?: number;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className="absolute inset-0 overflow-visible"
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ pointerEvents: onPointerDown ? "auto" : "none",
+ cursor,
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <g
+ transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`}
+ >
+ <path
+ d={pathData}
+ fill="transparent"
+ stroke="white"
+ strokeWidth={strokeWidth}
+ strokeOpacity={strokeOpacity}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "stroke" : "none" }}
+ />
+ </g>
+ </svg>
+ );
+}
+
+export function LineOverlay({
+ start,
+ end,
+ cursor,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+ cursor?: string;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ const dx = end.x - start.x;
+ const dy = end.y - start.y;
+ const length = Math.sqrt(dx * dx + dy * dy);
+ const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const cx = (start.x + end.x) / 2;
+ const cy = (start.y + end.y) / 2;
+
+ const sharedStyle = {
+ left: cx - length / 2,
+ width: length,
+ transform: `rotate(${angleDeg}deg)`,
+ transformOrigin: "center center",
+ };
+
+ return (
+ <>
+ {onPointerDown && (
+ <div
+ className="absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - LINE_HIT_AREA_SIZE / 2,
+ height: LINE_HIT_AREA_SIZE,
+ pointerEvents: "auto",
+ cursor,
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ />
+ )}
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - 0.5,
+ height: 1,
+ backgroundColor: "white",
+ opacity: 0.75,
+ }}
+ />
+ </>
+ );
+}
diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx
new file mode 100644
index 00000000..d3cb2a49
--- /dev/null
+++ b/apps/web/src/preview/components/index.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import useDeepCompareEffect from "use-deep-compare-effect";
+import { useEditor } from "@/editor/use-editor";
+import { useRafLoop } from "@/hooks/use-raf-loop";
+import { useContainerSize } from "@/hooks/use-container-size";
+import { useFullscreen } from "@/hooks/use-fullscreen";
+import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
+import { TICKS_PER_SECOND } from "@/wasm";
+import type { RootNode } from "@/services/renderer/nodes/root-node";
+import { buildScene } from "@/services/renderer/scene-builder";
+import { PreviewOverlayLayer } from "./overlay-layer";
+import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
+import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
+import type {
+ PreviewOverlayControl,
+ PreviewOverlayInstance,
+} from "@/preview/overlays";
+import { PreviewContextMenu } from "./context-menu";
+import { PreviewToolbar } from "./toolbar";
+import {
+ PreviewViewportProvider,
+ usePreviewViewportState,
+} from "./preview-viewport";
+
+function usePreviewSize() {
+ const canvasSize = useEditor(
+ (e) => e.project.getActive()?.settings.canvasSize,
+ );
+
+ return {
+ width: canvasSize?.width,
+ height: canvasSize?.height,
+ };
+}
+
+function normalizeWheelDelta({
+ delta,
+ deltaMode,
+ pageSize,
+}: {
+ delta: number;
+ deltaMode: number;
+ pageSize: number;
+}): number {
+ if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
+ return delta * 16;
+ }
+
+ if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
+ return delta * pageSize;
+ }
+
+ return delta;
+}
+
+export function PreviewPanel({
+ overlayControls,
+ overlayInstances,
+ onOverlayVisibilityChange,
+}: {
+ overlayControls: PreviewOverlayControl[];
+ overlayInstances: PreviewOverlayInstance[];
+ onOverlayVisibilityChange: (params: {
+ overlayId: string;
+ isVisible: boolean;
+ }) => void;
+}) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const { toggleFullscreen } = useFullscreen({ containerRef });
+
+ return (
+ <div
+ ref={containerRef}
+ className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
+ >
+ <PreviewCanvas
+ containerRef={containerRef}
+ onToggleFullscreen={toggleFullscreen}
+ overlayControls={overlayControls}
+ overlayInstances={overlayInstances}
+ onOverlayVisibilityChange={onOverlayVisibilityChange}
+ />
+ <RenderTreeController />
+ </div>
+ );
+}
+
+function RenderTreeController() {
+ const editor = useEditor();
+ const tracks = useEditor(
+ (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
+ );
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const activeProject = useEditor((e) => e.project.getActive());
+
+ const { width, height } = usePreviewSize();
+
+ useDeepCompareEffect(() => {
+ if (!activeProject) return;
+
+ const duration = editor.timeline.getTotalDuration();
+ const renderTree = buildScene({
+ tracks,
+ mediaAssets,
+ duration,
+ canvasSize: { width, height },
+ background: activeProject.settings.background,
+ isPreview: true,
+ });
+
+ editor.renderer.setRenderTree({ renderTree });
+ }, [tracks, mediaAssets, activeProject?.settings.background, width, height]);
+
+ return null;
+}
+
+function PreviewCanvas({
+ containerRef,
+ onToggleFullscreen,
+ overlayControls,
+ overlayInstances,
+ onOverlayVisibilityChange,
+}: {
+ containerRef: React.RefObject<HTMLElement | null>;
+ onToggleFullscreen: () => void;
+ overlayControls: PreviewOverlayControl[];
+ overlayInstances: PreviewOverlayInstance[];
+ onOverlayVisibilityChange: (params: {
+ overlayId: string;
+ isVisible: boolean;
+ }) => void;
+}) {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const viewportRef = useRef<HTMLDivElement>(null);
+ const lastFrameRef = useRef(-1);
+ const lastSceneRef = useRef<RootNode | null>(null);
+ const renderingRef = useRef(false);
+ const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
+ const viewportSize = useContainerSize({ containerRef: viewportRef });
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const renderTree = useEditor((e) => e.renderer.getRenderTree());
+ const viewport = usePreviewViewportState({
+ canvasHeight: nativeHeight,
+ canvasWidth: nativeWidth,
+ viewportHeight: viewportSize.height,
+ viewportRef,
+ viewportWidth: viewportSize.width,
+ });
+
+ const renderer = useMemo(() => {
+ return new CanvasRenderer({
+ width: nativeWidth,
+ height: nativeHeight,
+ fps: activeProject.settings.fps,
+ });
+ }, [nativeWidth, nativeHeight, activeProject.settings.fps]);
+
+ const render = useCallback(() => {
+ if (canvasRef.current && renderTree && !renderingRef.current) {
+ const renderTime = Math.min(
+ editor.playback.getCurrentTime(),
+ editor.timeline.getLastFrameTime(),
+ );
+ const ticksPerFrame = Math.round(
+ (TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator,
+ );
+ const frame = Math.floor(renderTime / ticksPerFrame);
+
+ if (
+ frame !== lastFrameRef.current ||
+ renderTree !== lastSceneRef.current
+ ) {
+ renderingRef.current = true;
+ lastSceneRef.current = renderTree;
+ lastFrameRef.current = frame;
+ renderer
+ .renderToCanvas({
+ node: renderTree,
+ time: renderTime,
+ targetCanvas: canvasRef.current,
+ })
+ .then(() => {
+ renderingRef.current = false;
+ });
+ }
+ }
+ }, [renderer, renderTree, editor.playback, editor.timeline]);
+
+ useRafLoop(render);
+
+ useEffect(() => {
+ const container = viewportRef.current;
+ if (!container) return;
+
+ let pendingZoomDelta = 0;
+ let pendingPanDeltaX = 0;
+ let pendingPanDeltaY = 0;
+ let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
+ let panRafId: ReturnType<typeof requestAnimationFrame> | null = null;
+
+ const onWheel = (event: WheelEvent) => {
+ const normalizedDeltaX = normalizeWheelDelta({
+ delta: event.deltaX,
+ deltaMode: event.deltaMode,
+ pageSize: container.clientWidth,
+ });
+ const normalizedDeltaY = normalizeWheelDelta({
+ delta: event.deltaY,
+ deltaMode: event.deltaMode,
+ pageSize: container.clientHeight,
+ });
+ const isZoomGesture = event.ctrlKey || event.metaKey;
+ if (isZoomGesture) {
+ event.preventDefault();
+ pendingZoomDelta += normalizedDeltaY;
+
+ if (zoomRafId === null) {
+ zoomRafId = requestAnimationFrame(() => {
+ const cappedDelta =
+ Math.sign(pendingZoomDelta) *
+ Math.min(Math.abs(pendingZoomDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+
+ viewport.scaleZoom({ factor: zoomFactor });
+ pendingZoomDelta = 0;
+ zoomRafId = null;
+ });
+ }
+
+ return;
+ }
+
+ if (!viewport.canPan) {
+ return;
+ }
+
+ if (normalizedDeltaX === 0 && normalizedDeltaY === 0) {
+ return;
+ }
+
+ event.preventDefault();
+ pendingPanDeltaX += normalizedDeltaX;
+ pendingPanDeltaY += normalizedDeltaY;
+
+ if (panRafId === null) {
+ panRafId = requestAnimationFrame(() => {
+ viewport.panByScreenDelta({
+ deltaX: pendingPanDeltaX,
+ deltaY: pendingPanDeltaY,
+ });
+ pendingPanDeltaX = 0;
+ pendingPanDeltaY = 0;
+ panRafId = null;
+ });
+ }
+ };
+
+ container.addEventListener("wheel", onWheel, {
+ capture: true,
+ passive: false,
+ });
+
+ return () => {
+ container.removeEventListener("wheel", onWheel, {
+ capture: true,
+ });
+ if (zoomRafId !== null) {
+ cancelAnimationFrame(zoomRafId);
+ }
+ if (panRafId !== null) {
+ cancelAnimationFrame(panRafId);
+ }
+ };
+ }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]);
+
+ return (
+ <PreviewViewportProvider value={viewport}>
+ <div className="flex size-full min-h-0 min-w-0 flex-col">
+ <div className="flex min-h-0 min-w-0 flex-1 p-2 pb-0">
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ ref={viewportRef}
+ className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
+ >
+ <canvas
+ ref={canvasRef}
+ width={nativeWidth}
+ height={nativeHeight}
+ className="absolute block border"
+ style={{
+ left: viewport.sceneLeft,
+ top: viewport.sceneTop,
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ background:
+ activeProject.settings.background.type === "blur"
+ ? "transparent"
+ : activeProject?.settings.background.color,
+ }}
+ />
+ <PreviewOverlayLayer
+ instances={overlayInstances}
+ plane="under-interaction"
+ />
+ <PreviewInteractionOverlay />
+ <PreviewOverlayLayer
+ instances={overlayInstances}
+ plane="over-interaction"
+ />
+ </div>
+ </ContextMenuTrigger>
+ <PreviewContextMenu
+ onToggleFullscreen={onToggleFullscreen}
+ containerRef={containerRef}
+ overlayControls={overlayControls}
+ onOverlayVisibilityChange={onOverlayVisibilityChange}
+ />
+ </ContextMenu>
+ </div>
+ <PreviewToolbar onToggleFullscreen={onToggleFullscreen} />
+ </div>
+ </PreviewViewportProvider>
+ );
+}
diff --git a/apps/web/src/preview/components/mask-handles.tsx b/apps/web/src/preview/components/mask-handles.tsx
new file mode 100644
index 00000000..340cab0a
--- /dev/null
+++ b/apps/web/src/preview/components/mask-handles.tsx
@@ -0,0 +1,308 @@
+"use client";
+
+import { PEN_CURSOR } from "@/preview/components/cursors";
+import { usePreviewViewport } from "@/preview/components/preview-viewport";
+import { useMaskHandles } from "@/masks/use-mask-handles";
+import type { SnapLine } from "@/preview/preview-snap";
+import {
+ CornerHandle,
+ CircleHandle,
+ CanvasPathOutline,
+ EdgeHandle,
+ IconHandle,
+ LineOverlay,
+ BoundingBoxOutline,
+ ShapeOutline,
+} from "./handle-primitives";
+
+const CUSTOM_MASK_ANCHOR_SIZE = 7;
+const CUSTOM_MASK_TANGENT_SIZE = 6;
+import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
+
+export function MaskHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}) {
+ const viewport = usePreviewViewport();
+ const {
+ selectedWithMask,
+ handlePositions,
+ overlays,
+ isCreatingCustomMask,
+ handleCanvasPointerDown,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ } = useMaskHandles({ onSnapLinesChange });
+
+ if (!selectedWithMask) return null;
+
+ const toOverlay = ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) =>
+ viewport.canvasToOverlay({
+ canvasX,
+ canvasY,
+ });
+
+ const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
+ const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 });
+
+ const onPointerMove = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerMove({ event })) {
+ return;
+ }
+
+ handlePointerMove({ event });
+ };
+ const onPointerUp = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerUp({ event })) {
+ return;
+ }
+
+ handlePointerUp();
+ };
+ const handleMaskPointerDown = ({
+ event,
+ handleId,
+ }: {
+ event: React.PointerEvent;
+ handleId: string;
+ }) => {
+ if (viewport.handlePanPointerDown({ event })) {
+ return;
+ }
+
+ handlePointerDown({ event, handleId });
+ };
+ const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerDown({ event })) {
+ return;
+ }
+
+ handleCanvasPointerDown({ event });
+ };
+
+ return (
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
+ {isCreatingCustomMask ? (
+ <div
+ className="absolute inset-0 pointer-events-auto"
+ style={{ cursor: PEN_CURSOR }}
+ onPointerDown={handleCanvasOverlayPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ />
+ ) : null}
+ {overlays.map((overlay) => {
+ const overlayHandleId = overlay.handleId;
+ const pointerHandlers = overlayHandleId
+ ? {
+ onPointerDown: (event: React.PointerEvent) =>
+ handleMaskPointerDown({ event, handleId: overlayHandleId }),
+ onPointerMove,
+ onPointerUp,
+ }
+ : {};
+
+ if (overlay.type === "line") {
+ return (
+ <LineOverlay
+ key={overlay.id}
+ cursor={overlay.cursor}
+ start={toOverlay({
+ canvasX: overlay.start.x,
+ canvasY: overlay.start.y,
+ })}
+ end={toOverlay({
+ canvasX: overlay.end.x,
+ canvasY: overlay.end.y,
+ })}
+ {...pointerHandlers}
+ />
+ );
+ }
+
+ if (overlay.type === "rect") {
+ return (
+ <BoundingBoxOutline
+ key={overlay.id}
+ center={toOverlay({
+ canvasX: overlay.center.x,
+ canvasY: overlay.center.y,
+ })}
+ outlineWidth={overlay.width * scaleX}
+ outlineHeight={overlay.height * scaleY}
+ rotation={overlay.rotation}
+ cursor={overlay.cursor}
+ dashed={overlay.dashed}
+ {...pointerHandlers}
+ />
+ );
+ }
+
+ if (overlay.type === "shape") {
+ return (
+ <ShapeOutline
+ key={overlay.id}
+ center={toOverlay({
+ canvasX: overlay.center.x,
+ canvasY: overlay.center.y,
+ })}
+ outlineWidth={overlay.width * scaleX}
+ outlineHeight={overlay.height * scaleY}
+ rotation={overlay.rotation}
+ pathData={overlay.pathData}
+ cursor={overlay.cursor}
+ {...pointerHandlers}
+ />
+ );
+ }
+
+ if (overlay.type === "canvas-path") {
+ return (
+ <CanvasPathOutline
+ key={overlay.id}
+ pathData={overlay.pathData}
+ translateX={
+ overlay.coordinateSpace === "canvas" ? canvasOrigin.x : 0
+ }
+ translateY={
+ overlay.coordinateSpace === "canvas" ? canvasOrigin.y : 0
+ }
+ scaleX={overlay.coordinateSpace === "canvas" ? scaleX : 1}
+ scaleY={overlay.coordinateSpace === "canvas" ? scaleY : 1}
+ cursor={overlay.cursor}
+ strokeWidth={overlay.strokeWidth}
+ strokeOpacity={overlay.strokeOpacity}
+ {...pointerHandlers}
+ />
+ );
+ }
+
+ return null;
+ })}
+ {handlePositions.map((handle) => {
+ const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
+
+ if (handle.kind === "icon" && handle.icon === "rotate") {
+ return (
+ <IconHandle
+ key={handle.id}
+ icon={Rotate01Icon}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleMaskPointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.kind === "icon" && handle.icon === "feather") {
+ return (
+ <IconHandle
+ key={handle.id}
+ icon={FeatherIcon}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleMaskPointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
+ return (
+ <EdgeHandle
+ key={handle.id}
+ edge="right"
+ screen={screen}
+ rotation={handle.rotation ?? 0}
+ onPointerDown={(event) =>
+ handleMaskPointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
+ return (
+ <EdgeHandle
+ key={handle.id}
+ edge="bottom"
+ screen={screen}
+ rotation={handle.rotation ?? 0}
+ onPointerDown={(event) =>
+ handleMaskPointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.kind === "point" || handle.kind === "tangent") {
+ return (
+ <CircleHandle
+ key={handle.id}
+ screen={screen}
+ size={
+ handle.kind === "tangent"
+ ? CUSTOM_MASK_TANGENT_SIZE
+ : CUSTOM_MASK_ANCHOR_SIZE
+ }
+ isSelected={handle.isSelected}
+ onPointerDown={(event) =>
+ handleMaskPointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.kind === "corner") {
+ return (
+ <CornerHandle
+ key={handle.id}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleMaskPointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ return (
+ <CornerHandle
+ key={handle.id}
+ cursor={handle.cursor}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleMaskPointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/preview/components/overlay-layer.tsx b/apps/web/src/preview/components/overlay-layer.tsx
new file mode 100644
index 00000000..eb730f6e
--- /dev/null
+++ b/apps/web/src/preview/components/overlay-layer.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import type {
+ PreviewOverlayHudAnchor,
+ PreviewOverlayInstance,
+ PreviewOverlayPlane,
+} from "@/preview/overlays";
+import { usePreviewViewport } from "./preview-viewport";
+
+const HUD_ANCHOR_CLASS_NAMES: Record<PreviewOverlayHudAnchor, string> = {
+ "top-left": "absolute top-2 left-2 flex flex-col items-start gap-1.5",
+ "top-right": "absolute top-2 right-2 flex flex-col items-end gap-1.5",
+ "bottom-left":
+ "absolute bottom-2 left-2 flex flex-col-reverse items-start gap-1.5",
+ "bottom-right":
+ "absolute right-2 bottom-2 flex flex-col-reverse items-end gap-1.5",
+};
+
+function getPositionedMountStyle({
+ instance,
+ baseStyle,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ sceneHeight,
+}: {
+ instance: PreviewOverlayInstance;
+ baseStyle: {
+ pointerEvents: "none" | "auto";
+ zIndex: number | undefined;
+ };
+ sceneLeft: number;
+ sceneTop: number;
+ sceneWidth: number;
+ sceneHeight: number;
+}) {
+ if (instance.mount.kind === "hud") {
+ return baseStyle;
+ }
+
+ const isSceneMount = instance.mount.kind === "scene";
+ const mount = instance.mount;
+ const hasExplicitBounds =
+ mount.x != null ||
+ mount.y != null ||
+ mount.width != null ||
+ mount.height != null;
+
+ return {
+ ...baseStyle,
+ left: (isSceneMount ? sceneLeft : 0) + (mount.x ?? 0),
+ top: (isSceneMount ? sceneTop : 0) + (mount.y ?? 0),
+ ...(hasExplicitBounds
+ ? {
+ ...(mount.width != null ? { width: mount.width } : {}),
+ ...(mount.height != null ? { height: mount.height } : {}),
+ }
+ : {
+ width: isSceneMount ? sceneWidth : "100%",
+ height: isSceneMount ? sceneHeight : "100%",
+ }),
+ } as const;
+}
+
+export function PreviewOverlayLayer({
+ instances,
+ plane,
+}: {
+ instances: PreviewOverlayInstance[];
+ plane: PreviewOverlayPlane;
+}) {
+ const viewport = usePreviewViewport();
+ const visibleInstances = instances
+ .filter((instance) => (instance.plane ?? "over-interaction") === plane)
+ .sort((left, right) => (left.zIndex ?? 0) - (right.zIndex ?? 0));
+
+ if (visibleInstances.length === 0) {
+ return null;
+ }
+
+ const hudInstancesByAnchor = visibleInstances.reduce<
+ Record<PreviewOverlayHudAnchor, PreviewOverlayInstance[]>
+ >(
+ (groups, instance) => {
+ if (instance.mount.kind === "hud") {
+ groups[instance.mount.anchor].push(instance);
+ }
+ return groups;
+ },
+ {
+ "top-left": [],
+ "top-right": [],
+ "bottom-left": [],
+ "bottom-right": [],
+ },
+ );
+
+ return (
+ <>
+ {visibleInstances.map((instance) => {
+ if (instance.mount.kind === "hud") {
+ return null;
+ }
+
+ const pointerEvents = instance.pointerEvents ?? "none";
+ const baseStyle = {
+ pointerEvents,
+ zIndex: instance.zIndex,
+ } as const;
+ const content = instance.render({
+ sceneHeight: viewport.sceneHeight,
+ sceneWidth: viewport.sceneWidth,
+ });
+
+ switch (instance.mount.kind) {
+ case "scene":
+ return (
+ <div
+ key={instance.id}
+ className="absolute"
+ style={getPositionedMountStyle({
+ instance,
+ baseStyle,
+ sceneLeft: viewport.sceneLeft,
+ sceneTop: viewport.sceneTop,
+ sceneWidth: viewport.sceneWidth,
+ sceneHeight: viewport.sceneHeight,
+ })}
+ >
+ {content}
+ </div>
+ );
+
+ case "viewport":
+ return (
+ <div
+ key={instance.id}
+ className="absolute"
+ style={getPositionedMountStyle({
+ instance,
+ baseStyle,
+ sceneLeft: viewport.sceneLeft,
+ sceneTop: viewport.sceneTop,
+ sceneWidth: viewport.sceneWidth,
+ sceneHeight: viewport.sceneHeight,
+ })}
+ >
+ {content}
+ </div>
+ );
+
+ default:
+ return null;
+ }
+ })}
+ {(
+ Object.entries(hudInstancesByAnchor) as Array<
+ [PreviewOverlayHudAnchor, PreviewOverlayInstance[]]
+ >
+ ).map(([anchor, anchorInstances]) => {
+ if (anchorInstances.length === 0) {
+ return null;
+ }
+
+ const sortedAnchorInstances = [...anchorInstances].sort(
+ (left, right) =>
+ (left.mount.kind === "hud" ? (left.mount.order ?? 0) : 0) -
+ (right.mount.kind === "hud" ? (right.mount.order ?? 0) : 0),
+ );
+
+ return (
+ <div key={anchor} className={HUD_ANCHOR_CLASS_NAMES[anchor]}>
+ {sortedAnchorInstances.map((instance) => {
+ const pointerEvents = instance.pointerEvents ?? "none";
+ const content = instance.render({
+ sceneHeight: viewport.sceneHeight,
+ sceneWidth: viewport.sceneWidth,
+ });
+
+ return (
+ <div
+ key={instance.id}
+ style={{
+ pointerEvents,
+ zIndex: instance.zIndex,
+ }}
+ >
+ {content}
+ </div>
+ );
+ })}
+ </div>
+ );
+ })}
+ </>
+ );
+}
diff --git a/apps/web/src/preview/components/preview-interaction-overlay.tsx b/apps/web/src/preview/components/preview-interaction-overlay.tsx
new file mode 100644
index 00000000..e16a31d2
--- /dev/null
+++ b/apps/web/src/preview/components/preview-interaction-overlay.tsx
@@ -0,0 +1,103 @@
+import { useState } from "react";
+import { usePreviewViewport } from "@/preview/components/preview-viewport";
+import { usePreviewInteraction } from "@/preview/hooks/use-preview-interaction";
+import type { SnapLine } from "@/preview/preview-snap";
+import { TransformHandles } from "./transform-handles";
+import { MaskHandles } from "./mask-handles";
+import { SnapGuides } from "./snap-guides";
+import { TextEditOverlay } from "./text-edit-overlay";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
+import { useEditor } from "@/editor/use-editor";
+
+export function PreviewInteractionOverlay() {
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
+
+ const selectedRef =
+ selectedElements.length === 1 ? selectedElements[0] : null;
+ const activeTrack = selectedRef
+ ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
+ : null;
+ const activeElement =
+ activeTrack?.elements.find(
+ (element) => element.id === selectedRef?.elementId,
+ ) ?? null;
+ const isMaskMode = activeElement
+ ? activeTabPerType[activeElement.type] === "masks"
+ : false;
+
+ const {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onDoubleClick,
+ editingText,
+ commitTextEdit,
+ } = usePreviewInteraction({
+ onSnapLinesChange: setSnapLines,
+ isMaskMode,
+ });
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerDown({ event })) {
+ return;
+ }
+
+ onPointerDown(event);
+ };
+
+ const handlePointerMove = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerMove({ event })) {
+ return;
+ }
+
+ onPointerMove(event);
+ };
+
+ const handlePointerUp = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerUp({ event })) {
+ return;
+ }
+
+ onPointerUp(event);
+ };
+
+ return (
+ <div className="absolute inset-0">
+ <div
+ className="absolute inset-0 pointer-events-auto"
+ role="application"
+ aria-label="Preview canvas"
+ style={{
+ cursor: viewport.isPanning
+ ? "grabbing"
+ : viewport.canPan
+ ? "default"
+ : undefined,
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerCancel={handlePointerUp}
+ onDoubleClick={onDoubleClick}
+ onDragStart={(e) => e.preventDefault()}
+ />
+ {editingText ? (
+ <TextEditOverlay
+ trackId={editingText.trackId}
+ elementId={editingText.elementId}
+ element={editingText.element}
+ onCommit={commitTextEdit}
+ />
+ ) : isMaskMode ? (
+ <MaskHandles onSnapLinesChange={setSnapLines} />
+ ) : (
+ <TransformHandles onSnapLinesChange={setSnapLines} />
+ )}
+ <SnapGuides lines={snapLines} />
+ </div>
+ );
+}
diff --git a/apps/web/src/preview/components/preview-viewport.tsx b/apps/web/src/preview/components/preview-viewport.tsx
new file mode 100644
index 00000000..6dc9f084
--- /dev/null
+++ b/apps/web/src/preview/components/preview-viewport.tsx
@@ -0,0 +1,576 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ canvasToOverlay,
+ getDisplayScale,
+ positionToOverlay,
+ screenPixelsToLogicalThreshold,
+ screenToCanvas,
+} from "@/preview/preview-coords";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import { PREVIEW_ZOOM } from "@/preview/zoom";
+
+const MIDDLE_MOUSE_BUTTON = 1;
+const IS_AT_FIT_EPSILON = 0.001;
+const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
+
+interface PreviewViewportContextValue {
+ canPan: boolean;
+ isAtFit: boolean;
+ isAtActualSize: boolean;
+ isPanning: boolean;
+ sceneHeight: number;
+ sceneLeft: number;
+ sceneTop: number;
+ sceneWidth: number;
+ zoomPercent: number;
+ canvasToOverlay: ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) => { x: number; y: number };
+ fitToScreen: () => void;
+ getDisplayScale: () => { x: number; y: number };
+ handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
+ positionToOverlay: ({
+ positionX,
+ positionY,
+ }: {
+ positionX: number;
+ positionY: number;
+ }) => { x: number; y: number };
+ panByScreenDelta: ({
+ deltaX,
+ deltaY,
+ }: {
+ deltaX: number;
+ deltaY: number;
+ }) => void;
+ resetPan: () => void;
+ scaleZoom: ({ factor }: { factor: number }) => void;
+ screenPixelsToLogicalThreshold: ({
+ screenPixels,
+ }: {
+ screenPixels: number;
+ }) => { x: number; y: number };
+ screenToCanvas: ({
+ clientX,
+ clientY,
+ }: {
+ clientX: number;
+ clientY: number;
+ }) => { x: number; y: number } | null;
+ setActualSize: () => void;
+ setViewportPercent: ({ percent }: { percent: number }) => void;
+ zoomIn: () => void;
+ zoomOut: () => void;
+}
+
+const PreviewViewportContext =
+ createContext<PreviewViewportContextValue | null>(null);
+
+interface PreviewViewportProviderProps {
+ children: React.ReactNode;
+ value: PreviewViewportContextValue;
+}
+
+interface PreviewViewportStateOptions {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportRef: React.RefObject<HTMLDivElement | null>;
+ viewportWidth: number;
+}
+
+interface PanSession {
+ centerX: number;
+ centerY: number;
+ clientX: number;
+ clientY: number;
+ pointerId: number;
+}
+
+function getClampedCenterAxis({
+ axisSize,
+ center,
+ scale,
+ viewportSize,
+}: {
+ axisSize: number;
+ center: number;
+ scale: number;
+ viewportSize: number;
+}): number {
+ if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
+ return axisSize / 2;
+ }
+
+ const visibleHalfSpan = viewportSize / (2 * scale);
+ if (visibleHalfSpan >= axisSize / 2) {
+ return axisSize / 2;
+ }
+
+ return clamp({
+ value: center,
+ min: visibleHalfSpan,
+ max: axisSize - visibleHalfSpan,
+ });
+}
+
+function clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX,
+ centerY,
+ scale,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ centerX: number;
+ centerY: number;
+ scale: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): { x: number; y: number } {
+ return {
+ x: getClampedCenterAxis({
+ center: centerX,
+ axisSize: canvasWidth,
+ scale,
+ viewportSize: viewportWidth,
+ }),
+ y: getClampedCenterAxis({
+ center: centerY,
+ axisSize: canvasHeight,
+ scale,
+ viewportSize: viewportHeight,
+ }),
+ };
+}
+
+function getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): number {
+ if (
+ canvasHeight <= 0 ||
+ canvasWidth <= 0 ||
+ viewportHeight <= 0 ||
+ viewportWidth <= 0
+ ) {
+ return 1;
+ }
+
+ return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
+}
+
+function getClampedZoom({ zoom }: { zoom: number }): number {
+ return clamp({
+ value: zoom,
+ min: PREVIEW_ZOOM.min,
+ max: PREVIEW_ZOOM.max,
+ });
+}
+
+export function PreviewViewportProvider({
+ children,
+ value,
+}: PreviewViewportProviderProps) {
+ return (
+ <PreviewViewportContext.Provider value={value}>
+ {children}
+ </PreviewViewportContext.Provider>
+ );
+}
+
+export function usePreviewViewportState({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportRef,
+ viewportWidth,
+}: PreviewViewportStateOptions): PreviewViewportContextValue {
+ const [zoom, setZoomState] = useState(1);
+ const [center, setCenter] = useState(() => ({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ }));
+ const [isPanning, setIsPanning] = useState(false);
+ const panSessionRef = useRef<PanSession | null>(null);
+ const centerRef = useRef(center);
+ centerRef.current = center;
+
+ const fitScale = useMemo(
+ () =>
+ getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [canvasHeight, canvasWidth, viewportHeight, viewportWidth],
+ );
+ const viewportScale = fitScale * zoom;
+ const geometry = useMemo(
+ () => ({
+ canvasHeight,
+ canvasWidth,
+ centerX: center.x,
+ centerY: center.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [
+ canvasHeight,
+ canvasWidth,
+ center.x,
+ center.y,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ ],
+ );
+
+ const scaleZoom = useCallback(({ factor }: { factor: number }) => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * factor,
+ }),
+ );
+ }, []);
+
+ const panByScreenDelta = useCallback(
+ ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
+ if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
+ return;
+ }
+
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x + deltaX / viewportScale,
+ centerY: previousCenter.y + deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ },
+ [
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ zoom,
+ ],
+ );
+
+ const resetPan = useCallback(() => {
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const fitToScreen = useCallback(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const zoomIn = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const zoomOut = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom / PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const setActualSize = useCallback(() => {
+ const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
+ setZoomState(
+ getClampedZoom({
+ zoom: actualSizeZoom,
+ }),
+ );
+ }, [fitScale]);
+
+ const setViewportPercent = useCallback(
+ ({ percent }: { percent: number }) => {
+ const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
+ setZoomState(getClampedZoom({ zoom: targetZoom }));
+ },
+ [fitScale],
+ );
+
+ const getViewportRect = useCallback(
+ () => viewportRef.current?.getBoundingClientRect() ?? null,
+ [viewportRef],
+ );
+
+ const screenToCanvasWithViewport = useCallback(
+ ({ clientX, clientY }: { clientX: number; clientY: number }) => {
+ const viewportRect = getViewportRect();
+ if (!viewportRect) return null;
+
+ return screenToCanvas({
+ clientX,
+ clientY,
+ geometry,
+ viewportRect,
+ });
+ },
+ [getViewportRect, geometry],
+ );
+
+ const canvasToOverlayWithViewport = useCallback(
+ ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
+ canvasToOverlay({
+ canvasX,
+ canvasY,
+ geometry,
+ }),
+ [geometry],
+ );
+
+ const positionToOverlayWithViewport = useCallback(
+ ({ positionX, positionY }: { positionX: number; positionY: number }) =>
+ positionToOverlay({
+ geometry,
+ positionX,
+ positionY,
+ }),
+ [geometry],
+ );
+
+ const getDisplayScaleWithViewport = useCallback(
+ () => getDisplayScale({ geometry }),
+ [geometry],
+ );
+
+ const getLogicalThresholdForScreenPixels = useCallback(
+ ({ screenPixels }: { screenPixels: number }) =>
+ screenPixelsToLogicalThreshold({
+ geometry,
+ screenPixels,
+ }),
+ [geometry],
+ );
+
+ const handlePanPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ panSessionRef.current = {
+ centerX: centerRef.current.x,
+ centerY: centerRef.current.y,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ pointerId: event.pointerId,
+ };
+ setIsPanning(true);
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ return true;
+ },
+ [zoom],
+ );
+
+ const handlePanPointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const deltaX = event.clientX - panSession.clientX;
+ const deltaY = event.clientY - panSession.clientY;
+ const nextCenter = clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: panSession.centerX - deltaX / viewportScale,
+ centerY: panSession.centerY - deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ });
+
+ setCenter(nextCenter);
+ return true;
+ },
+ [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
+ );
+
+ const handlePanPointerUp = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ if (
+ (event.currentTarget as HTMLElement).hasPointerCapture(
+ panSession.pointerId,
+ )
+ ) {
+ (event.currentTarget as HTMLElement).releasePointerCapture(
+ panSession.pointerId,
+ );
+ }
+
+ panSessionRef.current = null;
+ setIsPanning(false);
+ return true;
+ },
+ [],
+ );
+
+ useEffect(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ panSessionRef.current = null;
+ setIsPanning(false);
+ }, [canvasHeight, canvasWidth]);
+
+ useEffect(() => {
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x,
+ centerY: previousCenter.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
+
+ const sceneWidth = canvasWidth * viewportScale;
+ const sceneHeight = canvasHeight * viewportScale;
+ const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
+ const sceneTop = viewportHeight / 2 - center.y * viewportScale;
+ const canPan = zoom > 1;
+ const zoomPercent = Math.round(viewportScale * 100);
+
+ return useMemo(
+ () => ({
+ canPan,
+ isAtActualSize: isNearlyEqual({
+ leftValue: viewportScale,
+ rightValue: 1,
+ epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
+ }),
+ isAtFit: isNearlyEqual({
+ leftValue: zoom,
+ rightValue: 1,
+ epsilon: IS_AT_FIT_EPSILON,
+ }),
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlay: canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScale: getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlay: positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
+ screenToCanvas: screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ }),
+ [
+ canPan,
+ viewportScale,
+ zoom,
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ getLogicalThresholdForScreenPixels,
+ screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ ],
+ );
+}
+
+export function usePreviewViewport() {
+ const context = useContext(PreviewViewportContext);
+ if (!context) {
+ throw new Error(
+ "usePreviewViewport must be used within PreviewViewportProvider",
+ );
+ }
+
+ return context;
+}
diff --git a/apps/web/src/preview/components/snap-guides.tsx b/apps/web/src/preview/components/snap-guides.tsx
new file mode 100644
index 00000000..5c2e1b9f
--- /dev/null
+++ b/apps/web/src/preview/components/snap-guides.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { usePreviewViewport } from "@/preview/components/preview-viewport";
+import type { SnapLine } from "@/preview/preview-snap";
+
+export function SnapGuides({ lines }: { lines: SnapLine[] }) {
+ const viewport = usePreviewViewport();
+
+ if (lines.length === 0) {
+ return null;
+ }
+
+ const toOverlayX = (logicalX: number) =>
+ viewport.positionToOverlay({
+ positionX: logicalX,
+ positionY: 0,
+ }).x;
+
+ const toOverlayY = (logicalY: number) =>
+ viewport.positionToOverlay({
+ positionX: 0,
+ positionY: logicalY,
+ }).y;
+
+ return (
+ <div className="pointer-events-none absolute inset-0" aria-hidden>
+ {lines.map((line) => {
+ if (line.type === "vertical") {
+ return (
+ <div
+ key={`vertical-${line.position}`}
+ className="absolute top-0 bottom-0 w-px bg-white/70"
+ style={{ left: toOverlayX(line.position) }}
+ />
+ );
+ }
+ return (
+ <div
+ key={`horizontal-${line.position}`}
+ className="absolute left-0 right-0 h-px bg-white/70"
+ style={{ top: toOverlayY(line.position) }}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx
new file mode 100644
index 00000000..bc27c724
--- /dev/null
+++ b/apps/web/src/preview/components/text-edit-overlay.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { usePreviewViewport } from "@/preview/components/preview-viewport";
+import { useEditor } from "@/editor/use-editor";
+import type { TextElement } from "@/timeline";
+import { DEFAULTS } from "@/timeline/defaults";
+import {
+ getElementLocalTime,
+ resolveTransformAtTime,
+} from "@/animation";
+import { resolveTextLayout } from "@/text/primitives";
+
+export function TextEditOverlay({
+ trackId,
+ elementId,
+ element,
+ onCommit,
+}: {
+ trackId: string;
+ elementId: string;
+ element: TextElement;
+ onCommit: () => void;
+}) {
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const divRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const div = divRef.current;
+ if (!div) return;
+ div.focus();
+ const range = document.createRange();
+ range.selectNodeContents(div);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }, []);
+
+ const handleInput = useCallback(() => {
+ const div = divRef.current;
+ if (!div) return;
+ const text = div.innerText;
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: { content: text } }],
+ });
+ }, [editor.timeline, trackId, elementId]);
+
+ const handleKeyDown = useCallback(
+ ({ event }: { event: React.KeyboardEvent }) => {
+ const { key } = event;
+ if (key === "Escape") {
+ event.preventDefault();
+ onCommit();
+ return;
+ }
+ },
+ [onCommit],
+ );
+
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ if (!canvasSize) return null;
+
+ const currentTime = editor.playback.getCurrentTime();
+ const localTime = getElementLocalTime({
+ timelineTime: currentTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const { x: posX, y: posY } = viewport.positionToOverlay({
+ positionX: transform.position.x,
+ positionY: transform.position.y,
+ });
+
+ const { x: displayScaleX } = viewport.getDisplayScale();
+ const resolvedTextLayout = resolveTextLayout({
+ text: {
+ content: element.content,
+ fontSize: element.fontSize,
+ fontFamily: element.fontFamily,
+ fontWeight: element.fontWeight,
+ fontStyle: element.fontStyle,
+ textAlign: element.textAlign,
+ textDecoration: element.textDecoration,
+ letterSpacing: element.letterSpacing,
+ lineHeight: element.lineHeight,
+ },
+ canvasHeight: canvasSize.height,
+ });
+
+ const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
+ const canvasLetterSpacing = element.letterSpacing ?? 0;
+ const lineHeightPx = resolvedTextLayout.lineHeightPx;
+
+ const bg = element.background;
+ const shouldShowBackground =
+ bg.enabled && bg.color && bg.color !== "transparent";
+ const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
+ const canvasPaddingX = shouldShowBackground
+ ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
+ : 0;
+ const canvasPaddingY = shouldShowBackground
+ ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio
+ : 0;
+
+ return (
+ <div
+ className="absolute"
+ style={{
+ left: posX,
+ top: posY,
+ transform: `translate(-50%, -50%) scale(${transform.scaleX * displayScaleX}, ${transform.scaleY * displayScaleX}) rotate(${transform.rotate}deg)`,
+ transformOrigin: "center center",
+ }}
+ >
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
+ <div
+ ref={divRef}
+ contentEditable
+ suppressContentEditableWarning
+ tabIndex={0}
+ role="textbox"
+ aria-label="Edit text"
+ className="cursor-text select-text outline-none whitespace-pre"
+ style={{
+ fontSize: resolvedTextLayout.scaledFontSize,
+ fontFamily: element.fontFamily,
+ fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
+ fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
+ textAlign: element.textAlign,
+ letterSpacing: `${canvasLetterSpacing}px`,
+ lineHeight,
+ color: "transparent",
+ caretColor: element.color,
+ backgroundColor: shouldShowBackground ? bg.color : "transparent",
+ minHeight: lineHeightPx,
+ textDecoration: element.textDecoration ?? "none",
+ padding: shouldShowBackground
+ ? `${canvasPaddingY}px ${canvasPaddingX}px`
+ : 0,
+ minWidth: 1,
+ }}
+ onInput={handleInput}
+ onBlur={onCommit}
+ onKeyDown={(event) => handleKeyDown({ event })}
+ >
+ {element.content || ""}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx
new file mode 100644
index 00000000..b44e42ef
--- /dev/null
+++ b/apps/web/src/preview/components/toolbar.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useEditor } from "@/editor/use-editor";
+import { formatTimecode } from "opencut-wasm";
+import { invokeAction } from "@/actions";
+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,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+} 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 />
+ <PlayPauseButton />
+ <div className="justify-self-end flex items-center gap-2.5">
+ <ZoomSelect />
+ <Separator orientation="vertical" className="h-4" />
+ {/* v0.4.0 */}
+ {/* <GridPopover>
+ <Button
+ variant={activeGuideDefinition ? "secondary" : "text"}
+ size="icon"
+ >
+ {activeGuideDefinition ? (
+ activeGuideDefinition.renderTriggerIcon()
+ ) : (
+ <HugeiconsIcon icon={GridTableIcon} />
+ )}
+ </Button>
+ </GridPopover> */}
+ <Button variant="text" onClick={onToggleFullscreen}>
+ <HugeiconsIcon icon={FullScreenIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function TimecodeDisplay() {
+ const editor = useEditor();
+ const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
+ const fps = useEditor((e) => e.project.getActive().settings.fps);
+ const [currentTime, setCurrentTime] = useState(() =>
+ editor.playback.getCurrentTime(),
+ );
+
+ useEffect(() => {
+ const handler = (e: Event) =>
+ setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
+ window.addEventListener("playback-update", handler);
+ window.addEventListener("playback-seek", handler);
+ return () => {
+ window.removeEventListener("playback-update", handler);
+ window.removeEventListener("playback-seek", handler);
+ };
+ }, []);
+
+ return (
+ <div className="flex items-center">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <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 })}
+ </span>
+ </div>
+ );
+}
+
+function ZoomSelect() {
+ const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
+ usePreviewViewport();
+
+ const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
+
+ const onValueChange = (value: string) => {
+ if (value === "fit") {
+ fitToScreen();
+ } else {
+ setViewportPercent({ percent: Number(value) });
+ }
+ };
+
+ return (
+ <Select
+ value={isAtFit ? "fit" : String(zoomPercent)}
+ onValueChange={onValueChange}
+ >
+ <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
+ <SelectContent>
+ <SelectItem value="fit">Fit</SelectItem>
+ <SelectSeparator />
+ {PREVIEW_ZOOM_PRESETS.map((preset) => (
+ <SelectItem key={preset} value={String(preset)}>
+ {preset}%
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
+
+function PlayPauseButton() {
+ const isPlaying = useEditor((e) => e.playback.getIsPlaying());
+
+ return (
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => invokeAction("toggle-play")}
+ >
+ <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
+ </Button>
+ );
+}
diff --git a/apps/web/src/preview/components/transform-handles.tsx b/apps/web/src/preview/components/transform-handles.tsx
new file mode 100644
index 00000000..ad496cbc
--- /dev/null
+++ b/apps/web/src/preview/components/transform-handles.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import { usePreviewViewport } from "@/preview/components/preview-viewport";
+import { useTransformHandles } from "@/preview/hooks/use-transform-handles";
+import { isVisualElement } from "@/timeline/element-utils";
+import {
+ getCornerPosition,
+ getEdgeHandlePosition,
+ ROTATION_HANDLE_OFFSET,
+ type Corner,
+ type Edge,
+} from "@/preview/element-bounds";
+import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
+import {
+ BoundingBoxOutline,
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ getResizeCursor,
+} from "./handle-primitives";
+import { Rotate01Icon } from "@hugeicons/core-free-icons";
+
+const CORNERS: Corner[] = [
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+];
+const EDGES: Edge[] = ["right", "left", "bottom"];
+
+export function TransformHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: OnSnapLinesChange;
+}) {
+ const viewport = usePreviewViewport();
+ const {
+ selectedWithBounds,
+ hasVisualSelection,
+ handleCornerPointerDown,
+ handleEdgePointerDown,
+ handleRotationPointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ } = useTransformHandles({ onSnapLinesChange });
+
+ if (!hasVisualSelection || !selectedWithBounds) return null;
+
+ const { bounds, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return null;
+
+ const displayScale = viewport.getDisplayScale();
+
+ const toOverlay = ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) =>
+ viewport.canvasToOverlay({
+ canvasX,
+ canvasY,
+ });
+
+ const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
+ const outlineWidth = Math.abs(bounds.width) * displayScale.x;
+ const outlineHeight = Math.abs(bounds.height) * displayScale.y;
+
+ const rotationAngleRad = (bounds.rotation * Math.PI) / 180;
+ const topCenterLocalY = -bounds.height / 2;
+ const topCenterScreen = toOverlay({
+ canvasX: bounds.cx - topCenterLocalY * Math.sin(rotationAngleRad),
+ canvasY: bounds.cy + topCenterLocalY * Math.cos(rotationAngleRad),
+ });
+ const rotationHandleScreen = {
+ x: topCenterScreen.x + Math.sin(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
+ y: topCenterScreen.y - Math.cos(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
+ };
+
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
+ return (
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
+ <BoundingBoxOutline
+ center={center}
+ outlineWidth={outlineWidth}
+ outlineHeight={outlineHeight}
+ rotation={bounds.rotation}
+ />
+ {CORNERS.map((corner) => {
+ const cornerPosition = getCornerPosition({ bounds, corner });
+ const screen = toOverlay({
+ canvasX: cornerPosition.x,
+ canvasY: cornerPosition.y,
+ });
+ const angleDeg =
+ Math.atan2(screen.y - center.y, screen.x - center.x) *
+ (180 / Math.PI);
+ return (
+ <CornerHandle
+ key={corner}
+ cursor={getResizeCursor({ angleDeg })}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleCornerPointerDown({ event, corner })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ {EDGES.map((edge) => {
+ const edgePosition = getEdgeHandlePosition({ bounds, edge });
+ const screen = toOverlay({
+ canvasX: edgePosition.x,
+ canvasY: edgePosition.y,
+ });
+ return (
+ <EdgeHandle
+ key={edge}
+ edge={edge}
+ screen={screen}
+ rotation={bounds.rotation}
+ onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ <IconHandle
+ icon={Rotate01Icon}
+ screen={rotationHandleScreen}
+ onPointerDown={(event) => handleRotationPointerDown({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts
new file mode 100644
index 00000000..ac556181
--- /dev/null
+++ b/apps/web/src/preview/element-bounds.ts
@@ -0,0 +1,308 @@
+import type { SceneTracks, TimelineElement } from "@/timeline";
+import type { MediaAsset } from "@/media/types";
+import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
+import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
+import { measureTextElement } from "@/text/measure-element";
+import {
+ getElementLocalTime,
+ resolveTransformAtTime,
+} from "@/animation";
+
+export interface ElementBounds {
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+}
+
+export interface ElementWithBounds {
+ trackId: string;
+ elementId: string;
+ element: TimelineElement;
+ bounds: ElementBounds;
+}
+
+function getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ sourceWidth: number;
+ sourceHeight: number;
+ transform: {
+ scaleX: number;
+ scaleY: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const containScale = Math.min(
+ canvasWidth / sourceWidth,
+ canvasHeight / sourceHeight,
+ );
+ const scaledWidth = sourceWidth * containScale * transform.scaleX;
+ const scaledHeight = sourceHeight * containScale * transform.scaleY;
+ const cx = canvasWidth / 2 + transform.position.x;
+ const cy = canvasHeight / 2 + transform.position.y;
+
+ return {
+ cx,
+ cy,
+ width: scaledWidth,
+ height: scaledHeight,
+ rotation: transform.rotate,
+ };
+}
+
+function getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ rect: { left: number; top: number; width: number; height: number };
+ transform: {
+ scaleX: number;
+ scaleY: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const localCenterX = rect.left + rect.width / 2;
+ const localCenterY = rect.top + rect.height / 2;
+ const scaledCenterX = localCenterX * transform.scaleX;
+ const scaledCenterY = localCenterY * transform.scaleY;
+ const rotationRad = (transform.rotate * Math.PI) / 180;
+ const cos = Math.cos(rotationRad);
+ const sin = Math.sin(rotationRad);
+ return {
+ cx:
+ canvasWidth / 2 +
+ transform.position.x +
+ scaledCenterX * cos -
+ scaledCenterY * sin,
+ cy:
+ canvasHeight / 2 +
+ transform.position.y +
+ scaledCenterX * sin +
+ scaledCenterY * cos,
+ width: rect.width * transform.scaleX,
+ height: rect.height * transform.scaleY,
+ rotation: transform.rotate,
+ };
+}
+
+/**
+ * Bounds policy: bounds reflect base content geometry (text glyphs + background,
+ * sticker/image/video content area) and base transform. Post-effect spill (blur,
+ * glow) and mask-clipped regions are intentionally excluded — handles manipulate
+ * the canonical element geometry, not visual effect output.
+ */
+function getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+ localTime,
+}: {
+ element: TimelineElement;
+ canvasSize: { width: number; height: number };
+ mediaAsset?: MediaAsset | null;
+ localTime: number;
+}): ElementBounds | null {
+ if (element.type === "audio" || element.type === "effect") return null;
+ if ("hidden" in element && element.hidden) return null;
+
+ const { width: canvasWidth, height: canvasHeight } = canvasSize;
+
+ if (element.type === "video" || element.type === "image") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ const sourceWidth = mediaAsset?.width ?? canvasWidth;
+ const sourceHeight = mediaAsset?.height ?? canvasHeight;
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform,
+ });
+ }
+
+ if (element.type === "sticker") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
+ sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
+ transform,
+ });
+ }
+
+ if (element.type === "graphic") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ transform,
+ });
+ }
+
+ if (element.type === "text") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+
+ const measured = measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+ });
+
+ return getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect: measured.visualRect,
+ transform,
+ });
+ }
+
+ return null;
+}
+
+export const ROTATION_HANDLE_OFFSET = 24;
+
+export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+export type Edge = "right" | "left" | "bottom";
+
+export function getCornerPosition({
+ bounds,
+ corner,
+}: {
+ bounds: ElementBounds;
+ corner: Corner;
+}): { x: number; y: number } {
+ const halfW = bounds.width / 2;
+ const halfH = 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" ? -halfW : halfW;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getEdgeHandlePosition({
+ bounds,
+ edge,
+}: {
+ bounds: ElementBounds;
+ edge: Edge;
+}): { x: number; y: 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 = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
+ const localY = edge === "bottom" ? halfHeight : 0;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+}: {
+ tracks: SceneTracks;
+ currentTime: number;
+ canvasSize: { width: number; height: number };
+ mediaAssets: MediaAsset[];
+}): ElementWithBounds[] {
+ const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
+ const orderedTracks = [
+ ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
+ ...(!tracks.main.hidden ? [tracks.main] : []),
+ ].reverse();
+
+ const result: ElementWithBounds[] = [];
+
+ for (const track of orderedTracks) {
+ const elements = track.elements
+ .filter((element) => !("hidden" in element && element.hidden))
+ .filter(
+ (element) =>
+ currentTime >= element.startTime &&
+ currentTime < element.startTime + element.duration,
+ )
+ .slice()
+ .sort((a, b) => {
+ if (a.startTime !== b.startTime) return a.startTime - b.startTime;
+ return a.id.localeCompare(b.id);
+ });
+
+ for (const element of elements) {
+ const localTime = getElementLocalTime({
+ timelineTime: currentTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const mediaAsset =
+ element.type === "video" || element.type === "image"
+ ? mediaMap.get(element.mediaId)
+ : undefined;
+ const bounds = getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+ localTime,
+ });
+ if (bounds) {
+ result.push({
+ trackId: track.id,
+ elementId: element.id,
+ element,
+ bounds,
+ });
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/apps/web/src/preview/hit-test.ts b/apps/web/src/preview/hit-test.ts
new file mode 100644
index 00000000..0f5fd45e
--- /dev/null
+++ b/apps/web/src/preview/hit-test.ts
@@ -0,0 +1,102 @@
+import type { ElementWithBounds } from "./element-bounds";
+import type { ElementRef } from "@/timeline/types";
+
+function pointInRotatedRect({
+ px,
+ py,
+ cx,
+ cy,
+ width,
+ height,
+ rotation,
+}: {
+ px: number;
+ py: number;
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+}): boolean {
+ const angleRad = (rotation * Math.PI) / 180;
+ const cos = Math.cos(-angleRad);
+ const sin = Math.sin(-angleRad);
+ const dx = px - cx;
+ const dy = py - cy;
+ const localX = dx * cos - dy * sin;
+ const localY = dx * sin + dy * cos;
+ const halfW = Math.abs(width) / 2;
+ const halfH = Math.abs(height) / 2;
+ return (
+ localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH
+ );
+}
+
+export function getHitElements({
+ canvasX,
+ canvasY,
+ elementsWithBounds,
+}: {
+ canvasX: number;
+ canvasY: number;
+ elementsWithBounds: ElementWithBounds[];
+}): ElementWithBounds[] {
+ const hits: ElementWithBounds[] = [];
+
+ for (let i = elementsWithBounds.length - 1; i >= 0; i--) {
+ const { bounds } = elementsWithBounds[i];
+ if (
+ pointInRotatedRect({
+ px: canvasX,
+ py: canvasY,
+ cx: bounds.cx,
+ cy: bounds.cy,
+ width: bounds.width,
+ height: bounds.height,
+ rotation: bounds.rotation,
+ })
+ ) {
+ hits.push(elementsWithBounds[i]);
+ }
+ }
+
+ return hits;
+}
+
+export function hitTest({
+ canvasX,
+ canvasY,
+ elementsWithBounds,
+}: {
+ canvasX: number;
+ canvasY: number;
+ elementsWithBounds: ElementWithBounds[];
+}): ElementWithBounds | null {
+ return (
+ getHitElements({
+ canvasX,
+ canvasY,
+ elementsWithBounds,
+ })[0] ?? null
+ );
+}
+
+export function resolvePreferredHit({
+ hits,
+ preferredElements,
+}: {
+ hits: ElementWithBounds[];
+ preferredElements: ElementRef[];
+}): ElementWithBounds | null {
+ if (preferredElements.length === 0) return null;
+
+ return (
+ hits.find((hit) =>
+ preferredElements.some(
+ (preferredElement) =>
+ preferredElement.trackId === hit.trackId &&
+ preferredElement.elementId === hit.elementId,
+ ),
+ ) ?? null
+ );
+}
diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts
new file mode 100644
index 00000000..78212e74
--- /dev/null
+++ b/apps/web/src/preview/hooks/use-preview-interaction.ts
@@ -0,0 +1,456 @@
+import { useCallback, useEffect, useRef, useState } 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 { registerCanceller } from "@/editor/cancel-interaction";
+
+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,
+}: {
+ onSnapLinesChange?: OnSnapLinesChange;
+ isMaskMode?: boolean;
+}) {
+ 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 commitTextEdit = useCallback(() => {
+ const current = editingTextRef.current;
+ if (!current) return;
+ editingTextRef.current = null;
+ editor.timeline.commitPreview();
+ setEditingText(null);
+ }, [editor.timeline]);
+
+ 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]);
+
+ 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],
+ );
+
+ return {
+ onPointerDown: handlePointerDown,
+ onPointerMove: handlePointerMove,
+ onPointerUp: handlePointerUp,
+ onDoubleClick: handleDoubleClick,
+ editingText,
+ commitTextEdit,
+ };
+}
diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts
new file mode 100644
index 00000000..030e026c
--- /dev/null
+++ b/apps/web/src/preview/hooks/use-transform-handles.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/preview/overlays.ts b/apps/web/src/preview/overlays.ts
new file mode 100644
index 00000000..6e5b0816
--- /dev/null
+++ b/apps/web/src/preview/overlays.ts
@@ -0,0 +1,108 @@
+import type { ReactNode } from "react";
+
+export type PreviewOverlayHudAnchor =
+ | "top-left"
+ | "top-right"
+ | "bottom-left"
+ | "bottom-right";
+
+export type PreviewOverlayMount =
+ | {
+ kind: "hud";
+ anchor: PreviewOverlayHudAnchor;
+ order?: number;
+ }
+ | {
+ kind: "scene";
+ x?: number;
+ y?: number;
+ width?: number;
+ height?: number;
+ }
+ | {
+ kind: "viewport";
+ x?: number;
+ y?: number;
+ width?: number;
+ height?: number;
+ };
+
+export type PreviewOverlayPlane = "under-interaction" | "over-interaction";
+
+export interface PreviewOverlayRenderContext {
+ sceneHeight: number;
+ sceneWidth: number;
+}
+
+export interface PreviewOverlayInstance {
+ id: string;
+ mount: PreviewOverlayMount;
+ plane?: PreviewOverlayPlane;
+ pointerEvents?: "none" | "auto";
+ zIndex?: number;
+ render: (context: PreviewOverlayRenderContext) => ReactNode;
+}
+
+export interface PreviewOverlayDefinition {
+ id: string;
+ label: string;
+ defaultVisible?: boolean;
+}
+
+export interface PreviewOverlayControl extends PreviewOverlayDefinition {
+ isVisible: boolean;
+}
+
+export interface PreviewOverlaySourceResult {
+ definitions: PreviewOverlayDefinition[];
+ instances: PreviewOverlayInstance[];
+}
+
+export const EMPTY_PREVIEW_OVERLAY_SOURCE_RESULT: PreviewOverlaySourceResult = {
+ definitions: [],
+ instances: [],
+};
+
+export function isPreviewOverlayVisible({
+ overlay,
+ overlays,
+}: {
+ overlay: PreviewOverlayDefinition;
+ overlays: Record<string, boolean>;
+}): boolean {
+ return overlays[overlay.id] ?? overlay.defaultVisible ?? true;
+}
+
+export function createPreviewOverlayControl({
+ overlay,
+ overlays,
+}: {
+ overlay: PreviewOverlayDefinition;
+ overlays: Record<string, boolean>;
+}): PreviewOverlayControl {
+ return {
+ ...overlay,
+ isVisible: isPreviewOverlayVisible({ overlay, overlays }),
+ };
+}
+
+export function mergePreviewOverlaySources({
+ sources,
+}: {
+ sources: PreviewOverlaySourceResult[];
+}): PreviewOverlaySourceResult {
+ const definitionsById = new Map<string, PreviewOverlayDefinition>();
+ const instances: PreviewOverlayInstance[] = [];
+
+ for (const source of sources) {
+ for (const definition of source.definitions) {
+ definitionsById.set(definition.id, definition);
+ }
+ instances.push(...source.instances);
+ }
+
+ return {
+ definitions: [...definitionsById.values()],
+ instances,
+ };
+}
diff --git a/apps/web/src/preview/preview-coords.ts b/apps/web/src/preview/preview-coords.ts
new file mode 100644
index 00000000..64184990
--- /dev/null
+++ b/apps/web/src/preview/preview-coords.ts
@@ -0,0 +1,101 @@
+export interface PreviewViewportGeometry {
+ canvasHeight: number;
+ canvasWidth: number;
+ centerX: number;
+ centerY: number;
+ scale: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}
+
+function getCanvasOrigin({
+ geometry,
+}: {
+ geometry: PreviewViewportGeometry;
+}): { x: number; y: number } {
+ return {
+ x: geometry.viewportWidth / 2 - geometry.centerX * geometry.scale,
+ y: geometry.viewportHeight / 2 - geometry.centerY * geometry.scale,
+ };
+}
+
+export function screenToCanvas({
+ clientX,
+ clientY,
+ geometry,
+ viewportRect,
+}: {
+ clientX: number;
+ clientY: number;
+ geometry: PreviewViewportGeometry;
+ viewportRect: DOMRect;
+}): { x: number; y: number } {
+ const overlayX = clientX - viewportRect.left;
+ const overlayY = clientY - viewportRect.top;
+
+ return {
+ x:
+ geometry.centerX +
+ (overlayX - geometry.viewportWidth / 2) / geometry.scale,
+ y:
+ geometry.centerY +
+ (overlayY - geometry.viewportHeight / 2) / geometry.scale,
+ };
+}
+
+export function canvasToOverlay({
+ canvasX,
+ canvasY,
+ geometry,
+}: {
+ canvasX: number;
+ canvasY: number;
+ geometry: PreviewViewportGeometry;
+}): { x: number; y: number } {
+ const canvasOrigin = getCanvasOrigin({ geometry });
+
+ return {
+ x: canvasOrigin.x + canvasX * geometry.scale,
+ y: canvasOrigin.y + canvasY * geometry.scale,
+ };
+}
+
+export function positionToOverlay({
+ positionX,
+ positionY,
+ geometry,
+}: {
+ positionX: number;
+ positionY: number;
+ geometry: PreviewViewportGeometry;
+}): { x: number; y: number } {
+ return canvasToOverlay({
+ canvasX: geometry.canvasWidth / 2 + positionX,
+ canvasY: geometry.canvasHeight / 2 + positionY,
+ geometry,
+ });
+}
+
+export function getDisplayScale({
+ geometry,
+}: {
+ geometry: PreviewViewportGeometry;
+}): { x: number; y: number } {
+ return {
+ x: geometry.scale,
+ y: geometry.scale,
+ };
+}
+
+export function screenPixelsToLogicalThreshold({
+ geometry,
+ screenPixels,
+}: {
+ geometry: PreviewViewportGeometry;
+ screenPixels: number;
+}): { x: number; y: number } {
+ return {
+ x: screenPixels / geometry.scale,
+ y: screenPixels / geometry.scale,
+ };
+}
diff --git a/apps/web/src/preview/preview-snap.ts b/apps/web/src/preview/preview-snap.ts
new file mode 100644
index 00000000..86d35390
--- /dev/null
+++ b/apps/web/src/preview/preview-snap.ts
@@ -0,0 +1,539 @@
+export interface SnapLine {
+ type: "horizontal" | "vertical";
+ position: number;
+}
+
+const ROTATION_SNAP_STEP_DEGREES = 90;
+const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
+export const MIN_SCALE = 0.01;
+export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
+
+export interface SnapResult {
+ snappedPosition: { x: number; y: number };
+ activeLines: SnapLine[];
+}
+
+type ScaleEdge = "left" | "right" | "top" | "bottom";
+
+export interface ScaleEdgePreference {
+ left?: boolean;
+ right?: boolean;
+ top?: boolean;
+ bottom?: boolean;
+}
+
+function hasPreferredEdge({
+ preferredEdges,
+ edge,
+}: {
+ preferredEdges?: ScaleEdgePreference;
+ edge: ScaleEdge;
+}): boolean {
+ return preferredEdges?.[edge] === true;
+}
+
+function pickClosestScaleCandidate<T extends { distance: number; edge: ScaleEdge }>({
+ candidates,
+ preferredEdges,
+}: {
+ candidates: T[];
+ preferredEdges?: ScaleEdgePreference;
+}): T | null {
+ if (candidates.length === 0) {
+ return null;
+ }
+
+ return candidates.reduce((bestCandidate, candidate) => {
+ if (candidate.distance < bestCandidate.distance) {
+ return candidate;
+ }
+ if (candidate.distance > bestCandidate.distance) {
+ return bestCandidate;
+ }
+
+ const shouldPreferCandidate = hasPreferredEdge({
+ preferredEdges,
+ edge: candidate.edge,
+ });
+ const shouldPreferBestCandidate = hasPreferredEdge({
+ preferredEdges,
+ edge: bestCandidate.edge,
+ });
+
+ return shouldPreferCandidate && !shouldPreferBestCandidate
+ ? candidate
+ : bestCandidate;
+ });
+}
+
+export function snapPosition({
+ proposedPosition,
+ canvasSize,
+ elementSize,
+ rotation = 0,
+ snapThreshold,
+}: {
+ proposedPosition: { x: number; y: number };
+ canvasSize: { width: number; height: number };
+ elementSize: { width: number; height: number };
+ rotation?: number;
+ snapThreshold: { x: number; y: number };
+}): SnapResult {
+ const centerX = 0;
+ const centerY = 0;
+ const left = -canvasSize.width / 2;
+ const right = canvasSize.width / 2;
+ const top = -canvasSize.height / 2;
+ const bottom = canvasSize.height / 2;
+
+ const rotRad = (rotation * Math.PI) / 180;
+ const cosR = Math.abs(Math.cos(rotRad));
+ const sinR = Math.abs(Math.sin(rotRad));
+ const halfWidth = (elementSize.width * cosR + elementSize.height * sinR) / 2;
+ const halfHeight = (elementSize.width * sinR + elementSize.height * cosR) / 2;
+ const activeLines: SnapLine[] = [];
+
+ type AxisSnapCandidate = {
+ snappedPosition: number;
+ line: SnapLine;
+ distance: number;
+ };
+
+ function getClosestAxisSnap({
+ candidates,
+ threshold,
+ }: {
+ candidates: AxisSnapCandidate[];
+ threshold: number;
+ }): AxisSnapCandidate | null {
+ const snapCandidatesWithinThreshold = candidates.filter(
+ (candidate) => candidate.distance <= threshold,
+ );
+ if (snapCandidatesWithinThreshold.length === 0) {
+ return null;
+ }
+ return snapCandidatesWithinThreshold.reduce((closest, current) =>
+ current.distance < closest.distance ? current : closest,
+ );
+ }
+
+ const verticalTargets = [centerX, left, right];
+ const horizontalTargets = [centerY, top, bottom];
+
+ const xCandidates: AxisSnapCandidate[] = [];
+ for (const targetX of verticalTargets) {
+ xCandidates.push({
+ snappedPosition: targetX,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x - targetX),
+ });
+ xCandidates.push({
+ snappedPosition: targetX + halfWidth,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x - halfWidth - targetX),
+ });
+ xCandidates.push({
+ snappedPosition: targetX - halfWidth,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x + halfWidth - targetX),
+ });
+ }
+ const yCandidates: AxisSnapCandidate[] = [];
+ for (const targetY of horizontalTargets) {
+ yCandidates.push({
+ snappedPosition: targetY,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y - targetY),
+ });
+ yCandidates.push({
+ snappedPosition: targetY + halfHeight,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y - halfHeight - targetY),
+ });
+ yCandidates.push({
+ snappedPosition: targetY - halfHeight,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y + halfHeight - targetY),
+ });
+ }
+
+ const closestX = getClosestAxisSnap({
+ candidates: xCandidates,
+ threshold: snapThreshold.x,
+ });
+ const closestY = getClosestAxisSnap({
+ candidates: yCandidates,
+ threshold: snapThreshold.y,
+ });
+
+ const x = closestX?.snappedPosition ?? proposedPosition.x;
+ const y = closestY?.snappedPosition ?? proposedPosition.y;
+ if (closestX) {
+ activeLines.push(closestX.line);
+ }
+ if (closestY) {
+ activeLines.push(closestY.line);
+ }
+
+ return {
+ snappedPosition: { x, y },
+ activeLines,
+ };
+}
+
+export interface ScaleSnapResult {
+ snappedScale: number;
+ activeLines: SnapLine[];
+}
+
+export function snapScale({
+ proposedScale,
+ position,
+ baseWidth,
+ baseHeight,
+ rotation = 0,
+ canvasSize,
+ snapThreshold,
+ preferredEdges,
+}: {
+ proposedScale: number;
+ position: { x: number; y: number };
+ baseWidth: number;
+ baseHeight: number;
+ rotation?: number;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+ preferredEdges?: ScaleEdgePreference;
+}): ScaleSnapResult {
+ const centerX = 0;
+ const centerY = 0;
+ const left = -canvasSize.width / 2;
+ const right = canvasSize.width / 2;
+ const top = -canvasSize.height / 2;
+ const bottom = canvasSize.height / 2;
+
+ const rotRad = (rotation * Math.PI) / 180;
+ const cosR = Math.abs(Math.cos(rotRad));
+ const sinR = Math.abs(Math.sin(rotRad));
+ const aabbBaseHalfW = (baseWidth * cosR + baseHeight * sinR) / 2;
+ const aabbBaseHalfH = (baseWidth * sinR + baseHeight * cosR) / 2;
+
+ const leftEdge = position.x - aabbBaseHalfW * proposedScale;
+ const rightEdge = position.x + aabbBaseHalfW * proposedScale;
+ const topEdge = position.y - aabbBaseHalfH * proposedScale;
+ const bottomEdge = position.y + aabbBaseHalfH * proposedScale;
+
+ interface SnapCandidate {
+ scale: number;
+ distance: number;
+ lines: SnapLine[];
+ edge: ScaleEdge;
+ }
+
+ const candidates: SnapCandidate[] = [];
+
+ const verticalTargets = [
+ { position: left, line: { type: "vertical" as const, position: left } },
+ {
+ position: centerX,
+ line: { type: "vertical" as const, position: centerX },
+ },
+ { position: right, line: { type: "vertical" as const, position: right } },
+ ];
+
+ for (const target of verticalTargets) {
+ const distanceLeft = Math.abs(leftEdge - target.position);
+ if (distanceLeft <= snapThreshold.x) {
+ const scale = (position.x - target.position) / aabbBaseHalfW;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceLeft,
+ lines: [target.line],
+ edge: "left",
+ });
+ }
+ }
+ const distanceRight = Math.abs(rightEdge - target.position);
+ if (distanceRight <= snapThreshold.x) {
+ const scale = (target.position - position.x) / aabbBaseHalfW;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceRight,
+ lines: [target.line],
+ edge: "right",
+ });
+ }
+ }
+ }
+
+ const horizontalTargets = [
+ { position: top, line: { type: "horizontal" as const, position: top } },
+ {
+ position: centerY,
+ line: { type: "horizontal" as const, position: centerY },
+ },
+ {
+ position: bottom,
+ line: { type: "horizontal" as const, position: bottom },
+ },
+ ];
+
+ for (const target of horizontalTargets) {
+ const distanceTop = Math.abs(topEdge - target.position);
+ if (distanceTop <= snapThreshold.y) {
+ const scale = (position.y - target.position) / aabbBaseHalfH;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceTop,
+ lines: [target.line],
+ edge: "top",
+ });
+ }
+ }
+ const distanceBottom = Math.abs(bottomEdge - target.position);
+ if (distanceBottom <= snapThreshold.y) {
+ const scale = (target.position - position.y) / aabbBaseHalfH;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceBottom,
+ lines: [target.line],
+ edge: "bottom",
+ });
+ }
+ }
+ }
+
+ const best = pickClosestScaleCandidate({
+ candidates,
+ preferredEdges,
+ });
+ if (!best) {
+ return { snappedScale: proposedScale, activeLines: [] };
+ }
+
+ const snappedLeft = position.x - aabbBaseHalfW * best.scale;
+ const snappedRight = position.x + aabbBaseHalfW * best.scale;
+ const snappedTop = position.y - aabbBaseHalfH * best.scale;
+ const snappedBottom = position.y + aabbBaseHalfH * best.scale;
+
+ const activeLines: SnapLine[] = [];
+ const seenKeys = new Set<string>();
+
+ function addLine({ line }: { line: SnapLine }) {
+ const key = `${line.type}-${line.position}`;
+ if (!seenKeys.has(key)) {
+ seenKeys.add(key);
+ activeLines.push(line);
+ }
+ }
+
+ for (const target of verticalTargets) {
+ if (
+ (hasPreferredEdge({ preferredEdges, edge: "left" }) &&
+ Math.abs(snappedLeft - target.position) <= 1) ||
+ (hasPreferredEdge({ preferredEdges, edge: "right" }) &&
+ Math.abs(snappedRight - target.position) <= 1) ||
+ (!preferredEdges &&
+ (Math.abs(snappedLeft - target.position) <= 1 ||
+ Math.abs(snappedRight - target.position) <= 1))
+ ) {
+ addLine({ line: target.line });
+ }
+ }
+ for (const target of horizontalTargets) {
+ if (
+ (hasPreferredEdge({ preferredEdges, edge: "top" }) &&
+ Math.abs(snappedTop - target.position) <= 1) ||
+ (hasPreferredEdge({ preferredEdges, edge: "bottom" }) &&
+ Math.abs(snappedBottom - target.position) <= 1) ||
+ (!preferredEdges &&
+ (Math.abs(snappedTop - target.position) <= 1 ||
+ Math.abs(snappedBottom - target.position) <= 1))
+ ) {
+ addLine({ line: target.line });
+ }
+ }
+
+ return {
+ snappedScale: best.scale,
+ activeLines,
+ };
+}
+
+export interface AxisSnapResult {
+ snappedScale: number;
+ /** Infinity when no snap candidate was within threshold */
+ snapDistance: number;
+ activeLines: SnapLine[];
+}
+
+export function snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY,
+ position,
+ baseWidth,
+ baseHeight,
+ rotation = 0,
+ canvasSize,
+ snapThreshold,
+ preferredEdges,
+}: {
+ proposedScaleX: number;
+ proposedScaleY: number;
+ position: { x: number; y: number };
+ baseWidth: number;
+ baseHeight: number;
+ rotation?: number;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+ preferredEdges?: ScaleEdgePreference;
+}): { x: AxisSnapResult; y: AxisSnapResult } {
+ const canvasLeft = -canvasSize.width / 2;
+ const canvasRight = canvasSize.width / 2;
+ const canvasTop = -canvasSize.height / 2;
+ const canvasBottom = canvasSize.height / 2;
+
+ const rotRad = (rotation * Math.PI) / 180;
+ const cosR = Math.abs(Math.cos(rotRad));
+ const sinR = Math.abs(Math.sin(rotRad));
+ const EPSILON = 1e-6;
+
+ // Current AABB edges at proposed scales
+ const currentAabbHalfW = (baseWidth * proposedScaleX * cosR + baseHeight * proposedScaleY * sinR) / 2;
+ const currentAabbHalfH = (baseWidth * proposedScaleX * sinR + baseHeight * proposedScaleY * cosR) / 2;
+ const currentLeftEdge = position.x - currentAabbHalfW;
+ const currentRightEdge = position.x + currentAabbHalfW;
+ const currentTopEdge = position.y - currentAabbHalfH;
+ const currentBottomEdge = position.y + currentAabbHalfH;
+
+ interface Candidate {
+ scale: number;
+ distance: number;
+ line: SnapLine;
+ edge: ScaleEdge;
+ }
+
+ function bestCandidate({
+ candidates,
+ proposedScale,
+ }: {
+ candidates: Candidate[];
+ proposedScale: number;
+ }): AxisSnapResult {
+ const best = pickClosestScaleCandidate({
+ candidates,
+ preferredEdges,
+ });
+ if (!best) {
+ return { snappedScale: proposedScale, snapDistance: Infinity, activeLines: [] };
+ }
+ return { snappedScale: best.scale, snapDistance: best.distance, activeLines: [best.line] };
+ }
+
+ // sX candidates: snap via vertical targets (left/right AABB edges) — only valid when cosR ≠ 0
+ // snap via horizontal targets (top/bottom AABB edges) — only valid when sinR ≠ 0
+ const xCandidates: Candidate[] = [];
+ const yContribW = baseHeight * proposedScaleY * sinR;
+ const yContribH = baseHeight * proposedScaleY * cosR;
+
+ if (cosR > EPSILON) {
+ for (const T of [canvasLeft, 0, canvasRight]) {
+ const line: SnapLine = { type: "vertical", position: T };
+ const distLeft = Math.abs(currentLeftEdge - T);
+ if (distLeft <= snapThreshold.x) {
+ const scale = (2 * (position.x - T) - yContribW) / (baseWidth * cosR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distLeft, line, edge: "left" });
+ }
+ const distRight = Math.abs(currentRightEdge - T);
+ if (distRight <= snapThreshold.x) {
+ const scale = (2 * (T - position.x) - yContribW) / (baseWidth * cosR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distRight, line, edge: "right" });
+ }
+ }
+ }
+
+ if (sinR > EPSILON) {
+ for (const T of [canvasTop, 0, canvasBottom]) {
+ const line: SnapLine = { type: "horizontal", position: T };
+ const distTop = Math.abs(currentTopEdge - T);
+ if (distTop <= snapThreshold.y) {
+ const scale = (2 * (position.y - T) - yContribH) / (baseWidth * sinR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distTop, line, edge: "top" });
+ }
+ const distBottom = Math.abs(currentBottomEdge - T);
+ if (distBottom <= snapThreshold.y) {
+ const scale = (2 * (T - position.y) - yContribH) / (baseWidth * sinR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
+ }
+ }
+ }
+
+ // sY candidates: snap via vertical targets — only valid when sinR ≠ 0
+ // snap via horizontal targets — only valid when cosR ≠ 0
+ const yCandidates: Candidate[] = [];
+ const xContribW = baseWidth * proposedScaleX * cosR;
+ const xContribH = baseWidth * proposedScaleX * sinR;
+
+ if (sinR > EPSILON) {
+ for (const T of [canvasLeft, 0, canvasRight]) {
+ const line: SnapLine = { type: "vertical", position: T };
+ const distLeft = Math.abs(currentLeftEdge - T);
+ if (distLeft <= snapThreshold.x) {
+ const scale = (2 * (position.x - T) - xContribW) / (baseHeight * sinR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distLeft, line, edge: "left" });
+ }
+ const distRight = Math.abs(currentRightEdge - T);
+ if (distRight <= snapThreshold.x) {
+ const scale = (2 * (T - position.x) - xContribW) / (baseHeight * sinR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distRight, line, edge: "right" });
+ }
+ }
+ }
+
+ if (cosR > EPSILON) {
+ for (const T of [canvasTop, 0, canvasBottom]) {
+ const line: SnapLine = { type: "horizontal", position: T };
+ const distTop = Math.abs(currentTopEdge - T);
+ if (distTop <= snapThreshold.y) {
+ const scale = (2 * (position.y - T) - xContribH) / (baseHeight * cosR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distTop, line, edge: "top" });
+ }
+ const distBottom = Math.abs(currentBottomEdge - T);
+ if (distBottom <= snapThreshold.y) {
+ const scale = (2 * (T - position.y) - xContribH) / (baseHeight * cosR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
+ }
+ }
+ }
+
+ return {
+ x: bestCandidate({ candidates: xCandidates, proposedScale: proposedScaleX }),
+ y: bestCandidate({ candidates: yCandidates, proposedScale: proposedScaleY }),
+ };
+}
+
+export interface RotationSnapResult {
+ snappedRotation: number;
+ isSnapped: boolean;
+}
+
+export function snapRotation({
+ proposedRotation,
+}: {
+ proposedRotation: number;
+}): RotationSnapResult {
+ const nearestRotationSnap =
+ Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
+ ROTATION_SNAP_STEP_DEGREES;
+ const distanceToNearestSnap = Math.abs(
+ proposedRotation - nearestRotationSnap,
+ );
+ if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
+ return { snappedRotation: nearestRotationSnap, isSnapped: true };
+ }
+ return { snappedRotation: proposedRotation, isSnapped: false };
+}
diff --git a/apps/web/src/preview/preview-store.ts b/apps/web/src/preview/preview-store.ts
new file mode 100644
index 00000000..0cfcbdff
--- /dev/null
+++ b/apps/web/src/preview/preview-store.ts
@@ -0,0 +1,104 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { isGuideId, type GuideId } from "@/guides";
+import { DEFAULT_GRID_CONFIG } from "@/guides/grid";
+import type { GridConfig } from "@/guides/types";
+
+type PreviewOverlaysState = Record<string, boolean>;
+
+interface PersistedPreviewState {
+ activeGuide?: string | null;
+ layoutGuide?: {
+ platform?: string | null;
+ };
+ overlays?: PreviewOverlaysState;
+ gridConfig?: GridConfig;
+}
+
+interface PreviewState {
+ activeGuide: GuideId | null;
+ overlays: PreviewOverlaysState;
+ gridConfig: GridConfig;
+ toggleGuide: (guideId: GuideId) => void;
+ setGridConfig: (config: Partial<GridConfig>) => void;
+ setOverlayVisibility: ({
+ overlayId,
+ isVisible,
+ }: {
+ overlayId: string;
+ isVisible: boolean;
+ }) => void;
+ toggleOverlayVisibility: ({ overlayId }: { overlayId: string }) => void;
+}
+
+const DEFAULT_PREVIEW_OVERLAYS: PreviewOverlaysState = {};
+
+function getPersistedActiveGuide(
+ state: PersistedPreviewState | undefined,
+): GuideId | null {
+ const persistedGuide =
+ state?.activeGuide ?? state?.layoutGuide?.platform ?? null;
+
+ if (typeof persistedGuide !== "string") {
+ return null;
+ }
+
+ return isGuideId(persistedGuide) ? persistedGuide : null;
+}
+
+export const usePreviewStore = create<PreviewState>()(
+ persist(
+ (set) => ({
+ activeGuide: null,
+ overlays: DEFAULT_PREVIEW_OVERLAYS,
+ gridConfig: DEFAULT_GRID_CONFIG,
+ toggleGuide: (guideId) => {
+ set((state) => ({
+ activeGuide: state.activeGuide === guideId ? null : guideId,
+ }));
+ },
+ setGridConfig: (config) => {
+ set((state) => ({
+ gridConfig: { ...state.gridConfig, ...config },
+ }));
+ },
+ setOverlayVisibility: ({ overlayId, isVisible }) => {
+ set((state) => ({
+ overlays: {
+ ...state.overlays,
+ [overlayId]: isVisible,
+ },
+ }));
+ },
+ toggleOverlayVisibility: ({ overlayId }) => {
+ set((state) => ({
+ overlays: {
+ ...state.overlays,
+ [overlayId]: !state.overlays[overlayId],
+ },
+ }));
+ },
+ }),
+ {
+ name: "preview-settings",
+ version: 6,
+ migrate: (persistedState) => {
+ const state = persistedState as PersistedPreviewState | undefined;
+
+ return {
+ activeGuide: getPersistedActiveGuide(state),
+ overlays: DEFAULT_PREVIEW_OVERLAYS,
+ gridConfig: {
+ rows: state?.gridConfig?.rows ?? DEFAULT_GRID_CONFIG.rows,
+ cols: state?.gridConfig?.cols ?? DEFAULT_GRID_CONFIG.cols,
+ },
+ };
+ },
+ partialize: (state) => ({
+ activeGuide: state.activeGuide,
+ overlays: state.overlays,
+ gridConfig: state.gridConfig,
+ }),
+ },
+ ),
+);
diff --git a/apps/web/src/preview/zoom.ts b/apps/web/src/preview/zoom.ts
new file mode 100644
index 00000000..9e6f5905
--- /dev/null
+++ b/apps/web/src/preview/zoom.ts
@@ -0,0 +1,7 @@
+export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200];
+
+export const PREVIEW_ZOOM = {
+ min: 0.25,
+ max: 16,
+ step: 1.25,
+} as const;