From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/preview/components/context-menu.tsx | 85 +++ apps/web/src/preview/components/cursors.ts | 21 + apps/web/src/preview/components/guide-popover.tsx | 100 ++++ .../src/preview/components/handle-primitives.tsx | 427 ++++++++++++++ apps/web/src/preview/components/index.tsx | 328 +++++++++++ apps/web/src/preview/components/mask-handles.tsx | 308 ++++++++++ apps/web/src/preview/components/overlay-layer.tsx | 197 +++++++ .../components/preview-interaction-overlay.tsx | 103 ++++ .../src/preview/components/preview-viewport.tsx | 576 +++++++++++++++++++ apps/web/src/preview/components/snap-guides.tsx | 47 ++ .../src/preview/components/text-edit-overlay.tsx | 158 ++++++ apps/web/src/preview/components/toolbar.tsx | 148 +++++ .../src/preview/components/transform-handles.tsx | 145 +++++ apps/web/src/preview/element-bounds.ts | 308 ++++++++++ apps/web/src/preview/hit-test.ts | 102 ++++ .../src/preview/hooks/use-preview-interaction.ts | 456 +++++++++++++++ .../web/src/preview/hooks/use-transform-handles.ts | 629 +++++++++++++++++++++ apps/web/src/preview/overlays.ts | 108 ++++ apps/web/src/preview/preview-coords.ts | 101 ++++ apps/web/src/preview/preview-snap.ts | 539 ++++++++++++++++++ apps/web/src/preview/preview-store.ts | 104 ++++ apps/web/src/preview/zoom.ts | 7 + 22 files changed, 4997 insertions(+) create mode 100644 apps/web/src/preview/components/context-menu.tsx create mode 100644 apps/web/src/preview/components/cursors.ts create mode 100644 apps/web/src/preview/components/guide-popover.tsx create mode 100644 apps/web/src/preview/components/handle-primitives.tsx create mode 100644 apps/web/src/preview/components/index.tsx create mode 100644 apps/web/src/preview/components/mask-handles.tsx create mode 100644 apps/web/src/preview/components/overlay-layer.tsx create mode 100644 apps/web/src/preview/components/preview-interaction-overlay.tsx create mode 100644 apps/web/src/preview/components/preview-viewport.tsx create mode 100644 apps/web/src/preview/components/snap-guides.tsx create mode 100644 apps/web/src/preview/components/text-edit-overlay.tsx create mode 100644 apps/web/src/preview/components/toolbar.tsx create mode 100644 apps/web/src/preview/components/transform-handles.tsx create mode 100644 apps/web/src/preview/element-bounds.ts create mode 100644 apps/web/src/preview/hit-test.ts create mode 100644 apps/web/src/preview/hooks/use-preview-interaction.ts create mode 100644 apps/web/src/preview/hooks/use-transform-handles.ts create mode 100644 apps/web/src/preview/overlays.ts create mode 100644 apps/web/src/preview/preview-coords.ts create mode 100644 apps/web/src/preview/preview-snap.ts create mode 100644 apps/web/src/preview/preview-store.ts create mode 100644 apps/web/src/preview/zoom.ts (limited to 'apps/web/src/preview') 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; + 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 ( + + + Fit to screen + + + + Full screen + + + Save snapshot + + + Copy snapshot + + {overlayControls.length > 0 ? : null} + {overlayControls.map((overlayControl) => ( + + onOverlayVisibilityChange({ + overlayId: overlayControl.id, + isVisible: !!checked, + }) + } + > + {overlayControl.label} + + ))} + + ); +} 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: ` + + +`, + 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 ( + + {children} + +
+ +
+ {GUIDE_REGISTRY.map((guide) => ( + toggleGuide(guide.id)} + /> + ))} +
+
+ + {options && ( + + +
{options}
+
+ )} +
+
+
+ ); +} + +function GridItem({ + label, + preview, + isSelected, + onClick, +}: { + label: string; + preview: React.ReactNode; + isSelected?: boolean; + onClick: () => void; +}) { + return ( + + ); +} 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 ( + + ); +} + +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 ( + +
+ + ); +} + +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 ( + +
+ + ); +} + +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 ( + +
+ + ); +} + +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 ( + + + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 && ( +
+ )} +
+ + ); +} 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(null); + const { toggleFullscreen } = useFullscreen({ containerRef }); + + return ( +
+ + +
+ ); +} + +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; + onToggleFullscreen: () => void; + overlayControls: PreviewOverlayControl[]; + overlayInstances: PreviewOverlayInstance[]; + onOverlayVisibilityChange: (params: { + overlayId: string; + isVisible: boolean; + }) => void; +}) { + const canvasRef = useRef(null); + const viewportRef = useRef(null); + const lastFrameRef = useRef(-1); + const lastSceneRef = useRef(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 | null = null; + let panRafId: ReturnType | 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 ( + +
+
+ + +
+ + + + +
+
+ +
+
+ +
+
+ ); +} 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 ( +
+ {isCreatingCustomMask ? ( +
+ ) : 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 ( + + ); + } + + if (overlay.type === "rect") { + return ( + + ); + } + + if (overlay.type === "shape") { + return ( + + ); + } + + if (overlay.type === "canvas-path") { + return ( + + ); + } + + return null; + })} + {handlePositions.map((handle) => { + const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); + + if (handle.kind === "icon" && handle.icon === "rotate") { + return ( + + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.kind === "icon" && handle.icon === "feather") { + return ( + + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.kind === "edge" && handle.edgeAxis === "horizontal") { + return ( + + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.kind === "edge" && handle.edgeAxis === "vertical") { + return ( + + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.kind === "point" || handle.kind === "tangent") { + return ( + + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.kind === "corner") { + return ( + + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + return ( + + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} +
+ ); +} 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 = { + "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 + >( + (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 ( +
+ {content} +
+ ); + + case "viewport": + return ( +
+ {content} +
+ ); + + 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 ( +
+ {sortedAnchorInstances.map((instance) => { + const pointerEvents = instance.pointerEvents ?? "none"; + const content = instance.render({ + sceneHeight: viewport.sceneHeight, + sceneWidth: viewport.sceneWidth, + }); + + return ( +
+ {content} +
+ ); + })} +
+ ); + })} + + ); +} 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([]); + 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 ( +
+
e.preventDefault()} + /> + {editingText ? ( + + ) : isMaskMode ? ( + + ) : ( + + )} + +
+ ); +} 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(null); + +interface PreviewViewportProviderProps { + children: React.ReactNode; + value: PreviewViewportContextValue; +} + +interface PreviewViewportStateOptions { + canvasHeight: number; + canvasWidth: number; + viewportHeight: number; + viewportRef: React.RefObject; + 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 ( + + {children} + + ); +} + +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(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 ( +
+ {lines.map((line) => { + if (line.type === "vertical") { + return ( +
+ ); + } + return ( +
+ ); + })} +
+ ); +} 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(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 ( +
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} +
handleKeyDown({ event })} + > + {element.content || ""} +
+
+ ); +} 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 ( +
+ + +
+ + + {/* v0.4.0 */} + {/* + + */} + +
+
+ ); +} + +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 ( +
+ editor.playback.seek({ time })} + className="text-center" + /> + / + + {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })} + +
+ ); +} + +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 ( + + ); +} + +function PlayPauseButton() { + const isPlaying = useEditor((e) => e.playback.getIsPlaying()); + + return ( + + ); +} 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 ( +
+ + {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 ( + + 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 ( + handleEdgePointerDown({ event, edge })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} + handleRotationPointerDown({ event })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> +
+ ); +} 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(null); + const pendingGestureRef = useRef(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(null); + const scaleStateRef = useRef(null); + const rotationStateRef = useRef(null); + const edgeScaleStateRef = useRef(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; +}): boolean { + return overlays[overlay.id] ?? overlay.defaultVisible ?? true; +} + +export function createPreviewOverlayControl({ + overlay, + overlays, +}: { + overlay: PreviewOverlayDefinition; + overlays: Record; +}): PreviewOverlayControl { + return { + ...overlay, + isVisible: isPreviewOverlayVisible({ overlay, overlays }), + }; +} + +export function mergePreviewOverlaySources({ + sources, +}: { + sources: PreviewOverlaySourceResult[]; +}): PreviewOverlaySourceResult { + const definitionsById = new Map(); + 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({ + 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(); + + 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; + +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) => 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()( + 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; -- cgit v1.3.1