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