diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/preview | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components/editor/panels/preview')
13 files changed, 1870 insertions, 470 deletions
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx index d4bcdcae..13226378 100644 --- a/apps/web/src/components/editor/panels/preview/context-menu.tsx +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -4,9 +4,12 @@ import { ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, } from "@/components/ui/context-menu"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useEditor } from "@/hooks/use-editor"; import { usePreviewStore } from "@/stores/preview-store"; +import { toast } from "sonner"; export function PreviewContextMenu({ onToggleFullscreen, @@ -16,25 +19,55 @@ export function PreviewContextMenu({ containerRef: React.RefObject<HTMLElement | null>; }) { const editor = useEditor(); + const viewport = usePreviewViewport(); const { overlays, setOverlayVisibility } = usePreviewStore(); + 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={() => editor.renderer.saveSnapshot()} inset> + <ContextMenuItem onClick={handleSaveSnapshot} inset> Save snapshot </ContextMenuItem> + <ContextMenuItem onClick={handleCopySnapshot} inset> + Copy snapshot + </ContextMenuItem> + <ContextMenuSeparator /> <ContextMenuCheckboxItem checked={overlays.bookmarks} onCheckedChange={(checked) => setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) } > - Show bookmarks + Show bookmark notes </ContextMenuCheckboxItem> - <ContextMenuItem inset>Show grid</ContextMenuItem> </ContextMenuContent> ); } diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx new file mode 100644 index 00000000..ad7a621f --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { getGuideById } from "@/lib/guides"; +import { usePreviewStore } from "@/stores/preview-store"; +import { usePreviewViewport } from "./preview-viewport"; + +export function GuideOverlay() { + const activeGuide = usePreviewStore((state) => state.activeGuide); + const viewport = usePreviewViewport(); + const guide = getGuideById(activeGuide); + + if (!guide) { + return null; + } + + return ( + <div + className="pointer-events-none absolute" + style={{ + left: viewport.sceneLeft, + top: viewport.sceneTop, + width: viewport.sceneWidth, + height: viewport.sceneHeight, + }} + > + {guide.renderOverlay({ + width: viewport.sceneWidth, + height: viewport.sceneHeight, + })} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx new file mode 100644 index 00000000..e6d3df51 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/guide-popover.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides"; +import { usePreviewStore } from "@/stores/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/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx new file mode 100644 index 00000000..44b8a7f1 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx @@ -0,0 +1,332 @@ +"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 "cursor-ew-resize"; + if (normalized < 67.5) return "cursor-nwse-resize"; + if (normalized < 112.5) return "cursor-ns-resize"; + return "cursor-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", + cursor, + className, + )} + style={{ + left: screen.x - hitAreaSize / 2, + top: screen.y - hitAreaSize / 2, + width: hitAreaSize, + height: hitAreaSize, + pointerEvents: "auto", + }} + 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 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={cn("absolute overflow-visible", cursor)} + 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", + }} + 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={cn("absolute overflow-visible", cursor)} + 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", + }} + 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 LineOverlay({ + start, + end, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + start: { x: number; y: number }; + end: { x: number; y: number }; + 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", + }} + 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/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index df60db01..c625b9bf 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import useDeepCompareEffect from "use-deep-compare-effect"; import { useEditor } from "@/hooks/use-editor"; import { useRafLoop } from "@/hooks/use-raf-loop"; @@ -12,50 +12,70 @@ import { buildScene } from "@/services/renderer/scene-builder"; import { getLastFrameTime } from "@/lib/time"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; +import { GuideOverlay } from "./guide-overlay"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { usePreviewStore } from "@/stores/preview-store"; import { PreviewContextMenu } from "./context-menu"; import { PreviewToolbar } from "./toolbar"; +import { + PreviewViewportProvider, + usePreviewViewportState, +} from "./preview-viewport"; function usePreviewSize() { - const editor = useEditor(); - const activeProject = editor.project.getActive(); + const canvasSize = useEditor( + (e) => e.project.getActive()?.settings.canvasSize, + ); return { - width: activeProject?.settings.canvasSize.width, - height: activeProject?.settings.canvasSize.height, + 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() { const containerRef = useRef<HTMLDivElement>(null); - const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); + 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" > - <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0"> - <PreviewCanvas - onToggleFullscreen={toggleFullscreen} - containerRef={containerRef} - /> - <RenderTreeController /> - </div> - <PreviewToolbar - isFullscreen={isFullscreen} + <PreviewCanvas + containerRef={containerRef} onToggleFullscreen={toggleFullscreen} /> + <RenderTreeController /> </div> ); } function RenderTreeController() { const editor = useEditor(); - const tracks = editor.timeline.getTracks(); - const mediaAssets = editor.media.getAssets(); - const activeProject = editor.project.getActive(); + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const activeProject = useEditor((e) => e.project.getActive()); const { width, height } = usePreviewSize(); @@ -79,23 +99,30 @@ function RenderTreeController() { } function PreviewCanvas({ - onToggleFullscreen, containerRef, + onToggleFullscreen, }: { - onToggleFullscreen: () => void; containerRef: React.RefObject<HTMLElement | null>; + onToggleFullscreen: () => void; }) { const canvasRef = useRef<HTMLCanvasElement>(null); - const outerContainerRef = useRef<HTMLDivElement>(null); - const canvasBoundsRef = useRef<HTMLDivElement>(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 containerSize = useContainerSize({ containerRef: outerContainerRef }); + const viewportSize = useContainerSize({ containerRef: viewportRef }); const editor = useEditor(); - const activeProject = editor.project.getActive(); + const activeProject = useEditor((e) => e.project.getActive()); + const renderTree = useEditor((e) => e.renderer.getRenderTree()); const { overlays } = usePreviewStore(); + const viewport = usePreviewViewportState({ + canvasHeight: nativeHeight, + canvasWidth: nativeWidth, + viewportHeight: viewportSize.height, + viewportRef, + viewportWidth: viewportSize.width, + }); const renderer = useMemo(() => { return new CanvasRenderer({ @@ -105,37 +132,6 @@ function PreviewCanvas({ }); }, [nativeWidth, nativeHeight, activeProject.settings.fps]); - const displaySize = useMemo(() => { - if ( - !nativeWidth || - !nativeHeight || - containerSize.width === 0 || - containerSize.height === 0 - ) { - return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 }; - } - - const paddingBuffer = 4; - const availableWidth = containerSize.width - paddingBuffer; - const availableHeight = containerSize.height - paddingBuffer; - - const aspectRatio = nativeWidth / nativeHeight; - const containerAspect = availableWidth / availableHeight; - - const displayWidth = - containerAspect > aspectRatio - ? availableHeight * aspectRatio - : availableWidth; - const displayHeight = - containerAspect > aspectRatio - ? availableHeight - : availableWidth / aspectRatio; - - return { width: displayWidth, height: displayHeight }; - }, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]); - - const renderTree = editor.renderer.getRenderTree(); - const render = useCallback(() => { if (canvasRef.current && renderTree && !renderingRef.current) { const time = editor.playback.getCurrentTime(); @@ -168,44 +164,130 @@ function PreviewCanvas({ 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 ( - <div - ref={outerContainerRef} - className="relative flex size-full items-center justify-center" - > - <ContextMenu> - <ContextMenuTrigger asChild> - <div - ref={canvasBoundsRef} - className="relative" - style={{ width: displaySize.width, height: displaySize.height }} - > - <canvas - ref={canvasRef} - width={nativeWidth} - height={nativeHeight} - className="block border" - style={{ - width: displaySize.width, - height: displaySize.height, - background: - activeProject.settings.background.type === "blur" - ? "transparent" - : activeProject?.settings.background.color, - }} + <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, + }} + /> + <GuideOverlay /> + <PreviewInteractionOverlay /> + {overlays.bookmarks && <BookmarkNoteOverlay />} + </div> + </ContextMenuTrigger> + <PreviewContextMenu + onToggleFullscreen={onToggleFullscreen} + containerRef={containerRef} /> - <PreviewInteractionOverlay - canvasRef={canvasRef} - containerRef={canvasBoundsRef} - /> - {overlays.bookmarks && <BookmarkNoteOverlay />} - </div> - </ContextMenuTrigger> - <PreviewContextMenu - onToggleFullscreen={onToggleFullscreen} - containerRef={containerRef} - /> - </ContextMenu> - </div> + </ContextMenu> + </div> + <PreviewToolbar onToggleFullscreen={onToggleFullscreen} /> + </div> + </PreviewViewportProvider> ); } diff --git a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx deleted file mode 100644 index 44efb4c8..00000000 --- a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { usePreviewStore } from "@/stores/preview-store"; -import Image from "next/image"; - -function TikTokGuide() { - return ( - <div className="pointer-events-none absolute inset-0"> - <Image - src="/platform-guides/tiktok-blueprint.png" - alt="TikTok layout guide" - className="absolute inset-0 size-full object-contain" - draggable={false} - fill - /> - </div> - ); -} - -export function LayoutGuideOverlay() { - const { layoutGuide } = usePreviewStore(); - - if (layoutGuide.platform === null) return null; - if (layoutGuide.platform === "tiktok") return <TikTokGuide />; - - return null; -} diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx new file mode 100644 index 00000000..0c11f27a --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/mask-handles.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useMaskHandles } from "@/hooks/use-mask-handles"; +import { masksRegistry } from "@/lib/masks"; +import type { SnapLine } from "@/lib/preview/preview-snap"; +import type { ParamValues } from "@/lib/params"; +import type { RectangleMaskParams } from "@/lib/masks/types"; +import { + CornerHandle, + EdgeHandle, + IconHandle, + LineOverlay, + BoundingBoxOutline, + ShapeOutline, +} from "./handle-primitives"; +import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons"; + +function hasRectangleOutlineParams( + params: ParamValues, +): params is RectangleMaskParams { + return ( + typeof params.centerX === "number" && + typeof params.centerY === "number" && + typeof params.width === "number" && + typeof params.height === "number" + ); +} + +export function MaskHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: (lines: SnapLine[]) => void; +}) { + const viewport = usePreviewViewport(); + const { + selectedWithMask, + handlePositions, + linePoints, + handlePointerDown, + handlePointerMove, + handlePointerUp, + } = useMaskHandles({ onSnapLinesChange }); + + if (!selectedWithMask) return null; + + const toOverlay = ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => + viewport.canvasToOverlay({ + canvasX, + canvasY, + }); + + const def = masksRegistry.get(selectedWithMask.mask.type); + const { bounds } = selectedWithMask; + const maskRotation = selectedWithMask.mask.params.rotation; + + const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); + + const rectangleOutlineProps = hasRectangleOutlineParams( + selectedWithMask.mask.params, + ) + ? { + center: toOverlay({ + canvasX: + bounds.cx + selectedWithMask.mask.params.centerX * bounds.width, + canvasY: + bounds.cy + selectedWithMask.mask.params.centerY * bounds.height, + }), + outlineWidth: + selectedWithMask.mask.params.width * bounds.width * scaleX, + outlineHeight: + selectedWithMask.mask.params.height * bounds.height * scaleY, + rotation: maskRotation, + } + : null; + + const onPointerMove = (event: React.PointerEvent) => + handlePointerMove({ event }); + const onPointerUp = () => handlePointerUp(); + + return ( + <div + className="pointer-events-none absolute inset-0 overflow-hidden" + aria-hidden + > + {def.overlayShape === "line" && linePoints && ( + <LineOverlay + start={toOverlay({ + canvasX: linePoints.start.x, + canvasY: linePoints.start.y, + })} + end={toOverlay({ + canvasX: linePoints.end.x, + canvasY: linePoints.end.y, + })} + onPointerDown={(event) => + handlePointerDown({ event, handleId: "position" }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + )} + {def.overlayShape === "box" && rectangleOutlineProps && ( + def.buildOverlayPath ? ( + <> + <BoundingBoxOutline {...rectangleOutlineProps} dashed /> + <ShapeOutline + {...rectangleOutlineProps} + pathData={def.buildOverlayPath({ + width: rectangleOutlineProps.outlineWidth, + height: rectangleOutlineProps.outlineHeight, + })} + onPointerDown={(event) => + handlePointerDown({ event, handleId: "position" }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + </> + ) : ( + <BoundingBoxOutline + {...rectangleOutlineProps} + cursor="cursor-move" + onPointerDown={(event) => + handlePointerDown({ event, handleId: "position" }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ) + )} + {handlePositions.map((handle) => { + const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); + + if (handle.id === "rotation") { + return ( + <IconHandle + key={handle.id} + icon={Rotate01Icon} + screen={screen} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.id === "feather") { + return ( + <IconHandle + key={handle.id} + icon={FeatherIcon} + screen={screen} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.id === "right" || handle.id === "left") { + return ( + <EdgeHandle + key={handle.id} + edge="right" + screen={screen} + rotation={maskRotation} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.id === "bottom" || handle.id === "top") { + return ( + <EdgeHandle + key={handle.id} + edge="bottom" + screen={screen} + rotation={maskRotation} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if ( + handle.id === "top-left" || + handle.id === "top-right" || + handle.id === "bottom-left" || + handle.id === "bottom-right" || + handle.id === "scale" + ) { + return ( + <CornerHandle + key={handle.id} + screen={screen} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + return ( + <CornerHandle + key={handle.id} + cursor={handle.cursor} + screen={screen} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index 4c33a1a1..65c11726 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,54 +1,117 @@ +import { useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; +import type { SnapLine } from "@/lib/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 "../properties/stores/properties-store"; +import { useEditor } from "@/hooks/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; -export function PreviewInteractionOverlay({ - canvasRef, - containerRef, -}: { - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; -}) { const { onPointerDown, onPointerMove, onPointerUp, onDoubleClick, - snapLines, editingText, commitTextEdit, - cancelTextEdit, - } = usePreviewInteraction({ canvasRef }); + } = 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); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + const x = e.nativeEvent.offsetX; + const y = e.nativeEvent.offsetY; + const outsideCanvas = + x < viewport.sceneLeft || + x > viewport.sceneLeft + viewport.sceneWidth || + y < viewport.sceneTop || + y > viewport.sceneTop + viewport.sceneHeight; + if (outsideCanvas) { + e.stopPropagation(); + } + }; return ( <div className="absolute inset-0"> - {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */} <div className="absolute inset-0 pointer-events-auto" - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} + 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()} + onContextMenu={handleContextMenu} /> {editingText ? ( <TextEditOverlay - canvasRef={canvasRef} - containerRef={containerRef} trackId={editingText.trackId} elementId={editingText.elementId} element={editingText.element} onCommit={commitTextEdit} - onCancel={cancelTextEdit} /> + ) : isMaskMode ? ( + <MaskHandles onSnapLinesChange={setSnapLines} /> ) : ( - <TransformHandles canvasRef={canvasRef} containerRef={containerRef} /> + <TransformHandles onSnapLinesChange={setSnapLines} /> )} - <SnapGuides - lines={snapLines} - canvasRef={canvasRef} - containerRef={containerRef} - /> + <SnapGuides lines={snapLines} /> </div> ); } diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx new file mode 100644 index 00000000..e38183af --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/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 "@/lib/preview/preview-coords"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import { PREVIEW_ZOOM } from "@/constants/editor-constants"; + +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/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx index 4aa714a1..744304ea 100644 --- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -1,43 +1,25 @@ "use client"; -import { useEditor } from "@/hooks/use-editor"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import type { SnapLine } from "@/lib/preview/preview-snap"; -import { positionToOverlay } from "@/lib/preview/preview-coords"; -export function SnapGuides({ - lines, - canvasRef, - containerRef, -}: { - lines: SnapLine[]; - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; -}) { - const editor = useEditor(); - const canvasSize = editor.project.getActive().settings.canvasSize; - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); +export function SnapGuides({ lines }: { lines: SnapLine[] }) { + const viewport = usePreviewViewport(); - if (!canvasRect || !containerRect || lines.length === 0) { + if (lines.length === 0) { return null; } const toOverlayX = (logicalX: number) => - positionToOverlay({ + viewport.positionToOverlay({ positionX: logicalX, positionY: 0, - canvasRect, - containerRect, - canvasSize, }).x; const toOverlayY = (logicalY: number) => - positionToOverlay({ + viewport.positionToOverlay({ positionX: 0, positionY: logicalY, - canvasRect, - containerRect, - canvasSize, }).y; return ( diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx index 26649b26..d3c61a24 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -1,38 +1,89 @@ "use client"; import { useCallback, useEffect, useRef } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useEditor } from "@/hooks/use-editor"; -import type { TextElement } from "@/types/timeline"; +import type { TextElement } from "@/lib/timeline"; import { - positionToOverlay, - getDisplayScale, -} from "@/lib/preview/preview-coords"; -import { - DEFAULT_LINE_HEIGHT, FONT_SIZE_SCALE_REFERENCE, } from "@/constants/text-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { + getMetricAscent, + getMetricDescent, + setCanvasLetterSpacing, +} from "@/lib/text/layout"; + +let cachedCanvas: HTMLCanvasElement | null = null; + +function getMeasurementContext(): CanvasRenderingContext2D | null { + if (!cachedCanvas) cachedCanvas = document.createElement("canvas"); + return cachedCanvas.getContext("2d"); +} + +/** + * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than + * actualBoundingBox to model where the browser places the baseline within the + * line box, then measures actual glyph bounds to find the visual center. + */ +function measureCSSVisualCenterOffset({ + lines, + fontString, + letterSpacingPx, + lineHeightPx, + displayFontSize, +}: { + lines: string[]; + fontString: string; + letterSpacingPx: number; + lineHeightPx: number; + displayFontSize: number; +}): number { + const ctx = getMeasurementContext(); + if (!ctx) return 0; + + ctx.font = fontString; + ctx.textBaseline = "alphabetic"; + setCanvasLetterSpacing({ ctx, letterSpacingPx }); + + const probe = ctx.measureText("M"); + const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8; + const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2; + const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2; -const TEXT_BACKGROUND_PADDING = "4px 8px"; -const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06; + let visualTop = Number.POSITIVE_INFINITY; + let visualBottom = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < lines.length; i++) { + const metrics = ctx.measureText(lines[i] || " "); + const baseline = i * lineHeightPx + halfLeading + fontAscent; + visualTop = Math.min( + visualTop, + baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }), + ); + visualBottom = Math.max( + visualBottom, + baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }), + ); + } + + const cssBlockHeight = lines.length * lineHeightPx; + return (visualTop + visualBottom) / 2 - cssBlockHeight / 2; +} export function TextEditOverlay({ - canvasRef, - containerRef, trackId, elementId, element, onCommit, - onCancel, }: { - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; trackId: string; elementId: string; element: TextElement; onCommit: () => void; - onCancel: () => void; }) { const editor = useEditor(); + const viewport = usePreviewViewport(); const divRef = useRef<HTMLDivElement>(null); useEffect(() => { @@ -60,59 +111,67 @@ export function TextEditOverlay({ const { key } = event; if (key === "Escape") { event.preventDefault(); - onCancel(); + onCommit(); return; } }, - [onCancel], + [onCommit], ); - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); const canvasSize = editor.project.getActive().settings.canvasSize; - if (!canvasRect || !containerRect || !canvasSize) return null; + if (!canvasSize) return null; - const { x: posX, y: posY } = positionToOverlay({ + const { x: posX, y: posY } = viewport.positionToOverlay({ positionX: element.transform.position.x, positionY: element.transform.position.y, - canvasRect, - containerRect, - canvasSize, }); - const { x: displayScaleX } = getDisplayScale({ - canvasRect, - canvasSize, - }); + const { x: displayScaleX } = viewport.getDisplayScale(); const displayFontSize = element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * displayScaleX; - const verticalAlignmentOffset = - displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM; - - const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT; + const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const letterSpacing = element.letterSpacing ?? 0; + const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX; + const lineHeightPx = displayFontSize * lineHeight; + const lines = (element.content || "").split("\n"); + const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`; + + const cssVisualCenterOffset = measureCSSVisualCenterOffset({ + lines, + fontString, + letterSpacingPx: displayLetterSpacing, + lineHeightPx, + displayFontSize, + }); + + const bg = element.background; const shouldShowBackground = - element.background.enabled && - element.background.color && - element.background.color !== "transparent"; - const backgroundColor = shouldShowBackground - ? element.background.color - : "transparent"; + bg.enabled && bg.color && bg.color !== "transparent"; + const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; + const displayPaddingX = shouldShowBackground + ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * + fontSizeRatio * + displayScaleX + : 0; + const displayPaddingY = shouldShowBackground + ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * + fontSizeRatio * + displayScaleX + : 0; return ( <div className="absolute" style={{ left: posX, - top: posY - verticalAlignmentOffset, - transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`, + top: posY - cssVisualCenterOffset, + transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`, transformOrigin: "center center", }} > @@ -131,13 +190,15 @@ export function TextEditOverlay({ fontWeight, fontStyle, textAlign: element.textAlign, - letterSpacing: `${letterSpacing}px`, + letterSpacing: `${displayLetterSpacing}px`, lineHeight, color: element.color, - backgroundColor, - minHeight: displayFontSize * lineHeight, + backgroundColor: shouldShowBackground ? bg.color : "transparent", + minHeight: lineHeightPx, textDecoration: element.textDecoration ?? "none", - padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0, + padding: shouldShowBackground + ? `${displayPaddingY}px ${displayPaddingX}px` + : 0, minWidth: 1, }} onInput={handleInput} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx index 7f5cd9bc..56cb784a 100644 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useEffect } from "react"; import { useEditor } from "@/hooks/use-editor"; import { formatTimeCode } from "@/lib/time"; import { invokeAction } from "@/lib/actions"; @@ -7,74 +8,144 @@ 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 { OcSocialIcon } from "@opencut/ui/icons"; +import { getGuideById } from "@/lib/guides"; import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectSeparator, +} from "@/components/ui/select"; +import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants"; +import { usePreviewViewport } from "./preview-viewport"; +import { GridPopover } from "./guide-popover"; +import { usePreviewStore } from "@/stores/preview-store"; export function PreviewToolbar({ - isFullscreen, onToggleFullscreen, }: { - isFullscreen: boolean; onToggleFullscreen: () => void; }) { - const editor = useEditor(); - const isPlaying = editor.playback.getIsPlaying(); - const currentTime = editor.playback.getCurrentTime(); - const totalDuration = editor.timeline.getTotalDuration(); - const fps = editor.project.getActive().settings.fps; + 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"> - <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({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - </span> - </div> - - <Button - variant="text" - size="icon" - onClick={() => invokeAction("toggle-play")} - > - <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> - </Button> - + <TimecodeDisplay /> + <PlayPauseButton /> <div className="justify-self-end flex items-center gap-2.5"> - <Button - variant="secondary" - size="sm" - className="[&_svg]:size-auto px-1 h-7" - onClick={onToggleFullscreen} - title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} - > - <OcSocialIcon size={20} /> - </Button> + <ZoomSelect /> <Separator orientation="vertical" className="h-4" /> - <Button - variant="text" - onClick={onToggleFullscreen} - title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} - > + <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({ + timeInSeconds: totalDuration, + format: "HH:MM:SS:FF", + 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/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx index 2fcf8096..88332625 100644 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -1,112 +1,55 @@ "use client"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useTransformHandles } from "@/hooks/use-transform-handles"; -import { useEditor } from "@/hooks/use-editor"; import { isVisualElement } from "@/lib/timeline/element-utils"; -import { SnapGuides } from "./snap-guides"; -import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; +import { + getCornerPosition, + getEdgeHandlePosition, + getRotationHandlePosition, + type Corner, + type Edge, +} from "@/lib/preview/element-bounds"; +import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; +import { + BoundingBoxOutline, + CornerHandle, + EdgeHandle, + IconHandle, + getResizeCursor, +} from "./handle-primitives"; import { Rotate01Icon } from "@hugeicons/core-free-icons"; -const HANDLE_SIZE = 10; -const ROTATION_HANDLE_OFFSET = 24; -const ROTATION_HANDLE_RADIUS = 10; -const CORNER_HIT_AREA_SIZE = 18; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; const CORNERS: Corner[] = [ "top-left", "top-right", "bottom-left", "bottom-right", ]; - -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 getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { - x: number; - y: number; -} { - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET; - return { - x: bounds.cx - localY * sin, - y: bounds.cy + localY * cos, - }; -} - -export function getOverlayContext({ - canvasRef, - containerRef, -}: { - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; -}): { - canvasRect: DOMRect; - containerRect: DOMRect; -} | null { - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); - if (!canvasRect || !containerRect) return null; - return { canvasRect, containerRect }; -} +const EDGES: Edge[] = ["right", "left", "bottom"]; export function TransformHandles({ - canvasRef, - containerRef, + onSnapLinesChange, }: { - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; + onSnapLinesChange?: OnSnapLinesChange; }) { + const viewport = usePreviewViewport(); const { selectedWithBounds, hasVisualSelection, - snapLines, handleCornerPointerDown, + handleEdgePointerDown, handleRotationPointerDown, handlePointerMove, handlePointerUp, - } = useTransformHandles({ canvasRef }); - - const editor = useEditor(); - const canvasSize = editor.project.getActive().settings.canvasSize; + } = useTransformHandles({ onSnapLinesChange }); if (!hasVisualSelection || !selectedWithBounds) return null; - const overlayContext = getOverlayContext({ canvasRef, containerRef }); - if (!overlayContext) return null; - const { bounds, element } = selectedWithBounds; if (!isVisualElement(element)) return null; - const { canvasRect, containerRect } = overlayContext; - const displayScale = getDisplayScale({ canvasRect, canvasSize }); + const displayScale = viewport.getDisplayScale(); const toOverlay = ({ canvasX, @@ -115,31 +58,30 @@ export function TransformHandles({ canvasX: number; canvasY: number; }) => - canvasToOverlay({ + viewport.canvasToOverlay({ canvasX, canvasY, - canvasRect, - containerRect, - canvasSize, }); const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); - const outlineWidth = bounds.width * displayScale.x; - const outlineHeight = bounds.height * displayScale.y; + const outlineWidth = Math.abs(bounds.width) * displayScale.x; + const outlineHeight = Math.abs(bounds.height) * displayScale.y; - const rotationHandle = getRotationHandlePosition({ bounds }); + const rotationHandleCanvas = getRotationHandlePosition({ bounds }); const rotationHandleScreen = toOverlay({ - canvasX: rotationHandle.x, - canvasY: rotationHandle.y, + canvasX: rotationHandleCanvas.x, + canvasY: rotationHandleCanvas.y, }); + const onPointerMove = (event: React.PointerEvent) => + handlePointerMove({ event }); + const onPointerUp = () => handlePointerUp(); + return ( - <div className="pointer-events-none absolute inset-0" aria-hidden> - <SnapGuides - lines={snapLines} - canvasRef={canvasRef} - containerRef={containerRef} - /> + <div + className="pointer-events-none absolute inset-0 overflow-hidden" + aria-hidden + > <BoundingBoxOutline center={center} outlineWidth={outlineWidth} @@ -152,132 +94,47 @@ export function TransformHandles({ 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} - corner={corner} + cursor={getResizeCursor({ angleDeg })} screen={screen} onPointerDown={(event) => handleCornerPointerDown({ event, corner }) } - onPointerMove={(event) => handlePointerMove({ event })} - onPointerUp={(event) => handlePointerUp({ event })} + 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} /> ); })} - <RotationHandle + <IconHandle + icon={Rotate01Icon} screen={rotationHandleScreen} onPointerDown={(event) => handleRotationPointerDown({ event })} - onPointerMove={(event) => handlePointerMove({ event })} - onPointerUp={(event) => handlePointerUp({ event })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} /> </div> ); } - -function BoundingBoxOutline({ - center, - outlineWidth, - outlineHeight, - rotation, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; -}) { - return ( - <div - className="pointer-events-none absolute" - style={{ - left: center.x - outlineWidth / 2, - top: center.y - outlineHeight / 2, - width: outlineWidth, - height: outlineHeight, - transform: `rotate(${rotation}deg)`, - transformOrigin: "center center", - border: "1px dashed white", - boxSizing: "border-box", - opacity: 0.75, - }} - /> - ); -} - -function CornerHandle({ - corner, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - corner: Corner; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - const isNesw = corner === "top-right" || corner === "bottom-left"; - - return ( - <button - type="button" - className={cn( - "absolute flex items-center justify-center outline-none", - isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize", - )} - style={{ - left: screen.x - CORNER_HIT_AREA_SIZE / 2, - top: screen.y - CORNER_HIT_AREA_SIZE / 2, - width: CORNER_HIT_AREA_SIZE, - height: CORNER_HIT_AREA_SIZE, - pointerEvents: "auto", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} - onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} - > - <div - className="rounded-sm bg-white" - style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} - /> - </button> - ); -} - -function RotationHandle({ - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - <button - type="button" - className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none" - style={{ - left: screen.x - ROTATION_HANDLE_RADIUS, - top: screen.y - ROTATION_HANDLE_RADIUS, - width: ROTATION_HANDLE_RADIUS * 2, - height: ROTATION_HANDLE_RADIUS * 2, - pointerEvents: "auto", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} - onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} - > - <HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} /> - </button> - ); -} |
