summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/preview
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/preview')
-rw-r--r--apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx59
-rw-r--r--apps/web/src/components/editor/panels/preview/context-menu.tsx73
-rw-r--r--apps/web/src/components/editor/panels/preview/cursors.ts21
-rw-r--r--apps/web/src/components/editor/panels/preview/guide-overlay.tsx32
-rw-r--r--apps/web/src/components/editor/panels/preview/guide-popover.tsx100
-rw-r--r--apps/web/src/components/editor/panels/preview/handle-primitives.tsx427
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx294
-rw-r--r--apps/web/src/components/editor/panels/preview/mask-handles.tsx308
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx103
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-viewport.tsx576
-rw-r--r--apps/web/src/components/editor/panels/preview/snap-guides.tsx47
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx158
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx148
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx145
14 files changed, 0 insertions, 2491 deletions
diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx
deleted file mode 100644
index 3d96f762..00000000
--- a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-"use client";
-
-import { useEditor } from "@/hooks/use-editor";
-import { findCurrentScene } from "@/lib/scenes";
-import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
-
-export function BookmarkNoteOverlay() {
- const editor = useEditor();
- const currentTime = editor.playback.getCurrentTime();
- const activeProject = editor.project.getActive();
-
- if (!activeProject) {
- return null;
- }
-
- const activeScene = findCurrentScene({
- scenes: activeProject.scenes,
- currentSceneId: activeProject.currentSceneId,
- });
-
- if (!activeScene) {
- return null;
- }
-
- const bookmarks = activeScene.bookmarks;
- const activeBookmarks = getBookmarksActiveAtTime({
- bookmarks,
- time: currentTime,
- });
- const bookmarksWithNotes = activeBookmarks.filter(
- (bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
- );
-
- if (bookmarksWithNotes.length === 0) {
- return null;
- }
-
- return (
- <div
- className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
- aria-live="polite"
- >
- {bookmarksWithNotes.map((bookmark) => (
- <div
- key={bookmark.time}
- className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
- style={{
- backgroundColor: "rgb(0 0 0 / 0.5)",
- borderLeft: bookmark.color
- ? `3px solid ${bookmark.color}`
- : "3px solid var(--primary)",
- }}
- >
- {bookmark.note}
- </div>
- ))}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx
deleted file mode 100644
index 677f35db..00000000
--- a/apps/web/src/components/editor/panels/preview/context-menu.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-"use client";
-
-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,
- containerRef,
-}: {
- onToggleFullscreen: () => void;
- 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={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 bookmark notes
- </ContextMenuCheckboxItem>
- </ContextMenuContent>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/cursors.ts b/apps/web/src/components/editor/panels/preview/cursors.ts
deleted file mode 100644
index f04dbef0..00000000
--- a/apps/web/src/components/editor/panels/preview/cursors.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-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/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
deleted file mode 100644
index 4270fcb3..00000000
--- a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-"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
deleted file mode 100644
index bcac8075..00000000
--- a/apps/web/src/components/editor/panels/preview/guide-popover.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-"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
deleted file mode 100644
index 33baf9fc..00000000
--- a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
+++ /dev/null
@@ -1,427 +0,0 @@
-"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/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
deleted file mode 100644
index 369d854c..00000000
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ /dev/null
@@ -1,294 +0,0 @@
-"use client";
-
-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";
-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 "@/lib/wasm";
-import type { RootNode } from "@/services/renderer/nodes/root-node";
-import { buildScene } from "@/services/renderer/scene-builder";
-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 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() {
- 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}
- />
- <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,
-}: {
- containerRef: React.RefObject<HTMLElement | null>;
- onToggleFullscreen: () => 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 { overlays } = usePreviewStore();
- 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.numerator, activeProject.settings.fps.denominator]);
-
- 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]);
-
- 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,
- }}
- />
- <GuideOverlay />
- <PreviewInteractionOverlay />
- {overlays.bookmarks && <BookmarkNoteOverlay />}
- </div>
- </ContextMenuTrigger>
- <PreviewContextMenu
- onToggleFullscreen={onToggleFullscreen}
- containerRef={containerRef}
- />
- </ContextMenu>
- </div>
- <PreviewToolbar onToggleFullscreen={onToggleFullscreen} />
- </div>
- </PreviewViewportProvider>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
deleted file mode 100644
index d32b6ff9..00000000
--- a/apps/web/src/components/editor/panels/preview/mask-handles.tsx
+++ /dev/null
@@ -1,308 +0,0 @@
-"use client";
-
-import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useMaskHandles } from "@/hooks/use-mask-handles";
-import type { SnapLine } from "@/lib/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/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
deleted file mode 100644
index 6ee4b584..00000000
--- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-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;
-
- 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/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
deleted file mode 100644
index 2ac6bd92..00000000
--- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
+++ /dev/null
@@ -1,576 +0,0 @@
-"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 "@/lib/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/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
deleted file mode 100644
index ced5e921..00000000
--- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client";
-
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import type { SnapLine } from "@/lib/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/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
deleted file mode 100644
index eabbde8a..00000000
--- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-"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 "@/lib/timeline";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import {
- getElementLocalTime,
- resolveTransformAtTime,
-} from "@/lib/animation";
-import { resolveTextLayout } from "@/lib/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/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
deleted file mode 100644
index 9ee6c822..00000000
--- a/apps/web/src/components/editor/panels/preview/toolbar.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { formatTimecode } from "opencut-wasm";
-import { invokeAction } from "@/lib/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 "@/lib/guides";
-import { Separator } from "@/components/ui/separator";
-import {
- Select,
- SelectTrigger,
- SelectContent,
- SelectItem,
- SelectSeparator,
-} from "@/components/ui/select";
-import { PREVIEW_ZOOM_PRESETS } from "@/lib/preview/zoom";
-import { usePreviewViewport } from "./preview-viewport";
-import { GridPopover } from "./guide-popover";
-import { usePreviewStore } from "@/stores/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/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
deleted file mode 100644
index 2b81b557..00000000
--- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-"use client";
-
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useTransformHandles } from "@/hooks/use-transform-handles";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import {
- getCornerPosition,
- getEdgeHandlePosition,
- ROTATION_HANDLE_OFFSET,
- 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 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>
- );
-}