diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/preview')
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> - ); -} |
