diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-27 00:55:15 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:56:48 +0200 |
| commit | 4d127a038bf768eded68aae3cfa5c1b12241bdac (patch) | |
| tree | 16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/components/editor/panels | |
| parent | 82817cb50cb0487350573d6129130aec705f924b (diff) | |
| download | opencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz | |
chore: normalize line endings
Diffstat (limited to 'apps/web/src/components/editor/panels')
44 files changed, 8625 insertions, 8631 deletions
diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx index 9d9ae903..00ccc6e4 100644 --- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx +++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx @@ -1,61 +1,61 @@ -import { HugeiconsIcon } from "@hugeicons/react"; -import { UploadIcon } from "@hugeicons/core-free-icons"; - -interface MediaDragOverlayProps { - isVisible: boolean; - isProcessing?: boolean; - progress?: number; - onClick?: () => void; -} - -export function MediaDragOverlay({ - isVisible, - isProcessing = false, - progress = 0, - onClick, -}: MediaDragOverlayProps) { - if (!isVisible) return null; - - const handleClick = ({ - event, - }: { - event: React.MouseEvent<HTMLButtonElement>; - }) => { - if (isProcessing || !onClick) return; - event.preventDefault(); - event.stopPropagation(); - onClick(); - }; - - return ( - <button - className="bg-foreground/5 hover:bg-foreground/10 flex flex-col items-center justify-center gap-4 rounded-lg p-8 text-center" - type="button" - disabled={isProcessing || !onClick} - onClick={(event) => handleClick({ event })} - > - <div className="flex items-center justify-center"> - <HugeiconsIcon icon={UploadIcon} className="text-foreground size-10" /> - </div> - - <div className="space-y-2"> - <p className="text-muted-foreground max-w-sm text-xs"> - {isProcessing - ? `Processing your files (${progress}%)` - : "Drag and drop videos, photos, and audio files here"} - </p> - </div> - - {isProcessing && ( - <div className="w-full max-w-xs"> - <div className="bg-muted/50 h-2 w-full rounded-full"> - <div - className="bg-primary h-2 rounded-full" - style={{ width: `${progress}%` }} - /> - </div> - </div> - )} - </button> - ); -} +import { HugeiconsIcon } from "@hugeicons/react";
+import { UploadIcon } from "@hugeicons/core-free-icons";
+
+interface MediaDragOverlayProps {
+ isVisible: boolean;
+ isProcessing?: boolean;
+ progress?: number;
+ onClick?: () => void;
+}
+
+export function MediaDragOverlay({
+ isVisible,
+ isProcessing = false,
+ progress = 0,
+ onClick,
+}: MediaDragOverlayProps) {
+ if (!isVisible) return null;
+
+ const handleClick = ({
+ event,
+ }: {
+ event: React.MouseEvent<HTMLButtonElement>;
+ }) => {
+ if (isProcessing || !onClick) return;
+ event.preventDefault();
+ event.stopPropagation();
+ onClick();
+ };
+
+ return (
+ <button
+ className="bg-foreground/5 hover:bg-foreground/10 flex flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
+ type="button"
+ disabled={isProcessing || !onClick}
+ onClick={(event) => handleClick({ event })}
+ >
+ <div className="flex items-center justify-center">
+ <HugeiconsIcon icon={UploadIcon} className="text-foreground size-10" />
+ </div>
+
+ <div className="space-y-2">
+ <p className="text-muted-foreground max-w-sm text-xs">
+ {isProcessing
+ ? `Processing your files (${progress}%)`
+ : "Drag and drop videos, photos, and audio files here"}
+ </p>
+ </div>
+
+ {isProcessing && (
+ <div className="w-full max-w-xs">
+ <div className="bg-muted/50 h-2 w-full rounded-full">
+ <div
+ className="bg-primary h-2 rounded-full"
+ style={{ width: `${progress}%` }}
+ />
+ </div>
+ </div>
+ )}
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 8c9acce8..166c81db 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -1,239 +1,239 @@ -"use client"; - -import { Plus } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { AspectRatio } from "@/components/ui/aspect-ratio"; -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useEditor } from "@/hooks/use-editor"; -import { clearDragData, setDragData } from "@/lib/drag-data"; -import type { TimelineDragData } from "@/lib/timeline/drag"; -import { cn } from "@/utils/ui"; - -export interface DraggableItemProps { - name: string; - preview: ReactNode; - dragData: TimelineDragData; - onDragStart?: ({ e }: { e: React.DragEvent }) => void; - onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; - aspectRatio?: number; - className?: string; - containerClassName?: string; - shouldShowPlusOnDrag?: boolean; - shouldShowLabel?: boolean; - isRounded?: boolean; - variant?: "card" | "compact"; - isDraggable?: boolean; -} - -export function DraggableItem({ - name, - preview, - dragData, - onDragStart, - onAddToTimeline, - aspectRatio = 16 / 9, - className = "", - containerClassName, - shouldShowPlusOnDrag = true, - shouldShowLabel = true, - isRounded = true, - variant = "card", - isDraggable = true, -}: DraggableItemProps) { - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); - const dragRef = useRef<HTMLDivElement>(null); - const editor = useEditor(); - - const handleAddToTimeline = () => { - onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); - }; - - const emptyImg = new window.Image(); - emptyImg.src = - "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; - - useEffect(() => { - if (!isDragging) return; - - const handleDragOver = (e: DragEvent) => { - setDragPosition({ x: e.clientX, y: e.clientY }); - }; - - document.addEventListener("dragover", handleDragOver); - - return () => { - document.removeEventListener("dragover", handleDragOver); - }; - }, [isDragging]); - - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.setDragImage(emptyImg, 0, 0); - - setDragData({ dataTransfer: e.dataTransfer, dragData }); - e.dataTransfer.effectAllowed = "copy"; - - setDragPosition({ x: e.clientX, y: e.clientY }); - setIsDragging(true); - - onDragStart?.({ e }); - }; - - const handleDragEnd = () => { - setIsDragging(false); - clearDragData(); - }; - - return ( - <> - {variant === "card" ? ( - <div - ref={dragRef} - className={cn("group relative", containerClassName ?? "w-28")} - > - <div - className={cn( - "relative flex h-auto w-full cursor-default flex-col gap-1 p-", - className, - )} - > - <AspectRatio - ratio={aspectRatio} - className={cn( - "bg-accent relative overflow-hidden", - isRounded && "rounded-sm", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - {preview} - {!isDragging && ( - <PlusButton - className="opacity-0 group-hover:opacity-100" - onClick={handleAddToTimeline} - /> - )} - </AspectRatio> - {shouldShowLabel && ( - <span - className="text-muted-foreground w-full truncate text-left text-[0.7rem]" - title={name} - > - <span className="sr-only">{name}</span> - <span aria-hidden="true"> - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - </span> - </span> - )} - </div> - </div> - ) : ( - <div - ref={dragRef} - className={cn("group relative w-full", containerClassName)} - > - <button - type="button" - className={cn( - "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - className, - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - <div className="size-6 shrink-0 overflow-hidden rounded-sm"> - {preview} - </div> - <span className="w-full flex-1 truncate text-sm text-left"> - {name} - </span> - </button> - </div> - )} - - {isDraggable && - isDragging && - typeof document !== "undefined" && - createPortal( - <div - className="pointer-events-none fixed z-9999" - style={{ - left: dragPosition.x - 40, - top: dragPosition.y - 40, - }} - > - <div className="w-[80px]"> - <AspectRatio - ratio={1} - className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3" - > - <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover"> - {preview} - </div> - {shouldShowPlusOnDrag && ( - <PlusButton - onClick={handleAddToTimeline} - tooltipText="Add to timeline or drag to position" - /> - )} - </AspectRatio> - </div> - </div>, - document.body, - )} - </> - ); -} - -function PlusButton({ - className, - onClick, - tooltipText, -}: { - className?: string; - onClick?: () => void; - tooltipText?: string; -}) { - const button = ( - <Button - size="icon" - className={cn( - "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5", - className, - )} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(); - }} - title={tooltipText} - > - <Plus /> - </Button> - ); - - if (tooltipText) { - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent> - <p>{tooltipText}</p> - </TooltipContent> - </Tooltip> - ); - } - - return button; -} +"use client";
+
+import { Plus } from "lucide-react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/hooks/use-editor";
+import { clearDragData, setDragData } from "@/lib/drag-data";
+import type { TimelineDragData } from "@/lib/timeline/drag";
+import { cn } from "@/utils/ui";
+
+export interface DraggableItemProps {
+ name: string;
+ preview: ReactNode;
+ dragData: TimelineDragData;
+ onDragStart?: ({ e }: { e: React.DragEvent }) => void;
+ onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
+ aspectRatio?: number;
+ className?: string;
+ containerClassName?: string;
+ shouldShowPlusOnDrag?: boolean;
+ shouldShowLabel?: boolean;
+ isRounded?: boolean;
+ variant?: "card" | "compact";
+ isDraggable?: boolean;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
+ };
+
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleDragOver = (e: DragEvent) => {
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ };
+ }, [isDragging]);
+
+ const handleDragStart = (e: React.DragEvent) => {
+ e.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ setDragData({ dataTransfer: e.dataTransfer, dragData });
+ e.dataTransfer.effectAllowed = "copy";
+
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ clearDragData();
+ };
+
+ return (
+ <>
+ {variant === "card" ? (
+ <div
+ ref={dragRef}
+ className={cn("group relative", containerClassName ?? "w-28")}
+ >
+ <div
+ className={cn(
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
+ className,
+ )}
+ >
+ <AspectRatio
+ ratio={aspectRatio}
+ className={cn(
+ "bg-accent relative overflow-hidden",
+ isRounded && "rounded-sm",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ {preview}
+ {!isDragging && (
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
+ )}
+ </AspectRatio>
+ {shouldShowLabel && (
+ <span
+ className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
+ title={name}
+ >
+ <span className="sr-only">{name}</span>
+ <span aria-hidden="true">
+ {name.length > 8
+ ? `${name.slice(0, 16)}...${name.slice(-3)}`
+ : name}
+ </span>
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div
+ ref={dragRef}
+ className={cn("group relative w-full", containerClassName)}
+ >
+ <button
+ type="button"
+ className={cn(
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ className,
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
+ {preview}
+ </div>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
+ </button>
+ </div>
+ )}
+
+ {isDraggable &&
+ isDragging &&
+ typeof document !== "undefined" &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed z-9999"
+ style={{
+ left: dragPosition.x - 40,
+ top: dragPosition.y - 40,
+ }}
+ >
+ <div className="w-[80px]">
+ <AspectRatio
+ ratio={1}
+ className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
+ >
+ <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
+ {preview}
+ </div>
+ {shouldShowPlusOnDrag && (
+ <PlusButton
+ onClick={handleAddToTimeline}
+ tooltipText="Add to timeline or drag to position"
+ />
+ )}
+ </AspectRatio>
+ </div>
+ </div>,
+ document.body,
+ )}
+ </>
+ );
+}
+
+function PlusButton({
+ className,
+ onClick,
+ tooltipText,
+}: {
+ className?: string;
+ onClick?: () => void;
+ tooltipText?: string;
+}) {
+ const button = (
+ <Button
+ size="icon"
+ className={cn(
+ "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
+ className,
+ )}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
+ title={tooltipText}
+ >
+ <Plus />
+ </Button>
+ );
+
+ if (tooltipText) {
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return button;
+}
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index a4e072f1..da53bed0 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -1,111 +1,111 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/utils/ui"; -import { - TAB_KEYS, - tabs, - useAssetsPanelStore, -} from "@/stores/assets-panel-store"; - -export function TabBar() { - const { activeTab, setActiveTab } = useAssetsPanelStore(); - const [showTopFade, setShowTopFade] = useState(false); - const [showBottomFade, setShowBottomFade] = useState(false); - const scrollRef = useRef<HTMLDivElement>(null); - - const checkScrollPosition = useCallback(() => { - const element = scrollRef.current; - if (!element) return; - - const { scrollTop, scrollHeight, clientHeight } = element; - setShowTopFade(scrollTop > 0); - setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1); - }, []); - - useEffect(() => { - const element = scrollRef.current; - if (!element) return; - - checkScrollPosition(); - element.addEventListener("scroll", checkScrollPosition); - - const resizeObserver = new ResizeObserver(checkScrollPosition); - resizeObserver.observe(element); - - return () => { - element.removeEventListener("scroll", checkScrollPosition); - resizeObserver.disconnect(); - }; - }, [checkScrollPosition]); - - return ( - <div className="relative flex"> - <div - ref={scrollRef} - className="scrollbar-hidden relative flex size-full p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto" - > - {TAB_KEYS.map((tabKey) => { - const tab = tabs[tabKey]; - return ( - <Tooltip key={tabKey} delayDuration={10}> - <TooltipTrigger asChild> - <Button - variant={activeTab === tabKey ? "secondary" : "ghost"} - size="icon" - aria-label={tab.label} - className={cn( - "shrink-0", - "h-8 w-8", - activeTab !== tabKey && "text-muted-foreground", - )} - onClick={() => setActiveTab(tabKey)} - > - <tab.icon /> - </Button> - </TooltipTrigger> - <TooltipContent - side="right" - align="center" - variant="sidebar" - sideOffset={8} - > - <div className="text-foreground text-sm leading-none font-medium"> - {tab.label} - </div> - </TooltipContent> - </Tooltip> - ); - })} - </div> - - <FadeOverlay direction="top" show={showTopFade} /> - <FadeOverlay direction="bottom" show={showBottomFade} /> - </div> - ); -} - -function FadeOverlay({ - direction, - show, -}: { - direction: "top" | "bottom"; - show: boolean; -}) { - return ( - <div - className={cn( - "pointer-events-none absolute right-0 left-0 h-6", - direction === "top" && show - ? "from-background top-0 bg-linear-to-b to-transparent" - : "from-background bottom-0 bg-linear-to-t to-transparent", - )} - /> - ); -} +"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/utils/ui";
+import {
+ TAB_KEYS,
+ tabs,
+ useAssetsPanelStore,
+} from "@/stores/assets-panel-store";
+
+export function TabBar() {
+ const { activeTab, setActiveTab } = useAssetsPanelStore();
+ const [showTopFade, setShowTopFade] = useState(false);
+ const [showBottomFade, setShowBottomFade] = useState(false);
+ const scrollRef = useRef<HTMLDivElement>(null);
+
+ const checkScrollPosition = useCallback(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ setShowTopFade(scrollTop > 0);
+ setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
+ }, []);
+
+ useEffect(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ checkScrollPosition();
+ element.addEventListener("scroll", checkScrollPosition);
+
+ const resizeObserver = new ResizeObserver(checkScrollPosition);
+ resizeObserver.observe(element);
+
+ return () => {
+ element.removeEventListener("scroll", checkScrollPosition);
+ resizeObserver.disconnect();
+ };
+ }, [checkScrollPosition]);
+
+ return (
+ <div className="relative flex">
+ <div
+ ref={scrollRef}
+ className="scrollbar-hidden relative flex size-full p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto"
+ >
+ {TAB_KEYS.map((tabKey) => {
+ const tab = tabs[tabKey];
+ return (
+ <Tooltip key={tabKey} delayDuration={10}>
+ <TooltipTrigger asChild>
+ <Button
+ variant={activeTab === tabKey ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={tab.label}
+ className={cn(
+ "shrink-0",
+ "h-8 w-8",
+ activeTab !== tabKey && "text-muted-foreground",
+ )}
+ onClick={() => setActiveTab(tabKey)}
+ >
+ <tab.icon />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent
+ side="right"
+ align="center"
+ variant="sidebar"
+ sideOffset={8}
+ >
+ <div className="text-foreground text-sm leading-none font-medium">
+ {tab.label}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ );
+ })}
+ </div>
+
+ <FadeOverlay direction="top" show={showTopFade} />
+ <FadeOverlay direction="bottom" show={showBottomFade} />
+ </div>
+ );
+}
+
+function FadeOverlay({
+ direction,
+ show,
+}: {
+ direction: "top" | "bottom";
+ show: boolean;
+}) {
+ return (
+ <div
+ className={cn(
+ "pointer-events-none absolute right-0 left-0 h-6",
+ direction === "top" && show
+ ? "from-background top-0 bg-linear-to-b to-transparent"
+ : "from-background bottom-0 bg-linear-to-t to-transparent",
+ )}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/base-panel.tsx b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx index 0ece9b19..869304cd 100644 --- a/apps/web/src/components/editor/panels/assets/views/base-panel.tsx +++ b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx @@ -1,57 +1,57 @@ -import { cn } from "@/utils/ui"; - -interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> { - title?: string; - actions?: React.ReactNode; - children: React.ReactNode; - contentClassName?: string; - scrollClassName?: string; - hideHeader?: boolean; - ref?: React.Ref<HTMLDivElement>; - onScroll?: React.UIEventHandler<HTMLDivElement>; - scrollRef?: React.Ref<HTMLDivElement>; -} - -export function PanelView({ - title, - actions, - children, - className, - contentClassName, - scrollClassName, - hideHeader = false, - ref, - onScroll, - scrollRef, - ...rest -}: PanelViewProps) { - return ( - <div - className={cn("relative flex h-full flex-col", className)} - ref={ref} - {...rest} - > - {!hideHeader && ( - <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b"> - {title && ( - <span className="text-muted-foreground text-sm">{title}</span> - )} - {actions} - </div> - )} - <div - className={cn( - "scrollbar-hidden size-full overflow-y-auto", - hideHeader ? "pt-4" : "pt-2", - scrollClassName, - )} - ref={scrollRef} - onScroll={onScroll} - > - <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}> - {children} - </div> - </div> - </div> - ); -} +import { cn } from "@/utils/ui";
+
+interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
+ title?: string;
+ actions?: React.ReactNode;
+ children: React.ReactNode;
+ contentClassName?: string;
+ scrollClassName?: string;
+ hideHeader?: boolean;
+ ref?: React.Ref<HTMLDivElement>;
+ onScroll?: React.UIEventHandler<HTMLDivElement>;
+ scrollRef?: React.Ref<HTMLDivElement>;
+}
+
+export function PanelView({
+ title,
+ actions,
+ children,
+ className,
+ contentClassName,
+ scrollClassName,
+ hideHeader = false,
+ ref,
+ onScroll,
+ scrollRef,
+ ...rest
+}: PanelViewProps) {
+ return (
+ <div
+ className={cn("relative flex h-full flex-col", className)}
+ ref={ref}
+ {...rest}
+ >
+ {!hideHeader && (
+ <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b">
+ {title && (
+ <span className="text-muted-foreground text-sm">{title}</span>
+ )}
+ {actions}
+ </div>
+ )}
+ <div
+ className={cn(
+ "scrollbar-hidden size-full overflow-y-auto",
+ hideHeader ? "pt-4" : "pt-2",
+ scrollClassName,
+ )}
+ ref={scrollRef}
+ onScroll={onScroll}
+ >
+ <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
+ {children}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index 023e1578..f22c0211 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,179 +1,179 @@ -import { Button } from "@/components/ui/button"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useState, useRef } from "react"; -import { extractTimelineAudio } from "@/lib/media/mediabunny"; -import { useEditor } from "@/hooks/use-editor"; -import { - DEFAULT_TRANSCRIPTION_SAMPLE_RATE, - TRANSCRIPTION_LANGUAGES, -} from "@/constants/transcription-constants"; -import type { - TranscriptionLanguage, - TranscriptionProgress, -} from "@/lib/transcription/types"; -import { transcriptionService } from "@/services/transcription/service"; -import { decodeAudioToFloat32 } from "@/lib/media/audio"; -import { buildCaptionChunks } from "@/lib/transcription/caption"; -import { Spinner } from "@/components/ui/spinner"; -import { - Section, - SectionContent, - SectionField, - SectionFields, -} from "@/components/section"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - AddTrackCommand, - BatchCommand, - InsertElementCommand, -} from "@/lib/commands"; - -export function Captions() { - const [selectedLanguage, setSelectedLanguage] = - useState<TranscriptionLanguage>("auto"); - const [isProcessing, setIsProcessing] = useState(false); - const [processingStep, setProcessingStep] = useState(""); - const [error, setError] = useState<string | null>(null); - const containerRef = useRef<HTMLDivElement>(null); - const editor = useEditor(); - - const handleProgress = (progress: TranscriptionProgress) => { - if (progress.status === "loading-model") { - setProcessingStep(`Loading model ${Math.round(progress.progress)}%`); - } else if (progress.status === "transcribing") { - setProcessingStep("Transcribing..."); - } - }; - - const handleGenerateTranscript = async () => { - try { - setIsProcessing(true); - setError(null); - setProcessingStep("Extracting audio..."); - - const audioBlob = await extractTimelineAudio({ - tracks: editor.timeline.getTracks(), - mediaAssets: editor.media.getAssets(), - totalDuration: editor.timeline.getTotalDuration(), - }); - - setProcessingStep("Preparing audio..."); - const { samples } = await decodeAudioToFloat32({ - audioBlob, - sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE, - }); - - const result = await transcriptionService.transcribe({ - audioData: samples, - language: selectedLanguage === "auto" ? undefined : selectedLanguage, - onProgress: handleProgress, - }); - - setProcessingStep("Generating captions..."); - const captionChunks = buildCaptionChunks({ segments: result.segments }); - - const addTrackCommand = new AddTrackCommand("text", 0); - const insertCommands = captionChunks.map( - (caption, i) => - new InsertElementCommand({ - placement: { - mode: "explicit", - trackId: addTrackCommand.getTrackId(), - }, - element: { - ...DEFAULTS.text.element, - name: `Caption ${i + 1}`, - content: caption.text, - duration: caption.duration, - startTime: caption.startTime, - fontSize: 65, - fontWeight: "bold", - }, - }), - ); - - editor.command.execute({ - command: new BatchCommand([addTrackCommand, ...insertCommands]), - }); - } catch (error) { - console.error("Transcription failed:", error); - setError( - error instanceof Error ? error.message : "An unexpected error occurred", - ); - } finally { - setIsProcessing(false); - setProcessingStep(""); - } - }; - - const handleLanguageChange = ({ value }: { value: string }) => { - if (value === "auto") { - setSelectedLanguage("auto"); - return; - } - - const matchedLanguage = TRANSCRIPTION_LANGUAGES.find( - (language) => language.code === value, - ); - if (!matchedLanguage) return; - setSelectedLanguage(matchedLanguage.code); - }; - - return ( - <PanelView - title="Captions" - contentClassName="px-0 flex flex-col h-full" - ref={containerRef} - > - <Section showTopBorder={false} showBottomBorder={false} className="flex-1"> - <SectionContent className="flex flex-col gap-4 h-full pt-1"> - <SectionFields> - <SectionField label="Language"> - <Select - value={selectedLanguage} - onValueChange={(value) => handleLanguageChange({ value })} - > - <SelectTrigger> - <SelectValue placeholder="Select a language" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="auto">Auto detect</SelectItem> - {TRANSCRIPTION_LANGUAGES.map((language) => ( - <SelectItem key={language.code} value={language.code}> - {language.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </SectionField> - </SectionFields> - - {error && ( - <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3"> - <p className="text-destructive text-sm">{error}</p> - </div> - )} - </SectionContent> - </Section> - <Section showBottomBorder={false} showTopBorder={false}> - <SectionContent> - <Button - className="w-full" - onClick={handleGenerateTranscript} - disabled={isProcessing} - > - {isProcessing && <Spinner className="mr-1" />} - {isProcessing ? processingStep : "Generate transcript"} - </Button> - </SectionContent> - </Section> - </PanelView> - ); -} +import { Button } from "@/components/ui/button";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useState, useRef } from "react";
+import { extractTimelineAudio } from "@/lib/media/mediabunny";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
+ TRANSCRIPTION_LANGUAGES,
+} from "@/constants/transcription-constants";
+import type {
+ TranscriptionLanguage,
+ TranscriptionProgress,
+} from "@/lib/transcription/types";
+import { transcriptionService } from "@/services/transcription/service";
+import { decodeAudioToFloat32 } from "@/lib/media/audio";
+import { buildCaptionChunks } from "@/lib/transcription/caption";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+} from "@/components/section";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ AddTrackCommand,
+ BatchCommand,
+ InsertElementCommand,
+} from "@/lib/commands";
+
+export function Captions() {
+ const [selectedLanguage, setSelectedLanguage] =
+ useState<TranscriptionLanguage>("auto");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [processingStep, setProcessingStep] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleProgress = (progress: TranscriptionProgress) => {
+ if (progress.status === "loading-model") {
+ setProcessingStep(`Loading model ${Math.round(progress.progress)}%`);
+ } else if (progress.status === "transcribing") {
+ setProcessingStep("Transcribing...");
+ }
+ };
+
+ const handleGenerateTranscript = async () => {
+ try {
+ setIsProcessing(true);
+ setError(null);
+ setProcessingStep("Extracting audio...");
+
+ const audioBlob = await extractTimelineAudio({
+ tracks: editor.timeline.getTracks(),
+ mediaAssets: editor.media.getAssets(),
+ totalDuration: editor.timeline.getTotalDuration(),
+ });
+
+ setProcessingStep("Preparing audio...");
+ const { samples } = await decodeAudioToFloat32({
+ audioBlob,
+ sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
+ });
+
+ const result = await transcriptionService.transcribe({
+ audioData: samples,
+ language: selectedLanguage === "auto" ? undefined : selectedLanguage,
+ onProgress: handleProgress,
+ });
+
+ setProcessingStep("Generating captions...");
+ const captionChunks = buildCaptionChunks({ segments: result.segments });
+
+ const addTrackCommand = new AddTrackCommand("text", 0);
+ const insertCommands = captionChunks.map(
+ (caption, i) =>
+ new InsertElementCommand({
+ placement: {
+ mode: "explicit",
+ trackId: addTrackCommand.getTrackId(),
+ },
+ element: {
+ ...DEFAULTS.text.element,
+ name: `Caption ${i + 1}`,
+ content: caption.text,
+ duration: caption.duration,
+ startTime: caption.startTime,
+ fontSize: 65,
+ fontWeight: "bold",
+ },
+ }),
+ );
+
+ editor.command.execute({
+ command: new BatchCommand([addTrackCommand, ...insertCommands]),
+ });
+ } catch (error) {
+ console.error("Transcription failed:", error);
+ setError(
+ error instanceof Error ? error.message : "An unexpected error occurred",
+ );
+ } finally {
+ setIsProcessing(false);
+ setProcessingStep("");
+ }
+ };
+
+ const handleLanguageChange = ({ value }: { value: string }) => {
+ if (value === "auto") {
+ setSelectedLanguage("auto");
+ return;
+ }
+
+ const matchedLanguage = TRANSCRIPTION_LANGUAGES.find(
+ (language) => language.code === value,
+ );
+ if (!matchedLanguage) return;
+ setSelectedLanguage(matchedLanguage.code);
+ };
+
+ return (
+ <PanelView
+ title="Captions"
+ contentClassName="px-0 flex flex-col h-full"
+ ref={containerRef}
+ >
+ <Section showTopBorder={false} showBottomBorder={false} className="flex-1">
+ <SectionContent className="flex flex-col gap-4 h-full pt-1">
+ <SectionFields>
+ <SectionField label="Language">
+ <Select
+ value={selectedLanguage}
+ onValueChange={(value) => handleLanguageChange({ value })}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select a language" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="auto">Auto detect</SelectItem>
+ {TRANSCRIPTION_LANGUAGES.map((language) => (
+ <SelectItem key={language.code} value={language.code}>
+ {language.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </SectionFields>
+
+ {error && (
+ <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
+ <p className="text-destructive text-sm">{error}</p>
+ </div>
+ )}
+ </SectionContent>
+ </Section>
+ <Section showBottomBorder={false} showTopBorder={false}>
+ <SectionContent>
+ <Button
+ className="w-full"
+ onClick={handleGenerateTranscript}
+ disabled={isProcessing}
+ >
+ {isProcessing && <Spinner className="mr-1" />}
+ {isProcessing ? processingStep : "Generate transcript"}
+ </Button>
+ </SectionContent>
+ </Section>
+ </PanelView>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx index b3ef1eb1..7a81188f 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx @@ -1,227 +1,227 @@ -"use client"; - -import { memo, useCallback, useEffect, useMemo, useRef } from "react"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - BLUR_INTENSITY_PRESETS, - DEFAULT_BLUR_INTENSITY, - DEFAULT_COLOR, -} from "@/constants/project-constants"; -import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { colors } from "@/data/colors/solid"; -import { syntaxUIGradients } from "@/data/colors/syntax-ui"; -import { useEditor } from "@/hooks/use-editor"; -import { effectPreviewService } from "@/services/renderer/effect-preview"; -import type { TCanvasSize } from "@/lib/project/types"; -import { cn } from "@/utils/ui"; - -const BlurPreview = memo( - ({ - blur, - canvasSize, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - canvasSize: TCanvasSize; - isSelected: boolean; - onSelect: () => void; - }) => { - const canvasRef = useRef<HTMLCanvasElement>(null); - const { width, height } = canvasSize; - - useEffect(() => { - const renderPreview = () => { - if (!canvasRef.current) return; - - effectPreviewService.renderPreview({ - effectType: "blur", - params: { intensity: blur.value }, - targetCanvas: canvasRef.current, - uniformDimensions: { width, height }, - }); - }; - - renderPreview(); - return effectPreviewService.onPreviewImageReady({ callback: renderPreview }); - }, [blur.value, width, height]); - - return ( - <button - className={cn( - "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border", - isSelected && "border-primary border-2", - )} - onClick={onSelect} - type="button" - aria-label={`Select ${blur.label} blur`} - > - <canvas - ref={canvasRef} - className="absolute inset-0 h-full w-full object-cover" - /> - <div className="absolute right-1 bottom-1 left-1 text-center"> - <span className="rounded bg-black/50 px-1 text-xs text-white"> - {blur.label} - </span> - </div> - </button> - ); - }, -); - -BlurPreview.displayName = "BlurPreview"; - -const BackgroundPreviews = memo( - ({ - backgrounds, - currentBackgroundColor, - isColorBackground, - onSelect, - useBackgroundColor = false, - }: { - backgrounds: string[]; - currentBackgroundColor: string; - isColorBackground: boolean; - onSelect: (bg: string) => void; - useBackgroundColor?: boolean; - }) => { - return useMemo( - () => - backgrounds.map((bg) => ( - <button - key={bg} - className={cn( - "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", - isColorBackground && - bg === currentBackgroundColor && - "border-primary border-2", - )} - style={ - useBackgroundColor - ? { backgroundColor: bg } - : { - background: bg, - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", - } - } - onClick={() => onSelect(bg)} - type="button" - aria-label={`Select background ${bg}`} - /> - )), - [ - backgrounds, - isColorBackground, - currentBackgroundColor, - onSelect, - useBackgroundColor, - ], - ); - }, -); - -BackgroundPreviews.displayName = "BackgroundPreviews"; - -const COLOR_SECTIONS = [ - { title: "Colors", backgrounds: colors, useBackgroundColor: true }, - { title: "Pattern craft", backgrounds: patternCraftGradients }, - { title: "Syntax UI", backgrounds: syntaxUIGradients }, -] as const; - -export function BackgroundContent() { - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - - const handleBlurSelect = useCallback( - async (blurIntensity: number) => { - await editor.project.updateSettings({ - settings: { background: { type: "blur", blurIntensity } }, - }); - }, - [editor.project], - ); - - const handleColorSelect = useCallback( - async (color: string) => { - await editor.project.updateSettings({ - settings: { background: { type: "color", color } }, - }); - }, - [editor.project], - ); - - const isBlurBackground = activeProject.settings.background.type === "blur"; - const isColorBackground = activeProject.settings.background.type === "color"; - - const currentBlurIntensity = isBlurBackground - ? (activeProject.settings.background as { blurIntensity: number }) - .blurIntensity - : DEFAULT_BLUR_INTENSITY; - - const currentBackgroundColor = isColorBackground - ? (activeProject.settings.background as { color: string }).color - : DEFAULT_COLOR; - const canvasSize = activeProject.settings.canvasSize; - - const blurPreviews = useMemo( - () => - BLUR_INTENSITY_PRESETS.map((blur) => ( - <BlurPreview - key={blur.value} - blur={blur} - canvasSize={canvasSize} - isSelected={isBlurBackground && currentBlurIntensity === blur.value} - onSelect={() => handleBlurSelect(blur.value)} - /> - )), - [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect], - ); - - return ( - <div className="flex flex-col"> - <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}> - <SectionHeader> - <SectionTitle>Blur</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex flex-wrap gap-2">{blurPreviews}</div> - </SectionContent> - </Section> - {COLOR_SECTIONS.map((section) => ( - <Section - key={section.title} - collapsible - defaultOpen={false} - sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`} - > - <SectionHeader> - <SectionTitle>{section.title}</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex flex-wrap gap-2"> - <BackgroundPreviews - backgrounds={section.backgrounds as string[]} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - onSelect={handleColorSelect} - useBackgroundColor={ - "useBackgroundColor" in section - ? section.useBackgroundColor - : false - } - /> - </div> - </SectionContent> - </Section> - ))} - </div> - ); -} +"use client";
+
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+} from "@/constants/project-constants";
+import { patternCraftGradients } from "@/data/colors/pattern-craft";
+import { colors } from "@/data/colors/solid";
+import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { useEditor } from "@/hooks/use-editor";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
+import type { TCanvasSize } from "@/lib/project/types";
+import { cn } from "@/utils/ui";
+
+const BlurPreview = memo(
+ ({
+ blur,
+ canvasSize,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ canvasSize: TCanvasSize;
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const { width, height } = canvasSize;
+
+ useEffect(() => {
+ const renderPreview = () => {
+ if (!canvasRef.current) return;
+
+ effectPreviewService.renderPreview({
+ effectType: "blur",
+ params: { intensity: blur.value },
+ targetCanvas: canvasRef.current,
+ uniformDimensions: { width, height },
+ });
+ };
+
+ renderPreview();
+ return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
+ }, [blur.value, width, height]);
+
+ return (
+ <button
+ className={cn(
+ "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
+ isSelected && "border-primary border-2",
+ )}
+ onClick={onSelect}
+ type="button"
+ aria-label={`Select ${blur.label} blur`}
+ >
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 h-full w-full object-cover"
+ />
+ <div className="absolute right-1 bottom-1 left-1 text-center">
+ <span className="rounded bg-black/50 px-1 text-xs text-white">
+ {blur.label}
+ </span>
+ </div>
+ </button>
+ );
+ },
+);
+
+BlurPreview.displayName = "BlurPreview";
+
+const BackgroundPreviews = memo(
+ ({
+ backgrounds,
+ currentBackgroundColor,
+ isColorBackground,
+ onSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ onSelect: (bg: string) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg) => (
+ <button
+ key={bg}
+ className={cn(
+ "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
+ isColorBackground &&
+ bg === currentBackgroundColor &&
+ "border-primary border-2",
+ )}
+ style={
+ useBackgroundColor
+ ? { backgroundColor: bg }
+ : {
+ background: bg,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ }
+ }
+ onClick={() => onSelect(bg)}
+ type="button"
+ aria-label={`Select background ${bg}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ onSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+const COLOR_SECTIONS = [
+ { title: "Colors", backgrounds: colors, useBackgroundColor: true },
+ { title: "Pattern craft", backgrounds: patternCraftGradients },
+ { title: "Syntax UI", backgrounds: syntaxUIGradients },
+] as const;
+
+export function BackgroundContent() {
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+
+ const handleBlurSelect = useCallback(
+ async (blurIntensity: number) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async (color: string) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const currentBlurIntensity = isBlurBackground
+ ? (activeProject.settings.background as { blurIntensity: number })
+ .blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor = isColorBackground
+ ? (activeProject.settings.background as { color: string }).color
+ : DEFAULT_COLOR;
+ const canvasSize = activeProject.settings.canvasSize;
+
+ const blurPreviews = useMemo(
+ () =>
+ BLUR_INTENSITY_PRESETS.map((blur) => (
+ <BlurPreview
+ key={blur.value}
+ blur={blur}
+ canvasSize={canvasSize}
+ isSelected={isBlurBackground && currentBlurIntensity === blur.value}
+ onSelect={() => handleBlurSelect(blur.value)}
+ />
+ )),
+ [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>Blur</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">{blurPreviews}</div>
+ </SectionContent>
+ </Section>
+ {COLOR_SECTIONS.map((section) => (
+ <Section
+ key={section.title}
+ collapsible
+ defaultOpen={false}
+ sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
+ >
+ <SectionHeader>
+ <SectionTitle>{section.title}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">
+ <BackgroundPreviews
+ backgrounds={section.backgrounds as string[]}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ onSelect={handleColorSelect}
+ useBackgroundColor={
+ "useBackgroundColor" in section
+ ? section.useBackgroundColor
+ : false
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx index e0229ea9..0578eeb7 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -1,369 +1,369 @@ -"use client"; - -import { useRef, useState } from "react"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { FPS_PRESETS } from "@/constants/project-constants"; -import { useEditor } from "@/hooks/use-editor"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { BackgroundContent } from "./background"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { NumberField } from "@/components/ui/number-field"; -import { useEditorStore } from "@/stores/editor-store"; -import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Tick02Icon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; -import { dimensionToAspectRatio } from "@/utils/geometry"; -import { formatNumberForDisplay } from "@/utils/math"; -import { OcSquarePlusIcon } from "@opencut/ui/icons"; -import type { TCanvasSize } from "@/lib/project/types"; - -type SettingsView = "project-info" | "background"; - -const PRESET_LABELS: Record<string, string> = { - "1:1": "1:1", - "16:9": "16:9", - "9:16": "9:16", - "4:3": "4:3", -}; - -function areCanvasSizesEqual({ - left, - right, -}: { - left: TCanvasSize; - right: TCanvasSize; -}) { - return left.width === right.width && left.height === right.height; -} - -function formatCanvasDimension({ value }: { value: number }) { - return formatNumberForDisplay({ value, maxFractionDigits: 0 }); -} - -function parseCanvasDimension({ input }: { input: string }): number | null { - const trimmed = input.trim(); - if (!trimmed) return null; - - const parsed = Number(trimmed); - if (!Number.isFinite(parsed)) return null; - - const rounded = Math.round(parsed); - return rounded > 0 ? rounded : null; -} - -function useCanvasDimensionDraft({ - value, - onCommit, -}: { - value: number; - onCommit: (value: number) => void; -}) { - const pendingValueRef = useRef(value); - const syncedValueRef = useRef(value); - - if (syncedValueRef.current !== value) { - syncedValueRef.current = value; - pendingValueRef.current = value; - } - - return usePropertyDraft({ - displayValue: formatCanvasDimension({ value }), - parse: (input) => parseCanvasDimension({ input }), - onPreview: (nextValue) => { - pendingValueRef.current = nextValue; - }, - onCommit: () => { - if (pendingValueRef.current !== value) { - onCommit(pendingValueRef.current); - } - }, - }); -} - -export function SettingsView() { - const [view, setView] = useState<SettingsView>("project-info"); - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - const { canvasPresets } = useEditorStore(); - const currentCanvasSize = activeProject.settings.canvasSize; - const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset"; - const lastCustomCanvasSize = - activeProject.settings.lastCustomCanvasSize ?? null; - - const presetItems = canvasPresets.map((preset, index) => { - const ratio = dimensionToAspectRatio(preset); - return { - id: index.toString(), - label: PRESET_LABELS[ratio] ?? ratio, - ratio, - canvasSize: preset, - }; - }); - - const selectedPresetId = canvasSizeMode === "preset" - ? (presetItems.find((preset) => - areCanvasSizesEqual({ - left: preset.canvasSize, - right: currentCanvasSize, - }), - )?.id ?? null) - : null; - - const updateCustomCanvasSize = ({ - canvasSize, - }: { - canvasSize: TCanvasSize; - }) => { - const shouldUpdateCanvasSize = !areCanvasSizesEqual({ - left: canvasSize, - right: currentCanvasSize, - }); - const shouldUpdateLastCustomCanvasSize = - lastCustomCanvasSize === null || - !areCanvasSizesEqual({ - left: canvasSize, - right: lastCustomCanvasSize, - }); - const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom"; - - if ( - !shouldUpdateCanvasSize && - !shouldUpdateLastCustomCanvasSize && - !shouldUpdateCanvasSizeMode - ) { - return; - } - - editor.project.updateSettings({ - settings: { - ...(shouldUpdateCanvasSize ? { canvasSize } : {}), - ...(shouldUpdateCanvasSizeMode - ? { canvasSizeMode: "custom" as const } - : {}), - lastCustomCanvasSize: canvasSize, - }, - }); - }; - - const selectPresetCanvasSize = ({ - canvasSize, - }: { - canvasSize: TCanvasSize; - }) => { - const shouldUpdateCanvasSize = !areCanvasSizesEqual({ - left: canvasSize, - right: currentCanvasSize, - }); - const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset"; - - if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return; - - editor.project.updateSettings({ - settings: { - ...(shouldUpdateCanvasSize ? { canvasSize } : {}), - ...(shouldUpdateCanvasSizeMode - ? { canvasSizeMode: "preset" as const } - : {}), - }, - }); - }; - - const selectCustomCanvasSize = () => { - updateCustomCanvasSize({ - canvasSize: lastCustomCanvasSize ?? currentCanvasSize, - }); - }; - - const widthDraft = useCanvasDimensionDraft({ - value: currentCanvasSize.width, - onCommit: (width) => - updateCustomCanvasSize({ - canvasSize: { width, height: currentCanvasSize.height }, - }), - }); - - const heightDraft = useCanvasDimensionDraft({ - value: currentCanvasSize.height, - onCommit: (height) => - updateCustomCanvasSize({ - canvasSize: { width: currentCanvasSize.width, height }, - }), - }); - - const isCustomSelected = canvasSizeMode === "custom"; - - return ( - <PanelView - contentClassName="px-0" - scrollClassName="pt-0" - actions={ - <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> - <TabsList> - <TabsTrigger value="project-info">Project info</TabsTrigger> - <TabsTrigger value="background">Background</TabsTrigger> - </TabsList> - </Tabs> - } - > - {view === "project-info" && ( - <div className="flex flex-col"> - <Section showTopBorder={false}> - <SectionHeader> - <SectionTitle className="flex-1">Name</SectionTitle> - <span className="text-sm truncate"> - {activeProject.metadata.name} - </span> - </SectionHeader> - </Section> - <Section showTopBorder={false}> - <SectionHeader className="justify-between"> - <SectionTitle className="flex-1">Frame rate</SectionTitle> - <Select - value={activeProject.settings.fps.toString()} - onValueChange={(value) => { - const fps = parseFloat(value); - editor.project.updateSettings({ settings: { fps } }); - }} - > - <SelectTrigger className="bg-transparent border-none p-1 h-auto"> - <SelectValue placeholder="Select a frame rate" /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map((preset) => ( - <SelectItem key={preset.value} value={preset.value}> - {preset.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </SectionHeader> - </Section> - <Section - showTopBorder={false} - collapsible - sectionKey="settings:aspect-ratio" - > - <SectionHeader> - <SectionTitle className="flex-1">Aspect ratio</SectionTitle> - </SectionHeader> - <SectionContent className="px-2 flex flex-col gap-1 pb-2"> - {presetItems.map((preset) => ( - <AspectRatioItem - key={preset.id} - label={preset.label} - previewIcon={<AspectRatioPreview ratio={preset.ratio} />} - isSelected={selectedPresetId === preset.id} - onClick={() => { - selectPresetCanvasSize({ - canvasSize: preset.canvasSize, - }); - }} - /> - ))} - <div className="pb-2"> - <AspectRatioItem - key="custom" - label="Custom" - previewIcon={<OcSquarePlusIcon />} - isSelected={isCustomSelected} - onClick={selectCustomCanvasSize} - uiOptions={ - <div className=" flex items-center gap-2 text-foreground"> - <NumberField - value={widthDraft.displayValue} - className="w-full" - aria-label="Canvas width" - onFocus={widthDraft.onFocus} - onChange={widthDraft.onChange} - onBlur={widthDraft.onBlur} - /> - <NumberField - value={heightDraft.displayValue} - className="w-full" - aria-label="Canvas height" - onFocus={heightDraft.onFocus} - onChange={heightDraft.onChange} - onBlur={heightDraft.onBlur} - /> - </div> - } - /> - </div> - </SectionContent> - </Section> - </div> - )} - {view === "background" && <BackgroundContent />} - </PanelView> - ); -} - -function AspectRatioItem({ - label, - previewIcon, - isSelected, - onClick, - uiOptions, -}: { - label: string; - previewIcon: React.ReactNode; - isSelected: boolean; - onClick: () => void; - uiOptions?: React.ReactNode; -}) { - return ( - <Button - variant={isSelected ? "secondary" : "ghost"} - className={cn( - "px-2 py-0 flex flex-col h-fit w-full", - !isSelected && "border border-transparent opacity-75!", - )} - onClick={onClick} - > - <div className="w-full flex justify-between items-center h-8"> - <div className="flex-1 flex items-center gap-2"> - <div className="flex items-center justify-center size-5"> - {previewIcon} - </div> - <span className="text-sm truncate">{label}</span> - </div> - <div> - {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />} - </div> - </div> - {uiOptions && isSelected && ( - <div className="w-full pb-2">{uiOptions}</div> - )} - </Button> - ); -} - -function AspectRatioPreview({ ratio }: { ratio?: string }) { - if (!ratio) return null; - - const [w, h] = ratio.split(":").map(Number); - const maxSize = 16; - const width = w >= h ? maxSize : (w / h) * maxSize; - const height = h >= w ? maxSize : (h / w) * maxSize; - - return ( - <div - style={{ width, height, borderWidth: 1.5 }} - className="rounded-xs border-current opacity-60" - /> - ); -} +"use client";
+
+import { useRef, useState } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FPS_PRESETS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { BackgroundContent } from "./background";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { useEditorStore } from "@/stores/editor-store";
+import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Tick02Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { formatNumberForDisplay } from "@/utils/math";
+import { OcSquarePlusIcon } from "@opencut/ui/icons";
+import type { TCanvasSize } from "@/lib/project/types";
+
+type SettingsView = "project-info" | "background";
+
+const PRESET_LABELS: Record<string, string> = {
+ "1:1": "1:1",
+ "16:9": "16:9",
+ "9:16": "9:16",
+ "4:3": "4:3",
+};
+
+function areCanvasSizesEqual({
+ left,
+ right,
+}: {
+ left: TCanvasSize;
+ right: TCanvasSize;
+}) {
+ return left.width === right.width && left.height === right.height;
+}
+
+function formatCanvasDimension({ value }: { value: number }) {
+ return formatNumberForDisplay({ value, maxFractionDigits: 0 });
+}
+
+function parseCanvasDimension({ input }: { input: string }): number | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) return null;
+
+ const rounded = Math.round(parsed);
+ return rounded > 0 ? rounded : null;
+}
+
+function useCanvasDimensionDraft({
+ value,
+ onCommit,
+}: {
+ value: number;
+ onCommit: (value: number) => void;
+}) {
+ const pendingValueRef = useRef(value);
+ const syncedValueRef = useRef(value);
+
+ if (syncedValueRef.current !== value) {
+ syncedValueRef.current = value;
+ pendingValueRef.current = value;
+ }
+
+ return usePropertyDraft({
+ displayValue: formatCanvasDimension({ value }),
+ parse: (input) => parseCanvasDimension({ input }),
+ onPreview: (nextValue) => {
+ pendingValueRef.current = nextValue;
+ },
+ onCommit: () => {
+ if (pendingValueRef.current !== value) {
+ onCommit(pendingValueRef.current);
+ }
+ },
+ });
+}
+
+export function SettingsView() {
+ const [view, setView] = useState<SettingsView>("project-info");
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const { canvasPresets } = useEditorStore();
+ const currentCanvasSize = activeProject.settings.canvasSize;
+ const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
+ const lastCustomCanvasSize =
+ activeProject.settings.lastCustomCanvasSize ?? null;
+
+ const presetItems = canvasPresets.map((preset, index) => {
+ const ratio = dimensionToAspectRatio(preset);
+ return {
+ id: index.toString(),
+ label: PRESET_LABELS[ratio] ?? ratio,
+ ratio,
+ canvasSize: preset,
+ };
+ });
+
+ const selectedPresetId = canvasSizeMode === "preset"
+ ? (presetItems.find((preset) =>
+ areCanvasSizesEqual({
+ left: preset.canvasSize,
+ right: currentCanvasSize,
+ }),
+ )?.id ?? null)
+ : null;
+
+ const updateCustomCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateLastCustomCanvasSize =
+ lastCustomCanvasSize === null ||
+ !areCanvasSizesEqual({
+ left: canvasSize,
+ right: lastCustomCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
+
+ if (
+ !shouldUpdateCanvasSize &&
+ !shouldUpdateLastCustomCanvasSize &&
+ !shouldUpdateCanvasSizeMode
+ ) {
+ return;
+ }
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "custom" as const }
+ : {}),
+ lastCustomCanvasSize: canvasSize,
+ },
+ });
+ };
+
+ const selectPresetCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
+
+ if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "preset" as const }
+ : {}),
+ },
+ });
+ };
+
+ const selectCustomCanvasSize = () => {
+ updateCustomCanvasSize({
+ canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
+ });
+ };
+
+ const widthDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.width,
+ onCommit: (width) =>
+ updateCustomCanvasSize({
+ canvasSize: { width, height: currentCanvasSize.height },
+ }),
+ });
+
+ const heightDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.height,
+ onCommit: (height) =>
+ updateCustomCanvasSize({
+ canvasSize: { width: currentCanvasSize.width, height },
+ }),
+ });
+
+ const isCustomSelected = canvasSizeMode === "custom";
+
+ return (
+ <PanelView
+ contentClassName="px-0"
+ scrollClassName="pt-0"
+ actions={
+ <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
+ <TabsList>
+ <TabsTrigger value="project-info">Project info</TabsTrigger>
+ <TabsTrigger value="background">Background</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ }
+ >
+ {view === "project-info" && (
+ <div className="flex flex-col">
+ <Section showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle className="flex-1">Name</SectionTitle>
+ <span className="text-sm truncate">
+ {activeProject.metadata.name}
+ </span>
+ </SectionHeader>
+ </Section>
+ <Section showTopBorder={false}>
+ <SectionHeader className="justify-between">
+ <SectionTitle className="flex-1">Frame rate</SectionTitle>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={(value) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ }}
+ >
+ <SelectTrigger className="bg-transparent border-none p-1 h-auto">
+ <SelectValue placeholder="Select a frame rate" />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map((preset) => (
+ <SelectItem key={preset.value} value={preset.value}>
+ {preset.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionHeader>
+ </Section>
+ <Section
+ showTopBorder={false}
+ collapsible
+ sectionKey="settings:aspect-ratio"
+ >
+ <SectionHeader>
+ <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
+ </SectionHeader>
+ <SectionContent className="px-2 flex flex-col gap-1 pb-2">
+ {presetItems.map((preset) => (
+ <AspectRatioItem
+ key={preset.id}
+ label={preset.label}
+ previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
+ isSelected={selectedPresetId === preset.id}
+ onClick={() => {
+ selectPresetCanvasSize({
+ canvasSize: preset.canvasSize,
+ });
+ }}
+ />
+ ))}
+ <div className="pb-2">
+ <AspectRatioItem
+ key="custom"
+ label="Custom"
+ previewIcon={<OcSquarePlusIcon />}
+ isSelected={isCustomSelected}
+ onClick={selectCustomCanvasSize}
+ uiOptions={
+ <div className=" flex items-center gap-2 text-foreground">
+ <NumberField
+ value={widthDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas width"
+ onFocus={widthDraft.onFocus}
+ onChange={widthDraft.onChange}
+ onBlur={widthDraft.onBlur}
+ />
+ <NumberField
+ value={heightDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas height"
+ onFocus={heightDraft.onFocus}
+ onChange={heightDraft.onChange}
+ onBlur={heightDraft.onBlur}
+ />
+ </div>
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ </div>
+ )}
+ {view === "background" && <BackgroundContent />}
+ </PanelView>
+ );
+}
+
+function AspectRatioItem({
+ label,
+ previewIcon,
+ isSelected,
+ onClick,
+ uiOptions,
+}: {
+ label: string;
+ previewIcon: React.ReactNode;
+ isSelected: boolean;
+ onClick: () => void;
+ uiOptions?: React.ReactNode;
+}) {
+ return (
+ <Button
+ variant={isSelected ? "secondary" : "ghost"}
+ className={cn(
+ "px-2 py-0 flex flex-col h-fit w-full",
+ !isSelected && "border border-transparent opacity-75!",
+ )}
+ onClick={onClick}
+ >
+ <div className="w-full flex justify-between items-center h-8">
+ <div className="flex-1 flex items-center gap-2">
+ <div className="flex items-center justify-center size-5">
+ {previewIcon}
+ </div>
+ <span className="text-sm truncate">{label}</span>
+ </div>
+ <div>
+ {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
+ </div>
+ </div>
+ {uiOptions && isSelected && (
+ <div className="w-full pb-2">{uiOptions}</div>
+ )}
+ </Button>
+ );
+}
+
+function AspectRatioPreview({ ratio }: { ratio?: string }) {
+ if (!ratio) return null;
+
+ const [w, h] = ratio.split(":").map(Number);
+ const maxSize = 16;
+ const width = w >= h ? maxSize : (w / h) * maxSize;
+ const height = h >= w ? maxSize : (h / w) * maxSize;
+
+ return (
+ <div
+ style={{ width, height, borderWidth: 1.5 }}
+ className="rounded-xs border-current opacity-60"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index 4fb43ac2..dc92ad9c 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -1,574 +1,574 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; -import { useSoundSearch } from "@/hooks/use-sound-search"; -import { useSoundsStore } from "@/stores/sounds-store"; -import type { SavedSound, SoundEffect } from "@/lib/sounds/types"; -import { cn } from "@/utils/ui"; -import { - FavouriteIcon, - FilterMailIcon, - PauseIcon, - PlayIcon, - PlusSignIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function SoundsView() { - return ( - <div className="flex h-full flex-col"> - <Tabs defaultValue="sound-effects" className="flex h-full flex-col"> - <div className="px-3 pt-4 pb-0"> - <TabsList> - <TabsTrigger value="sound-effects">Sound effects</TabsTrigger> - <TabsTrigger value="songs">Songs</TabsTrigger> - <TabsTrigger value="saved">Saved</TabsTrigger> - </TabsList> - </div> - <Separator className="my-4" /> - <TabsContent - value="sound-effects" - className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" - > - <SoundEffectsView /> - </TabsContent> - <TabsContent - value="saved" - className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" - > - <SavedSoundsView /> - </TabsContent> - <TabsContent - value="songs" - className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" - > - <SongsView /> - </TabsContent> - </Tabs> - </div> - ); -} - -function SoundEffectsView() { - const { - topSoundEffects, - isLoading, - searchQuery, - setSearchQuery, - scrollPosition, - setScrollPosition, - loadSavedSounds, - showCommercialOnly, - toggleCommercialFilter, - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - } = useSoundsStore(); - const { - results: searchResults, - isLoading: isSearching, - loadMore, - hasNextPage, - isLoadingMore, - } = useSoundSearch({ - query: searchQuery, - commercialOnly: showCommercialOnly, - }); - - const [playingId, setPlayingId] = useState<number | null>(null); - const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( - null, - ); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: loadMore, - hasMore: hasNextPage, - isLoading: isLoadingMore || isSearching, - }); - - useEffect(() => { - loadSavedSounds(); - }, [loadSavedSounds]); - - useEffect(() => { - if (hasLoaded) { - return; - } - - let shouldIgnore = false; - - const fetchTopSounds = async () => { - try { - if (!shouldIgnore) { - setLoading({ loading: true }); - setError({ error: null }); - } - - const response = await fetch( - "/api/sounds/search?page_size=50&sort=downloads", - ); - - if (!shouldIgnore) { - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - const data = await response.json(); - setTopSoundEffects({ sounds: data.results }); - setHasLoaded({ loaded: true }); - - setCurrentPage({ page: 1 }); - setHasNextPage({ hasNext: !!data.next }); - setTotalCount({ count: data.count }); - } - } catch (error) { - if (!shouldIgnore) { - console.error("Failed to fetch top sounds:", error); - setError({ - error: - error instanceof Error ? error.message : "Failed to load sounds", - }); - } - } finally { - if (!shouldIgnore) { - setLoading({ loading: false }); - } - } - }; - - const timeoutId = setTimeout(fetchTopSounds, 100, {}); - - return () => { - shouldIgnore = true; - clearTimeout(timeoutId); - }; - }, [ - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - ]); - - useEffect(() => { - if (!scrollAreaRef.current || scrollPosition <= 0) { - return; - } - - const restoreScrollPosition = () => { - scrollAreaRef.current?.scrollTo({ top: scrollPosition }); - }; - - const timeoutId = setTimeout(restoreScrollPosition, 100, {}); - - return () => clearTimeout(timeoutId); - }, [scrollPosition, scrollAreaRef]); - - const handleScrollWithPosition = ({ - currentTarget, - }: React.UIEvent<HTMLDivElement>) => { - const { scrollTop } = currentTarget; - setScrollPosition({ position: scrollTop }); - handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>); - }; - - const displayedSounds = searchQuery ? searchResults : topSoundEffects; - - const playSound = ({ sound }: { sound: SoundEffect }) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", () => { - setPlayingId(null); - }); - audio.play().catch((error) => { - console.error("Failed to play sound preview:", error); - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - return ( - <div className="mt-1 flex h-full flex-col gap-5"> - <div className="flex items-center gap-3"> - <Input - placeholder="Search sound effects" - className="w-full" - containerClassName="w-full" - value={searchQuery} - onChange={({ currentTarget }) => - setSearchQuery({ query: currentTarget.value }) - } - showClearIcon - onClear={() => setSearchQuery({ query: "" })} - /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="text" - size="icon" - className={cn(showCommercialOnly && "text-primary")} - > - <HugeiconsIcon icon={FilterMailIcon} /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuCheckboxItem - checked={showCommercialOnly} - onCheckedChange={() => toggleCommercialFilter()} - > - Show only commercially licensed - </DropdownMenuCheckboxItem> - <div className="text-muted-foreground px-2 py-1.5 text-xs"> - {showCommercialOnly - ? "Only showing sounds licensed for commercial use" - : "Showing all sounds regardless of license"} - </div> - </DropdownMenuContent> - </DropdownMenu> - </div> - - <div className="relative h-full overflow-hidden"> - <ScrollArea - className="h-full flex-1" - ref={scrollAreaRef} - onScrollCapture={handleScrollWithPosition} - > - <div className="flex flex-col gap-4"> - {isLoading && !searchQuery && ( - <div className="text-muted-foreground text-sm"> - Loading sounds... - </div> - )} - {isSearching && searchQuery && ( - <div className="text-muted-foreground text-sm">Searching...</div> - )} - {displayedSounds.map((sound) => ( - <AudioItem - key={sound.id} - sound={sound} - isPlaying={playingId === sound.id} - onPlay={playSound} - /> - ))} - {!isLoading && !isSearching && displayedSounds.length === 0 && ( - <div className="text-muted-foreground text-sm"> - {searchQuery ? "No sounds found" : "No sounds available"} - </div> - )} - {isLoadingMore && ( - <div className="text-muted-foreground py-4 text-center text-sm"> - Loading more sounds... - </div> - )} - </div> - </ScrollArea> - </div> - </div> - ); -} - -function SavedSoundsView() { - const { - savedSounds, - isLoadingSavedSounds, - savedSoundsError, - loadSavedSounds, - clearSavedSounds, - } = useSoundsStore(); - - const [playingId, setPlayingId] = useState<number | null>(null); - const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( - null, - ); - - const [showClearDialog, setShowClearDialog] = useState(false); - - useEffect(() => { - loadSavedSounds(); - }, [loadSavedSounds]); - - const playSound = ({ sound }: { sound: SoundEffect }) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", () => { - setPlayingId(null); - }); - audio.play().catch((error) => { - console.error("Failed to play sound preview:", error); - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - const convertToSoundEffect = ({ - savedSound, - }: { - savedSound: SavedSound; - }): SoundEffect => ({ - id: savedSound.id, - name: savedSound.name, - description: "", - url: "", - previewUrl: savedSound.previewUrl, - downloadUrl: savedSound.downloadUrl, - duration: savedSound.duration, - filesize: 0, - type: "audio", - channels: 0, - bitrate: 0, - bitdepth: 0, - samplerate: 0, - username: savedSound.username, - tags: savedSound.tags, - license: savedSound.license, - created: savedSound.savedAt, - downloads: 0, - rating: 0, - ratingCount: 0, - }); - - if (isLoadingSavedSounds) { - return ( - <div className="flex h-full items-center justify-center"> - <div className="text-muted-foreground text-sm"> - Loading saved sounds... - </div> - </div> - ); - } - - if (savedSoundsError) { - return ( - <div className="flex h-full items-center justify-center"> - <div className="text-destructive text-sm"> - Error: {savedSoundsError} - </div> - </div> - ); - } - - if (savedSounds.length === 0) { - return ( - <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={FavouriteIcon} - className="text-muted-foreground size-10" - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">No saved sounds</p> - <p className="text-muted-foreground text-sm text-balance"> - Click the heart icon on any sound to save it here - </p> - </div> - </div> - ); - } - - return ( - <div className="mt-1 flex h-full flex-col gap-5"> - <div className="flex items-center justify-between"> - <p className="text-muted-foreground text-sm"> - {savedSounds.length} saved{" "} - {savedSounds.length === 1 ? "sound" : "sounds"} - </p> - <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}> - <DialogTrigger asChild> - <Button - variant="text" - size="sm" - className="text-muted-foreground hover:text-destructive h-auto !opacity-100" - > - Clear all - </Button> - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Clear all saved sounds?</DialogTitle> - <DialogDescription> - This will permanently remove all {savedSounds.length} saved - sounds from your collection. This action cannot be undone. - </DialogDescription> - </DialogHeader> - <DialogFooter> - <Button variant="text" onClick={() => setShowClearDialog(false)}> - Cancel - </Button> - <Button - variant="destructive" - onClick={async ({ - stopPropagation, - }: React.MouseEvent<HTMLButtonElement>) => { - stopPropagation(); - await clearSavedSounds(); - setShowClearDialog(false); - }} - > - Clear all sounds - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </div> - - <div className="relative h-full overflow-hidden"> - <ScrollArea className="h-full flex-1"> - <div className="flex flex-col gap-4"> - {savedSounds.map((sound) => ( - <AudioItem - key={sound.id} - sound={convertToSoundEffect({ savedSound: sound })} - isPlaying={playingId === sound.id} - onPlay={playSound} - /> - ))} - </div> - </ScrollArea> - </div> - </div> - ); -} - -function SongsView() { - return <div>Songs</div>; -} - -interface AudioItemProps { - sound: SoundEffect; - isPlaying: boolean; - onPlay: ({ sound }: { sound: SoundEffect }) => void; -} - -function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) { - const { addSoundToTimeline, isSoundSaved, toggleSavedSound } = - useSoundsStore(); - const isSaved = isSoundSaved({ soundId: sound.id }); - - const handleClick = () => { - onPlay({ sound }); - }; - - const handleSaveClick = ({ - stopPropagation, - }: React.MouseEvent<HTMLButtonElement>) => { - stopPropagation(); - toggleSavedSound({ soundEffect: sound }); - }; - - const handleAddToTimeline = async ({ - stopPropagation, - }: React.MouseEvent<HTMLButtonElement>) => { - stopPropagation(); - await addSoundToTimeline({ sound }); - }; - - return ( - <div className="group flex items-center gap-3 opacity-100 hover:opacity-75"> - <button - type="button" - className="flex min-w-0 flex-1 items-center gap-3 text-left" - onClick={handleClick} - > - <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md"> - <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" /> - {isPlaying ? ( - <HugeiconsIcon icon={PauseIcon} className="size-5" /> - ) : ( - <HugeiconsIcon icon={PlayIcon} className="size-5" /> - )} - </div> - - <div className="min-w-0 flex-1 overflow-hidden"> - <p className="truncate text-sm font-medium">{sound.name}</p> - <span className="text-muted-foreground block truncate text-xs"> - {sound.username} - </span> - </div> - </button> - - <div className="flex items-center gap-3 pr-2"> - <Button - variant="text" - size="icon" - className="text-muted-foreground hover:text-foreground w-auto !opacity-100" - onClick={handleAddToTimeline} - title="Add to timeline" - > - <HugeiconsIcon icon={PlusSignIcon} /> - </Button> - <Button - variant="text" - size="icon" - className={`hover:text-foreground w-auto !opacity-100 ${ - isSaved - ? "text-red-500 hover:text-red-600" - : "text-muted-foreground" - }`} - onClick={handleSaveClick} - title={isSaved ? "Remove from saved" : "Save sound"} - > - <HugeiconsIcon - icon={FavouriteIcon} - className={`${isSaved ? "fill-current" : ""}`} - /> - </Button> - </div> - </div> - ); -} +"use client";
+
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import { useSoundSearch } from "@/hooks/use-sound-search";
+import { useSoundsStore } from "@/stores/sounds-store";
+import type { SavedSound, SoundEffect } from "@/lib/sounds/types";
+import { cn } from "@/utils/ui";
+import {
+ FavouriteIcon,
+ FilterMailIcon,
+ PauseIcon,
+ PlayIcon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function SoundsView() {
+ return (
+ <div className="flex h-full flex-col">
+ <Tabs defaultValue="sound-effects" className="flex h-full flex-col">
+ <div className="px-3 pt-4 pb-0">
+ <TabsList>
+ <TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
+ <TabsTrigger value="songs">Songs</TabsTrigger>
+ <TabsTrigger value="saved">Saved</TabsTrigger>
+ </TabsList>
+ </div>
+ <Separator className="my-4" />
+ <TabsContent
+ value="sound-effects"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SoundEffectsView />
+ </TabsContent>
+ <TabsContent
+ value="saved"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SavedSoundsView />
+ </TabsContent>
+ <TabsContent
+ value="songs"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SongsView />
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+}
+
+function SoundEffectsView() {
+ const {
+ topSoundEffects,
+ isLoading,
+ searchQuery,
+ setSearchQuery,
+ scrollPosition,
+ setScrollPosition,
+ loadSavedSounds,
+ showCommercialOnly,
+ toggleCommercialFilter,
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ } = useSoundsStore();
+ const {
+ results: searchResults,
+ isLoading: isSearching,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ } = useSoundSearch({
+ query: searchQuery,
+ commercialOnly: showCommercialOnly,
+ });
+
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null,
+ );
+
+ const { scrollAreaRef, handleScroll } = useInfiniteScroll({
+ onLoadMore: loadMore,
+ hasMore: hasNextPage,
+ isLoading: isLoadingMore || isSearching,
+ });
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ useEffect(() => {
+ if (hasLoaded) {
+ return;
+ }
+
+ let shouldIgnore = false;
+
+ const fetchTopSounds = async () => {
+ try {
+ if (!shouldIgnore) {
+ setLoading({ loading: true });
+ setError({ error: null });
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads",
+ );
+
+ if (!shouldIgnore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects({ sounds: data.results });
+ setHasLoaded({ loaded: true });
+
+ setCurrentPage({ page: 1 });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ }
+ } catch (error) {
+ if (!shouldIgnore) {
+ console.error("Failed to fetch top sounds:", error);
+ setError({
+ error:
+ error instanceof Error ? error.message : "Failed to load sounds",
+ });
+ }
+ } finally {
+ if (!shouldIgnore) {
+ setLoading({ loading: false });
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(fetchTopSounds, 100, {});
+
+ return () => {
+ shouldIgnore = true;
+ clearTimeout(timeoutId);
+ };
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
+
+ useEffect(() => {
+ if (!scrollAreaRef.current || scrollPosition <= 0) {
+ return;
+ }
+
+ const restoreScrollPosition = () => {
+ scrollAreaRef.current?.scrollTo({ top: scrollPosition });
+ };
+
+ const timeoutId = setTimeout(restoreScrollPosition, 100, {});
+
+ return () => clearTimeout(timeoutId);
+ }, [scrollPosition, scrollAreaRef]);
+
+ const handleScrollWithPosition = ({
+ currentTarget,
+ }: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop } = currentTarget;
+ setScrollPosition({ position: scrollTop });
+ handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>);
+ };
+
+ const displayedSounds = searchQuery ? searchResults : topSoundEffects;
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center gap-3">
+ <Input
+ placeholder="Search sound effects"
+ className="w-full"
+ containerClassName="w-full"
+ value={searchQuery}
+ onChange={({ currentTarget }) =>
+ setSearchQuery({ query: currentTarget.value })
+ }
+ showClearIcon
+ onClear={() => setSearchQuery({ query: "" })}
+ />
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className={cn(showCommercialOnly && "text-primary")}
+ >
+ <HugeiconsIcon icon={FilterMailIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuCheckboxItem
+ checked={showCommercialOnly}
+ onCheckedChange={() => toggleCommercialFilter()}
+ >
+ Show only commercially licensed
+ </DropdownMenuCheckboxItem>
+ <div className="text-muted-foreground px-2 py-1.5 text-xs">
+ {showCommercialOnly
+ ? "Only showing sounds licensed for commercial use"
+ : "Showing all sounds regardless of license"}
+ </div>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea
+ className="h-full flex-1"
+ ref={scrollAreaRef}
+ onScrollCapture={handleScrollWithPosition}
+ >
+ <div className="flex flex-col gap-4">
+ {isLoading && !searchQuery && (
+ <div className="text-muted-foreground text-sm">
+ Loading sounds...
+ </div>
+ )}
+ {isSearching && searchQuery && (
+ <div className="text-muted-foreground text-sm">Searching...</div>
+ )}
+ {displayedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={sound}
+ isPlaying={playingId === sound.id}
+ onPlay={playSound}
+ />
+ ))}
+ {!isLoading && !isSearching && displayedSounds.length === 0 && (
+ <div className="text-muted-foreground text-sm">
+ {searchQuery ? "No sounds found" : "No sounds available"}
+ </div>
+ )}
+ {isLoadingMore && (
+ <div className="text-muted-foreground py-4 text-center text-sm">
+ Loading more sounds...
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SavedSoundsView() {
+ const {
+ savedSounds,
+ isLoadingSavedSounds,
+ savedSoundsError,
+ loadSavedSounds,
+ clearSavedSounds,
+ } = useSoundsStore();
+
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null,
+ );
+
+ const [showClearDialog, setShowClearDialog] = useState(false);
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ const convertToSoundEffect = ({
+ savedSound,
+ }: {
+ savedSound: SavedSound;
+ }): SoundEffect => ({
+ id: savedSound.id,
+ name: savedSound.name,
+ description: "",
+ url: "",
+ previewUrl: savedSound.previewUrl,
+ downloadUrl: savedSound.downloadUrl,
+ duration: savedSound.duration,
+ filesize: 0,
+ type: "audio",
+ channels: 0,
+ bitrate: 0,
+ bitdepth: 0,
+ samplerate: 0,
+ username: savedSound.username,
+ tags: savedSound.tags,
+ license: savedSound.license,
+ created: savedSound.savedAt,
+ downloads: 0,
+ rating: 0,
+ ratingCount: 0,
+ });
+
+ if (isLoadingSavedSounds) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-muted-foreground text-sm">
+ Loading saved sounds...
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSoundsError) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-destructive text-sm">
+ Error: {savedSoundsError}
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSounds.length === 0) {
+ return (
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No saved sounds</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click the heart icon on any sound to save it here
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center justify-between">
+ <p className="text-muted-foreground text-sm">
+ {savedSounds.length} saved{" "}
+ {savedSounds.length === 1 ? "sound" : "sounds"}
+ </p>
+ <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
+ <DialogTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className="text-muted-foreground hover:text-destructive h-auto !opacity-100"
+ >
+ Clear all
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Clear all saved sounds?</DialogTitle>
+ <DialogDescription>
+ This will permanently remove all {savedSounds.length} saved
+ sounds from your collection. This action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="text" onClick={() => setShowClearDialog(false)}>
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={async ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await clearSavedSounds();
+ setShowClearDialog(false);
+ }}
+ >
+ Clear all sounds
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea className="h-full flex-1">
+ <div className="flex flex-col gap-4">
+ {savedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={convertToSoundEffect({ savedSound: sound })}
+ isPlaying={playingId === sound.id}
+ onPlay={playSound}
+ />
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SongsView() {
+ return <div>Songs</div>;
+}
+
+interface AudioItemProps {
+ sound: SoundEffect;
+ isPlaying: boolean;
+ onPlay: ({ sound }: { sound: SoundEffect }) => void;
+}
+
+function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
+ const { addSoundToTimeline, isSoundSaved, toggleSavedSound } =
+ useSoundsStore();
+ const isSaved = isSoundSaved({ soundId: sound.id });
+
+ const handleClick = () => {
+ onPlay({ sound });
+ };
+
+ const handleSaveClick = ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ toggleSavedSound({ soundEffect: sound });
+ };
+
+ const handleAddToTimeline = async ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await addSoundToTimeline({ sound });
+ };
+
+ return (
+ <div className="group flex items-center gap-3 opacity-100 hover:opacity-75">
+ <button
+ type="button"
+ className="flex min-w-0 flex-1 items-center gap-3 text-left"
+ onClick={handleClick}
+ >
+ <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md">
+ <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" />
+ {isPlaying ? (
+ <HugeiconsIcon icon={PauseIcon} className="size-5" />
+ ) : (
+ <HugeiconsIcon icon={PlayIcon} className="size-5" />
+ )}
+ </div>
+
+ <div className="min-w-0 flex-1 overflow-hidden">
+ <p className="truncate text-sm font-medium">{sound.name}</p>
+ <span className="text-muted-foreground block truncate text-xs">
+ {sound.username}
+ </span>
+ </div>
+ </button>
+
+ <div className="flex items-center gap-3 pr-2">
+ <Button
+ variant="text"
+ size="icon"
+ className="text-muted-foreground hover:text-foreground w-auto !opacity-100"
+ onClick={handleAddToTimeline}
+ title="Add to timeline"
+ >
+ <HugeiconsIcon icon={PlusSignIcon} />
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ className={`hover:text-foreground w-auto !opacity-100 ${
+ isSaved
+ ? "text-red-500 hover:text-red-600"
+ : "text-muted-foreground"
+ }`}
+ onClick={handleSaveClick}
+ title={isSaved ? "Remove from saved" : "Save sound"}
+ >
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className={`${isSaved ? "fill-current" : ""}`}
+ />
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index af489630..285b37d3 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -1,470 +1,470 @@ -"use client"; - -import Image from "next/image"; -import type { CSSProperties } from "react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Spinner } from "@/components/ui/spinner"; -import { useEditor } from "@/hooks/use-editor"; -import { resolveStickerIntrinsicSize } from "@/lib/stickers"; -import { - buildGraphicElement, - buildStickerElement, -} from "@/lib/timeline/element-utils"; -import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; -import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers"; -import { parseShapeStickerId } from "@/lib/stickers/providers/shapes"; -import type { TimelineDragData } from "@/lib/timeline/drag"; -import type { - StickerBrowseSection, - StickerCategory, - StickerItem as StickerData, -} from "@/lib/stickers"; -import { useStickersStore } from "@/stores/stickers-store"; -import { cn } from "@/utils/ui"; -import { - HappyIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function StickersView() { - const { - browseContent, - browseStickers, - searchQuery, - searchStickers, - selectedCategory, - setSearchQuery, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - useEffect(() => { - if (viewMode === "browse" && !browseContent) { - void browseStickers(); - } - }, [browseContent, browseStickers, viewMode]); - - return ( - <div className="flex h-full flex-col py-2"> - <div className="px-2"> - <Input - size="sm" - variant="default" - placeholder="Search..." - value={searchQuery} - onChange={(e) => { - setSearchQuery({ query: e.target.value }); - void searchStickers({ query: e.target.value }); - }} - showClearIcon - onClear={() => { - setSearchQuery({ query: "" }); - void searchStickers({ query: "" }); - }} - className="w-full" - containerClassName="w-full" - /> - </div> - - <div className="mt-2 flex min-h-0 flex-1 flex-col"> - <div className="border-b border-border px-2"> - <div - role="tablist" - aria-label="Sticker categories" - className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0" - > - {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { - const isActive = key === selectedCategory; - return ( - <button - key={key} - type="button" - role="tab" - aria-selected={isActive} - onClick={() => - setSelectedCategory({ - category: key as StickerCategory, - }) - } - className={cn( - "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px", - isActive && "text-primary border-primary", - )} - > - {label} - </button> - ); - })} - </div> - </div> - <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4"> - <StickersContentView /> - </div> - </div> - </div> - ); -} - -function StickerGrid({ - items, - shouldCapSize = false, -}: { - items: StickerData[]; - shouldCapSize?: boolean; -}) { - const gridStyle: CSSProperties & { - "--sticker-min": string; - "--sticker-max"?: string; - } = { - gridTemplateColumns: shouldCapSize - ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))" - : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))", - "--sticker-min": "80px", - ...(shouldCapSize ? { "--sticker-max": "140px" } : {}), - }; - - return ( - <div className="grid gap-2" style={gridStyle}> - {items.map((item) => ( - <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} /> - ))} - </div> - ); -} - -function StickerRow({ items }: { items: StickerData[] }) { - return ( - <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden"> - {items.map((item) => ( - <div key={item.id} className="w-20 shrink-0"> - <StickerItem item={item} shouldCapSize containerClassName="w-full" /> - </div> - ))} - </div> - ); -} - -function EmptyView({ message }: { message: string }) { - return ( - <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={HappyIcon} - className="text-muted-foreground size-10" - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">No stickers found</p> - <p className="text-muted-foreground text-sm text-balance">{message}</p> - </div> - </div> - ); -} - -function RegionBanner({ region }: { region: string }) { - return ( - <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2"> - <svg - width="12" - height="12" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - className="shrink-0 text-sky-600" - aria-hidden="true" - > - <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /> - <circle cx="12" cy="10" r="3" /> - </svg> - <span className="text-xs font-semibold text-sky-600">{region}</span> - </div> - ); -} - -function StickersContentView() { - const { - browseContent, - clearRecentStickers, - isBrowsing, - isSearching, - searchQuery, - searchResults, - selectedCategory, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - if (viewMode === "search") { - if (isSearching) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ); - } - - if (searchResults?.items.length) { - const normalizedQuery = searchQuery.trim().toLowerCase(); - const isRegionSearch = - selectedCategory === "flags" && - resolveQueryToRegions({ query: normalizedQuery }) !== null; - const regionLabel = getRegionLabel({ query: normalizedQuery }); - - return ( - <div className="flex flex-col gap-3 pb-4"> - {isRegionSearch && <RegionBanner region={regionLabel} />} - <div className="flex items-center justify-between"> - <span className="text-muted-foreground text-sm"> - {searchResults.total} results - </span> - </div> - <StickerGrid items={searchResults.items} /> - </div> - ); - } - - // "all" tab search — sections are in browseContent, fall through to section rendering below - if (selectedCategory !== "all" && searchQuery) { - return <EmptyView message={`No stickers found for "${searchQuery}"`} />; - } - } - - if (isBrowsing && !browseContent) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ); - } - - if (!browseContent?.sections.length) { - const categoryLabel = STICKER_CATEGORIES[selectedCategory]; - return ( - <EmptyView - message={ - viewMode === "search" - ? `No stickers found for "${searchQuery}"` - : selectedCategory === "all" - ? "No stickers available yet." - : `No stickers available in ${categoryLabel.toLowerCase()} yet.` - } - /> - ); - } - - return ( - <div className="flex flex-col gap-4 pb-4"> - {browseContent.sections.map((section) => ( - <StickerSection - key={section.id} - section={section} - onClearRecent={clearRecentStickers} - onSeeAll={(category) => { - setSelectedCategory({ category }); - }} - /> - ))} - </div> - ); -} - -function StickerSection({ - section, - onClearRecent, - onSeeAll, -}: { - section: StickerBrowseSection; - onClearRecent: () => void; - onSeeAll: (category: StickerCategory) => void; -}) { - const hasHeader = - Boolean(section.title) || section.id === "recent" || section.action; - - return ( - <div className="flex flex-col gap-2"> - {hasHeader && ( - <div className="flex items-center justify-between gap-2"> - {section.title ? ( - <p className="text-xs text-muted-foreground">{section.title}</p> - ) : ( - <div /> - )} - - <div className="ml-auto flex items-center gap-2"> - {section.id === "recent" && ( - <Button - onClick={onClearRecent} - variant="text" - size="sm" - className="h-auto gap-1 p-0 text-xs text-muted-foreground" - > - Clear - </Button> - )} - - {section.action?.type === "see-all" && section.action.category && ( - <Button - variant="text" - size="sm" - className="h-auto gap-1 p-0 text-xs text-primary" - onClick={() => { - onSeeAll(section.action?.category as StickerCategory); - }} - > - See all - </Button> - )} - </div> - </div> - )} - - {section.layout === "row" ? ( - <StickerRow items={section.items} /> - ) : ( - <StickerGrid items={section.items} /> - )} - </div> - ); -} - -interface StickerItemProps { - item: StickerData; - shouldCapSize?: boolean; - containerClassName?: string; -} - -function StickerItem({ - item, - shouldCapSize = false, - containerClassName, -}: StickerItemProps) { - const editor = useEditor(); - const { addToRecentStickers } = useStickersStore(); - const [isAdding, setIsAdding] = useState(false); - const [hasImageError, setHasImageError] = useState(false); - - useEffect(() => { - if (!item.id) { - return; - } - - setHasImageError(false); - }, [item.id]); - - const displayName = item.name; - const shapePreset = - item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null; - - const handleAdd = async () => { - setIsAdding(true); - try { - const currentTime = editor.playback.getCurrentTime(); - - let element: - | ReturnType<typeof buildGraphicElement> - | ReturnType<typeof buildStickerElement>; - if (shapePreset) { - element = buildGraphicElement({ - definitionId: shapePreset.definitionId, - name: shapePreset.name, - startTime: currentTime, - params: shapePreset.params, - }); - } else { - const { width: intrinsicWidth, height: intrinsicHeight } = - await resolveStickerIntrinsicSize({ stickerId: item.id }); - element = buildStickerElement({ - stickerId: item.id, - name: item.name, - startTime: currentTime, - intrinsicWidth, - intrinsicHeight, - }); - } - - editor.timeline.insertElement({ - placement: { mode: "auto" }, - element, - }); - - addToRecentStickers({ stickerId: item.id }); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } finally { - setIsAdding(false); - } - }; - - const preview = ( - <div className="flex size-full items-center justify-center p-3"> - {hasImageError ? ( - <span className="text-muted-foreground text-center text-xs break-all"> - {displayName} - </span> - ) : ( - <Image - src={item.previewUrl} - alt={displayName} - width={64} - height={64} - className="size-full object-contain" - style={ - shouldCapSize - ? { - maxWidth: "var(--sticker-max, 160px)", - maxHeight: "var(--sticker-max, 160px)", - } - : undefined - } - onError={() => setHasImageError(true)} - loading="lazy" - unoptimized - /> - )} - </div> - ); - - const dragData: TimelineDragData = shapePreset - ? { - id: item.id, - type: "graphic", - name: displayName, - definitionId: shapePreset.definitionId, - params: shapePreset.params ?? {}, - } - : { - id: item.id, - type: "sticker", - name: displayName, - stickerId: item.id, - }; - - return ( - <div - className={cn("relative", isAdding && "pointer-events-none opacity-50")} - > - <DraggableItem - name={displayName} - preview={preview} - dragData={dragData} - onAddToTimeline={handleAdd} - aspectRatio={1} - shouldShowLabel={false} - isRounded - variant="card" - containerClassName={containerClassName ?? "w-full"} - /> - {isAdding && ( - <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> - <Spinner className="size-6 text-white" /> - </div> - )} - </div> - ); -} +"use client";
+
+import Image from "next/image";
+import type { CSSProperties } from "react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { useEditor } from "@/hooks/use-editor";
+import { resolveStickerIntrinsicSize } from "@/lib/stickers";
+import {
+ buildGraphicElement,
+ buildStickerElement,
+} from "@/lib/timeline/element-utils";
+import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
+import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
+import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
+import type { TimelineDragData } from "@/lib/timeline/drag";
+import type {
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem as StickerData,
+} from "@/lib/stickers";
+import { useStickersStore } from "@/stores/stickers-store";
+import { cn } from "@/utils/ui";
+import {
+ HappyIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function StickersView() {
+ const {
+ browseContent,
+ browseStickers,
+ searchQuery,
+ searchStickers,
+ selectedCategory,
+ setSearchQuery,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ useEffect(() => {
+ if (viewMode === "browse" && !browseContent) {
+ void browseStickers();
+ }
+ }, [browseContent, browseStickers, viewMode]);
+
+ return (
+ <div className="flex h-full flex-col py-2">
+ <div className="px-2">
+ <Input
+ size="sm"
+ variant="default"
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(e) => {
+ setSearchQuery({ query: e.target.value });
+ void searchStickers({ query: e.target.value });
+ }}
+ showClearIcon
+ onClear={() => {
+ setSearchQuery({ query: "" });
+ void searchStickers({ query: "" });
+ }}
+ className="w-full"
+ containerClassName="w-full"
+ />
+ </div>
+
+ <div className="mt-2 flex min-h-0 flex-1 flex-col">
+ <div className="border-b border-border px-2">
+ <div
+ role="tablist"
+ aria-label="Sticker categories"
+ className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
+ >
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
+ const isActive = key === selectedCategory;
+ return (
+ <button
+ key={key}
+ type="button"
+ role="tab"
+ aria-selected={isActive}
+ onClick={() =>
+ setSelectedCategory({
+ category: key as StickerCategory,
+ })
+ }
+ className={cn(
+ "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
+ isActive && "text-primary border-primary",
+ )}
+ >
+ {label}
+ </button>
+ );
+ })}
+ </div>
+ </div>
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
+ <StickersContentView />
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function StickerGrid({
+ items,
+ shouldCapSize = false,
+}: {
+ items: StickerData[];
+ shouldCapSize?: boolean;
+}) {
+ const gridStyle: CSSProperties & {
+ "--sticker-min": string;
+ "--sticker-max"?: string;
+ } = {
+ gridTemplateColumns: shouldCapSize
+ ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
+ : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
+ "--sticker-min": "80px",
+ ...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
+ };
+
+ return (
+ <div className="grid gap-2" style={gridStyle}>
+ {items.map((item) => (
+ <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
+ ))}
+ </div>
+ );
+}
+
+function StickerRow({ items }: { items: StickerData[] }) {
+ return (
+ <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
+ {items.map((item) => (
+ <div key={item.id} className="w-20 shrink-0">
+ <StickerItem item={item} shouldCapSize containerClassName="w-full" />
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function EmptyView({ message }: { message: string }) {
+ return (
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={HappyIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No stickers found</p>
+ <p className="text-muted-foreground text-sm text-balance">{message}</p>
+ </div>
+ </div>
+ );
+}
+
+function RegionBanner({ region }: { region: string }) {
+ return (
+ <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className="shrink-0 text-sky-600"
+ aria-hidden="true"
+ >
+ <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
+ <circle cx="12" cy="10" r="3" />
+ </svg>
+ <span className="text-xs font-semibold text-sky-600">{region}</span>
+ </div>
+ );
+}
+
+function StickersContentView() {
+ const {
+ browseContent,
+ clearRecentStickers,
+ isBrowsing,
+ isSearching,
+ searchQuery,
+ searchResults,
+ selectedCategory,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ if (viewMode === "search") {
+ if (isSearching) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (searchResults?.items.length) {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const isRegionSearch =
+ selectedCategory === "flags" &&
+ resolveQueryToRegions({ query: normalizedQuery }) !== null;
+ const regionLabel = getRegionLabel({ query: normalizedQuery });
+
+ return (
+ <div className="flex flex-col gap-3 pb-4">
+ {isRegionSearch && <RegionBanner region={regionLabel} />}
+ <div className="flex items-center justify-between">
+ <span className="text-muted-foreground text-sm">
+ {searchResults.total} results
+ </span>
+ </div>
+ <StickerGrid items={searchResults.items} />
+ </div>
+ );
+ }
+
+ // "all" tab search — sections are in browseContent, fall through to section rendering below
+ if (selectedCategory !== "all" && searchQuery) {
+ return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
+ }
+ }
+
+ if (isBrowsing && !browseContent) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (!browseContent?.sections.length) {
+ const categoryLabel = STICKER_CATEGORIES[selectedCategory];
+ return (
+ <EmptyView
+ message={
+ viewMode === "search"
+ ? `No stickers found for "${searchQuery}"`
+ : selectedCategory === "all"
+ ? "No stickers available yet."
+ : `No stickers available in ${categoryLabel.toLowerCase()} yet.`
+ }
+ />
+ );
+ }
+
+ return (
+ <div className="flex flex-col gap-4 pb-4">
+ {browseContent.sections.map((section) => (
+ <StickerSection
+ key={section.id}
+ section={section}
+ onClearRecent={clearRecentStickers}
+ onSeeAll={(category) => {
+ setSelectedCategory({ category });
+ }}
+ />
+ ))}
+ </div>
+ );
+}
+
+function StickerSection({
+ section,
+ onClearRecent,
+ onSeeAll,
+}: {
+ section: StickerBrowseSection;
+ onClearRecent: () => void;
+ onSeeAll: (category: StickerCategory) => void;
+}) {
+ const hasHeader =
+ Boolean(section.title) || section.id === "recent" || section.action;
+
+ return (
+ <div className="flex flex-col gap-2">
+ {hasHeader && (
+ <div className="flex items-center justify-between gap-2">
+ {section.title ? (
+ <p className="text-xs text-muted-foreground">{section.title}</p>
+ ) : (
+ <div />
+ )}
+
+ <div className="ml-auto flex items-center gap-2">
+ {section.id === "recent" && (
+ <Button
+ onClick={onClearRecent}
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-muted-foreground"
+ >
+ Clear
+ </Button>
+ )}
+
+ {section.action?.type === "see-all" && section.action.category && (
+ <Button
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-primary"
+ onClick={() => {
+ onSeeAll(section.action?.category as StickerCategory);
+ }}
+ >
+ See all
+ </Button>
+ )}
+ </div>
+ </div>
+ )}
+
+ {section.layout === "row" ? (
+ <StickerRow items={section.items} />
+ ) : (
+ <StickerGrid items={section.items} />
+ )}
+ </div>
+ );
+}
+
+interface StickerItemProps {
+ item: StickerData;
+ shouldCapSize?: boolean;
+ containerClassName?: string;
+}
+
+function StickerItem({
+ item,
+ shouldCapSize = false,
+ containerClassName,
+}: StickerItemProps) {
+ const editor = useEditor();
+ const { addToRecentStickers } = useStickersStore();
+ const [isAdding, setIsAdding] = useState(false);
+ const [hasImageError, setHasImageError] = useState(false);
+
+ useEffect(() => {
+ if (!item.id) {
+ return;
+ }
+
+ setHasImageError(false);
+ }, [item.id]);
+
+ const displayName = item.name;
+ const shapePreset =
+ item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
+
+ const handleAdd = async () => {
+ setIsAdding(true);
+ try {
+ const currentTime = editor.playback.getCurrentTime();
+
+ let element:
+ | ReturnType<typeof buildGraphicElement>
+ | ReturnType<typeof buildStickerElement>;
+ if (shapePreset) {
+ element = buildGraphicElement({
+ definitionId: shapePreset.definitionId,
+ name: shapePreset.name,
+ startTime: currentTime,
+ params: shapePreset.params,
+ });
+ } else {
+ const { width: intrinsicWidth, height: intrinsicHeight } =
+ await resolveStickerIntrinsicSize({ stickerId: item.id });
+ element = buildStickerElement({
+ stickerId: item.id,
+ name: item.name,
+ startTime: currentTime,
+ intrinsicWidth,
+ intrinsicHeight,
+ });
+ }
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto" },
+ element,
+ });
+
+ addToRecentStickers({ stickerId: item.id });
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ } finally {
+ setIsAdding(false);
+ }
+ };
+
+ const preview = (
+ <div className="flex size-full items-center justify-center p-3">
+ {hasImageError ? (
+ <span className="text-muted-foreground text-center text-xs break-all">
+ {displayName}
+ </span>
+ ) : (
+ <Image
+ src={item.previewUrl}
+ alt={displayName}
+ width={64}
+ height={64}
+ className="size-full object-contain"
+ style={
+ shouldCapSize
+ ? {
+ maxWidth: "var(--sticker-max, 160px)",
+ maxHeight: "var(--sticker-max, 160px)",
+ }
+ : undefined
+ }
+ onError={() => setHasImageError(true)}
+ loading="lazy"
+ unoptimized
+ />
+ )}
+ </div>
+ );
+
+ const dragData: TimelineDragData = shapePreset
+ ? {
+ id: item.id,
+ type: "graphic",
+ name: displayName,
+ definitionId: shapePreset.definitionId,
+ params: shapePreset.params ?? {},
+ }
+ : {
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ };
+
+ return (
+ <div
+ className={cn("relative", isAdding && "pointer-events-none opacity-50")}
+ >
+ <DraggableItem
+ name={displayName}
+ preview={preview}
+ dragData={dragData}
+ onAddToTimeline={handleAdd}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded
+ variant="card"
+ containerClassName={containerClassName ?? "w-full"}
+ />
+ {isAdding && (
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
+ <Spinner className="size-6 text-white" />
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 075cc25b..da295da3 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,46 +1,46 @@ -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { buildTextElement } from "@/lib/timeline/element-utils"; - -export function TextView() { - const editor = useEditor(); - - const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => { - const activeScene = editor.scenes.getActiveScene(); - if (!activeScene) return; - - const element = buildTextElement({ - raw: DEFAULTS.text.element, - startTime: currentTime, - }); - - editor.timeline.insertElement({ - element, - placement: { mode: "auto" }, - }); - }; - - return ( - <PanelView title="Text"> - <DraggableItem - name="Default text" - preview={ - <div className="bg-accent flex size-full items-center justify-center rounded"> - <span className="text-xs select-none">Default text</span> - </div> - } - dragData={{ - id: "temp-text-id", - type: DEFAULTS.text.element.type, - name: DEFAULTS.text.element.name, - content: DEFAULTS.text.element.content, - }} - aspectRatio={1} - onAddToTimeline={handleAddToTimeline} - shouldShowLabel={false} - /> - </PanelView> - ); -} +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { buildTextElement } from "@/lib/timeline/element-utils";
+
+export function TextView() {
+ const editor = useEditor();
+
+ const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
+ const activeScene = editor.scenes.getActiveScene();
+ if (!activeScene) return;
+
+ const element = buildTextElement({
+ raw: DEFAULTS.text.element,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+ <PanelView title="Text">
+ <DraggableItem
+ name="Default text"
+ preview={
+ <div className="bg-accent flex size-full items-center justify-center rounded">
+ <span className="text-xs select-none">Default text</span>
+ </div>
+ }
+ dragData={{
+ id: "temp-text-id",
+ type: DEFAULTS.text.element.type,
+ name: DEFAULTS.text.element.name,
+ content: DEFAULTS.text.element.content,
+ }}
+ aspectRatio={1}
+ onAddToTimeline={handleAddToTimeline}
+ shouldShowLabel={false}
+ />
+ </PanelView>
+ );
+}
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 index 4ace25ac..3d96f762 100644 --- a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx @@ -1,59 +1,59 @@ -"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> - ); -} +"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 index 13226378..677f35db 100644 --- a/apps/web/src/components/editor/panels/preview/context-menu.tsx +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -1,73 +1,73 @@ -"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> - ); -} +"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/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx index ad7a621f..4270fcb3 100644 --- a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx @@ -1,32 +1,32 @@ -"use client"; - -import { getGuideById } from "@/lib/guides"; -import { usePreviewStore } from "@/stores/preview-store"; -import { usePreviewViewport } from "./preview-viewport"; - -export function GuideOverlay() { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const viewport = usePreviewViewport(); - const guide = getGuideById(activeGuide); - - if (!guide) { - return null; - } - - return ( - <div - className="pointer-events-none absolute" - style={{ - left: viewport.sceneLeft, - top: viewport.sceneTop, - width: viewport.sceneWidth, - height: viewport.sceneHeight, - }} - > - {guide.renderOverlay({ - width: viewport.sceneWidth, - height: viewport.sceneHeight, - })} - </div> - ); -} +"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 index e6d3df51..bcac8075 100644 --- a/apps/web/src/components/editor/panels/preview/guide-popover.tsx +++ b/apps/web/src/components/editor/panels/preview/guide-popover.tsx @@ -1,100 +1,100 @@ -"use client"; - -import { AnimatePresence, motion } from "motion/react"; -import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides"; -import { usePreviewStore } from "@/stores/preview-store"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/utils/ui"; - -export function GridPopover({ children }: { children: React.ReactNode }) { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const toggleGuide = usePreviewStore((state) => state.toggleGuide); - const activeGuideDef = getGuideById(activeGuide); - const options = activeGuideDef?.renderOptions?.(); - - return ( - <Popover> - <PopoverTrigger>{children}</PopoverTrigger> - <PopoverContent sideOffset={8} className="w-60 px-0"> - <div className="flex flex-col gap-2 px-4"> - <Label>Guides</Label> - <div className="grid grid-cols-3 gap-1"> - {GUIDE_REGISTRY.map((guide) => ( - <GridItem - key={guide.id} - label={guide.label} - preview={guide.renderPreview()} - isSelected={activeGuide === guide.id} - onClick={() => toggleGuide(guide.id)} - /> - ))} - </div> - </div> - <AnimatePresence> - {options && ( - <motion.div - initial={{ height: 0, opacity: 0 }} - animate={{ height: "auto", opacity: 1 }} - exit={{ height: 0, opacity: 0 }} - transition={{ - height: { type: "spring", duration: 0.2, bounce: 0 }, - opacity: { duration: 0.2 }, - }} - className="overflow-hidden" - > - <Separator className="my-3" /> - <div className="px-4">{options}</div> - </motion.div> - )} - </AnimatePresence> - </PopoverContent> - </Popover> - ); -} - -function GridItem({ - label, - preview, - isSelected, - onClick, -}: { - label: string; - preview: React.ReactNode; - isSelected?: boolean; - onClick: () => void; -}) { - return ( - <button - type="button" - onClick={onClick} - aria-pressed={isSelected} - className={cn( - "flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5", - isSelected && "bg-primary/5! text-primary", - )} - > - <div - className={cn( - "aspect-video bg-foreground/5 flex items-center justify-center rounded-sm", - isSelected && "bg-primary/5!", - )} - > - {preview} - </div> - <span - className={cn( - "text-xs", - isSelected ? "text-primary" : "text-muted-foreground", - )} - > - {label} - </span> - </button> - ); -} +"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 index 44b8a7f1..d4de3332 100644 --- a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx +++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx @@ -1,332 +1,332 @@ -"use client"; - -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import type { IconSvgElement } from "@hugeicons/react"; - -export const HANDLE_SIZE = 10; -export const HANDLE_HIT_AREA_SIZE = 18; -export const ICON_HANDLE_RADIUS = 10; -export const EDGE_HANDLE_THIN_SIZE = 6; -export const EDGE_HANDLE_THICK_SIZE = 14; -export const LINE_HIT_AREA_SIZE = 48; - -export function getResizeCursor({ angleDeg }: { angleDeg: number }): string { - const normalized = ((angleDeg % 180) + 180) % 180; - if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize"; - if (normalized < 67.5) return "cursor-nwse-resize"; - if (normalized < 112.5) return "cursor-ns-resize"; - return "cursor-nesw-resize"; -} - -export function HandleButton({ - screen, - cursor, - hitAreaSize, - className, - onPointerDown, - onPointerMove, - onPointerUp, - children, -}: { - screen: { x: number; y: number }; - cursor?: string; - hitAreaSize: number; - className?: string; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; - children: React.ReactNode; -}) { - return ( - <button - type="button" - className={cn( - "absolute flex items-center justify-center outline-none", - cursor, - className, - )} - style={{ - left: screen.x - hitAreaSize / 2, - top: screen.y - hitAreaSize / 2, - width: hitAreaSize, - height: hitAreaSize, - pointerEvents: "auto", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} - onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} - > - {children} - </button> - ); -} - -export function CornerHandle({ - cursor, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - cursor?: string; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - <HandleButton - screen={screen} - cursor={cursor} - hitAreaSize={HANDLE_HIT_AREA_SIZE} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <div - className="rounded-sm bg-white" - style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} - /> - </HandleButton> - ); -} - -export function EdgeHandle({ - edge, - screen, - rotation, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - edge: "right" | "left" | "bottom"; - screen: { x: number; y: number }; - rotation: number; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - const isHorizontalEdge = edge === "right" || edge === "left"; - const width = isHorizontalEdge - ? EDGE_HANDLE_THIN_SIZE - : EDGE_HANDLE_THICK_SIZE; - const height = isHorizontalEdge - ? EDGE_HANDLE_THICK_SIZE - : EDGE_HANDLE_THIN_SIZE; - const cursor = getResizeCursor({ - angleDeg: isHorizontalEdge ? rotation : rotation + 90, - }); - - return ( - <HandleButton - screen={screen} - cursor={cursor} - hitAreaSize={HANDLE_HIT_AREA_SIZE} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <div - className="rounded-sm bg-white" - style={{ width, height, transform: `rotate(${rotation}deg)` }} - /> - </HandleButton> - ); -} - -export function IconHandle({ - icon, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - icon: IconSvgElement; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - <HandleButton - screen={screen} - hitAreaSize={ICON_HANDLE_RADIUS * 2} - className="rounded-full bg-white text-black shadow-sm" - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} /> - </HandleButton> - ); -} - -export function BoundingBoxOutline({ - center, - outlineWidth, - outlineHeight, - rotation, - cursor, - dashed = false, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; - cursor?: string; - dashed?: boolean; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - <svg - className={cn("absolute overflow-visible", cursor)} - aria-hidden="true" - focusable="false" - style={{ - left: center.x - outlineWidth / 2, - top: center.y - outlineHeight / 2, - width: outlineWidth, - height: outlineHeight, - transform: `rotate(${rotation}deg)`, - transformOrigin: "center center", - pointerEvents: onPointerDown ? "auto" : "none", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - > - <rect - x={0.5} - y={0.5} - width={Math.max(outlineWidth - 1, 0)} - height={Math.max(outlineHeight - 1, 0)} - fill="transparent" - stroke="white" - strokeDasharray={dashed ? "4 4" : undefined} - strokeOpacity={0.75} - vectorEffect="non-scaling-stroke" - style={{ pointerEvents: onPointerDown ? "all" : "none" }} - /> - </svg> - ); -} - -export function ShapeOutline({ - center, - outlineWidth, - outlineHeight, - rotation, - pathData, - cursor, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; - pathData: string; - cursor?: string; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - <svg - className={cn("absolute overflow-visible", cursor)} - aria-hidden="true" - focusable="false" - style={{ - left: center.x - outlineWidth / 2, - top: center.y - outlineHeight / 2, - width: outlineWidth, - height: outlineHeight, - transform: `rotate(${rotation}deg)`, - transformOrigin: "center center", - pointerEvents: onPointerDown ? "auto" : "none", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - > - <path - d={pathData} - fill="transparent" - stroke="white" - strokeOpacity={0.75} - vectorEffect="non-scaling-stroke" - style={{ pointerEvents: onPointerDown ? "all" : "none" }} - /> - </svg> - ); -} - -export function LineOverlay({ - start, - end, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - start: { x: number; y: number }; - end: { x: number; y: number }; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - const dx = end.x - start.x; - const dy = end.y - start.y; - const length = Math.sqrt(dx * dx + dy * dy); - const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; - const cx = (start.x + end.x) / 2; - const cy = (start.y + end.y) / 2; - - const sharedStyle = { - left: cx - length / 2, - width: length, - transform: `rotate(${angleDeg}deg)`, - transformOrigin: "center center", - }; - - return ( - <> - {onPointerDown && ( - <div - className="absolute" - style={{ - ...sharedStyle, - top: cy - LINE_HIT_AREA_SIZE / 2, - height: LINE_HIT_AREA_SIZE, - pointerEvents: "auto", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - /> - )} - <div - className="pointer-events-none absolute" - style={{ - ...sharedStyle, - top: cy - 0.5, - height: 1, - backgroundColor: "white", - opacity: 0.75, - }} - /> - </> - ); -} +"use client";
+
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { IconSvgElement } from "@hugeicons/react";
+
+export const HANDLE_SIZE = 10;
+export const HANDLE_HIT_AREA_SIZE = 18;
+export const ICON_HANDLE_RADIUS = 10;
+export const EDGE_HANDLE_THIN_SIZE = 6;
+export const EDGE_HANDLE_THICK_SIZE = 14;
+export const LINE_HIT_AREA_SIZE = 48;
+
+export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
+ const normalized = ((angleDeg % 180) + 180) % 180;
+ if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
+ if (normalized < 67.5) return "cursor-nwse-resize";
+ if (normalized < 112.5) return "cursor-ns-resize";
+ return "cursor-nesw-resize";
+}
+
+export function HandleButton({
+ screen,
+ cursor,
+ hitAreaSize,
+ className,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ children,
+}: {
+ screen: { x: number; y: number };
+ cursor?: string;
+ hitAreaSize: number;
+ className?: string;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+ children: React.ReactNode;
+}) {
+ return (
+ <button
+ type="button"
+ className={cn(
+ "absolute flex items-center justify-center outline-none",
+ cursor,
+ className,
+ )}
+ style={{
+ left: screen.x - hitAreaSize / 2,
+ top: screen.y - hitAreaSize / 2,
+ width: hitAreaSize,
+ height: hitAreaSize,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ {children}
+ </button>
+ );
+}
+
+export function CornerHandle({
+ cursor,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ cursor?: string;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
+ />
+ </HandleButton>
+ );
+}
+
+export function EdgeHandle({
+ edge,
+ screen,
+ rotation,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ edge: "right" | "left" | "bottom";
+ screen: { x: number; y: number };
+ rotation: number;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ const isHorizontalEdge = edge === "right" || edge === "left";
+ const width = isHorizontalEdge
+ ? EDGE_HANDLE_THIN_SIZE
+ : EDGE_HANDLE_THICK_SIZE;
+ const height = isHorizontalEdge
+ ? EDGE_HANDLE_THICK_SIZE
+ : EDGE_HANDLE_THIN_SIZE;
+ const cursor = getResizeCursor({
+ angleDeg: isHorizontalEdge ? rotation : rotation + 90,
+ });
+
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width, height, transform: `rotate(${rotation}deg)` }}
+ />
+ </HandleButton>
+ );
+}
+
+export function IconHandle({
+ icon,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ icon: IconSvgElement;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ hitAreaSize={ICON_HANDLE_RADIUS * 2}
+ className="rounded-full bg-white text-black shadow-sm"
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
+ </HandleButton>
+ );
+}
+
+export function BoundingBoxOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ cursor,
+ dashed = false,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ cursor?: string;
+ dashed?: boolean;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className={cn("absolute overflow-visible", cursor)}
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <rect
+ x={0.5}
+ y={0.5}
+ width={Math.max(outlineWidth - 1, 0)}
+ height={Math.max(outlineHeight - 1, 0)}
+ fill="transparent"
+ stroke="white"
+ strokeDasharray={dashed ? "4 4" : undefined}
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function ShapeOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ pathData,
+ cursor,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ pathData: string;
+ cursor?: string;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className={cn("absolute overflow-visible", cursor)}
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <path
+ d={pathData}
+ fill="transparent"
+ stroke="white"
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function LineOverlay({
+ start,
+ end,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ const dx = end.x - start.x;
+ const dy = end.y - start.y;
+ const length = Math.sqrt(dx * dx + dy * dy);
+ const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const cx = (start.x + end.x) / 2;
+ const cy = (start.y + end.y) / 2;
+
+ const sharedStyle = {
+ left: cx - length / 2,
+ width: length,
+ transform: `rotate(${angleDeg}deg)`,
+ transformOrigin: "center center",
+ };
+
+ return (
+ <>
+ {onPointerDown && (
+ <div
+ className="absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - LINE_HIT_AREA_SIZE / 2,
+ height: LINE_HIT_AREA_SIZE,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ />
+ )}
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - 0.5,
+ height: 1,
+ backgroundColor: "white",
+ opacity: 0.75,
+ }}
+ />
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index c625b9bf..8cfe7f49 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -9,7 +9,6 @@ import { useFullscreen } from "@/hooks/use-fullscreen"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; -import { getLastFrameTime } from "@/lib/time"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; import { GuideOverlay } from "./guide-overlay"; @@ -134,12 +133,7 @@ function PreviewCanvas({ const render = useCallback(() => { if (canvasRef.current && renderTree && !renderingRef.current) { - const time = editor.playback.getCurrentTime(); - const lastFrameTime = getLastFrameTime({ - duration: renderTree.duration, - fps: renderer.fps, - }); - const renderTime = Math.min(time, lastFrameTime); + const renderTime = editor.playback.getCurrentTime(); const frame = Math.floor(renderTime * renderer.fps); if ( diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index 65c11726..515cb91a 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,117 +1,117 @@ -import { useState } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; -import type { SnapLine } from "@/lib/preview/preview-snap"; -import { TransformHandles } from "./transform-handles"; -import { MaskHandles } from "./mask-handles"; -import { SnapGuides } from "./snap-guides"; -import { TextEditOverlay } from "./text-edit-overlay"; -import { usePropertiesStore } from "../properties/stores/properties-store"; -import { useEditor } from "@/hooks/use-editor"; - -export function PreviewInteractionOverlay() { - const [snapLines, setSnapLines] = useState<SnapLine[]>([]); - const editor = useEditor(); - const viewport = usePreviewViewport(); - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType); - - const selectedRef = - selectedElements.length === 1 ? selectedElements[0] : null; - const activeTrack = selectedRef - ? editor.timeline.getTrackById({ trackId: selectedRef.trackId }) - : null; - const activeElement = - activeTrack?.elements.find( - (element) => element.id === selectedRef?.elementId, - ) ?? null; - const isMaskMode = activeElement - ? activeTabPerType[activeElement.type] === "masks" - : false; - - 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); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - const x = e.nativeEvent.offsetX; - const y = e.nativeEvent.offsetY; - const outsideCanvas = - x < viewport.sceneLeft || - x > viewport.sceneLeft + viewport.sceneWidth || - y < viewport.sceneTop || - y > viewport.sceneTop + viewport.sceneHeight; - if (outsideCanvas) { - e.stopPropagation(); - } - }; - - return ( - <div className="absolute inset-0"> - <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()} - onContextMenu={handleContextMenu} - /> - {editingText ? ( - <TextEditOverlay - trackId={editingText.trackId} - elementId={editingText.elementId} - element={editingText.element} - onCommit={commitTextEdit} - /> - ) : isMaskMode ? ( - <MaskHandles onSnapLinesChange={setSnapLines} /> - ) : ( - <TransformHandles onSnapLinesChange={setSnapLines} /> - )} - <SnapGuides lines={snapLines} /> - </div> - ); -} +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);
+ };
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ const x = e.nativeEvent.offsetX;
+ const y = e.nativeEvent.offsetY;
+ const outsideCanvas =
+ x < viewport.sceneLeft ||
+ x > viewport.sceneLeft + viewport.sceneWidth ||
+ y < viewport.sceneTop ||
+ y > viewport.sceneTop + viewport.sceneHeight;
+ if (outsideCanvas) {
+ e.stopPropagation();
+ }
+ };
+
+ return (
+ <div className="absolute inset-0">
+ <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()}
+ onContextMenu={handleContextMenu}
+ />
+ {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 index e38183af..8fd38768 100644 --- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx @@ -1,576 +1,576 @@ -"use client"; - -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { - canvasToOverlay, - getDisplayScale, - positionToOverlay, - screenPixelsToLogicalThreshold, - screenToCanvas, -} from "@/lib/preview/preview-coords"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import { PREVIEW_ZOOM } from "@/constants/editor-constants"; - -const MIDDLE_MOUSE_BUTTON = 1; -const IS_AT_FIT_EPSILON = 0.001; -const IS_AT_ACTUAL_SIZE_EPSILON = 0.01; - -interface PreviewViewportContextValue { - canPan: boolean; - isAtFit: boolean; - isAtActualSize: boolean; - isPanning: boolean; - sceneHeight: number; - sceneLeft: number; - sceneTop: number; - sceneWidth: number; - zoomPercent: number; - canvasToOverlay: ({ - canvasX, - canvasY, - }: { - canvasX: number; - canvasY: number; - }) => { x: number; y: number }; - fitToScreen: () => void; - getDisplayScale: () => { x: number; y: number }; - handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean; - handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean; - handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean; - positionToOverlay: ({ - positionX, - positionY, - }: { - positionX: number; - positionY: number; - }) => { x: number; y: number }; - panByScreenDelta: ({ - deltaX, - deltaY, - }: { - deltaX: number; - deltaY: number; - }) => void; - resetPan: () => void; - scaleZoom: ({ factor }: { factor: number }) => void; - screenPixelsToLogicalThreshold: ({ - screenPixels, - }: { - screenPixels: number; - }) => { x: number; y: number }; - screenToCanvas: ({ - clientX, - clientY, - }: { - clientX: number; - clientY: number; - }) => { x: number; y: number } | null; - setActualSize: () => void; - setViewportPercent: ({ percent }: { percent: number }) => void; - zoomIn: () => void; - zoomOut: () => void; -} - -const PreviewViewportContext = - createContext<PreviewViewportContextValue | null>(null); - -interface PreviewViewportProviderProps { - children: React.ReactNode; - value: PreviewViewportContextValue; -} - -interface PreviewViewportStateOptions { - canvasHeight: number; - canvasWidth: number; - viewportHeight: number; - viewportRef: React.RefObject<HTMLDivElement | null>; - viewportWidth: number; -} - -interface PanSession { - centerX: number; - centerY: number; - clientX: number; - clientY: number; - pointerId: number; -} - -function getClampedCenterAxis({ - axisSize, - center, - scale, - viewportSize, -}: { - axisSize: number; - center: number; - scale: number; - viewportSize: number; -}): number { - if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) { - return axisSize / 2; - } - - const visibleHalfSpan = viewportSize / (2 * scale); - if (visibleHalfSpan >= axisSize / 2) { - return axisSize / 2; - } - - return clamp({ - value: center, - min: visibleHalfSpan, - max: axisSize - visibleHalfSpan, - }); -} - -function clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX, - centerY, - scale, - viewportHeight, - viewportWidth, -}: { - canvasHeight: number; - canvasWidth: number; - centerX: number; - centerY: number; - scale: number; - viewportHeight: number; - viewportWidth: number; -}): { x: number; y: number } { - return { - x: getClampedCenterAxis({ - center: centerX, - axisSize: canvasWidth, - scale, - viewportSize: viewportWidth, - }), - y: getClampedCenterAxis({ - center: centerY, - axisSize: canvasHeight, - scale, - viewportSize: viewportHeight, - }), - }; -} - -function getFitScale({ - canvasHeight, - canvasWidth, - viewportHeight, - viewportWidth, -}: { - canvasHeight: number; - canvasWidth: number; - viewportHeight: number; - viewportWidth: number; -}): number { - if ( - canvasHeight <= 0 || - canvasWidth <= 0 || - viewportHeight <= 0 || - viewportWidth <= 0 - ) { - return 1; - } - - return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight); -} - -function getClampedZoom({ zoom }: { zoom: number }): number { - return clamp({ - value: zoom, - min: PREVIEW_ZOOM.min, - max: PREVIEW_ZOOM.max, - }); -} - -export function PreviewViewportProvider({ - children, - value, -}: PreviewViewportProviderProps) { - return ( - <PreviewViewportContext.Provider value={value}> - {children} - </PreviewViewportContext.Provider> - ); -} - -export function usePreviewViewportState({ - canvasHeight, - canvasWidth, - viewportHeight, - viewportRef, - viewportWidth, -}: PreviewViewportStateOptions): PreviewViewportContextValue { - const [zoom, setZoomState] = useState(1); - const [center, setCenter] = useState(() => ({ - x: canvasWidth / 2, - y: canvasHeight / 2, - })); - const [isPanning, setIsPanning] = useState(false); - const panSessionRef = useRef<PanSession | null>(null); - const centerRef = useRef(center); - centerRef.current = center; - - const fitScale = useMemo( - () => - getFitScale({ - canvasHeight, - canvasWidth, - viewportHeight, - viewportWidth, - }), - [canvasHeight, canvasWidth, viewportHeight, viewportWidth], - ); - const viewportScale = fitScale * zoom; - const geometry = useMemo( - () => ({ - canvasHeight, - canvasWidth, - centerX: center.x, - centerY: center.y, - scale: viewportScale, - viewportHeight, - viewportWidth, - }), - [ - canvasHeight, - canvasWidth, - center.x, - center.y, - viewportHeight, - viewportScale, - viewportWidth, - ], - ); - - const scaleZoom = useCallback(({ factor }: { factor: number }) => { - setZoomState((previousZoom) => - getClampedZoom({ - zoom: previousZoom * factor, - }), - ); - }, []); - - const panByScreenDelta = useCallback( - ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => { - if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) { - return; - } - - setCenter((previousCenter) => - clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX: previousCenter.x + deltaX / viewportScale, - centerY: previousCenter.y + deltaY / viewportScale, - scale: viewportScale, - viewportHeight, - viewportWidth, - }), - ); - }, - [ - canvasHeight, - canvasWidth, - viewportHeight, - viewportScale, - viewportWidth, - zoom, - ], - ); - - const resetPan = useCallback(() => { - setCenter({ - x: canvasWidth / 2, - y: canvasHeight / 2, - }); - }, [canvasHeight, canvasWidth]); - - const fitToScreen = useCallback(() => { - setZoomState(1); - setCenter({ - x: canvasWidth / 2, - y: canvasHeight / 2, - }); - }, [canvasHeight, canvasWidth]); - - const zoomIn = useCallback(() => { - setZoomState((previousZoom) => - getClampedZoom({ - zoom: previousZoom * PREVIEW_ZOOM.step, - }), - ); - }, []); - - const zoomOut = useCallback(() => { - setZoomState((previousZoom) => - getClampedZoom({ - zoom: previousZoom / PREVIEW_ZOOM.step, - }), - ); - }, []); - - const setActualSize = useCallback(() => { - const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1; - setZoomState( - getClampedZoom({ - zoom: actualSizeZoom, - }), - ); - }, [fitScale]); - - const setViewportPercent = useCallback( - ({ percent }: { percent: number }) => { - const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1; - setZoomState(getClampedZoom({ zoom: targetZoom })); - }, - [fitScale], - ); - - const getViewportRect = useCallback( - () => viewportRef.current?.getBoundingClientRect() ?? null, - [viewportRef], - ); - - const screenToCanvasWithViewport = useCallback( - ({ clientX, clientY }: { clientX: number; clientY: number }) => { - const viewportRect = getViewportRect(); - if (!viewportRect) return null; - - return screenToCanvas({ - clientX, - clientY, - geometry, - viewportRect, - }); - }, - [getViewportRect, geometry], - ); - - const canvasToOverlayWithViewport = useCallback( - ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) => - canvasToOverlay({ - canvasX, - canvasY, - geometry, - }), - [geometry], - ); - - const positionToOverlayWithViewport = useCallback( - ({ positionX, positionY }: { positionX: number; positionY: number }) => - positionToOverlay({ - geometry, - positionX, - positionY, - }), - [geometry], - ); - - const getDisplayScaleWithViewport = useCallback( - () => getDisplayScale({ geometry }), - [geometry], - ); - - const getLogicalThresholdForScreenPixels = useCallback( - ({ screenPixels }: { screenPixels: number }) => - screenPixelsToLogicalThreshold({ - geometry, - screenPixels, - }), - [geometry], - ); - - const handlePanPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) { - return false; - } - - event.preventDefault(); - event.stopPropagation(); - - panSessionRef.current = { - centerX: centerRef.current.x, - centerY: centerRef.current.y, - clientX: event.clientX, - clientY: event.clientY, - pointerId: event.pointerId, - }; - setIsPanning(true); - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); - return true; - }, - [zoom], - ); - - const handlePanPointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - const panSession = panSessionRef.current; - if (!panSession) { - return false; - } - - event.preventDefault(); - event.stopPropagation(); - - const deltaX = event.clientX - panSession.clientX; - const deltaY = event.clientY - panSession.clientY; - const nextCenter = clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX: panSession.centerX - deltaX / viewportScale, - centerY: panSession.centerY - deltaY / viewportScale, - scale: viewportScale, - viewportHeight, - viewportWidth, - }); - - setCenter(nextCenter); - return true; - }, - [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth], - ); - - const handlePanPointerUp = useCallback( - ({ event }: { event: React.PointerEvent }) => { - const panSession = panSessionRef.current; - if (!panSession) { - return false; - } - - if ( - (event.currentTarget as HTMLElement).hasPointerCapture( - panSession.pointerId, - ) - ) { - (event.currentTarget as HTMLElement).releasePointerCapture( - panSession.pointerId, - ); - } - - panSessionRef.current = null; - setIsPanning(false); - return true; - }, - [], - ); - - useEffect(() => { - setZoomState(1); - setCenter({ - x: canvasWidth / 2, - y: canvasHeight / 2, - }); - panSessionRef.current = null; - setIsPanning(false); - }, [canvasHeight, canvasWidth]); - - useEffect(() => { - setCenter((previousCenter) => - clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX: previousCenter.x, - centerY: previousCenter.y, - scale: viewportScale, - viewportHeight, - viewportWidth, - }), - ); - }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]); - - const sceneWidth = canvasWidth * viewportScale; - const sceneHeight = canvasHeight * viewportScale; - const sceneLeft = viewportWidth / 2 - center.x * viewportScale; - const sceneTop = viewportHeight / 2 - center.y * viewportScale; - const canPan = zoom > 1; - const zoomPercent = Math.round(viewportScale * 100); - - return useMemo( - () => ({ - canPan, - isAtActualSize: isNearlyEqual({ - leftValue: viewportScale, - rightValue: 1, - epsilon: IS_AT_ACTUAL_SIZE_EPSILON, - }), - isAtFit: isNearlyEqual({ - leftValue: zoom, - rightValue: 1, - epsilon: IS_AT_FIT_EPSILON, - }), - isPanning, - sceneHeight, - sceneLeft, - sceneTop, - sceneWidth, - zoomPercent, - canvasToOverlay: canvasToOverlayWithViewport, - fitToScreen, - getDisplayScale: getDisplayScaleWithViewport, - handlePanPointerDown, - handlePanPointerMove, - handlePanPointerUp, - panByScreenDelta, - positionToOverlay: positionToOverlayWithViewport, - resetPan, - scaleZoom, - screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels, - screenToCanvas: screenToCanvasWithViewport, - setActualSize, - setViewportPercent, - zoomIn, - zoomOut, - }), - [ - canPan, - viewportScale, - zoom, - isPanning, - sceneHeight, - sceneLeft, - sceneTop, - sceneWidth, - zoomPercent, - canvasToOverlayWithViewport, - fitToScreen, - getDisplayScaleWithViewport, - handlePanPointerDown, - handlePanPointerMove, - handlePanPointerUp, - panByScreenDelta, - positionToOverlayWithViewport, - resetPan, - scaleZoom, - getLogicalThresholdForScreenPixels, - screenToCanvasWithViewport, - setActualSize, - setViewportPercent, - zoomIn, - zoomOut, - ], - ); -} - -export function usePreviewViewport() { - const context = useContext(PreviewViewportContext); - if (!context) { - throw new Error( - "usePreviewViewport must be used within PreviewViewportProvider", - ); - } - - return context; -} +"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ canvasToOverlay,
+ getDisplayScale,
+ positionToOverlay,
+ screenPixelsToLogicalThreshold,
+ screenToCanvas,
+} from "@/lib/preview/preview-coords";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import { PREVIEW_ZOOM } from "@/constants/editor-constants";
+
+const MIDDLE_MOUSE_BUTTON = 1;
+const IS_AT_FIT_EPSILON = 0.001;
+const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
+
+interface PreviewViewportContextValue {
+ canPan: boolean;
+ isAtFit: boolean;
+ isAtActualSize: boolean;
+ isPanning: boolean;
+ sceneHeight: number;
+ sceneLeft: number;
+ sceneTop: number;
+ sceneWidth: number;
+ zoomPercent: number;
+ canvasToOverlay: ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) => { x: number; y: number };
+ fitToScreen: () => void;
+ getDisplayScale: () => { x: number; y: number };
+ handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
+ positionToOverlay: ({
+ positionX,
+ positionY,
+ }: {
+ positionX: number;
+ positionY: number;
+ }) => { x: number; y: number };
+ panByScreenDelta: ({
+ deltaX,
+ deltaY,
+ }: {
+ deltaX: number;
+ deltaY: number;
+ }) => void;
+ resetPan: () => void;
+ scaleZoom: ({ factor }: { factor: number }) => void;
+ screenPixelsToLogicalThreshold: ({
+ screenPixels,
+ }: {
+ screenPixels: number;
+ }) => { x: number; y: number };
+ screenToCanvas: ({
+ clientX,
+ clientY,
+ }: {
+ clientX: number;
+ clientY: number;
+ }) => { x: number; y: number } | null;
+ setActualSize: () => void;
+ setViewportPercent: ({ percent }: { percent: number }) => void;
+ zoomIn: () => void;
+ zoomOut: () => void;
+}
+
+const PreviewViewportContext =
+ createContext<PreviewViewportContextValue | null>(null);
+
+interface PreviewViewportProviderProps {
+ children: React.ReactNode;
+ value: PreviewViewportContextValue;
+}
+
+interface PreviewViewportStateOptions {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportRef: React.RefObject<HTMLDivElement | null>;
+ viewportWidth: number;
+}
+
+interface PanSession {
+ centerX: number;
+ centerY: number;
+ clientX: number;
+ clientY: number;
+ pointerId: number;
+}
+
+function getClampedCenterAxis({
+ axisSize,
+ center,
+ scale,
+ viewportSize,
+}: {
+ axisSize: number;
+ center: number;
+ scale: number;
+ viewportSize: number;
+}): number {
+ if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
+ return axisSize / 2;
+ }
+
+ const visibleHalfSpan = viewportSize / (2 * scale);
+ if (visibleHalfSpan >= axisSize / 2) {
+ return axisSize / 2;
+ }
+
+ return clamp({
+ value: center,
+ min: visibleHalfSpan,
+ max: axisSize - visibleHalfSpan,
+ });
+}
+
+function clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX,
+ centerY,
+ scale,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ centerX: number;
+ centerY: number;
+ scale: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): { x: number; y: number } {
+ return {
+ x: getClampedCenterAxis({
+ center: centerX,
+ axisSize: canvasWidth,
+ scale,
+ viewportSize: viewportWidth,
+ }),
+ y: getClampedCenterAxis({
+ center: centerY,
+ axisSize: canvasHeight,
+ scale,
+ viewportSize: viewportHeight,
+ }),
+ };
+}
+
+function getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): number {
+ if (
+ canvasHeight <= 0 ||
+ canvasWidth <= 0 ||
+ viewportHeight <= 0 ||
+ viewportWidth <= 0
+ ) {
+ return 1;
+ }
+
+ return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
+}
+
+function getClampedZoom({ zoom }: { zoom: number }): number {
+ return clamp({
+ value: zoom,
+ min: PREVIEW_ZOOM.min,
+ max: PREVIEW_ZOOM.max,
+ });
+}
+
+export function PreviewViewportProvider({
+ children,
+ value,
+}: PreviewViewportProviderProps) {
+ return (
+ <PreviewViewportContext.Provider value={value}>
+ {children}
+ </PreviewViewportContext.Provider>
+ );
+}
+
+export function usePreviewViewportState({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportRef,
+ viewportWidth,
+}: PreviewViewportStateOptions): PreviewViewportContextValue {
+ const [zoom, setZoomState] = useState(1);
+ const [center, setCenter] = useState(() => ({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ }));
+ const [isPanning, setIsPanning] = useState(false);
+ const panSessionRef = useRef<PanSession | null>(null);
+ const centerRef = useRef(center);
+ centerRef.current = center;
+
+ const fitScale = useMemo(
+ () =>
+ getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [canvasHeight, canvasWidth, viewportHeight, viewportWidth],
+ );
+ const viewportScale = fitScale * zoom;
+ const geometry = useMemo(
+ () => ({
+ canvasHeight,
+ canvasWidth,
+ centerX: center.x,
+ centerY: center.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [
+ canvasHeight,
+ canvasWidth,
+ center.x,
+ center.y,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ ],
+ );
+
+ const scaleZoom = useCallback(({ factor }: { factor: number }) => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * factor,
+ }),
+ );
+ }, []);
+
+ const panByScreenDelta = useCallback(
+ ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
+ if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
+ return;
+ }
+
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x + deltaX / viewportScale,
+ centerY: previousCenter.y + deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ },
+ [
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ zoom,
+ ],
+ );
+
+ const resetPan = useCallback(() => {
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const fitToScreen = useCallback(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const zoomIn = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const zoomOut = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom / PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const setActualSize = useCallback(() => {
+ const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
+ setZoomState(
+ getClampedZoom({
+ zoom: actualSizeZoom,
+ }),
+ );
+ }, [fitScale]);
+
+ const setViewportPercent = useCallback(
+ ({ percent }: { percent: number }) => {
+ const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
+ setZoomState(getClampedZoom({ zoom: targetZoom }));
+ },
+ [fitScale],
+ );
+
+ const getViewportRect = useCallback(
+ () => viewportRef.current?.getBoundingClientRect() ?? null,
+ [viewportRef],
+ );
+
+ const screenToCanvasWithViewport = useCallback(
+ ({ clientX, clientY }: { clientX: number; clientY: number }) => {
+ const viewportRect = getViewportRect();
+ if (!viewportRect) return null;
+
+ return screenToCanvas({
+ clientX,
+ clientY,
+ geometry,
+ viewportRect,
+ });
+ },
+ [getViewportRect, geometry],
+ );
+
+ const canvasToOverlayWithViewport = useCallback(
+ ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
+ canvasToOverlay({
+ canvasX,
+ canvasY,
+ geometry,
+ }),
+ [geometry],
+ );
+
+ const positionToOverlayWithViewport = useCallback(
+ ({ positionX, positionY }: { positionX: number; positionY: number }) =>
+ positionToOverlay({
+ geometry,
+ positionX,
+ positionY,
+ }),
+ [geometry],
+ );
+
+ const getDisplayScaleWithViewport = useCallback(
+ () => getDisplayScale({ geometry }),
+ [geometry],
+ );
+
+ const getLogicalThresholdForScreenPixels = useCallback(
+ ({ screenPixels }: { screenPixels: number }) =>
+ screenPixelsToLogicalThreshold({
+ geometry,
+ screenPixels,
+ }),
+ [geometry],
+ );
+
+ const handlePanPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ panSessionRef.current = {
+ centerX: centerRef.current.x,
+ centerY: centerRef.current.y,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ pointerId: event.pointerId,
+ };
+ setIsPanning(true);
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ return true;
+ },
+ [zoom],
+ );
+
+ const handlePanPointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const deltaX = event.clientX - panSession.clientX;
+ const deltaY = event.clientY - panSession.clientY;
+ const nextCenter = clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: panSession.centerX - deltaX / viewportScale,
+ centerY: panSession.centerY - deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ });
+
+ setCenter(nextCenter);
+ return true;
+ },
+ [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
+ );
+
+ const handlePanPointerUp = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ if (
+ (event.currentTarget as HTMLElement).hasPointerCapture(
+ panSession.pointerId,
+ )
+ ) {
+ (event.currentTarget as HTMLElement).releasePointerCapture(
+ panSession.pointerId,
+ );
+ }
+
+ panSessionRef.current = null;
+ setIsPanning(false);
+ return true;
+ },
+ [],
+ );
+
+ useEffect(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ panSessionRef.current = null;
+ setIsPanning(false);
+ }, [canvasHeight, canvasWidth]);
+
+ useEffect(() => {
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x,
+ centerY: previousCenter.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
+
+ const sceneWidth = canvasWidth * viewportScale;
+ const sceneHeight = canvasHeight * viewportScale;
+ const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
+ const sceneTop = viewportHeight / 2 - center.y * viewportScale;
+ const canPan = zoom > 1;
+ const zoomPercent = Math.round(viewportScale * 100);
+
+ return useMemo(
+ () => ({
+ canPan,
+ isAtActualSize: isNearlyEqual({
+ leftValue: viewportScale,
+ rightValue: 1,
+ epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
+ }),
+ isAtFit: isNearlyEqual({
+ leftValue: zoom,
+ rightValue: 1,
+ epsilon: IS_AT_FIT_EPSILON,
+ }),
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlay: canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScale: getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlay: positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
+ screenToCanvas: screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ }),
+ [
+ canPan,
+ viewportScale,
+ zoom,
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ getLogicalThresholdForScreenPixels,
+ screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ ],
+ );
+}
+
+export function usePreviewViewport() {
+ const context = useContext(PreviewViewportContext);
+ if (!context) {
+ throw new Error(
+ "usePreviewViewport must be used within PreviewViewportProvider",
+ );
+ }
+
+ return context;
+}
diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx index 744304ea..ced5e921 100644 --- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -1,47 +1,47 @@ -"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> - ); -} +"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 index d3c61a24..aff77f0f 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -1,212 +1,212 @@ -"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 { - FONT_SIZE_SCALE_REFERENCE, -} from "@/constants/text-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - getMetricAscent, - getMetricDescent, - setCanvasLetterSpacing, -} from "@/lib/text/layout"; - -let cachedCanvas: HTMLCanvasElement | null = null; - -function getMeasurementContext(): CanvasRenderingContext2D | null { - if (!cachedCanvas) cachedCanvas = document.createElement("canvas"); - return cachedCanvas.getContext("2d"); -} - -/** - * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than - * actualBoundingBox to model where the browser places the baseline within the - * line box, then measures actual glyph bounds to find the visual center. - */ -function measureCSSVisualCenterOffset({ - lines, - fontString, - letterSpacingPx, - lineHeightPx, - displayFontSize, -}: { - lines: string[]; - fontString: string; - letterSpacingPx: number; - lineHeightPx: number; - displayFontSize: number; -}): number { - const ctx = getMeasurementContext(); - if (!ctx) return 0; - - ctx.font = fontString; - ctx.textBaseline = "alphabetic"; - setCanvasLetterSpacing({ ctx, letterSpacingPx }); - - const probe = ctx.measureText("M"); - const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8; - const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2; - const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2; - - let visualTop = Number.POSITIVE_INFINITY; - let visualBottom = Number.NEGATIVE_INFINITY; - - for (let i = 0; i < lines.length; i++) { - const metrics = ctx.measureText(lines[i] || " "); - const baseline = i * lineHeightPx + halfLeading + fontAscent; - visualTop = Math.min( - visualTop, - baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }), - ); - visualBottom = Math.max( - visualBottom, - baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }), - ); - } - - const cssBlockHeight = lines.length * lineHeightPx; - return (visualTop + visualBottom) / 2 - cssBlockHeight / 2; -} - -export function TextEditOverlay({ - 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 { x: posX, y: posY } = viewport.positionToOverlay({ - positionX: element.transform.position.x, - positionY: element.transform.position.y, - }); - - const { x: displayScaleX } = viewport.getDisplayScale(); - - const displayFontSize = - element.fontSize * - (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * - displayScaleX; - - const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; - const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; - const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX; - const lineHeightPx = displayFontSize * lineHeight; - const lines = (element.content || "").split("\n"); - const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`; - - const cssVisualCenterOffset = measureCSSVisualCenterOffset({ - lines, - fontString, - letterSpacingPx: displayLetterSpacing, - lineHeightPx, - displayFontSize, - }); - - const bg = element.background; - const shouldShowBackground = - bg.enabled && bg.color && bg.color !== "transparent"; - const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; - const displayPaddingX = shouldShowBackground - ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * - fontSizeRatio * - displayScaleX - : 0; - const displayPaddingY = shouldShowBackground - ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * - fontSizeRatio * - displayScaleX - : 0; - - return ( - <div - className="absolute" - style={{ - left: posX, - top: posY - cssVisualCenterOffset, - transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.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: displayFontSize, - fontFamily: element.fontFamily, - fontWeight, - fontStyle, - textAlign: element.textAlign, - letterSpacing: `${displayLetterSpacing}px`, - lineHeight, - color: element.color, - backgroundColor: shouldShowBackground ? bg.color : "transparent", - minHeight: lineHeightPx, - textDecoration: element.textDecoration ?? "none", - padding: shouldShowBackground - ? `${displayPaddingY}px ${displayPaddingX}px` - : 0, - minWidth: 1, - }} - onInput={handleInput} - onBlur={onCommit} - onKeyDown={(event) => handleKeyDown({ event })} - > - {element.content || ""} - </div> - </div> - ); -} +"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 {
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ getMetricAscent,
+ getMetricDescent,
+ setCanvasLetterSpacing,
+} from "@/lib/text/layout";
+
+let cachedCanvas: HTMLCanvasElement | null = null;
+
+function getMeasurementContext(): CanvasRenderingContext2D | null {
+ if (!cachedCanvas) cachedCanvas = document.createElement("canvas");
+ return cachedCanvas.getContext("2d");
+}
+
+/**
+ * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than
+ * actualBoundingBox to model where the browser places the baseline within the
+ * line box, then measures actual glyph bounds to find the visual center.
+ */
+function measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx,
+ lineHeightPx,
+ displayFontSize,
+}: {
+ lines: string[];
+ fontString: string;
+ letterSpacingPx: number;
+ lineHeightPx: number;
+ displayFontSize: number;
+}): number {
+ const ctx = getMeasurementContext();
+ if (!ctx) return 0;
+
+ ctx.font = fontString;
+ ctx.textBaseline = "alphabetic";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx });
+
+ const probe = ctx.measureText("M");
+ const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8;
+ const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2;
+ const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2;
+
+ let visualTop = Number.POSITIVE_INFINITY;
+ let visualBottom = Number.NEGATIVE_INFINITY;
+
+ for (let i = 0; i < lines.length; i++) {
+ const metrics = ctx.measureText(lines[i] || " ");
+ const baseline = i * lineHeightPx + halfLeading + fontAscent;
+ visualTop = Math.min(
+ visualTop,
+ baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ visualBottom = Math.max(
+ visualBottom,
+ baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ }
+
+ const cssBlockHeight = lines.length * lineHeightPx;
+ return (visualTop + visualBottom) / 2 - cssBlockHeight / 2;
+}
+
+export function TextEditOverlay({
+ 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 { x: posX, y: posY } = viewport.positionToOverlay({
+ positionX: element.transform.position.x,
+ positionY: element.transform.position.y,
+ });
+
+ const { x: displayScaleX } = viewport.getDisplayScale();
+
+ const displayFontSize =
+ element.fontSize *
+ (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
+ displayScaleX;
+
+ const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
+ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
+ const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX;
+ const lineHeightPx = displayFontSize * lineHeight;
+ const lines = (element.content || "").split("\n");
+ const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`;
+
+ const cssVisualCenterOffset = measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx: displayLetterSpacing,
+ lineHeightPx,
+ displayFontSize,
+ });
+
+ const bg = element.background;
+ const shouldShowBackground =
+ bg.enabled && bg.color && bg.color !== "transparent";
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const displayPaddingX = shouldShowBackground
+ ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
+ const displayPaddingY = shouldShowBackground
+ ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
+
+ return (
+ <div
+ className="absolute"
+ style={{
+ left: posX,
+ top: posY - cssVisualCenterOffset,
+ transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.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: displayFontSize,
+ fontFamily: element.fontFamily,
+ fontWeight,
+ fontStyle,
+ textAlign: element.textAlign,
+ letterSpacing: `${displayLetterSpacing}px`,
+ lineHeight,
+ color: element.color,
+ backgroundColor: shouldShowBackground ? bg.color : "transparent",
+ minHeight: lineHeightPx,
+ textDecoration: element.textDecoration ?? "none",
+ padding: shouldShowBackground
+ ? `${displayPaddingY}px ${displayPaddingX}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 index 56cb784a..5c183bbb 100644 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -1,151 +1,151 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { formatTimeCode } from "@/lib/time"; -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 "@/constants/editor-constants"; -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" /> - <GridPopover> - <Button - variant={activeGuideDefinition ? "secondary" : "text"} - size="icon" - > - {activeGuideDefinition ? ( - activeGuideDefinition.renderTriggerIcon() - ) : ( - <HugeiconsIcon icon={GridTableIcon} /> - )} - </Button> - </GridPopover> - <Button variant="text" onClick={onToggleFullscreen}> - <HugeiconsIcon icon={FullScreenIcon} /> - </Button> - </div> - </div> - ); -} - -function TimecodeDisplay() { - const editor = useEditor(); - const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); - const fps = useEditor((e) => e.project.getActive().settings.fps); - const [currentTime, setCurrentTime] = useState(() => - editor.playback.getCurrentTime(), - ); - - useEffect(() => { - const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); - window.addEventListener("playback-update", handler); - window.addEventListener("playback-seek", handler); - return () => { - window.removeEventListener("playback-update", handler); - window.removeEventListener("playback-seek", handler); - }; - }, []); - - return ( - <div className="flex items-center"> - <EditableTimecode - time={currentTime} - duration={totalDuration} - format="HH:MM:SS:FF" - fps={fps} - onTimeChange={({ time }) => editor.playback.seek({ time })} - className="text-center" - /> - <span className="text-muted-foreground px-2 font-mono text-xs">/</span> - <span className="text-muted-foreground font-mono text-xs"> - {formatTimeCode({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - </span> - </div> - ); -} - -function ZoomSelect() { - const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } = - usePreviewViewport(); - - const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`; - - const onValueChange = (value: string) => { - if (value === "fit") { - fitToScreen(); - } else { - setViewportPercent({ percent: Number(value) }); - } - }; - - return ( - <Select - value={isAtFit ? "fit" : String(zoomPercent)} - onValueChange={onValueChange} - > - <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger> - <SelectContent> - <SelectItem value="fit">Fit</SelectItem> - <SelectSeparator /> - {PREVIEW_ZOOM_PRESETS.map((preset) => ( - <SelectItem key={preset} value={String(preset)}> - {preset}% - </SelectItem> - ))} - </SelectContent> - </Select> - ); -} - -function PlayPauseButton() { - const isPlaying = useEditor((e) => e.playback.getIsPlaying()); - - return ( - <Button - variant="text" - size="icon" - onClick={() => invokeAction("toggle-play")} - > - <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> - </Button> - ); -} +"use client";
+
+import { useState, useEffect } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { formatTimeCode } from "@/lib/time";
+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 "@/constants/editor-constants";
+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" />
+ <GridPopover>
+ <Button
+ variant={activeGuideDefinition ? "secondary" : "text"}
+ size="icon"
+ >
+ {activeGuideDefinition ? (
+ activeGuideDefinition.renderTriggerIcon()
+ ) : (
+ <HugeiconsIcon icon={GridTableIcon} />
+ )}
+ </Button>
+ </GridPopover>
+ <Button variant="text" onClick={onToggleFullscreen}>
+ <HugeiconsIcon icon={FullScreenIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function TimecodeDisplay() {
+ const editor = useEditor();
+ const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
+ const fps = useEditor((e) => e.project.getActive().settings.fps);
+ const [currentTime, setCurrentTime] = useState(() =>
+ editor.playback.getCurrentTime(),
+ );
+
+ useEffect(() => {
+ const handler = (e: Event) =>
+ setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
+ window.addEventListener("playback-update", handler);
+ window.addEventListener("playback-seek", handler);
+ return () => {
+ window.removeEventListener("playback-update", handler);
+ window.removeEventListener("playback-seek", handler);
+ };
+ }, []);
+
+ return (
+ <div className="flex items-center">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
+ <span className="text-muted-foreground font-mono text-xs">
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+ </span>
+ </div>
+ );
+}
+
+function ZoomSelect() {
+ const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
+ usePreviewViewport();
+
+ const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
+
+ const onValueChange = (value: string) => {
+ if (value === "fit") {
+ fitToScreen();
+ } else {
+ setViewportPercent({ percent: Number(value) });
+ }
+ };
+
+ return (
+ <Select
+ value={isAtFit ? "fit" : String(zoomPercent)}
+ onValueChange={onValueChange}
+ >
+ <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
+ <SelectContent>
+ <SelectItem value="fit">Fit</SelectItem>
+ <SelectSeparator />
+ {PREVIEW_ZOOM_PRESETS.map((preset) => (
+ <SelectItem key={preset} value={String(preset)}>
+ {preset}%
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
+
+function PlayPauseButton() {
+ const isPlaying = useEditor((e) => e.playback.getIsPlaying());
+
+ return (
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => invokeAction("toggle-play")}
+ >
+ <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx index 88332625..141359c8 100644 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -1,140 +1,140 @@ -"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, - getRotationHandlePosition, - type Corner, - type Edge, -} from "@/lib/preview/element-bounds"; -import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; -import { - BoundingBoxOutline, - CornerHandle, - EdgeHandle, - IconHandle, - getResizeCursor, -} from "./handle-primitives"; -import { Rotate01Icon } from "@hugeicons/core-free-icons"; - -const 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 rotationHandleCanvas = getRotationHandlePosition({ bounds }); - const rotationHandleScreen = toOverlay({ - canvasX: rotationHandleCanvas.x, - canvasY: rotationHandleCanvas.y, - }); - - const onPointerMove = (event: React.PointerEvent) => - handlePointerMove({ event }); - const onPointerUp = () => handlePointerUp(); - - return ( - <div - className="pointer-events-none absolute inset-0 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> - ); -} +"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,
+ getRotationHandlePosition,
+ type Corner,
+ type Edge,
+} from "@/lib/preview/element-bounds";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
+import {
+ BoundingBoxOutline,
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ getResizeCursor,
+} from "./handle-primitives";
+import { Rotate01Icon } from "@hugeicons/core-free-icons";
+
+const 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 rotationHandleCanvas = getRotationHandlePosition({ bounds });
+ const rotationHandleScreen = toOverlay({
+ canvasX: rotationHandleCanvas.x,
+ canvasY: rotationHandleCanvas.y,
+ });
+
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
+ return (
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
+ <BoundingBoxOutline
+ center={center}
+ outlineWidth={outlineWidth}
+ outlineHeight={outlineHeight}
+ rotation={bounds.rotation}
+ />
+ {CORNERS.map((corner) => {
+ const cornerPosition = getCornerPosition({ bounds, corner });
+ const screen = toOverlay({
+ canvasX: cornerPosition.x,
+ canvasY: cornerPosition.y,
+ });
+ const angleDeg =
+ Math.atan2(screen.y - center.y, screen.x - center.x) *
+ (180 / Math.PI);
+ return (
+ <CornerHandle
+ key={corner}
+ cursor={getResizeCursor({ angleDeg })}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleCornerPointerDown({ event, corner })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ {EDGES.map((edge) => {
+ const edgePosition = getEdgeHandlePosition({ bounds, edge });
+ const screen = toOverlay({
+ canvasX: edgePosition.x,
+ canvasY: edgePosition.y,
+ });
+ return (
+ <EdgeHandle
+ key={edge}
+ edge={edge}
+ screen={screen}
+ rotation={bounds.rotation}
+ onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ <IconHandle
+ icon={Rotate01Icon}
+ screen={rotationHandleScreen}
+ onPointerDown={(event) => handleRotationPointerDown({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx index b5b8685f..00cea82b 100644 --- a/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx +++ b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx @@ -1,32 +1,32 @@ -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { KeyframeIcon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -export function KeyframeToggle({ - isActive, - isDisabled = false, - title, - onToggle, -}: { - isActive: boolean; - isDisabled?: boolean; - title: string; - onToggle: () => void; -}) { - return ( - <Button - variant="text" - aria-pressed={isActive} - disabled={isDisabled} - title={title} - onClick={onToggle} - className="[&>svg]:size-3.5 mb-0.5" - > - <HugeiconsIcon - icon={KeyframeIcon} - className={cn(isActive && "text-primary fill-primary")} - /> - </Button> - ); -} +import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { KeyframeIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+export function KeyframeToggle({
+ isActive,
+ isDisabled = false,
+ title,
+ onToggle,
+}: {
+ isActive: boolean;
+ isDisabled?: boolean;
+ title: string;
+ onToggle: () => void;
+}) {
+ return (
+ <Button
+ variant="text"
+ aria-pressed={isActive}
+ disabled={isDisabled}
+ title={title}
+ onClick={onToggle}
+ className="[&>svg]:size-3.5 mb-0.5"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(isActive && "text-primary fill-primary")}
+ />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx index c5576dda..547c0883 100644 --- a/apps/web/src/components/editor/panels/properties/empty-view.tsx +++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx @@ -1,20 +1,20 @@ -import { HugeiconsIcon } from "@hugeicons/react"; -import { Settings05Icon } from "@hugeicons/core-free-icons"; - -export function EmptyView() { - return ( - <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={Settings05Icon} - className="text-muted-foreground/75 size-10" - strokeWidth={1} - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium ">It's empty here</p> - <p className="text-muted-foreground text-sm text-balance"> - Click an element on the timeline to edit its properties - </p> - </div> - </div> - ); -} +import { HugeiconsIcon } from "@hugeicons/react";
+import { Settings05Icon } from "@hugeicons/core-free-icons";
+
+export function EmptyView() {
+ return (
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={Settings05Icon}
+ className="text-muted-foreground/75 size-10"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium ">It's empty here</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click an element on the timeline to edit its properties
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 46e6cb6d..95f338e1 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,24 +1,24 @@ -import { useEditor } from "@/hooks/use-editor"; -import { getElementLocalTime } from "@/lib/animation"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; - -export function useElementPlayhead({ - startTime, - duration, -}: { - startTime: number; - duration: number; -}) { - const editor = useEditor(); - const playheadTime = editor.playback.getCurrentTime(); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: startTime, - elementDuration: duration, - }); - const isPlayheadWithinElementRange = - playheadTime >= startTime - TIME_EPSILON_SECONDS && - playheadTime <= startTime + duration + TIME_EPSILON_SECONDS; - - return { localTime, isPlayheadWithinElementRange }; -} +import { useEditor } from "@/hooks/use-editor";
+import { getElementLocalTime } from "@/lib/animation";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+
+export function useElementPlayhead({
+ startTime,
+ duration,
+}: {
+ startTime: number;
+ duration: number;
+}) {
+ const editor = useEditor();
+ const playheadTime = editor.playback.getCurrentTime();
+ const localTime = getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: startTime,
+ elementDuration: duration,
+ });
+ const isPlayheadWithinElementRange =
+ playheadTime >= startTime - TIME_EPSILON_SECONDS &&
+ playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
+
+ return { localTime, isPlayheadWithinElementRange };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index 957ee1c9..a42e422f 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -1,95 +1,95 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; - -export function useKeyframedColorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedColor, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedColor: string; - buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>; -}) { - const editor = useEditor(); - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const onChange = ({ color }: { color: string }) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: color, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], - }); - }; - - const onChangeEnd = () => editor.timeline.commitPreview(); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, - ], - }); - }; - - return { - isKeyframedAtTime, - hasAnimatedKeyframes, - keyframeIdAtTime, - onChange, - onChangeEnd, - toggleKeyframe, - }; -} +import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+
+export function useKeyframedColorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedColor: string;
+ buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const onChange = ({ color }: { color: string }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: color,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
+ });
+ };
+
+ const onChangeEnd = () => editor.timeline.commitPreview();
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
+ ],
+ });
+ };
+
+ return {
+ isKeyframedAtTime,
+ hasAnimatedKeyframes,
+ keyframeIdAtTime,
+ onChange,
+ onChangeEnd,
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 0f0168f8..ddd3d2e4 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -1,161 +1,161 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedNumberProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - displayValue, - parse, - valueAtPlayhead, - step, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - displayValue: string; - parse: (input: string) => number | null; - valueAtPlayhead: number; - step?: number; - buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>; -}) { - const editor = useEditor(); - const snapValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: nextValue, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - const propertyDraft = usePropertyDraft({ - displayValue, - parse: (input) => { - const parsedValue = parse(input); - return parsedValue === null ? null : snapValue(parsedValue); - }, - onPreview: (value) => previewValue({ value }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: snapValue(valueAtPlayhead), - }, - ], - }); - }; - - const commitValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: nextValue, - }, - ], - }); - return; - } - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - return { - ...propertyDraft, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitValue, - }; -} +import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedNumberProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue,
+ parse,
+ valueAtPlayhead,
+ step,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ displayValue: string;
+ parse: (input: string) => number | null;
+ valueAtPlayhead: number;
+ step?: number;
+ buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+ const snapValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: nextValue,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value: nextValue }),
+ },
+ ],
+ });
+ };
+
+ const propertyDraft = usePropertyDraft({
+ displayValue,
+ parse: (input) => {
+ const parsedValue = parse(input);
+ return parsedValue === null ? null : snapValue(parsedValue);
+ },
+ onPreview: (value) => previewValue({ value }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: snapValue(valueAtPlayhead),
+ },
+ ],
+ });
+ };
+
+ const commitValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: nextValue,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value: nextValue }),
+ },
+ ],
+ });
+ };
+
+ return {
+ ...propertyDraft,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitValue,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 335e3fb6..92b5d634 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -1,148 +1,148 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { - buildGraphicParamPath, - getKeyframeAtTime, - getParamDefaultInterpolation, - getParamValueKind, - hasKeyframesForPath, - upsertPathKeyframe, -} from "@/lib/animation"; -import type { - ElementAnimations, -} from "@/lib/animation/types"; -import type { ParamDefinition } from "@/lib/params"; -import type { TimelineElement } from "@/lib/timeline"; - -export interface KeyframedParamPropertyResult { - hasAnimatedKeyframes: boolean; - isKeyframedAtTime: boolean; - keyframeIdAtTime: string | null; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; - toggleKeyframe: () => void; -} - -export function useKeyframedParamProperty({ - param, - trackId, - elementId, - animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - buildBaseUpdates, -}: { - param: ParamDefinition; - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: number | string | boolean; - buildBaseUpdates: ({ - value, - }: { - value: number | string | boolean; - }) => Partial<TimelineElement>; -}): KeyframedParamPropertyResult { - const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ - animations, - propertyPath, - time: localTime, - }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertPathKeyframe({ - animations, - propertyPath, - time: localTime, - value, - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ - param, - }), - numericRange: - param.type === "number" - ? { min: param.min, max: param.max, step: param.step } - : undefined, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value }), - }, - ], - }); - }; - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - return { - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - onPreview: previewValue, - onCommit: () => editor.timeline.commitPreview(), - toggleKeyframe, - }; -} +"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import {
+ buildGraphicParamPath,
+ getKeyframeAtTime,
+ getParamDefaultInterpolation,
+ getParamValueKind,
+ hasKeyframesForPath,
+ upsertPathKeyframe,
+} from "@/lib/animation";
+import type {
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { ParamDefinition } from "@/lib/params";
+import type { TimelineElement } from "@/lib/timeline";
+
+export interface KeyframedParamPropertyResult {
+ hasAnimatedKeyframes: boolean;
+ isKeyframedAtTime: boolean;
+ keyframeIdAtTime: string | null;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ toggleKeyframe: () => void;
+}
+
+export function useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId,
+ animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ buildBaseUpdates,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number | string | boolean;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: number | string | boolean;
+ }) => Partial<TimelineElement>;
+}): KeyframedParamPropertyResult {
+ const editor = useEditor();
+ const propertyPath = buildGraphicParamPath({ paramKey: param.key });
+ const hasAnimatedKeyframes = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time: localTime,
+ })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value,
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({
+ param,
+ }),
+ numericRange:
+ param.type === "number"
+ ? { min: param.min, max: param.max, step: param.step }
+ : undefined,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ return {
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ onPreview: previewValue,
+ onCommit: () => editor.timeline.commitPreview(),
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts index 188f05cb..a342859c 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts @@ -1,190 +1,190 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { - AnimationPropertyPath, - ElementAnimations, - VectorValue, -} from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedVectorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - displayX, - displayY, - parseComponent, - step, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: VectorValue; - displayX: string; - displayY: string; - parseComponent: (input: string) => number | null; - step?: number; - buildBaseUpdates: ({ - value, - }: { - value: VectorValue; - }) => Partial<TimelineElement>; -}) { - const editor = useEditor(); - const snapComponentValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewVector = ({ value }: { value: VectorValue }) => { - const nextValue = { - x: snapComponentValue(value.x), - y: snapComponentValue(value.y), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: nextValue, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }], - }); - }; - - const x = usePropertyDraft({ - displayValue: displayX, - parse: (input) => { - const parsedValue = parseComponent(input); - return parsedValue === null ? null : snapComponentValue(parsedValue); - }, - onPreview: (xVal) => - previewVector({ value: { x: xVal, y: resolvedValue.y } }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const y = usePropertyDraft({ - displayValue: displayY, - parse: (input) => { - const parsedValue = parseComponent(input); - return parsedValue === null ? null : snapComponentValue(parsedValue); - }, - onPreview: (yVal) => - previewVector({ value: { x: resolvedValue.x, y: yVal } }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - const commitX = ({ value }: { value: number }) => { - const vector: VectorValue = { - x: snapComponentValue(value), - y: snapComponentValue(resolvedValue.y), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: vector }, - ], - }); - return; - } - editor.timeline.updateElements({ - updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, - ], - }); - }; - - const commitY = ({ value }: { value: number }) => { - const vector: VectorValue = { - x: snapComponentValue(resolvedValue.x), - y: snapComponentValue(value), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: vector }, - ], - }); - return; - } - editor.timeline.updateElements({ - updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, - ], - }); - }; - - return { - x, - y, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitX, - commitY, - }; -} +import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ VectorValue,
+} from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedVectorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ displayX,
+ displayY,
+ parseComponent,
+ step,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: VectorValue;
+ displayX: string;
+ displayY: string;
+ parseComponent: (input: string) => number | null;
+ step?: number;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: VectorValue;
+ }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+ const snapComponentValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewVector = ({ value }: { value: VectorValue }) => {
+ const nextValue = {
+ x: snapComponentValue(value.x),
+ y: snapComponentValue(value.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: nextValue,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }],
+ });
+ };
+
+ const x = usePropertyDraft({
+ displayValue: displayX,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (xVal) =>
+ previewVector({ value: { x: xVal, y: resolvedValue.y } }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const y = usePropertyDraft({
+ displayValue: displayY,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (yVal) =>
+ previewVector({ value: { x: resolvedValue.x, y: yVal } }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ const commitX = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(value),
+ y: snapComponentValue(resolvedValue.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ const commitY = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(resolvedValue.x),
+ y: snapComponentValue(value),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ return {
+ x,
+ y,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitX,
+ commitY,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts index 395666cb..0e7ae6a7 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -1,72 +1,72 @@ -import { useReducer, useRef } from "react"; -import { evaluateMathExpression } from "@/utils/math"; - -function looksLikeExpression({ input }: { input: string }): boolean { - const trimmed = input.trim(); - if (!trimmed) return false; - if (/[+*/]/.test(input)) return true; - const minusIndex = trimmed.indexOf("-"); - return minusIndex > 0; -} - -export function usePropertyDraft<T>({ - displayValue: sourceDisplay, - parse, - onPreview, - onCommit, - supportsExpressions = true, -}: { - displayValue: string; - parse: (input: string) => T | null; - onPreview: (value: T) => void; - onCommit: () => void; - supportsExpressions?: boolean; -}) { - const [, forceRender] = useReducer( - (renderVersion: number) => renderVersion + 1, - 0, - ); - const isEditing = useRef(false); - const draft = useRef(""); - - return { - displayValue: isEditing.current ? draft.current : sourceDisplay, - scrubTo: (value: number) => { - const parsed = parse(String(value)); - if (parsed !== null) onPreview(parsed); - }, - commitScrub: onCommit, - onFocus: () => { - isEditing.current = true; - draft.current = sourceDisplay; - forceRender(); - }, - onChange: ( - event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, - ) => { - draft.current = event.target.value; - forceRender(); - - const parsed = parse(event.target.value); - if (parsed !== null) { - onPreview(parsed); - } - }, - onBlur: () => { - if ( - supportsExpressions && - looksLikeExpression({ input: draft.current }) - ) { - const evaluated = evaluateMathExpression({ input: draft.current }); - if (evaluated !== null) { - const parsed = parse(String(evaluated)); - if (parsed !== null) onPreview(parsed); - } - } - onCommit(); - isEditing.current = false; - draft.current = ""; - forceRender(); - }, - }; -} +import { useReducer, useRef } from "react";
+import { evaluateMathExpression } from "@/utils/math";
+
+function looksLikeExpression({ input }: { input: string }): boolean {
+ const trimmed = input.trim();
+ if (!trimmed) return false;
+ if (/[+*/]/.test(input)) return true;
+ const minusIndex = trimmed.indexOf("-");
+ return minusIndex > 0;
+}
+
+export function usePropertyDraft<T>({
+ displayValue: sourceDisplay,
+ parse,
+ onPreview,
+ onCommit,
+ supportsExpressions = true,
+}: {
+ displayValue: string;
+ parse: (input: string) => T | null;
+ onPreview: (value: T) => void;
+ onCommit: () => void;
+ supportsExpressions?: boolean;
+}) {
+ const [, forceRender] = useReducer(
+ (renderVersion: number) => renderVersion + 1,
+ 0,
+ );
+ const isEditing = useRef(false);
+ const draft = useRef("");
+
+ return {
+ displayValue: isEditing.current ? draft.current : sourceDisplay,
+ scrubTo: (value: number) => {
+ const parsed = parse(String(value));
+ if (parsed !== null) onPreview(parsed);
+ },
+ commitScrub: onCommit,
+ onFocus: () => {
+ isEditing.current = true;
+ draft.current = sourceDisplay;
+ forceRender();
+ },
+ onChange: (
+ event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
+ ) => {
+ draft.current = event.target.value;
+ forceRender();
+
+ const parsed = parse(event.target.value);
+ if (parsed !== null) {
+ onPreview(parsed);
+ }
+ },
+ onBlur: () => {
+ if (
+ supportsExpressions &&
+ looksLikeExpression({ input: draft.current })
+ ) {
+ const evaluated = evaluateMathExpression({ input: draft.current });
+ if (evaluated !== null) {
+ const parsed = parse(String(evaluated));
+ if (parsed !== null) onPreview(parsed);
+ }
+ }
+ onCommit();
+ isEditing.current = false;
+ draft.current = "";
+ forceRender();
+ },
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index 706faef4..38d0a189 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -1,303 +1,303 @@ -import type { ReactNode } from "react"; -import type { - EffectElement, - GraphicElement, - ImageElement, - MaskableElement, - RetimableElement, - StickerElement, - TextElement, - VisualElement, - VideoElement, - AudioElement, - TimelineElement, -} from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - TextFontIcon, - ArrowExpandIcon, - RainDropIcon, - MusicNote03Icon, - MagicWand05Icon, - DashboardSpeed02Icon, -} from "@hugeicons/core-free-icons"; -import { TransformTab } from "./tabs/transform-tab"; -import { BlendingTab } from "./tabs/blending-tab"; -import { AudioTab } from "./tabs/audio-tab"; -import { TextTab } from "./tabs/text-tab"; -import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab"; -import { MasksTab } from "./tabs/masks-tab"; -import { SpeedTab } from "./tabs/speed-tab"; -import { GraphicTab } from "./tabs/graphic-tab"; -import { OcShapesIcon } from "@opencut/ui/icons"; - -export type TabContentProps = { - trackId: string; -}; - -export type PropertiesTabDef = { - id: string; - label: string; - icon: ReactNode; - content: (props: TabContentProps) => ReactNode; -}; - -export type ElementPropertiesConfig = { - defaultTab: string; - tabs: PropertiesTabDef[]; -}; - -function buildTransformTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "transform", - label: "Transform", - icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, - content: ({ trackId }) => ( - <TransformTab element={element} trackId={trackId} /> - ), - }; -} - -function buildBlendingTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "blending", - label: "Blending", - icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, - content: ({ trackId }) => ( - <BlendingTab element={element} trackId={trackId} /> - ), - }; -} - -function buildAudioTab({ - element, -}: { - element: AudioElement | VideoElement; -}): PropertiesTabDef { - return { - id: "audio", - label: "Audio", - icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, - content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, - }; -} - -function buildSpeedTab({ - element, -}: { - element: RetimableElement; -}): PropertiesTabDef { - return { - id: "speed", - label: "Speed", - icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />, - content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />, - }; -} - -function buildMasksTab({ - element, -}: { - element: MaskableElement; -}): PropertiesTabDef { - return { - id: "masks", - label: "Masks", - icon: <OcShapesIcon size={16} />, - content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />, - }; -} - -function buildClipEffectsTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "effects", - label: "Effects", - icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, - content: ({ trackId }) => ( - <ClipEffectsTab element={element} trackId={trackId} /> - ), - }; -} - -function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { - return { - id: "text", - label: "Text", - icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, - content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, - }; -} - -function buildGraphicTab({ - element, -}: { - element: GraphicElement; -}): PropertiesTabDef { - return { - id: "graphic", - label: "Graphic", - icon: <OcShapesIcon size={16} />, - content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />, - }; -} - -function buildStandaloneEffectTab({ - element, -}: { - element: EffectElement; -}): PropertiesTabDef { - return { - id: "effects", - label: "Effects", - icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, - content: ({ trackId }) => ( - <StandaloneEffectTab element={element} trackId={trackId} /> - ), - }; -} - -function getTextConfig({ - element, -}: { - element: TextElement; -}): ElementPropertiesConfig { - return { - defaultTab: "text", - tabs: [ - buildTextTab({ element }), - buildTransformTab({ element }), - buildBlendingTab({ element }), - ], - }; -} - -function getVideoConfig({ - element, - mediaAsset, -}: { - element: VideoElement; - mediaAsset: MediaAsset | undefined; -}): ElementPropertiesConfig { - const showAudioTab = mediaAsset?.hasAudio !== false; - return { - defaultTab: "transform", - tabs: [ - buildTransformTab({ element }), - ...(showAudioTab ? [buildAudioTab({ element })] : []), - buildSpeedTab({ element }), - buildBlendingTab({ element }), - buildMasksTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getImageConfig({ - element, -}: { - element: ImageElement; -}): ElementPropertiesConfig { - return { - defaultTab: "transform", - tabs: [ - buildTransformTab({ element }), - buildBlendingTab({ element }), - buildMasksTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getStickerConfig({ - element, -}: { - element: StickerElement; -}): ElementPropertiesConfig { - return { - defaultTab: "transform", - tabs: [ - buildTransformTab({ element }), - buildBlendingTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getGraphicConfig({ - element, -}: { - element: GraphicElement; -}): ElementPropertiesConfig { - return { - defaultTab: "graphic", - tabs: [ - buildGraphicTab({ element }), - buildTransformTab({ element }), - buildBlendingTab({ element }), - buildMasksTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getAudioConfig({ - element, -}: { - element: AudioElement; -}): ElementPropertiesConfig { - return { - defaultTab: "audio", - tabs: [buildAudioTab({ element }), buildSpeedTab({ element })], - }; -} - -function getEffectConfig({ - element, -}: { - element: EffectElement; -}): ElementPropertiesConfig { - return { - defaultTab: "effects", - tabs: [buildStandaloneEffectTab({ element })], - }; -} - -export function getPropertiesConfig({ - element, - mediaAssets, -}: { - element: TimelineElement; - mediaAssets: MediaAsset[]; -}): ElementPropertiesConfig { - switch (element.type) { - case "text": - return getTextConfig({ element }); - case "video": { - const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId); - return getVideoConfig({ element, mediaAsset }); - } - case "image": - return getImageConfig({ element }); - case "sticker": - return getStickerConfig({ element }); - case "graphic": - return getGraphicConfig({ element }); - case "audio": - return getAudioConfig({ element }); - case "effect": - return getEffectConfig({ element }); - } -} +import type { ReactNode } from "react";
+import type {
+ EffectElement,
+ GraphicElement,
+ ImageElement,
+ MaskableElement,
+ RetimableElement,
+ StickerElement,
+ TextElement,
+ VisualElement,
+ VideoElement,
+ AudioElement,
+ TimelineElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ TextFontIcon,
+ ArrowExpandIcon,
+ RainDropIcon,
+ MusicNote03Icon,
+ MagicWand05Icon,
+ DashboardSpeed02Icon,
+} from "@hugeicons/core-free-icons";
+import { TransformTab } from "./tabs/transform-tab";
+import { BlendingTab } from "./tabs/blending-tab";
+import { AudioTab } from "./tabs/audio-tab";
+import { TextTab } from "./tabs/text-tab";
+import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
+import { MasksTab } from "./tabs/masks-tab";
+import { SpeedTab } from "./tabs/speed-tab";
+import { GraphicTab } from "./tabs/graphic-tab";
+import { OcShapesIcon } from "@opencut/ui/icons";
+
+export type TabContentProps = {
+ trackId: string;
+};
+
+export type PropertiesTabDef = {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ content: (props: TabContentProps) => ReactNode;
+};
+
+export type ElementPropertiesConfig = {
+ defaultTab: string;
+ tabs: PropertiesTabDef[];
+};
+
+function buildTransformTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "transform",
+ label: "Transform",
+ icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
+ content: ({ trackId }) => (
+ <TransformTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildBlendingTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "blending",
+ label: "Blending",
+ icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
+ content: ({ trackId }) => (
+ <BlendingTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildAudioTab({
+ element,
+}: {
+ element: AudioElement | VideoElement;
+}): PropertiesTabDef {
+ return {
+ id: "audio",
+ label: "Audio",
+ icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
+ content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildSpeedTab({
+ element,
+}: {
+ element: RetimableElement;
+}): PropertiesTabDef {
+ return {
+ id: "speed",
+ label: "Speed",
+ icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
+ content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildMasksTab({
+ element,
+}: {
+ element: MaskableElement;
+}): PropertiesTabDef {
+ return {
+ id: "masks",
+ label: "Masks",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildClipEffectsTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <ClipEffectsTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
+ return {
+ id: "text",
+ label: "Text",
+ icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
+ content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildGraphicTab({
+ element,
+}: {
+ element: GraphicElement;
+}): PropertiesTabDef {
+ return {
+ id: "graphic",
+ label: "Graphic",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildStandaloneEffectTab({
+ element,
+}: {
+ element: EffectElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <StandaloneEffectTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function getTextConfig({
+ element,
+}: {
+ element: TextElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "text",
+ tabs: [
+ buildTextTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ ],
+ };
+}
+
+function getVideoConfig({
+ element,
+ mediaAsset,
+}: {
+ element: VideoElement;
+ mediaAsset: MediaAsset | undefined;
+}): ElementPropertiesConfig {
+ const showAudioTab = mediaAsset?.hasAudio !== false;
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ ...(showAudioTab ? [buildAudioTab({ element })] : []),
+ buildSpeedTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getImageConfig({
+ element,
+}: {
+ element: ImageElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getStickerConfig({
+ element,
+}: {
+ element: StickerElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getGraphicConfig({
+ element,
+}: {
+ element: GraphicElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "graphic",
+ tabs: [
+ buildGraphicTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getAudioConfig({
+ element,
+}: {
+ element: AudioElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "audio",
+ tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
+ };
+}
+
+function getEffectConfig({
+ element,
+}: {
+ element: EffectElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "effects",
+ tabs: [buildStandaloneEffectTab({ element })],
+ };
+}
+
+export function getPropertiesConfig({
+ element,
+ mediaAssets,
+}: {
+ element: TimelineElement;
+ mediaAssets: MediaAsset[];
+}): ElementPropertiesConfig {
+ switch (element.type) {
+ case "text":
+ return getTextConfig({ element });
+ case "video": {
+ const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
+ return getVideoConfig({ element, mediaAsset });
+ }
+ case "image":
+ return getImageConfig({ element });
+ case "sticker":
+ return getStickerConfig({ element });
+ case "graphic":
+ return getGraphicConfig({ element });
+ case "audio":
+ return getAudioConfig({ element });
+ case "effect":
+ return getEffectConfig({ element });
+ }
+}
diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts index 1783e3fc..bd57d4b0 100644 --- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -1,18 +1,18 @@ -import { create } from "zustand"; - -interface PropertiesState { - activeTabPerType: Record<string, string>; - setActiveTab: (elementType: string, tabId: string) => void; - isTransformScaleLocked: boolean; - setTransformScaleLocked: (locked: boolean) => void; -} - -export const usePropertiesStore = create<PropertiesState>()((set) => ({ - activeTabPerType: {}, - setActiveTab: (elementType, tabId) => - set((state) => ({ - activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId }, - })), - isTransformScaleLocked: false, - setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }), -})); +import { create } from "zustand";
+
+interface PropertiesState {
+ activeTabPerType: Record<string, string>;
+ setActiveTab: (elementType: string, tabId: string) => void;
+ isTransformScaleLocked: boolean;
+ setTransformScaleLocked: (locked: boolean) => void;
+}
+
+export const usePropertiesStore = create<PropertiesState>()((set) => ({
+ activeTabPerType: {},
+ setActiveTab: (elementType, tabId) =>
+ set((state) => ({
+ activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
+ })),
+ isTransformScaleLocked: false,
+ setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+}));
diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx index 41b6ec6d..a92f7be2 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx @@ -1,109 +1,109 @@ -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; -import { resolveNumberAtTime } from "@/lib/animation"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { VolumeHighIcon } from "@hugeicons/core-free-icons"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedVolume = resolveNumberAtTime({ - baseValue: element.volume ?? DEFAULTS.element.volume, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - const volume = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "volume", - localTime, - isPlayheadWithinElementRange, - displayValue: resolvedVolume.toFixed(1), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }); - }, - valueAtPlayhead: resolvedVolume, - step: 0.1, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - - return ( - <Section collapsible sectionKey={`${element.id}:audio`}> - <SectionHeader> - <SectionTitle>Audio</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField - label="Volume" - beforeLabel={ - <KeyframeToggle - isActive={volume.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle volume keyframe" - onToggle={volume.toggleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={VolumeHighIcon} />} - value={volume.displayValue} - onFocus={volume.onFocus} - onChange={volume.onChange} - onBlur={volume.onBlur} - dragSensitivity="slow" - scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} - onScrub={volume.scrubTo} - onScrubEnd={volume.commitScrub} - onReset={() => - volume.commitValue({ - value: DEFAULTS.element.volume, - }) - } - isDefault={isDefault} - suffix="dB" - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} +import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import { resolveNumberAtTime } from "@/lib/animation";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: resolvedVolume.toFixed(1),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: 0.1,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx index c360817c..fdcde148 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx @@ -1,230 +1,230 @@ -import { useEditor } from "@/hooks/use-editor"; -import { clamp } from "@/utils/math"; -import { NumberField } from "@/components/ui/number-field"; -import { OcCheckerboardIcon } from "@opencut/ui/icons"; -import { Fragment, useRef } from "react"; -import { useMenuPreview } from "@/hooks/use-menu-preview"; -import { - Section, - SectionContent, - SectionField, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { BlendMode } from "@/lib/rendering"; -import type { ElementType } from "@/lib/timeline"; -import type { ElementAnimations } from "@/lib/animation/types"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { RainDropIcon } from "@hugeicons/core-free-icons"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/lib/animation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { isPropertyAtDefault } from "./transform-tab"; - -type BlendingElement = { - id: string; - opacity: number; - type: ElementType; - blendMode?: BlendMode; - startTime: number; - duration: number; - animations?: ElementAnimations; -}; - -const BLEND_MODE_GROUPS = [ - [{ value: "normal", label: "Normal" }], - [ - { value: "darken", label: "Darken" }, - { value: "multiply", label: "Multiply" }, - { value: "color-burn", label: "Color Burn" }, - ], - [ - { value: "lighten", label: "Lighten" }, - { value: "screen", label: "Screen" }, - { value: "plus-lighter", label: "Plus Lighter" }, - { value: "color-dodge", label: "Color Dodge" }, - ], - [ - { value: "overlay", label: "Overlay" }, - { value: "soft-light", label: "Soft Light" }, - { value: "hard-light", label: "Hard Light" }, - ], - [ - { value: "difference", label: "Difference" }, - { value: "exclusion", label: "Exclusion" }, - ], - [ - { value: "hue", label: "Hue" }, - { value: "saturation", label: "Saturation" }, - { value: "color", label: "Color" }, - { value: "luminosity", label: "Luminosity" }, - ], -]; - -export function BlendingTab({ - element, - trackId, -}: { - element: BlendingElement; - trackId: string; -}) { - const editor = useEditor(); - const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); - const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; - const committedBlendModeRef = useRef(blendMode); - if (!isPreviewActive) { - committedBlendModeRef.current = blendMode; - } - - const { - onPointerLeave, - onOpenChange: handleBlendModeOpenChange, - markCommitted, - } = useMenuPreview(); - - const previewBlendMode = ({ value }: { value: BlendMode }) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { blendMode: value } }, - ], - }); - - const commitBlendMode = (value: string) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { blendMode: value as BlendMode }, - }, - ], - }); - } - markCommitted(); - }; - - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedOpacity = resolveOpacityAtTime({ - baseOpacity: element.opacity, - animations: element.animations, - localTime, - }); - - const opacity = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "opacity", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOpacity * 100).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min: 0, max: 100 }) / 100; - }, - valueAtPlayhead: resolvedOpacity, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ opacity: value }), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:blending`}> - <SectionHeader> - <SectionTitle>Blending</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField - label="Opacity" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={opacity.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle opacity keyframe" - onToggle={opacity.toggleKeyframe} - /> - } - > - <NumberField - className="w-full" - icon={ - <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => - opacity.commitValue({ value: DEFAULTS.element.opacity }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOpacity, - staticValue: element.opacity, - defaultValue: DEFAULTS.element.opacity, - })} - dragSensitivity="slow" - /> - </SectionField> - <SectionField label="Blend mode" className="w-1/2"> - <Select - value={committedBlendModeRef.current} - onOpenChange={handleBlendModeOpenChange} - onValueChange={commitBlendMode} - > - <SelectTrigger - icon={<HugeiconsIcon icon={RainDropIcon} />} - className="w-full" - > - <SelectValue placeholder="Select blend mode" /> - </SelectTrigger> - <SelectContent className="w-36" onPointerLeave={onPointerLeave}> - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> - {group.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - onPointerEnter={() => - previewBlendMode({ value: option.value as BlendMode }) - } - > - {option.label} - </SelectItem> - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - <SelectSeparator /> - ) : null} - </Fragment> - ))} - </SelectContent> - </Select> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} +import { useEditor } from "@/hooks/use-editor";
+import { clamp } from "@/utils/math";
+import { NumberField } from "@/components/ui/number-field";
+import { OcCheckerboardIcon } from "@opencut/ui/icons";
+import { Fragment, useRef } from "react";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { BlendMode } from "@/lib/rendering";
+import type { ElementType } from "@/lib/timeline";
+import type { ElementAnimations } from "@/lib/animation/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { RainDropIcon } from "@hugeicons/core-free-icons";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { resolveOpacityAtTime } from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { isPropertyAtDefault } from "./transform-tab";
+
+type BlendingElement = {
+ id: string;
+ opacity: number;
+ type: ElementType;
+ blendMode?: BlendMode;
+ startTime: number;
+ duration: number;
+ animations?: ElementAnimations;
+};
+
+const BLEND_MODE_GROUPS = [
+ [{ value: "normal", label: "Normal" }],
+ [
+ { value: "darken", label: "Darken" },
+ { value: "multiply", label: "Multiply" },
+ { value: "color-burn", label: "Color Burn" },
+ ],
+ [
+ { value: "lighten", label: "Lighten" },
+ { value: "screen", label: "Screen" },
+ { value: "plus-lighter", label: "Plus Lighter" },
+ { value: "color-dodge", label: "Color Dodge" },
+ ],
+ [
+ { value: "overlay", label: "Overlay" },
+ { value: "soft-light", label: "Soft Light" },
+ { value: "hard-light", label: "Hard Light" },
+ ],
+ [
+ { value: "difference", label: "Difference" },
+ { value: "exclusion", label: "Exclusion" },
+ ],
+ [
+ { value: "hue", label: "Hue" },
+ { value: "saturation", label: "Saturation" },
+ { value: "color", label: "Color" },
+ { value: "luminosity", label: "Luminosity" },
+ ],
+];
+
+export function BlendingTab({
+ element,
+ trackId,
+}: {
+ element: BlendingElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
+ const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
+ const committedBlendModeRef = useRef(blendMode);
+ if (!isPreviewActive) {
+ committedBlendModeRef.current = blendMode;
+ }
+
+ const {
+ onPointerLeave,
+ onOpenChange: handleBlendModeOpenChange,
+ markCommitted,
+ } = useMenuPreview();
+
+ const previewBlendMode = ({ value }: { value: BlendMode }) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { blendMode: value } },
+ ],
+ });
+
+ const commitBlendMode = (value: string) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { blendMode: value as BlendMode },
+ },
+ ],
+ });
+ }
+ markCommitted();
+ };
+
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedOpacity = resolveOpacityAtTime({
+ baseOpacity: element.opacity,
+ animations: element.animations,
+ localTime,
+ });
+
+ const opacity = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "opacity",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOpacity * 100).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min: 0, max: 100 }) / 100;
+ },
+ valueAtPlayhead: resolvedOpacity,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({ opacity: value }),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:blending`}>
+ <SectionHeader>
+ <SectionTitle>Blending</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="Opacity"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={opacity.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle opacity keyframe"
+ onToggle={opacity.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ className="w-full"
+ icon={
+ <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ }
+ value={opacity.displayValue}
+ min={0}
+ max={100}
+ onFocus={opacity.onFocus}
+ onChange={opacity.onChange}
+ onBlur={opacity.onBlur}
+ onScrub={opacity.scrubTo}
+ onScrubEnd={opacity.commitScrub}
+ onReset={() =>
+ opacity.commitValue({ value: DEFAULTS.element.opacity })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOpacity,
+ staticValue: element.opacity,
+ defaultValue: DEFAULTS.element.opacity,
+ })}
+ dragSensitivity="slow"
+ />
+ </SectionField>
+ <SectionField label="Blend mode" className="w-1/2">
+ <Select
+ value={committedBlendModeRef.current}
+ onOpenChange={handleBlendModeOpenChange}
+ onValueChange={commitBlendMode}
+ >
+ <SelectTrigger
+ icon={<HugeiconsIcon icon={RainDropIcon} />}
+ className="w-full"
+ >
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
+ {BLEND_MODE_GROUPS.map((group, groupIndex) => (
+ <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
+ {group.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ onPointerEnter={() =>
+ previewBlendMode({ value: option.value as BlendMode })
+ }
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+ <SelectSeparator />
+ ) : null}
+ </Fragment>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx index 1b94ee2d..759ced60 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx @@ -1,309 +1,309 @@ -"use client"; - -import { useState } from "react"; -import type { ParamValues } from "@/lib/params"; -import type { Effect } from "@/lib/effects/types"; -import type { EffectElement, VisualElement } from "@/lib/timeline"; -import { effectsRegistry } from "@/lib/effects"; -import { useEditor } from "@/hooks/use-editor"; -import { useElementPreview } from "@/hooks/use-element-preview"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, - SectionFields, -} from "@/components/section"; -import { PropertyParamField } from "../components/property-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - Delete02Icon, - ViewIcon, - ViewOffSlashIcon, - MagicWand05Icon, -} from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; -import { Separator } from "@/components/ui/separator"; -import { useAssetsPanelStore } from "@/stores/assets-panel-store"; - -export function StandaloneEffectTab({ - element, - trackId, -}: { - element: EffectElement; - trackId: string; -}) { - const { renderElement, previewUpdates, commit } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const effect: Effect = { - id: element.id, - type: element.effectType, - params: element.params, - enabled: true, - }; - - const previewParam = (key: string) => (value: number | string | boolean) => { - previewUpdates({ - params: { ...(renderElement as EffectElement).params, [key]: value }, - }); - }; - - return ( - <div className="flex flex-col h-full"> - <div className="border-b px-3.5 h-11 shrink-0 flex items-center"> - <SectionTitle>Effect</SectionTitle> - </div> - <EffectSection - effect={effect} - renderParams={(renderElement as EffectElement).params} - previewParam={previewParam} - onCommit={commit} - /> - </div> - ); -} - -export function ClipEffectsTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const [dragIndex, setDragIndex] = useState<number | null>(null); - const [dropIndex, setDropIndex] = useState<number | null>(null); - const editor = useEditor(); - const { renderElement, previewUpdates, commit } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const effects: Effect[] = element.effects ?? []; - - const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => { - return ( - (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId) - ?.params ?? - effects.find((ef) => ef.id === effectId)?.params ?? - {} - ); - }; - - const buildPreviewParam = - (effectId: string) => - (key: string) => - (value: number | string | boolean) => { - const updatedEffects = ( - (renderElement as VisualElement).effects ?? [] - ).map((existing) => - existing.id !== effectId - ? existing - : { ...existing, params: { ...existing.params, [key]: value } }, - ); - previewUpdates({ effects: updatedEffects }); - }; - - const handleDragStart = ({ index }: { index: number }) => setDragIndex(index); - - const handleDragOver = ({ - event, - index, - }: { - event: React.DragEvent; - index: number; - }) => { - event.preventDefault(); - if (index !== dropIndex) setDropIndex(index); - }; - - const handleDrop = ({ toIndex }: { toIndex: number }) => { - if (dragIndex !== null && dragIndex !== toIndex) { - editor.timeline.reorderClipEffects({ - trackId, - elementId: element.id, - fromIndex: dragIndex, - toIndex, - }); - } - setDragIndex(null); - setDropIndex(null); - }; - - const handleDragEnd = () => { - setDragIndex(null); - setDropIndex(null); - }; - - return ( - <div className="flex flex-col h-full"> - <div className="border-b px-3.5 h-11 shrink-0 flex items-center"> - <SectionTitle>Effects</SectionTitle> - </div> - {effects.length === 0 ? ( - <EmptyView /> - ) : ( - <ul className="flex flex-col"> - {effects.map((effect, index) => { - const resolvedDragIndex = dragIndex ?? -1; - const isDragging = dragIndex === index; - const isDropTarget = - dropIndex === index && dragIndex !== null && dragIndex !== index; - const showTopDropIndicator = - isDropTarget && index < resolvedDragIndex; - const showBottomDropIndicator = - isDropTarget && index > resolvedDragIndex; - - return ( - <li - key={effect.id} - draggable - onDragStart={() => handleDragStart({ index })} - onDragOver={(event) => handleDragOver({ event, index })} - onDrop={() => handleDrop({ toIndex: index })} - onDragEnd={handleDragEnd} - className={cn( - "group list-none", - isDragging && "opacity-40", - showTopDropIndicator && "border-t-2 border-primary", - showBottomDropIndicator && "border-b-2 border-primary", - )} - > - <EffectSection - effect={effect} - renderParams={getRenderParams({ effectId: effect.id })} - previewParam={buildPreviewParam(effect.id)} - onCommit={commit} - onToggle={() => - editor.timeline.toggleClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - onRemove={() => - editor.timeline.removeClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - /> - </li> - ); - })} - </ul> - )} - </div> - ); -} - -function EmptyView() { - const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab); - - return ( - <div className="flex flex-col h-full items-center justify-center gap-4 text-center"> - <HugeiconsIcon - icon={MagicWand05Icon} - className="size-10 text-muted-foreground" - strokeWidth={1} - /> - <div className="flex flex-col gap-2"> - <h3 className="font-medium text-foreground">No effects</h3> - <p className="text-muted-foreground text-sm text-balance max-w-44"> - Add effects to this layer from the Assets panel. - </p> - </div> - <Button - variant="default" - size="sm" - onClick={() => setActiveTab("effects")} - > - Open effects - </Button> - </div> - ); -} - -function EffectSection({ - effect, - renderParams, - previewParam, - onCommit, - onToggle, - onRemove, -}: { - effect: Effect; - renderParams: ParamValues; - previewParam: (key: string) => (value: number | string | boolean) => void; - onCommit: () => void; - onToggle?: () => void; - onRemove?: () => void; -}) { - const definition = effectsRegistry.get(effect.type); - - return ( - <Section - sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined} - showTopBorder={false} - > - <SectionHeader - className={cn(onToggle && "cursor-move")} - trailing={ - onToggle && ( - <div className="flex items-center gap-1"> - <Button - variant={effect.enabled ? "secondary" : "ghost"} - size="icon" - aria-label={`Toggle ${definition.name}`} - onClick={onToggle} - > - <HugeiconsIcon - icon={effect.enabled ? ViewIcon : ViewOffSlashIcon} - /> - </Button> - <Button - variant="ghost" - size="icon" - aria-label={`Remove ${definition.name}`} - onClick={onRemove} - > - <HugeiconsIcon icon={Delete02Icon} /> - </Button> - </div> - ) - } - > - <SectionTitle - className={cn(onToggle && !effect.enabled && "text-muted-foreground")} - > - {definition.name} - </SectionTitle> - </SectionHeader> - <SectionContent - className={cn("p-0", onToggle && !effect.enabled && "opacity-50")} - > - <SectionFields> - {definition.params.map((param) => ( - <div key={param.key} className="flex flex-col gap-3.5"> - <div className="px-4"> - <PropertyParamField - param={param} - value={renderParams[param.key] ?? param.default} - onPreview={previewParam(param.key)} - onCommit={onCommit} - /> - </div> - <Separator /> - </div> - ))} - </SectionFields> - </SectionContent> - </Section> - ); -} +"use client";
+
+import { useState } from "react";
+import type { ParamValues } from "@/lib/params";
+import type { Effect } from "@/lib/effects/types";
+import type { EffectElement, VisualElement } from "@/lib/timeline";
+import { effectsRegistry } from "@/lib/effects";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ MagicWand05Icon,
+} from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { Separator } from "@/components/ui/separator";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+
+export function StandaloneEffectTab({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effect: Effect = {
+ id: element.id,
+ type: element.effectType,
+ params: element.params,
+ enabled: true,
+ };
+
+ const previewParam = (key: string) => (value: number | string | boolean) => {
+ previewUpdates({
+ params: { ...(renderElement as EffectElement).params, [key]: value },
+ });
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effect</SectionTitle>
+ </div>
+ <EffectSection
+ effect={effect}
+ renderParams={(renderElement as EffectElement).params}
+ previewParam={previewParam}
+ onCommit={commit}
+ />
+ </div>
+ );
+}
+
+export function ClipEffectsTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effects: Effect[] = element.effects ?? [];
+
+ const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
+ return (
+ (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
+ ?.params ??
+ effects.find((ef) => ef.id === effectId)?.params ??
+ {}
+ );
+ };
+
+ const buildPreviewParam =
+ (effectId: string) =>
+ (key: string) =>
+ (value: number | string | boolean) => {
+ const updatedEffects = (
+ (renderElement as VisualElement).effects ?? []
+ ).map((existing) =>
+ existing.id !== effectId
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ previewUpdates({ effects: updatedEffects });
+ };
+
+ const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
+
+ const handleDragOver = ({
+ event,
+ index,
+ }: {
+ event: React.DragEvent;
+ index: number;
+ }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effects</SectionTitle>
+ </div>
+ {effects.length === 0 ? (
+ <EmptyView />
+ ) : (
+ <ul className="flex flex-col">
+ {effects.map((effect, index) => {
+ const resolvedDragIndex = dragIndex ?? -1;
+ const isDragging = dragIndex === index;
+ const isDropTarget =
+ dropIndex === index && dragIndex !== null && dragIndex !== index;
+ const showTopDropIndicator =
+ isDropTarget && index < resolvedDragIndex;
+ const showBottomDropIndicator =
+ isDropTarget && index > resolvedDragIndex;
+
+ return (
+ <li
+ key={effect.id}
+ draggable
+ onDragStart={() => handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group list-none",
+ isDragging && "opacity-40",
+ showTopDropIndicator && "border-t-2 border-primary",
+ showBottomDropIndicator && "border-b-2 border-primary",
+ )}
+ >
+ <EffectSection
+ effect={effect}
+ renderParams={getRenderParams({ effectId: effect.id })}
+ previewParam={buildPreviewParam(effect.id)}
+ onCommit={commit}
+ onToggle={() =>
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ onRemove={() =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ />
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+function EmptyView() {
+ const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
+
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-10 text-muted-foreground"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No effects</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-44">
+ Add effects to this layer from the Assets panel.
+ </p>
+ </div>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setActiveTab("effects")}
+ >
+ Open effects
+ </Button>
+ </div>
+ );
+}
+
+function EffectSection({
+ effect,
+ renderParams,
+ previewParam,
+ onCommit,
+ onToggle,
+ onRemove,
+}: {
+ effect: Effect;
+ renderParams: ParamValues;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+ onToggle?: () => void;
+ onRemove?: () => void;
+}) {
+ const definition = effectsRegistry.get(effect.type);
+
+ return (
+ <Section
+ sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
+ showTopBorder={false}
+ >
+ <SectionHeader
+ className={cn(onToggle && "cursor-move")}
+ trailing={
+ onToggle && (
+ <div className="flex items-center gap-1">
+ <Button
+ variant={effect.enabled ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={`Toggle ${definition.name}`}
+ onClick={onToggle}
+ >
+ <HugeiconsIcon
+ icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name}`}
+ onClick={onRemove}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ )
+ }
+ >
+ <SectionTitle
+ className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
+ >
+ {definition.name}
+ </SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
+ >
+ <SectionFields>
+ {definition.params.map((param) => (
+ <div key={param.key} className="flex flex-col gap-3.5">
+ <div className="px-4">
+ <PropertyParamField
+ param={param}
+ value={renderParams[param.key] ?? param.default}
+ onPreview={previewParam(param.key)}
+ onCommit={onCommit}
+ />
+ </div>
+ <Separator />
+ </div>
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx index 22dae8cc..4593050e 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx @@ -1,238 +1,238 @@ -"use client"; - -import { useRef } from "react"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { - useKeyframedParamProperty, - type KeyframedParamPropertyResult, -} from "../hooks/use-keyframed-param-property"; -import { resolveGraphicParamsAtTime } from "@/lib/animation"; -import type { ParamDefinition, ParamValues } from "@/lib/params"; -import type { GraphicElement } from "@/lib/timeline"; -import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; -import { useElementPreview } from "@/hooks/use-element-preview"; -import { useEditor } from "@/hooks/use-editor"; -import { - Section, - SectionContent, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { PropertyParamField } from "../components/property-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -registerDefaultGraphics(); - -const DEFAULT_STROKE_WIDTH = 2; - -export function GraphicTab({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - - const shapeParams = definition.params.filter((p) => p.group !== "stroke"); - const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); - - return ( - <div className="flex flex-col"> - <Section collapsible sectionKey={`${element.id}:graphic`}> - <SectionHeader> - <SectionTitle>{definition.name}</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - {shapeParams.map((param) => ( - <AnimatedGraphicParamField - key={param.key} - param={param} - trackId={trackId} - element={liveElement} - localTime={localTime} - isPlayheadWithinElementRange={isPlayheadWithinElementRange} - resolvedParams={resolvedParams} - /> - ))} - </SectionFields> - </SectionContent> - </Section> - {hasStrokeParams && ( - <StrokeSection element={element} trackId={trackId} /> - )} - </div> - ); -} - -function StrokeSection({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const editor = useEditor(); - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - const strokeParams = definition.params.filter((p) => p.group === "stroke"); - const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); - const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; - - const toggleStroke = () => { - if (isStrokeEnabled) { - lastStrokeWidth.current = Number( - element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, - ); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { params: { ...element.params, strokeWidth: 0 } }, - }, - ], - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - params: { - ...element.params, - strokeWidth: lastStrokeWidth.current, - }, - }, - }, - ], - }); - } - }; - - return ( - <Section - collapsible - defaultOpen={isStrokeEnabled} - sectionKey={`${element.id}:stroke`} - > - <SectionHeader - trailing={ - <Button - variant="ghost" - size="icon" - onClick={(event) => { - event.stopPropagation(); - toggleStroke(); - }} - > - <HugeiconsIcon - icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon} - strokeWidth={1} - /> - </Button> - } - > - <SectionTitle>Stroke</SectionTitle> - </SectionHeader> - <SectionContent - className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")} - > - <SectionFields> - {strokeParams.map((param) => ( - <AnimatedGraphicParamField - key={param.key} - param={param} - trackId={trackId} - element={liveElement} - localTime={localTime} - isPlayheadWithinElementRange={isPlayheadWithinElementRange} - resolvedParams={resolvedParams} - /> - ))} - </SectionFields> - </SectionContent> - </Section> - ); -} - -function AnimatedGraphicParamField({ - param, - trackId, - element, - localTime, - isPlayheadWithinElementRange, - resolvedParams, -}: { - param: ParamDefinition; - trackId: string; - element: GraphicElement; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedParams: ParamValues; -}) { - const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({ - param, - trackId, - elementId: element.id, - animations: element.animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue: resolvedParams[param.key] ?? param.default, - buildBaseUpdates: ({ value }) => ({ - params: { - ...element.params, - [param.key]: value, - }, - }), - }); - - return ( - <PropertyParamField - param={param} - value={resolvedParams[param.key] ?? param.default} - onPreview={animatedParam.onPreview} - onCommit={animatedParam.onCommit} - keyframe={{ - isActive: animatedParam.isKeyframedAtTime, - isDisabled: !isPlayheadWithinElementRange, - onToggle: animatedParam.toggleKeyframe, - }} - /> - ); -} +"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "../hooks/use-keyframed-param-property";
+import { resolveGraphicParamsAtTime } from "@/lib/animation";
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+import type { GraphicElement } from "@/lib/timeline";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && (
+ <StrokeSection element={element} trackId={trackId} />
+ )}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ });
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx index 44f5dd74..4434890e 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx @@ -1,137 +1,137 @@ -import { useRef } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { NumberField } from "@/components/ui/number-field"; -import { Switch } from "@/components/ui/switch"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons"; -import { buildConstantRetime } from "@/lib/retime"; -import { - DEFAULT_RETIME_RATE, - MIN_RETIME_RATE, - MAX_RETIME_RATE, - clampRetimeRate, - canMaintainPitch, -} from "@/constants/retime-constants"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { usePropertyDraft } from "../hooks/use-property-draft"; - -function rateToDisplay({ rate }: { rate: number }): string { - return rate.toFixed(2); -} - -function parseSpeedInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - const rounded = Math.round(parsed * 100) / 100; - return clampRetimeRate({ rate: rounded }); -} - -function buildRetime({ - rate, - maintainPitch, -}: { - rate: number; - maintainPitch: boolean; -}) { - if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined; - return buildConstantRetime({ rate, maintainPitch }); -} - -export function SpeedTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - const rate = clampRetimeRate({ - rate: element.retime?.rate ?? DEFAULT_RETIME_RATE, - }); - const maintainPitchAvailable = canMaintainPitch({ rate }); - const maintainPitch = element.retime?.maintainPitch ?? false; - const pendingRateRef = useRef(rate); - - const applyRetime = ({ - rate: nextRate, - maintainPitch: nextMaintainPitch, - pushHistory = true, - }: { - rate: number; - maintainPitch: boolean; - pushHistory?: boolean; - }) => { - editor.timeline.updateElementRetime({ - trackId, - elementId: element.id, - retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }), - pushHistory, - }); - }; - - const speedDraft = usePropertyDraft({ - displayValue: rateToDisplay({ rate }), - parse: (input) => parseSpeedInput({ input }), - onPreview: (nextRate) => { - pendingRateRef.current = nextRate; - applyRetime({ rate: nextRate, maintainPitch, pushHistory: false }); - }, - onCommit: () => { - applyRetime({ rate: pendingRateRef.current, maintainPitch }); - }, - }); - - return ( - <Section collapsible sectionKey={`${element.id}:speed`}> - <SectionHeader> - <SectionTitle>Speed</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField label="Speed"> - <NumberField - icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />} - value={speedDraft.displayValue} - suffix="x" - scrubRanges={[ - { from: 0.01, to: 1, pixelsPerUnit: 160 }, - { from: 1, to: 5, pixelsPerUnit: 48 }, - ]} - scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }} - onFocus={() => { - pendingRateRef.current = rate; - speedDraft.onFocus(); - }} - onChange={speedDraft.onChange} - onBlur={speedDraft.onBlur} - onScrub={speedDraft.scrubTo} - onScrubEnd={speedDraft.commitScrub} - onReset={() => - applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch }) - } - isDefault={rate === DEFAULT_RETIME_RATE} - /> - </SectionField> - <div className="flex items-center justify-between"> - <span className="text-sm">Change pitch</span> - <Switch - checked={!maintainPitch} - disabled={!maintainPitchAvailable} - onCheckedChange={(checked) => - applyRetime({ rate, maintainPitch: !checked }) - } - /> - </div> - </SectionFields> - </SectionContent> - </Section> - ); -} +import { useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
+import { buildConstantRetime } from "@/lib/retime";
+import {
+ DEFAULT_RETIME_RATE,
+ MIN_RETIME_RATE,
+ MAX_RETIME_RATE,
+ clampRetimeRate,
+ canMaintainPitch,
+} from "@/constants/retime-constants";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+
+function rateToDisplay({ rate }: { rate: number }): string {
+ return rate.toFixed(2);
+}
+
+function parseSpeedInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ const rounded = Math.round(parsed * 100) / 100;
+ return clampRetimeRate({ rate: rounded });
+}
+
+function buildRetime({
+ rate,
+ maintainPitch,
+}: {
+ rate: number;
+ maintainPitch: boolean;
+}) {
+ if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined;
+ return buildConstantRetime({ rate, maintainPitch });
+}
+
+export function SpeedTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const rate = clampRetimeRate({
+ rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
+ });
+ const maintainPitchAvailable = canMaintainPitch({ rate });
+ const maintainPitch = element.retime?.maintainPitch ?? false;
+ const pendingRateRef = useRef(rate);
+
+ const applyRetime = ({
+ rate: nextRate,
+ maintainPitch: nextMaintainPitch,
+ pushHistory = true,
+ }: {
+ rate: number;
+ maintainPitch: boolean;
+ pushHistory?: boolean;
+ }) => {
+ editor.timeline.updateElementRetime({
+ trackId,
+ elementId: element.id,
+ retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
+ pushHistory,
+ });
+ };
+
+ const speedDraft = usePropertyDraft({
+ displayValue: rateToDisplay({ rate }),
+ parse: (input) => parseSpeedInput({ input }),
+ onPreview: (nextRate) => {
+ pendingRateRef.current = nextRate;
+ applyRetime({ rate: nextRate, maintainPitch, pushHistory: false });
+ },
+ onCommit: () => {
+ applyRetime({ rate: pendingRateRef.current, maintainPitch });
+ },
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:speed`}>
+ <SectionHeader>
+ <SectionTitle>Speed</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Speed">
+ <NumberField
+ icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />}
+ value={speedDraft.displayValue}
+ suffix="x"
+ scrubRanges={[
+ { from: 0.01, to: 1, pixelsPerUnit: 160 },
+ { from: 1, to: 5, pixelsPerUnit: 48 },
+ ]}
+ scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }}
+ onFocus={() => {
+ pendingRateRef.current = rate;
+ speedDraft.onFocus();
+ }}
+ onChange={speedDraft.onChange}
+ onBlur={speedDraft.onBlur}
+ onScrub={speedDraft.scrubTo}
+ onScrubEnd={speedDraft.commitScrub}
+ onReset={() =>
+ applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
+ }
+ isDefault={rate === DEFAULT_RETIME_RATE}
+ />
+ </SectionField>
+ <div className="flex items-center justify-between">
+ <span className="text-sm">Change pitch</span>
+ <Switch
+ checked={!maintainPitch}
+ disabled={!maintainPitchAvailable}
+ onCheckedChange={(checked) =>
+ applyRetime({ rate, maintainPitch: !checked })
+ }
+ />
+ </div>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx index f8e2527a..45831dc0 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -1,762 +1,762 @@ -"use client"; - -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import type { TextElement } from "@/lib/timeline"; -import { NumberField } from "@/components/ui/number-field"; -import { useRef } from "react"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { Button } from "@/components/ui/button"; -import { uppercase } from "@/utils/string"; -import { clamp } from "@/utils/math"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_COLOR } from "@/constants/project-constants"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, - MAX_FONT_SIZE, - MIN_FONT_SIZE, -} from "@/constants/text-constants"; -import { usePropertyDraft } from "../hooks/use-property-draft"; -import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { isPropertyAtDefault } from "./transform-tab"; -import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - MinusSignIcon, - PlusSignIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; -import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { cn } from "@/utils/ui"; - -export function TextTab({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - return ( - <div className="flex flex-col"> - <ContentSection element={element} trackId={trackId} /> - <TypographySection element={element} trackId={trackId} /> - <SpacingSection element={element} trackId={trackId} /> - <BackgroundSection element={element} trackId={trackId} /> - </div> - ); -} - -function ContentSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const content = usePropertyDraft({ - displayValue: element.content, - parse: (input) => input, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { content: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:content`}> - <SectionHeader> - <SectionTitle>Content</SectionTitle> - </SectionHeader> - <SectionContent> - <Textarea - placeholder="Name" - value={content.displayValue} - className="min-h-20" - onFocus={content.onFocus} - onChange={content.onChange} - onBlur={content.onBlur} - /> - </SectionContent> - </Section> - ); -} - -function TypographySection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTextColor = resolveColorAtTime({ - baseColor: element.color, - animations: element.animations, - propertyPath: "color", - localTime, - }); - - const textColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedTextColor, - buildBaseUpdates: ({ value }) => ({ color: value }), - }); - - const fontSize = usePropertyDraft({ - displayValue: element.fontSize.toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: MIN_FONT_SIZE, - max: MAX_FONT_SIZE, - }); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { fontSize: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:typography`}> - <SectionHeader> - <SectionTitle>Typography</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField label="Font"> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontFamily: value }, - }, - ], - }) - } - /> - </SectionField> - <SectionField label="Size"> - <NumberField - value={fontSize.displayValue} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={fontSize.onFocus} - onChange={fontSize.onChange} - onBlur={fontSize.onBlur} - onScrub={fontSize.scrubTo} - onScrubEnd={fontSize.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontSize: DEFAULTS.text.element.fontSize, - }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULTS.text.element.fontSize} - icon={<HugeiconsIcon icon={TextFontIcon} />} - /> - </SectionField> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={textColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle text color keyframe" - onToggle={textColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={uppercase({ - string: resolvedTextColor.replace("#", ""), - })} - onChange={(color) => textColor.onChange({ color: `#${color}` })} - onChangeEnd={textColor.onChangeEnd} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} - -function SpacingSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const letterSpacing = usePropertyDraft({ - displayValue: Math.round( - element.letterSpacing ?? DEFAULTS.text.letterSpacing, - ).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { letterSpacing: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const lineHeight = usePropertyDraft({ - displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) - ? null - : Math.max(0.1, Math.round(parsed * 10) / 10); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { lineHeight: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:spacing`}> - <SectionHeader> - <SectionTitle>Spacing</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField label="Letter spacing" className="w-1/2"> - <NumberField - value={letterSpacing.displayValue} - onFocus={letterSpacing.onFocus} - onChange={letterSpacing.onChange} - onBlur={letterSpacing.onBlur} - onScrub={letterSpacing.scrubTo} - onScrubEnd={letterSpacing.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { letterSpacing: DEFAULTS.text.letterSpacing }, - }, - ], - }) - } - isDefault={ - (element.letterSpacing ?? DEFAULTS.text.letterSpacing) === - DEFAULTS.text.letterSpacing - } - icon={<OcTextWidthIcon size={14} />} - /> - </SectionField> - <SectionField label="Line height" className="w-1/2"> - <NumberField - value={lineHeight.displayValue} - onFocus={lineHeight.onFocus} - onChange={lineHeight.onChange} - onBlur={lineHeight.onBlur} - onScrub={lineHeight.scrubTo} - onScrubEnd={lineHeight.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { lineHeight: DEFAULTS.text.lineHeight }, - }, - ], - }) - } - isDefault={ - (element.lineHeight ?? DEFAULTS.text.lineHeight) === - DEFAULTS.text.lineHeight - } - icon={<OcTextHeightIcon size={14} />} - /> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} - -function BackgroundSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_COLOR); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedBgColor = resolveColorAtTime({ - baseColor: element.background.color, - animations: element.animations, - propertyPath: "background.color", - localTime, - }); - - const bgColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedBgColor, - buildBaseUpdates: ({ value }) => ({ - background: { ...element.background, color: value }, - }), - }); - - const bg = element.background; - - const resolvedPaddingX = resolveNumberAtTime({ - baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - }); - const resolvedPaddingY = resolveNumberAtTime({ - baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - }); - const resolvedOffsetX = resolveNumberAtTime({ - baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - }); - const resolvedOffsetY = resolveNumberAtTime({ - baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - }); - const resolvedCornerRadius = resolveNumberAtTime({ - baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - }); - - const paddingX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingX: value }, - }), - }); - - const paddingY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingY: value }, - }), - }); - - const offsetX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetX: value }, - }), - }); - - const offsetY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetY: value }, - }), - }); - - const cornerRadius = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedCornerRadius).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - }); - }, - valueAtPlayhead: resolvedCornerRadius, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, cornerRadius: value }, - }), - }); - - const toggleBackgroundEnabled = () => { - const enabled = !element.background.enabled; - const color = - enabled && element.background.color === "transparent" - ? lastSelectedColor.current - : element.background.color; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - background: { - ...element.background, - enabled, - color, - }, - }, - }, - ], - }); - }; - - return ( - <Section - collapsible - defaultOpen={element.background.enabled} - sectionKey={`${element.id}:background`} - > - <SectionHeader - trailing={ - <Button - variant="ghost" - size="icon" - onClick={(event) => { - event.stopPropagation(); - toggleBackgroundEnabled(); - }} - > - <HugeiconsIcon - icon={element.background.enabled ? MinusSignIcon : PlusSignIcon} - strokeWidth={1} - /> - </Button> - } - > - <SectionTitle>Background</SectionTitle> - </SectionHeader> - <SectionContent - className={cn( - !element.background.enabled && "pointer-events-none opacity-50", - )} - > - <SectionFields> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={bgColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background color keyframe" - onToggle={bgColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={ - !element.background.enabled || - element.background.color === "transparent" - ? lastSelectedColor.current.replace("#", "") - : resolvedBgColor.replace("#", "") - } - onChange={(color) => { - const hexColor = `#${color}`; - if (color !== "transparent") { - lastSelectedColor.current = hexColor; - } - bgColor.onChange({ color: hexColor }); - }} - onChangeEnd={bgColor.onChangeEnd} - /> - </SectionField> - <div className="flex items-start gap-2"> - <SectionField - label="Width" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background width keyframe" - onToggle={paddingX.toggleKeyframe} - /> - } - > - <NumberField - icon="W" - value={paddingX.displayValue} - min={0} - onFocus={paddingX.onFocus} - onChange={paddingX.onChange} - onBlur={paddingX.onBlur} - onScrub={paddingX.scrubTo} - onScrubEnd={paddingX.commitScrub} - onReset={() => - paddingX.commitValue({ - value: DEFAULTS.text.background.paddingX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingX, - staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - defaultValue: DEFAULTS.text.background.paddingX, - })} - /> - </SectionField> - <SectionField - label="Height" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background height keyframe" - onToggle={paddingY.toggleKeyframe} - /> - } - > - <NumberField - icon="H" - value={paddingY.displayValue} - min={0} - onFocus={paddingY.onFocus} - onChange={paddingY.onChange} - onBlur={paddingY.onBlur} - onScrub={paddingY.scrubTo} - onScrubEnd={paddingY.commitScrub} - onReset={() => - paddingY.commitValue({ - value: DEFAULTS.text.background.paddingY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingY, - staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - defaultValue: DEFAULTS.text.background.paddingY, - })} - /> - </SectionField> - </div> - <div className="flex items-start gap-2"> - <SectionField - label="X-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle x-offset keyframe" - onToggle={offsetX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={offsetX.displayValue} - onFocus={offsetX.onFocus} - onChange={offsetX.onChange} - onBlur={offsetX.onBlur} - onScrub={offsetX.scrubTo} - onScrubEnd={offsetX.commitScrub} - onReset={() => - offsetX.commitValue({ - value: DEFAULTS.text.background.offsetX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetX, - staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - defaultValue: DEFAULTS.text.background.offsetX, - })} - /> - </SectionField> - <SectionField - label="Y-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle y-offset keyframe" - onToggle={offsetY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={offsetY.displayValue} - onFocus={offsetY.onFocus} - onChange={offsetY.onChange} - onBlur={offsetY.onBlur} - onScrub={offsetY.scrubTo} - onScrubEnd={offsetY.commitScrub} - onReset={() => - offsetY.commitValue({ - value: DEFAULTS.text.background.offsetY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetY, - staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - defaultValue: DEFAULTS.text.background.offsetY, - })} - /> - </SectionField> - </div> - <SectionField - label="Corner radius" - beforeLabel={ - <KeyframeToggle - isActive={cornerRadius.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle corner radius keyframe" - onToggle={cornerRadius.toggleKeyframe} - /> - } - > - <NumberField - icon="R" - value={cornerRadius.displayValue} - min={CORNER_RADIUS_MIN} - max={CORNER_RADIUS_MAX} - onFocus={cornerRadius.onFocus} - onChange={cornerRadius.onChange} - onBlur={cornerRadius.onBlur} - onScrub={cornerRadius.scrubTo} - onScrubEnd={cornerRadius.commitScrub} - onReset={() => - cornerRadius.commitValue({ value: CORNER_RADIUS_MIN }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedCornerRadius, - staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - defaultValue: CORNER_RADIUS_MIN, - })} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} +"use client";
+
+import { Textarea } from "@/components/ui/textarea";
+import { FontPicker } from "@/components/ui/font-picker";
+import type { TextElement } from "@/lib/timeline";
+import { NumberField } from "@/components/ui/number-field";
+import { useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
+import { uppercase } from "@/utils/string";
+import { clamp } from "@/utils/math";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_COLOR } from "@/constants/project-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+ MAX_FONT_SIZE,
+ MIN_FONT_SIZE,
+} from "@/constants/text-constants";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { isPropertyAtDefault } from "./transform-tab";
+import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ MinusSignIcon,
+ PlusSignIcon,
+ TextFontIcon,
+} from "@hugeicons/core-free-icons";
+import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { cn } from "@/utils/ui";
+
+export function TextTab({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ return (
+ <div className="flex flex-col">
+ <ContentSection element={element} trackId={trackId} />
+ <TypographySection element={element} trackId={trackId} />
+ <SpacingSection element={element} trackId={trackId} />
+ <BackgroundSection element={element} trackId={trackId} />
+ </div>
+ );
+}
+
+function ContentSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const content = usePropertyDraft({
+ displayValue: element.content,
+ parse: (input) => input,
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { content: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:content`}>
+ <SectionHeader>
+ <SectionTitle>Content</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <Textarea
+ placeholder="Name"
+ value={content.displayValue}
+ className="min-h-20"
+ onFocus={content.onFocus}
+ onChange={content.onChange}
+ onBlur={content.onBlur}
+ />
+ </SectionContent>
+ </Section>
+ );
+}
+
+function TypographySection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTextColor = resolveColorAtTime({
+ baseColor: element.color,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ });
+
+ const textColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedTextColor,
+ buildBaseUpdates: ({ value }) => ({ color: value }),
+ });
+
+ const fontSize = usePropertyDraft({
+ displayValue: element.fontSize.toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: MIN_FONT_SIZE,
+ max: MAX_FONT_SIZE,
+ });
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { fontSize: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:typography`}>
+ <SectionHeader>
+ <SectionTitle>Typography</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Font">
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value) =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontFamily: value },
+ },
+ ],
+ })
+ }
+ />
+ </SectionField>
+ <SectionField label="Size">
+ <NumberField
+ value={fontSize.displayValue}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ onFocus={fontSize.onFocus}
+ onChange={fontSize.onChange}
+ onBlur={fontSize.onBlur}
+ onScrub={fontSize.scrubTo}
+ onScrubEnd={fontSize.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontSize: DEFAULTS.text.element.fontSize,
+ },
+ },
+ ],
+ })
+ }
+ isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
+ icon={<HugeiconsIcon icon={TextFontIcon} />}
+ />
+ </SectionField>
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={textColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle text color keyframe"
+ onToggle={textColor.toggleKeyframe}
+ />
+ }
+ >
+ <ColorPicker
+ value={uppercase({
+ string: resolvedTextColor.replace("#", ""),
+ })}
+ onChange={(color) => textColor.onChange({ color: `#${color}` })}
+ onChangeEnd={textColor.onChangeEnd}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function SpacingSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const letterSpacing = usePropertyDraft({
+ displayValue: Math.round(
+ element.letterSpacing ?? DEFAULTS.text.letterSpacing,
+ ).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { letterSpacing: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const lineHeight = usePropertyDraft({
+ displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed)
+ ? null
+ : Math.max(0.1, Math.round(parsed * 10) / 10);
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { lineHeight: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:spacing`}>
+ <SectionHeader>
+ <SectionTitle>Spacing</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField label="Letter spacing" className="w-1/2">
+ <NumberField
+ value={letterSpacing.displayValue}
+ onFocus={letterSpacing.onFocus}
+ onChange={letterSpacing.onChange}
+ onBlur={letterSpacing.onBlur}
+ onScrub={letterSpacing.scrubTo}
+ onScrubEnd={letterSpacing.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { letterSpacing: DEFAULTS.text.letterSpacing },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
+ DEFAULTS.text.letterSpacing
+ }
+ icon={<OcTextWidthIcon size={14} />}
+ />
+ </SectionField>
+ <SectionField label="Line height" className="w-1/2">
+ <NumberField
+ value={lineHeight.displayValue}
+ onFocus={lineHeight.onFocus}
+ onChange={lineHeight.onChange}
+ onBlur={lineHeight.onBlur}
+ onScrub={lineHeight.scrubTo}
+ onScrubEnd={lineHeight.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { lineHeight: DEFAULTS.text.lineHeight },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
+ DEFAULTS.text.lineHeight
+ }
+ icon={<OcTextHeightIcon size={14} />}
+ />
+ </SectionField>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function BackgroundSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedBgColor = resolveColorAtTime({
+ baseColor: element.background.color,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ });
+
+ const bgColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedBgColor,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...element.background, color: value },
+ }),
+ });
+
+ const bg = element.background;
+
+ const resolvedPaddingX = resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ });
+ const resolvedPaddingY = resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ });
+ const resolvedOffsetX = resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ });
+ const resolvedOffsetY = resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ });
+ const resolvedCornerRadius = resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ });
+
+ const paddingX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ },
+ valueAtPlayhead: resolvedPaddingX,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingX: value },
+ }),
+ });
+
+ const paddingY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ },
+ valueAtPlayhead: resolvedPaddingY,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingY: value },
+ }),
+ });
+
+ const offsetX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ valueAtPlayhead: resolvedOffsetX,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetX: value },
+ }),
+ });
+
+ const offsetY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ valueAtPlayhead: resolvedOffsetY,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetY: value },
+ }),
+ });
+
+ const cornerRadius = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedCornerRadius).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedCornerRadius,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, cornerRadius: value },
+ }),
+ });
+
+ const toggleBackgroundEnabled = () => {
+ const enabled = !element.background.enabled;
+ const color =
+ enabled && element.background.color === "transparent"
+ ? lastSelectedColor.current
+ : element.background.color;
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: {
+ ...element.background,
+ enabled,
+ color,
+ },
+ },
+ },
+ ],
+ });
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={element.background.enabled}
+ sectionKey={`${element.id}:background`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleBackgroundEnabled();
+ }}
+ >
+ <HugeiconsIcon
+ icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Background</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(
+ !element.background.enabled && "pointer-events-none opacity-50",
+ )}
+ >
+ <SectionFields>
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={bgColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background color keyframe"
+ onToggle={bgColor.toggleKeyframe}
+ />
+ }
+ >
+ <ColorPicker
+ value={
+ !element.background.enabled ||
+ element.background.color === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : resolvedBgColor.replace("#", "")
+ }
+ onChange={(color) => {
+ const hexColor = `#${color}`;
+ if (color !== "transparent") {
+ lastSelectedColor.current = hexColor;
+ }
+ bgColor.onChange({ color: hexColor });
+ }}
+ onChangeEnd={bgColor.onChangeEnd}
+ />
+ </SectionField>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="Width"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background width keyframe"
+ onToggle={paddingX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="W"
+ value={paddingX.displayValue}
+ min={0}
+ onFocus={paddingX.onFocus}
+ onChange={paddingX.onChange}
+ onBlur={paddingX.onBlur}
+ onScrub={paddingX.scrubTo}
+ onScrubEnd={paddingX.commitScrub}
+ onReset={() =>
+ paddingX.commitValue({
+ value: DEFAULTS.text.background.paddingX,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingX,
+ staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ defaultValue: DEFAULTS.text.background.paddingX,
+ })}
+ />
+ </SectionField>
+ <SectionField
+ label="Height"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background height keyframe"
+ onToggle={paddingY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="H"
+ value={paddingY.displayValue}
+ min={0}
+ onFocus={paddingY.onFocus}
+ onChange={paddingY.onChange}
+ onBlur={paddingY.onBlur}
+ onScrub={paddingY.scrubTo}
+ onScrubEnd={paddingY.commitScrub}
+ onReset={() =>
+ paddingY.commitValue({
+ value: DEFAULTS.text.background.paddingY,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingY,
+ staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ defaultValue: DEFAULTS.text.background.paddingY,
+ })}
+ />
+ </SectionField>
+ </div>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="X-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle x-offset keyframe"
+ onToggle={offsetX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="X"
+ value={offsetX.displayValue}
+ onFocus={offsetX.onFocus}
+ onChange={offsetX.onChange}
+ onBlur={offsetX.onBlur}
+ onScrub={offsetX.scrubTo}
+ onScrubEnd={offsetX.commitScrub}
+ onReset={() =>
+ offsetX.commitValue({
+ value: DEFAULTS.text.background.offsetX,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetX,
+ staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ defaultValue: DEFAULTS.text.background.offsetX,
+ })}
+ />
+ </SectionField>
+ <SectionField
+ label="Y-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle y-offset keyframe"
+ onToggle={offsetY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="Y"
+ value={offsetY.displayValue}
+ onFocus={offsetY.onFocus}
+ onChange={offsetY.onChange}
+ onBlur={offsetY.onBlur}
+ onScrub={offsetY.scrubTo}
+ onScrubEnd={offsetY.commitScrub}
+ onReset={() =>
+ offsetY.commitValue({
+ value: DEFAULTS.text.background.offsetY,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetY,
+ staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ defaultValue: DEFAULTS.text.background.offsetY,
+ })}
+ />
+ </SectionField>
+ </div>
+ <SectionField
+ label="Corner radius"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={cornerRadius.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle corner radius keyframe"
+ onToggle={cornerRadius.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="R"
+ value={cornerRadius.displayValue}
+ min={CORNER_RADIUS_MIN}
+ max={CORNER_RADIUS_MAX}
+ onFocus={cornerRadius.onFocus}
+ onChange={cornerRadius.onChange}
+ onBlur={cornerRadius.onBlur}
+ onScrub={cornerRadius.scrubTo}
+ onScrubEnd={cornerRadius.commitScrub}
+ onReset={() =>
+ cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedCornerRadius,
+ staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ defaultValue: CORNER_RADIUS_MIN,
+ })}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx index a015a9bb..288b8349 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx @@ -1,424 +1,424 @@ -import { NumberField } from "@/components/ui/number-field"; -import { useEditor } from "@/hooks/use-editor"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { VisualElement } from "@/lib/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowExpandIcon, - Link05Icon, - RotateClockwiseIcon, -} from "@hugeicons/core-free-icons"; -import { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, - resolveTransformAtTime, -} from "@/lib/animation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property"; -import { usePropertiesStore } from "../stores/properties-store"; - -export function parseNumericInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : parsed; -} - -export function isPropertyAtDefault({ - hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue, - staticValue, - defaultValue, -}: { - hasAnimatedKeyframes: boolean; - isPlayheadWithinElementRange: boolean; - resolvedValue: number; - staticValue: number; - defaultValue: number; -}): boolean { - if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { - return isNearlyEqual({ - leftValue: resolvedValue, - rightValue: defaultValue, - }); - } - - return staticValue === defaultValue; -} - -export function TransformTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const editor = useEditor(); - const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); - const setTransformScaleLocked = usePropertiesStore( - (s) => s.setTransformScaleLocked, - ); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = useKeyframedVectorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.position", - localTime, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position, - displayX: Math.round(resolvedTransform.position.x).toString(), - displayY: Math.round(resolvedTransform.position.y).toString(), - parseComponent: (input) => parseNumericInput({ input }), - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { ...element.transform, position: value }, - }), - }); - - const parseScale = (input: string) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return parsed / 100; - }; - - const scaleX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleX, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleX: value, - ...(isScaleLocked ? { scaleY: value } : {}), - }, - }), - }); - - const scaleY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleY, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleY: value, - ...(isScaleLocked ? { scaleX: value } : {}), - }, - }), - }); - - const scaleFieldPropsX = { - value: scaleX.displayValue, - onFocus: scaleX.onFocus, - onChange: scaleX.onChange, - onBlur: scaleX.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleX.scrubTo, - onScrubEnd: scaleX.commitScrub, - onReset: () => - scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleX, - staticValue: element.transform.scaleX, - defaultValue: DEFAULTS.element.transform.scaleX, - }), - }; - - const scaleFieldPropsY = { - value: scaleY.displayValue, - onFocus: scaleY.onFocus, - onChange: scaleY.onChange, - onBlur: scaleY.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleY.scrubTo, - onScrubEnd: scaleY.commitScrub, - onReset: () => - scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleY, - staticValue: element.transform.scaleY, - defaultValue: DEFAULTS.element.transform.scaleY, - }), - }; - - const rotation = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.rotate", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.rotate).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return clamp({ value: parsed, min: -360, max: 360 }); - }, - valueAtPlayhead: resolvedTransform.rotate, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - rotate: value, - }, - }), - }); - - const hasScaleKeyframe = hasGroupKeyframeAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - - const toggleScaleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - const existing = getGroupKeyframesAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - if (existing.length > 0) { - editor.timeline.removeKeyframes({ - keyframes: existing.map((ref) => ({ - trackId, - elementId: element.id, - ...ref, - })), - }); - return; - } - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleX", - time: localTime, - value: resolvedTransform.scaleX, - }, - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleY", - time: localTime, - value: resolvedTransform.scaleY, - }, - ], - }); - }; - - const scaleLockButton = ( - <Button - type="button" - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setTransformScaleLocked(!isScaleLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - ); - - return ( - <Section collapsible sectionKey={`${element.id}:transform`}> - <SectionHeader> - <SectionTitle>Transform</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <div className="flex items-end gap-2"> - {isScaleLocked ? ( - <> - <SectionField - label="Scale" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={hasScaleKeyframe} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle scale keyframe" - onToggle={toggleScaleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldPropsX} - /> - </SectionField> - {scaleLockButton} - </> - ) : ( - <> - <SectionField - label="Width" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle width scale keyframe" - onToggle={scaleX.toggleKeyframe} - /> - } - > - <NumberField icon="W" {...scaleFieldPropsX} /> - </SectionField> - {scaleLockButton} - <SectionField - label="Height" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle height scale keyframe" - onToggle={scaleY.toggleKeyframe} - /> - } - > - <NumberField icon="H" {...scaleFieldPropsY} /> - </SectionField> - </> - )} - </div> - <SectionField - label="Position" - beforeLabel={ - <KeyframeToggle - isActive={position.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle position keyframe" - onToggle={position.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon="X" - className="flex-1" - value={position.x.displayValue} - onFocus={position.x.onFocus} - onChange={position.x.onChange} - onBlur={position.x.onBlur} - onScrub={position.x.scrubTo} - onScrubEnd={position.x.commitScrub} - onReset={() => - position.commitX({ - value: DEFAULTS.element.transform.position.x, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: position.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.x, - staticValue: element.transform.position.x, - defaultValue: DEFAULTS.element.transform.position.x, - })} - /> - <NumberField - icon="Y" - className="flex-1" - value={position.y.displayValue} - onFocus={position.y.onFocus} - onChange={position.y.onChange} - onBlur={position.y.onBlur} - onScrub={position.y.scrubTo} - onScrubEnd={position.y.commitScrub} - onReset={() => - position.commitY({ - value: DEFAULTS.element.transform.position.y, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: position.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.y, - staticValue: element.transform.position.y, - defaultValue: DEFAULTS.element.transform.position.y, - })} - /> - </div> - </SectionField> - - <SectionField - label="Rotation" - beforeLabel={ - <KeyframeToggle - isActive={rotation.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle rotation keyframe" - onToggle={rotation.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - className="flex-none" - value={rotation.displayValue} - onFocus={rotation.onFocus} - onChange={rotation.onChange} - onBlur={rotation.onBlur} - dragSensitivity="slow" - onScrub={rotation.scrubTo} - onScrubEnd={rotation.commitScrub} - onReset={() => - rotation.commitValue({ - value: DEFAULTS.element.transform.rotate, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.rotate, - staticValue: element.transform.rotate, - defaultValue: DEFAULTS.element.transform.rotate, - })} - /> - </div> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} +import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { VisualElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Link05Icon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
+import { usePropertiesStore } from "../stores/properties-store";
+
+export function parseNumericInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function isPropertyAtDefault({
+ hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ staticValue,
+ defaultValue,
+}: {
+ hasAnimatedKeyframes: boolean;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number;
+ staticValue: number;
+ defaultValue: number;
+}): boolean {
+ if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
+ return isNearlyEqual({
+ leftValue: resolvedValue,
+ rightValue: defaultValue,
+ });
+ }
+
+ return staticValue === defaultValue;
+}
+
+export function TransformTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
+ const setTransformScaleLocked = usePropertiesStore(
+ (s) => s.setTransformScaleLocked,
+ );
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const position = useKeyframedVectorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position,
+ displayX: Math.round(resolvedTransform.position.x).toString(),
+ displayY: Math.round(resolvedTransform.position.y).toString(),
+ parseComponent: (input) => parseNumericInput({ input }),
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: { ...element.transform, position: value },
+ }),
+ });
+
+ const parseScale = (input: string) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return parsed / 100;
+ };
+
+ const scaleX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleX,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleX: value,
+ ...(isScaleLocked ? { scaleY: value } : {}),
+ },
+ }),
+ });
+
+ const scaleY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleY,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleY: value,
+ ...(isScaleLocked ? { scaleX: value } : {}),
+ },
+ }),
+ });
+
+ const scaleFieldPropsX = {
+ value: scaleX.displayValue,
+ onFocus: scaleX.onFocus,
+ onChange: scaleX.onChange,
+ onBlur: scaleX.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleX.scrubTo,
+ onScrubEnd: scaleX.commitScrub,
+ onReset: () =>
+ scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleX,
+ staticValue: element.transform.scaleX,
+ defaultValue: DEFAULTS.element.transform.scaleX,
+ }),
+ };
+
+ const scaleFieldPropsY = {
+ value: scaleY.displayValue,
+ onFocus: scaleY.onFocus,
+ onChange: scaleY.onChange,
+ onBlur: scaleY.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleY.scrubTo,
+ onScrubEnd: scaleY.commitScrub,
+ onReset: () =>
+ scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleY,
+ staticValue: element.transform.scaleY,
+ defaultValue: DEFAULTS.element.transform.scaleY,
+ }),
+ };
+
+ const rotation = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.rotate",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.rotate).toString(),
+ parse: (input) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ valueAtPlayhead: resolvedTransform.rotate,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
+ });
+
+ const hasScaleKeyframe = hasGroupKeyframeAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+
+ const toggleScaleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+ const existing = getGroupKeyframesAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+ if (existing.length > 0) {
+ editor.timeline.removeKeyframes({
+ keyframes: existing.map((ref) => ({
+ trackId,
+ elementId: element.id,
+ ...ref,
+ })),
+ });
+ return;
+ }
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleX",
+ time: localTime,
+ value: resolvedTransform.scaleX,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleY",
+ time: localTime,
+ value: resolvedTransform.scaleY,
+ },
+ ],
+ });
+ };
+
+ const scaleLockButton = (
+ <Button
+ type="button"
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setTransformScaleLocked(!isScaleLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ );
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:transform`}>
+ <SectionHeader>
+ <SectionTitle>Transform</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <div className="flex items-end gap-2">
+ {isScaleLocked ? (
+ <>
+ <SectionField
+ label="Scale"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={hasScaleKeyframe}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle scale keyframe"
+ onToggle={toggleScaleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldPropsX}
+ />
+ </SectionField>
+ {scaleLockButton}
+ </>
+ ) : (
+ <>
+ <SectionField
+ label="Width"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle width scale keyframe"
+ onToggle={scaleX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="W" {...scaleFieldPropsX} />
+ </SectionField>
+ {scaleLockButton}
+ <SectionField
+ label="Height"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle height scale keyframe"
+ onToggle={scaleY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="H" {...scaleFieldPropsY} />
+ </SectionField>
+ </>
+ )}
+ </div>
+ <SectionField
+ label="Position"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={position.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle position keyframe"
+ onToggle={position.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon="X"
+ className="flex-1"
+ value={position.x.displayValue}
+ onFocus={position.x.onFocus}
+ onChange={position.x.onChange}
+ onBlur={position.x.onBlur}
+ onScrub={position.x.scrubTo}
+ onScrubEnd={position.x.commitScrub}
+ onReset={() =>
+ position.commitX({
+ value: DEFAULTS.element.transform.position.x,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULTS.element.transform.position.x,
+ })}
+ />
+ <NumberField
+ icon="Y"
+ className="flex-1"
+ value={position.y.displayValue}
+ onFocus={position.y.onFocus}
+ onChange={position.y.onChange}
+ onBlur={position.y.onBlur}
+ onScrub={position.y.scrubTo}
+ onScrubEnd={position.y.commitScrub}
+ onReset={() =>
+ position.commitY({
+ value: DEFAULTS.element.transform.position.y,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULTS.element.transform.position.y,
+ })}
+ />
+ </div>
+ </SectionField>
+
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ onToggle={rotation.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ className="flex-none"
+ value={rotation.displayValue}
+ onFocus={rotation.onFocus}
+ onChange={rotation.onChange}
+ onBlur={rotation.onBlur}
+ dragSensitivity="slow"
+ onScrub={rotation.scrubTo}
+ onScrubEnd={rotation.commitScrub}
+ onReset={() =>
+ rotation.commitValue({
+ value: DEFAULTS.element.transform.rotate,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.rotate,
+ staticValue: element.transform.rotate,
+ defaultValue: DEFAULTS.element.transform.rotate,
+ })}
+ />
+ </div>
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index 7597a620..a5a145b4 100644 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -1,29 +1,29 @@ -import { getDropLineY } from "@/lib/timeline/drop-utils"; -import type { TimelineTrack, DropTarget } from "@/lib/timeline"; -import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; - -interface DragLineProps { - dropTarget: DropTarget | null; - tracks: TimelineTrack[]; - isVisible: boolean; - headerHeight?: number; -} - -export function DragLine({ - dropTarget, - tracks, - isVisible, - headerHeight = 0, -}: DragLineProps) { - if (!isVisible || !dropTarget) return null; - - const y = getDropLineY({ dropTarget, tracks }); - const lineTop = y + headerHeight; - - return ( - <div - className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5" - style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }} - /> - ); -} +import { getDropLineY } from "@/lib/timeline/drop-utils";
+import type { TimelineTrack, DropTarget } from "@/lib/timeline";
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
+
+interface DragLineProps {
+ dropTarget: DropTarget | null;
+ tracks: TimelineTrack[];
+ isVisible: boolean;
+ headerHeight?: number;
+}
+
+export function DragLine({
+ dropTarget,
+ tracks,
+ isVisible,
+ headerHeight = 0,
+}: DragLineProps) {
+ if (!isVisible || !dropTarget) return null;
+
+ const y = getDropLineY({ dropTarget, tracks });
+ const lineTop = y + headerHeight;
+
+ return (
+ <div
+ className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
+ style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index acb57203..766d0843 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -1,50 +1,50 @@ -"use client"; - -import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { - getCenteredLineLeft, - TIMELINE_INDICATOR_LINE_WIDTH_PX, -} from "@/lib/timeline"; -import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; -interface SnapIndicatorProps { - snapPoint: SnapPoint | null; - zoomLevel: number; - isVisible: boolean; - timelineRef: React.RefObject<HTMLDivElement | null>; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; -} - -export function SnapIndicator({ - snapPoint, - zoomLevel, - isVisible, - timelineRef, - tracksScrollRef, -}: SnapIndicatorProps) { - const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ - snapPoint, - zoomLevel, - timelineRef, - tracksScrollRef, - }); - - if (!isVisible || !snapPoint) { - return null; - } - - return ( - <div - className="pointer-events-none absolute" - style={{ - left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`, - top: topPosition, - height: `${height}px`, - width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, - zIndex: TIMELINE_LAYERS.snapIndicator, - }} - > - <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> - </div> - ); -} +"use client";
+
+import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import {
+ getCenteredLineLeft,
+ TIMELINE_INDICATOR_LINE_WIDTH_PX,
+} from "@/lib/timeline";
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
+interface SnapIndicatorProps {
+ snapPoint: SnapPoint | null;
+ zoomLevel: number;
+ isVisible: boolean;
+ timelineRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+}
+
+export function SnapIndicator({
+ snapPoint,
+ zoomLevel,
+ isVisible,
+ timelineRef,
+ tracksScrollRef,
+}: SnapIndicatorProps) {
+ const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ timelineRef,
+ tracksScrollRef,
+ });
+
+ if (!isVisible || !snapPoint) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
+ top: topPosition,
+ height: `${height}px`,
+ width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.snapIndicator,
+ }}
+ >
+ <div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 13a31375..8960e3a6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -1,934 +1,934 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { useAssetsPanelStore } from "@/stores/assets-panel-store"; -import { AudioWaveform } from "./audio-waveform"; -import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; -import { - useKeyframeDrag, - type KeyframeDragState, -} from "@/hooks/timeline/element/use-keyframe-drag"; -import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { getElementKeyframes } from "@/lib/animation"; -import { - getElementClasses, - getTrackHeight, - canElementHaveAudio, - canElementBeHidden, - hasElementEffects, - hasMediaId, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/lib/timeline"; -import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import type { - TimelineElement as TimelineElementType, - TimelineTrack, - ElementDragState, - VideoElement, - ImageElement, - AudioElement, -} from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; -import { mediaSupportsAudio } from "@/lib/media/media-utils"; -import { - getActionDefinition, - type TAction, - type TActionWithOptionalArgs, - invokeAction, -} from "@/lib/actions"; -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { resolveStickerId } from "@/lib/stickers"; -import { buildGraphicPreviewUrl } from "@/lib/graphics"; -import Image from "next/image"; -import { - ScissorIcon, - Delete02Icon, - Copy01Icon, - ViewIcon, - ViewOffSlashIcon, - VolumeHighIcon, - VolumeOffIcon, - VolumeMute02Icon, - Search01Icon, - Exchange01Icon, - KeyframeIcon, - MagicWand05Icon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { uppercase } from "@/utils/string"; -import type { ComponentProps, ReactNode } from "react"; -import type { - SelectedKeyframeRef, - ElementKeyframe, -} from "@/lib/animation/types"; -import { cn } from "@/utils/ui"; -import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; - -const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; -const ELEMENT_RING_WIDTH_PX = 1.5; -const THUMBNAIL_ASPECT_RATIO = 16 / 9; - -interface KeyframeIndicator { - time: number; - offsetPx: number; - keyframes: SelectedKeyframeRef[]; -} - -export function buildKeyframeIndicator({ - keyframe, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, -}: { - keyframe: ElementKeyframe; - trackId: string; - elementId: string; - displayedStartTime: number; - zoomLevel: number; - elementLeft: number; -}): { - time: number; - offsetPx: number; - keyframeRef: SelectedKeyframeRef; -} { - const keyframeRef = { - trackId, - elementId, - propertyPath: keyframe.propertyPath, - keyframeId: keyframe.id, - }; - const keyframeLeft = timelineTimeToSnappedPixels({ - time: displayedStartTime + keyframe.time, - zoomLevel, - }); - return { - time: keyframe.time, - offsetPx: keyframeLeft - elementLeft, - keyframeRef, - }; -} - -export function getKeyframeIndicators({ - keyframes, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, - elementWidth, -}: { - keyframes: ElementKeyframe[]; - trackId: string; - elementId: string; - displayedStartTime: number; - zoomLevel: number; - elementLeft: number; - elementWidth: number; -}): KeyframeIndicator[] { - if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { - return []; - } - - const keyframesByTime = new Map<number, KeyframeIndicator>(); - for (const keyframe of keyframes) { - const indicator = buildKeyframeIndicator({ - keyframe, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, - }); - const existingIndicator = keyframesByTime.get(indicator.time); - if (!existingIndicator) { - keyframesByTime.set(indicator.time, { - time: indicator.time, - offsetPx: indicator.offsetPx, - keyframes: [indicator.keyframeRef], - }); - continue; - } - - existingIndicator.keyframes.push(indicator.keyframeRef); - } - - return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); -} - -export function getDisplayShortcut({ action }: { action: TAction }) { - const { defaultShortcuts } = getActionDefinition({ action }); - if (!defaultShortcuts?.length) { - return ""; - } - - return uppercase({ - string: defaultShortcuts[0].replace("+", " "), - }); -} - -interface TimelineElementProps { - element: TimelineElementType; - track: TimelineTrack; - zoomLevel: number; - isSelected: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; - onResizeStateChange?: (params: { isResizing: boolean }) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - dragState: ElementDragState; - isDropTarget?: boolean; -} - -export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onSnapPointChange, - onResizeStateChange, - onElementMouseDown, - onElementClick, - dragState, - isDropTarget = false, -}: TimelineElementProps) { - const mediaAssets = useEditor((e) => e.media.getAssets()); - const { selectedElements } = useElementSelection(); - const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); - - let mediaAsset: MediaAsset | null = null; - - if (hasMediaId(element)) { - mediaAsset = - mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; - } - - const hasAudio = mediaSupportsAudio({ media: mediaAsset }); - - const { handleResizeStart, isResizing, currentStartTime, currentDuration } = - useTimelineElementResize({ - element, - track, - zoomLevel, - onSnapPointChange, - onResizeStateChange, - }); - - const isCurrentElementSelected = selectedElements.some( - (selected) => - selected.elementId === element.id && selected.trackId === track.id, - ); - - const isBeingDragged = dragState.elementId === element.id; - const dragOffsetY = - isBeingDragged && dragState.isDragging - ? dragState.currentMouseY - dragState.startMouseY - : 0; - const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime - : element.startTime; - const displayedStartTime = isResizing ? currentStartTime : elementStartTime; - const displayedDuration = isResizing ? currentDuration : element.duration; - const elementWidth = timelineTimeToPixels({ - time: displayedDuration, - zoomLevel, - }); - const elementLeft = timelineTimeToSnappedPixels({ - time: displayedStartTime, - zoomLevel, - }); - const keyframeIndicators = isSelected - ? getKeyframeIndicators({ - keyframes: getElementKeyframes({ animations: element.animations }), - trackId: track.id, - elementId: element.id, - displayedStartTime, - zoomLevel, - elementLeft, - elementWidth, - }) - : []; - - const { - keyframeDragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, - } = useKeyframeDrag({ zoomLevel, element, displayedStartTime }); - const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { - event.stopPropagation(); - if (hasMediaId(element)) { - requestRevealMedia(element.mediaId); - } - }; - - const isMuted = canElementHaveAudio(element) && element.muted === true; - - return ( - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className="absolute top-0 h-full select-none" - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - transform: - isBeingDragged && dragState.isDragging - ? `translate3d(0, ${dragOffsetY}px, 0)` - : undefined, - }} - > - <ElementInner - element={element} - track={track} - isSelected={isSelected} - onElementClick={onElementClick} - onElementMouseDown={onElementMouseDown} - handleResizeStart={handleResizeStart} - isDropTarget={isDropTarget} - /> - {isSelected && ( - <div className="pointer-events-none absolute inset-0 overflow-hidden"> - <KeyframeIndicators - indicators={keyframeIndicators} - dragState={keyframeDragState} - displayedStartTime={displayedStartTime} - elementLeft={elementLeft} - onKeyframeMouseDown={handleKeyframeMouseDown} - onKeyframeClick={handleKeyframeClick} - getVisualOffsetPx={getVisualOffsetPx} - /> - </div> - )} - </div> - </ContextMenuTrigger> - <ContextMenuContent className="w-64"> - <ActionMenuItem - action="split" - icon={<HugeiconsIcon icon={ScissorIcon} />} - > - Split - </ActionMenuItem> - <CopyMenuItem /> - {canElementHaveAudio(element) && hasAudio && ( - <MuteMenuItem - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - isMuted={isMuted} - /> - )} - {canElementBeHidden(element) && ( - <VisibilityMenuItem - element={element} - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - /> - )} - {selectedElements.length === 1 && ( - <ActionMenuItem - action="duplicate-selected" - icon={<HugeiconsIcon icon={Copy01Icon} />} - > - Duplicate - </ActionMenuItem> - )} - {selectedElements.length === 1 && hasMediaId(element) && ( - <> - <ContextMenuItem - icon={<HugeiconsIcon icon={Search01Icon} />} - onClick={(event: React.MouseEvent) => - handleRevealInMedia({ event }) - } - > - Reveal media - </ContextMenuItem> - <ContextMenuItem - icon={<HugeiconsIcon icon={Exchange01Icon} />} - disabled - > - Replace media - </ContextMenuItem> - </> - )} - <ContextMenuSeparator /> - <DeleteMenuItem - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - elementType={element.type} - selectedCount={selectedElements.length} - /> - </ContextMenuContent> - </ContextMenu> - ); -} - -function ElementInner({ - element, - track, - isSelected, - onElementClick, - onElementMouseDown, - handleResizeStart, - isDropTarget = false, -}: { - element: TimelineElementType; - track: TimelineTrack; - isSelected: boolean; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - handleResizeStart: (params: { - event: React.MouseEvent; - elementId: string; - side: "left" | "right"; - }) => void; - isDropTarget?: boolean; -}) { - const isReducedOpacity = - (canElementBeHidden(element) && element.hidden) || isDropTarget; - return ( - <div - className="absolute top-0 bottom-0" - style={{ - left: `${ELEMENT_RING_WIDTH_PX}px`, - right: `${ELEMENT_RING_WIDTH_PX}px`, - }} - > - <div - className="absolute inset-0 rounded-sm" - style={ - isSelected - ? { - boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`, - } - : undefined - } - > - <div - className={cn( - "absolute inset-0 overflow-hidden rounded-sm", - getElementClasses({ type: track.type }), - isReducedOpacity && "opacity-50", - )} - > - <button - type="button" - tabIndex={-1} - className="absolute inset-0 size-full flex flex-col" - onClick={(event) => onElementClick(event, element)} - onMouseDown={(event) => onElementMouseDown(event, element)} - > - <div className="flex flex-1 min-h-0 items-center overflow-hidden"> - <ElementContent element={element} track={track} /> - </div> - </button> - </div> - </div> - - {isSelected && ( - <> - <ResizeHandle - side="left" - elementId={element.id} - handleResizeStart={handleResizeStart} - /> - <ResizeHandle - side="right" - elementId={element.id} - handleResizeStart={handleResizeStart} - /> - </> - )} - </div> - ); -} - -function ResizeHandle({ - side, - elementId, - handleResizeStart, -}: { - side: "left" | "right"; - elementId: string; - handleResizeStart: (params: { - event: React.MouseEvent; - elementId: string; - side: "left" | "right"; - }) => void; -}) { - const isLeft = side === "left"; - return ( - <button - type="button" - className={cn( - "absolute top-0 bottom-0 w-2", - isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize", - )} - onMouseDown={(event) => handleResizeStart({ event, elementId, side })} - onClick={(event) => event.stopPropagation()} - aria-label={`${isLeft ? "Left" : "Right"} resize handle`} - ></button> - ); -} - -function KeyframeIndicators({ - indicators, - dragState, - displayedStartTime, - elementLeft, - onKeyframeMouseDown, - onKeyframeClick, - getVisualOffsetPx, -}: { - indicators: KeyframeIndicator[]; - dragState: KeyframeDragState; - displayedStartTime: number; - elementLeft: number; - onKeyframeMouseDown: (params: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => void; - onKeyframeClick: (params: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; - }) => void; - getVisualOffsetPx: (params: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }) => number; -}) { - const { isKeyframeSelected } = useKeyframeSelection(); - const orderedKeyframes = indicators.flatMap( - (indicator) => indicator.keyframes, - ); - - return indicators.map((indicator) => { - const isIndicatorSelected = indicator.keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - const isBeingDragged = indicator.keyframes.some((keyframe) => - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - const visualOffsetPx = getVisualOffsetPx({ - indicatorTime: indicator.time, - indicatorOffsetPx: indicator.offsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }); - - return ( - <button - key={indicator.time} - type="button" - className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab" - style={{ left: visualOffsetPx }} - onMouseDown={(event) => - onKeyframeMouseDown({ event, keyframes: indicator.keyframes }) - } - onClick={(event) => - onKeyframeClick({ - event, - keyframes: indicator.keyframes, - orderedKeyframes, - indicatorTime: indicator.time, - }) - } - aria-label="Select keyframe" - > - <HugeiconsIcon - icon={KeyframeIcon} - className={cn( - "size-3.5 mt-1.5 text-black", - isIndicatorSelected ? "fill-primary" : "fill-white", - )} - strokeWidth={1.5} - /> - </button> - ); - }); -} - -interface ElementContentProps { - element: TimelineElementType; - track: TimelineTrack; -} - -function TextElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "text" }>; -}) { - return ( - <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white">{element.content}</span> - </div> - ); -} - -function EffectElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "effect" }>; -}) { - return ( - <div className="flex size-full items-center justify-start gap-1 pl-2"> - <HugeiconsIcon - icon={MagicWand05Icon} - className="size-4 shrink-0 text-white" - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); -} - -function StickerElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "sticker" }>; -}) { - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={resolveStickerId({ - stickerId: element.stickerId, - options: { width: 20, height: 20 }, - })} - alt={element.name} - className="size-4 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); -} - -function GraphicElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "graphic" }>; -}) { - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={buildGraphicPreviewUrl({ - definitionId: element.definitionId, - params: element.params, - size: 20, - })} - alt={element.name} - className="size-4 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); -} - -function AudioElementContent({ element }: { element: AudioElement }) { - const mediaAssets = useEditor((e) => e.media.getAssets()); - const mediaAsset = - element.sourceType === "upload" - ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) - : null; - - const audioBuffer = - element.sourceType === "library" ? element.buffer : undefined; - const audioUrl = - element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; - const mediaLabel = mediaAsset?.name ?? element.name; - - if (audioBuffer || audioUrl) { - return ( - <div className="relative size-full"> - <AudioWaveform - audioBuffer={audioBuffer} - audioUrl={audioUrl} - color={ELEMENT_TYPE_CONFIG.audio.waveformColor} - /> - <MediaElementHeader name={mediaLabel} hasFade={false} /> - </div> - ); - } - - return ( - <span className="text-foreground/80 truncate text-xs">{element.name}</span> - ); -} - -function EffectsButton({ - element, - track, -}: { - element: VideoElement | ImageElement; - track: TimelineTrack; -}) { - const editor = useEditor(); - const setActiveTab = usePropertiesStore((s) => s.setActiveTab); - - const handleClick = (event: React.MouseEvent) => { - event.stopPropagation(); - editor.selection.setSelectedElements({ - elements: [{ trackId: track.id, elementId: element.id }], - }); - setActiveTab(element.type, "effects"); - }; - - return ( - <button - type="button" - className="flex shrink-0 justify-center text-white cursor-pointer" - onMouseDown={(event) => event.stopPropagation()} - onClick={handleClick} - > - <HugeiconsIcon icon={MagicWand05Icon} size={12} /> - </button> - ); -} - -function TiledMediaContent({ - element, - track, -}: { - element: VideoElement | ImageElement; - track: TimelineTrack; -}) { - const mediaAssets = useEditor((e) => e.media.getAssets()); - - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - const imageUrl = - element.type === "video" - ? mediaAsset?.thumbnailUrl - : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); - - if (!imageUrl) { - return ( - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> - ); - } - - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; - - return ( - <> - <div - className="absolute inset-0" - style={{ - backgroundColor: "var(--muted)", - backgroundImage: `url(${imageUrl})`, - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - /> - <MediaElementHeader - name={mediaAsset?.name} - leading={ - hasElementEffects({ element }) ? ( - <EffectsButton element={element} track={track} /> - ) : null - } - hasFade={true} - /> - </> - ); -} - -function MediaElementHeader({ - name, - leading, - hasFade, -}: { - name?: string | null; - leading?: ReactNode; - hasFade?: boolean; -}) { - if (!name && !leading) { - return null; - } - - return ( - <div - className={cn( - "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1", - hasFade && "from-black/30 to-transparent", - )} - > - {leading && <div className="pl-1">{leading}</div>} - {name && ( - <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75"> - {name} - </span> - )} - </div> - ); -} - -function ElementContent({ element, track }: ElementContentProps) { - switch (element.type) { - case "text": - return <TextElementContent element={element} />; - case "effect": - return <EffectElementContent element={element} />; - case "sticker": - return <StickerElementContent element={element} />; - case "graphic": - return <GraphicElementContent element={element} />; - case "audio": - return <AudioElementContent element={element} />; - case "video": - case "image": - return <TiledMediaContent element={element} track={track} />; - } -} - -function CopyMenuItem() { - return ( - <ActionMenuItem - action="copy-selected" - icon={<HugeiconsIcon icon={Copy01Icon} />} - > - Copy - </ActionMenuItem> - ); -} - -function MuteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - isMuted, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - isMuted: boolean; -}) { - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return <HugeiconsIcon icon={VolumeMute02Icon} />; - } - return isMuted ? ( - <HugeiconsIcon icon={VolumeOffIcon} /> - ) : ( - <HugeiconsIcon icon={VolumeHighIcon} /> - ); - }; - - return ( - <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}> - {isMuted ? "Unmute" : "Mute"} - </ActionMenuItem> - ); -} - -function VisibilityMenuItem({ - element, - isMultipleSelected, - isCurrentElementSelected, -}: { - element: TimelineElementType; - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; -}) { - const isHidden = canElementBeHidden(element) && element.hidden; - - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return <HugeiconsIcon icon={ViewOffSlashIcon} />; - } - return isHidden ? ( - <HugeiconsIcon icon={ViewIcon} /> - ) : ( - <HugeiconsIcon icon={ViewOffSlashIcon} /> - ); - }; - - return ( - <ActionMenuItem - action="toggle-elements-visibility-selected" - icon={getIcon()} - > - {isHidden ? "Show" : "Hide"} - </ActionMenuItem> - ); -} - -function DeleteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - elementType, - selectedCount, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - elementType: TimelineElementType["type"]; - selectedCount: number; -}) { - return ( - <ActionMenuItem - action="delete-selected" - variant="destructive" - icon={<HugeiconsIcon icon={Delete02Icon} />} - > - {isMultipleSelected && isCurrentElementSelected - ? `Delete ${selectedCount} elements` - : `Delete ${elementType === "text" ? "text" : "clip"}`} - </ActionMenuItem> - ); -} - -function ActionMenuItem({ - action, - children, - ...props -}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { - action: TActionWithOptionalArgs; - children: ReactNode; -}) { - return ( - <ContextMenuItem - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - invokeAction(action); - }} - textRight={getDisplayShortcut({ action })} - {...props} - > - {children} - </ContextMenuItem> - ); -} +"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import { AudioWaveform } from "./audio-waveform";
+import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
+import {
+ useKeyframeDrag,
+ type KeyframeDragState,
+} from "@/hooks/timeline/element/use-keyframe-drag";
+import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import { getElementKeyframes } from "@/lib/animation";
+import {
+ getElementClasses,
+ getTrackHeight,
+ canElementHaveAudio,
+ canElementBeHidden,
+ hasElementEffects,
+ hasMediaId,
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
+import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import type {
+ TimelineElement as TimelineElementType,
+ TimelineTrack,
+ ElementDragState,
+ VideoElement,
+ ImageElement,
+ AudioElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import {
+ getActionDefinition,
+ type TAction,
+ type TActionWithOptionalArgs,
+ invokeAction,
+} from "@/lib/actions";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { resolveStickerId } from "@/lib/stickers";
+import { buildGraphicPreviewUrl } from "@/lib/graphics";
+import Image from "next/image";
+import {
+ ScissorIcon,
+ Delete02Icon,
+ Copy01Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+ VolumeMute02Icon,
+ Search01Icon,
+ Exchange01Icon,
+ KeyframeIcon,
+ MagicWand05Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { uppercase } from "@/utils/string";
+import type { ComponentProps, ReactNode } from "react";
+import type {
+ SelectedKeyframeRef,
+ ElementKeyframe,
+} from "@/lib/animation/types";
+import { cn } from "@/utils/ui";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
+
+const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
+const ELEMENT_RING_WIDTH_PX = 1.5;
+const THUMBNAIL_ASPECT_RATIO = 16 / 9;
+
+interface KeyframeIndicator {
+ time: number;
+ offsetPx: number;
+ keyframes: SelectedKeyframeRef[];
+}
+
+export function buildKeyframeIndicator({
+ keyframe,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+}: {
+ keyframe: ElementKeyframe;
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+}): {
+ time: number;
+ offsetPx: number;
+ keyframeRef: SelectedKeyframeRef;
+} {
+ const keyframeRef = {
+ trackId,
+ elementId,
+ propertyPath: keyframe.propertyPath,
+ keyframeId: keyframe.id,
+ };
+ const keyframeLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime + keyframe.time,
+ zoomLevel,
+ });
+ return {
+ time: keyframe.time,
+ offsetPx: keyframeLeft - elementLeft,
+ keyframeRef,
+ };
+}
+
+export function getKeyframeIndicators({
+ keyframes,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+}: {
+ keyframes: ElementKeyframe[];
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+ elementWidth: number;
+}): KeyframeIndicator[] {
+ if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
+ return [];
+ }
+
+ const keyframesByTime = new Map<number, KeyframeIndicator>();
+ for (const keyframe of keyframes) {
+ const indicator = buildKeyframeIndicator({
+ keyframe,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ });
+ const existingIndicator = keyframesByTime.get(indicator.time);
+ if (!existingIndicator) {
+ keyframesByTime.set(indicator.time, {
+ time: indicator.time,
+ offsetPx: indicator.offsetPx,
+ keyframes: [indicator.keyframeRef],
+ });
+ continue;
+ }
+
+ existingIndicator.keyframes.push(indicator.keyframeRef);
+ }
+
+ return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
+}
+
+export function getDisplayShortcut({ action }: { action: TAction }) {
+ const { defaultShortcuts } = getActionDefinition({ action });
+ if (!defaultShortcuts?.length) {
+ return "";
+ }
+
+ return uppercase({
+ string: defaultShortcuts[0].replace("+", " "),
+ });
+}
+
+interface TimelineElementProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ zoomLevel: number;
+ isSelected: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ dragState: ElementDragState;
+ isDropTarget?: boolean;
+}
+
+export function TimelineElement({
+ element,
+ track,
+ zoomLevel,
+ isSelected,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ dragState,
+ isDropTarget = false,
+}: TimelineElementProps) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const { selectedElements } = useElementSelection();
+ const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
+
+ let mediaAsset: MediaAsset | null = null;
+
+ if (hasMediaId(element)) {
+ mediaAsset =
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ }
+
+ const hasAudio = mediaSupportsAudio({ media: mediaAsset });
+
+ const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
+ useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onSnapPointChange,
+ onResizeStateChange,
+ });
+
+ const isCurrentElementSelected = selectedElements.some(
+ (selected) =>
+ selected.elementId === element.id && selected.trackId === track.id,
+ );
+
+ const isBeingDragged = dragState.elementId === element.id;
+ const dragOffsetY =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentMouseY - dragState.startMouseY
+ : 0;
+ const elementStartTime =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentTime
+ : element.startTime;
+ const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
+ const displayedDuration = isResizing ? currentDuration : element.duration;
+ const elementWidth = timelineTimeToPixels({
+ time: displayedDuration,
+ zoomLevel,
+ });
+ const elementLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime,
+ zoomLevel,
+ });
+ const keyframeIndicators = isSelected
+ ? getKeyframeIndicators({
+ keyframes: getElementKeyframes({ animations: element.animations }),
+ trackId: track.id,
+ elementId: element.id,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+ })
+ : [];
+
+ const {
+ keyframeDragState,
+ handleKeyframeMouseDown,
+ handleKeyframeClick,
+ getVisualOffsetPx,
+ } = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
+ const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
+ event.stopPropagation();
+ if (hasMediaId(element)) {
+ requestRevealMedia(element.mediaId);
+ }
+ };
+
+ const isMuted = canElementHaveAudio(element) && element.muted === true;
+
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ className="absolute top-0 h-full select-none"
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ transform:
+ isBeingDragged && dragState.isDragging
+ ? `translate3d(0, ${dragOffsetY}px, 0)`
+ : undefined,
+ }}
+ >
+ <ElementInner
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ onElementClick={onElementClick}
+ onElementMouseDown={onElementMouseDown}
+ handleResizeStart={handleResizeStart}
+ isDropTarget={isDropTarget}
+ />
+ {isSelected && (
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
+ <KeyframeIndicators
+ indicators={keyframeIndicators}
+ dragState={keyframeDragState}
+ displayedStartTime={displayedStartTime}
+ elementLeft={elementLeft}
+ onKeyframeMouseDown={handleKeyframeMouseDown}
+ onKeyframeClick={handleKeyframeClick}
+ getVisualOffsetPx={getVisualOffsetPx}
+ />
+ </div>
+ )}
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-64">
+ <ActionMenuItem
+ action="split"
+ icon={<HugeiconsIcon icon={ScissorIcon} />}
+ >
+ Split
+ </ActionMenuItem>
+ <CopyMenuItem />
+ {canElementHaveAudio(element) && hasAudio && (
+ <MuteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canElementBeHidden(element) && (
+ <VisibilityMenuItem
+ element={element}
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {selectedElements.length === 1 && (
+ <ActionMenuItem
+ action="duplicate-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Duplicate
+ </ActionMenuItem>
+ )}
+ {selectedElements.length === 1 && hasMediaId(element) && (
+ <>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Search01Icon} />}
+ onClick={(event: React.MouseEvent) =>
+ handleRevealInMedia({ event })
+ }
+ >
+ Reveal media
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Exchange01Icon} />}
+ disabled
+ >
+ Replace media
+ </ContextMenuItem>
+ </>
+ )}
+ <ContextMenuSeparator />
+ <DeleteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ elementType={element.type}
+ selectedCount={selectedElements.length}
+ />
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+}
+
+function ElementInner({
+ element,
+ track,
+ isSelected,
+ onElementClick,
+ onElementMouseDown,
+ handleResizeStart,
+ isDropTarget = false,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ onElementClick: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementMouseDown: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ handleResizeStart: (params: {
+ event: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+ isDropTarget?: boolean;
+}) {
+ const isReducedOpacity =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget;
+ return (
+ <div
+ className="absolute top-0 bottom-0"
+ style={{
+ left: `${ELEMENT_RING_WIDTH_PX}px`,
+ right: `${ELEMENT_RING_WIDTH_PX}px`,
+ }}
+ >
+ <div
+ className="absolute inset-0 rounded-sm"
+ style={
+ isSelected
+ ? {
+ boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
+ }
+ : undefined
+ }
+ >
+ <div
+ className={cn(
+ "absolute inset-0 overflow-hidden rounded-sm",
+ getElementClasses({ type: track.type }),
+ isReducedOpacity && "opacity-50",
+ )}
+ >
+ <button
+ type="button"
+ tabIndex={-1}
+ className="absolute inset-0 size-full flex flex-col"
+ onClick={(event) => onElementClick(event, element)}
+ onMouseDown={(event) => onElementMouseDown(event, element)}
+ >
+ <div className="flex flex-1 min-h-0 items-center overflow-hidden">
+ <ElementContent element={element} track={track} />
+ </div>
+ </button>
+ </div>
+ </div>
+
+ {isSelected && (
+ <>
+ <ResizeHandle
+ side="left"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ <ResizeHandle
+ side="right"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ </>
+ )}
+ </div>
+ );
+}
+
+function ResizeHandle({
+ side,
+ elementId,
+ handleResizeStart,
+}: {
+ side: "left" | "right";
+ elementId: string;
+ handleResizeStart: (params: {
+ event: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ const isLeft = side === "left";
+ return (
+ <button
+ type="button"
+ className={cn(
+ "absolute top-0 bottom-0 w-2",
+ isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
+ )}
+ onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
+ onClick={(event) => event.stopPropagation()}
+ aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
+ ></button>
+ );
+}
+
+function KeyframeIndicators({
+ indicators,
+ dragState,
+ displayedStartTime,
+ elementLeft,
+ onKeyframeMouseDown,
+ onKeyframeClick,
+ getVisualOffsetPx,
+}: {
+ indicators: KeyframeIndicator[];
+ dragState: KeyframeDragState;
+ displayedStartTime: number;
+ elementLeft: number;
+ onKeyframeMouseDown: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => void;
+ onKeyframeClick: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => void;
+ getVisualOffsetPx: (params: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }) => number;
+}) {
+ const { isKeyframeSelected } = useKeyframeSelection();
+ const orderedKeyframes = indicators.flatMap(
+ (indicator) => indicator.keyframes,
+ );
+
+ return indicators.map((indicator) => {
+ const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ const isBeingDragged = indicator.keyframes.some((keyframe) =>
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
+ );
+ const visualOffsetPx = getVisualOffsetPx({
+ indicatorTime: indicator.time,
+ indicatorOffsetPx: indicator.offsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ });
+
+ return (
+ <button
+ key={indicator.time}
+ type="button"
+ className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab"
+ style={{ left: visualOffsetPx }}
+ onMouseDown={(event) =>
+ onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
+ }
+ onClick={(event) =>
+ onKeyframeClick({
+ event,
+ keyframes: indicator.keyframes,
+ orderedKeyframes,
+ indicatorTime: indicator.time,
+ })
+ }
+ aria-label="Select keyframe"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(
+ "size-3.5 mt-1.5 text-black",
+ isIndicatorSelected ? "fill-primary" : "fill-white",
+ )}
+ strokeWidth={1.5}
+ />
+ </button>
+ );
+ });
+}
+
+interface ElementContentProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+}
+
+function TextElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "text" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start pl-2">
+ <span className="truncate text-xs text-white">{element.content}</span>
+ </div>
+ );
+}
+
+function EffectElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "effect" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start gap-1 pl-2">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-4 shrink-0 text-white"
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function StickerElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "sticker" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={resolveStickerId({
+ stickerId: element.stickerId,
+ options: { width: 20, height: 20 },
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function GraphicElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "graphic" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={buildGraphicPreviewUrl({
+ definitionId: element.definitionId,
+ params: element.params,
+ size: 20,
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function AudioElementContent({ element }: { element: AudioElement }) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const mediaAsset =
+ element.sourceType === "upload"
+ ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
+ : null;
+
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+ const audioUrl =
+ element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
+ const mediaLabel = mediaAsset?.name ?? element.name;
+
+ if (audioBuffer || audioUrl) {
+ return (
+ <div className="relative size-full">
+ <AudioWaveform
+ audioBuffer={audioBuffer}
+ audioUrl={audioUrl}
+ color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
+ />
+ <MediaElementHeader name={mediaLabel} hasFade={false} />
+ </div>
+ );
+ }
+
+ return (
+ <span className="text-foreground/80 truncate text-xs">{element.name}</span>
+ );
+}
+
+function EffectsButton({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const editor = useEditor();
+ const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
+
+ const handleClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ editor.selection.setSelectedElements({
+ elements: [{ trackId: track.id, elementId: element.id }],
+ });
+ setActiveTab(element.type, "effects");
+ };
+
+ return (
+ <button
+ type="button"
+ className="flex shrink-0 justify-center text-white cursor-pointer"
+ onMouseDown={(event) => event.stopPropagation()}
+ onClick={handleClick}
+ >
+ <HugeiconsIcon icon={MagicWand05Icon} size={12} />
+ </button>
+ );
+}
+
+function TiledMediaContent({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ const imageUrl =
+ element.type === "video"
+ ? mediaAsset?.thumbnailUrl
+ : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
+
+ if (!imageUrl) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
+
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
+
+ return (
+ <>
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundColor: "var(--muted)",
+ backgroundImage: `url(${imageUrl})`,
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ />
+ <MediaElementHeader
+ name={mediaAsset?.name}
+ leading={
+ hasElementEffects({ element }) ? (
+ <EffectsButton element={element} track={track} />
+ ) : null
+ }
+ hasFade={true}
+ />
+ </>
+ );
+}
+
+function MediaElementHeader({
+ name,
+ leading,
+ hasFade,
+}: {
+ name?: string | null;
+ leading?: ReactNode;
+ hasFade?: boolean;
+}) {
+ if (!name && !leading) {
+ return null;
+ }
+
+ return (
+ <div
+ className={cn(
+ "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1",
+ hasFade && "from-black/30 to-transparent",
+ )}
+ >
+ {leading && <div className="pl-1">{leading}</div>}
+ {name && (
+ <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
+ {name}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function ElementContent({ element, track }: ElementContentProps) {
+ switch (element.type) {
+ case "text":
+ return <TextElementContent element={element} />;
+ case "effect":
+ return <EffectElementContent element={element} />;
+ case "sticker":
+ return <StickerElementContent element={element} />;
+ case "graphic":
+ return <GraphicElementContent element={element} />;
+ case "audio":
+ return <AudioElementContent element={element} />;
+ case "video":
+ case "image":
+ return <TiledMediaContent element={element} track={track} />;
+ }
+}
+
+function CopyMenuItem() {
+ return (
+ <ActionMenuItem
+ action="copy-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Copy
+ </ActionMenuItem>
+ );
+}
+
+function MuteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ isMuted,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ isMuted: boolean;
+}) {
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={VolumeMute02Icon} />;
+ }
+ return isMuted ? (
+ <HugeiconsIcon icon={VolumeOffIcon} />
+ ) : (
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
+ {isMuted ? "Unmute" : "Mute"}
+ </ActionMenuItem>
+ );
+}
+
+function VisibilityMenuItem({
+ element,
+ isMultipleSelected,
+ isCurrentElementSelected,
+}: {
+ element: TimelineElementType;
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+}) {
+ const isHidden = canElementBeHidden(element) && element.hidden;
+
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={ViewOffSlashIcon} />;
+ }
+ return isHidden ? (
+ <HugeiconsIcon icon={ViewIcon} />
+ ) : (
+ <HugeiconsIcon icon={ViewOffSlashIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem
+ action="toggle-elements-visibility-selected"
+ icon={getIcon()}
+ >
+ {isHidden ? "Show" : "Hide"}
+ </ActionMenuItem>
+ );
+}
+
+function DeleteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ elementType,
+ selectedCount,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ elementType: TimelineElementType["type"];
+ selectedCount: number;
+}) {
+ return (
+ <ActionMenuItem
+ action="delete-selected"
+ variant="destructive"
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ >
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedCount} elements`
+ : `Delete ${elementType === "text" ? "text" : "clip"}`}
+ </ActionMenuItem>
+ );
+}
+
+function ActionMenuItem({
+ action,
+ children,
+ ...props
+}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
+ action: TActionWithOptionalArgs;
+ children: ReactNode;
+}) {
+ return (
+ <ContextMenuItem
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction(action);
+ }}
+ textRight={getDisplayShortcut({ action })}
+ {...props}
+ >
+ {children}
+ </ContextMenuItem>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 29fd513b..7d9a4bc2 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx @@ -1,39 +1,39 @@ -"use client"; - -import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; - -interface TimelineTickProps { - time: number; - zoomLevel: number; - fps: number; - showLabel: boolean; -} - -export function TimelineTick({ - time, - zoomLevel, - fps, - showLabel, -}: TimelineTickProps) { - const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel }); - - if (showLabel) { - const label = formatRulerLabel({ timeInSeconds: time, fps }); - return ( - <span - className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none" - style={{ left: `${leftPosition}px` }} - > - {label} - </span> - ); - } - - return ( - <div - className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l" - style={{ left: `${leftPosition}px` }} - /> - ); -} +"use client";
+
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
+import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
+
+interface TimelineTickProps {
+ time: number;
+ zoomLevel: number;
+ fps: number;
+ showLabel: boolean;
+}
+
+export function TimelineTick({
+ time,
+ zoomLevel,
+ fps,
+ showLabel,
+}: TimelineTickProps) {
+ const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
+
+ if (showLabel) {
+ const label = formatRulerLabel({ timeInSeconds: time, fps });
+ return (
+ <span
+ className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
+ style={{ left: `${leftPosition}px` }}
+ >
+ {label}
+ </span>
+ );
+ }
+
+ return (
+ <div
+ className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
+ style={{ left: `${leftPosition}px` }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 6a9e4dfe..ffc2007b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -1,128 +1,128 @@ -"use client"; - -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { TimelineElement } from "./timeline-element"; -import type { TimelineTrack } from "@/lib/timeline"; -import type { TimelineElement as TimelineElementType } from "@/lib/timeline"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants"; -import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import type { ElementDragState } from "@/lib/timeline"; -import { useEditor } from "@/hooks/use-editor"; - -interface TimelineTrackContentProps { - track: TimelineTrack; - zoomLevel: number; - dragState: ElementDragState; - rulerScrollRef: React.RefObject<HTMLDivElement | null>; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; - lastMouseXRef: React.RefObject<number>; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; - onResizeStateChange?: (params: { isResizing: boolean }) => void; - onElementMouseDown: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - }) => void; - onElementClick: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - }) => void; - onTrackMouseDown?: (event: React.MouseEvent) => void; - onTrackMouseUp?: (event: React.MouseEvent) => void; - shouldIgnoreClick?: () => boolean; - targetElementId?: string | null; -} - -export function TimelineTrackContent({ - track, - zoomLevel, - dragState, - rulerScrollRef, - tracksScrollRef, - lastMouseXRef, - onSnapPointChange, - onResizeStateChange, - onElementMouseDown, - onElementClick, - onTrackMouseDown, - onTrackMouseUp, - shouldIgnoreClick, - targetElementId = null, -}: TimelineTrackContentProps) { - const { isElementSelected } = useElementSelection(); - const duration = useEditor((e) => e.timeline.getTotalDuration()); - - useEdgeAutoScroll({ - isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current ?? 0, - rulerScrollRef, - tracksScrollRef, - contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, - }); - - return ( - <div className="relative size-full"> - <button - type="button" - className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0" - aria-label={`Select ${track.name} track`} - onMouseUp={(event) => { - if (shouldIgnoreClick?.()) return; - onTrackMouseUp?.(event); - }} - onMouseDown={(event) => { - event.preventDefault(); - onTrackMouseDown?.(event); - }} - /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} - <div - className="relative h-full min-w-full" - style={{ zIndex: TIMELINE_LAYERS.trackContent }} - onMouseUp={(event) => { - if (event.target !== event.currentTarget) return; - if (shouldIgnoreClick?.()) return; - onTrackMouseUp?.(event); - }} - onMouseDown={(event) => { - if (event.target !== event.currentTarget) return; - event.preventDefault(); - onTrackMouseDown?.(event); - }} - > - {track.elements.length === 0 ? ( - <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> - ) : ( - track.elements.map((element) => { - const isSelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - - return ( - <TimelineElement - key={element.id} - element={element} - track={track} - zoomLevel={zoomLevel} - isSelected={isSelected} - onSnapPointChange={onSnapPointChange} - onResizeStateChange={onResizeStateChange} - onElementMouseDown={(event, element) => - onElementMouseDown({ event, element, track }) - } - onElementClick={(event, element) => - onElementClick({ event, element, track }) - } - dragState={dragState} - isDropTarget={element.id === targetElementId} - /> - ); - }) - )} - </div> - </div> - ); -} +"use client";
+
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { TimelineElement } from "./timeline-element";
+import type { TimelineTrack } from "@/lib/timeline";
+import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
+import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import type { ElementDragState } from "@/lib/timeline";
+import { useEditor } from "@/hooks/use-editor";
+
+interface TimelineTrackContentProps {
+ track: TimelineTrack;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ lastMouseXRef: React.RefObject<number>;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onElementClick: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onTrackMouseDown?: (event: React.MouseEvent) => void;
+ onTrackMouseUp?: (event: React.MouseEvent) => void;
+ shouldIgnoreClick?: () => boolean;
+ targetElementId?: string | null;
+}
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+ dragState,
+ rulerScrollRef,
+ tracksScrollRef,
+ lastMouseXRef,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ onTrackMouseUp,
+ shouldIgnoreClick,
+ targetElementId = null,
+}: TimelineTrackContentProps) {
+ const { isElementSelected } = useElementSelection();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
+
+ useEdgeAutoScroll({
+ isActive: dragState.isDragging,
+ getMouseClientX: () => lastMouseXRef.current ?? 0,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ });
+
+ return (
+ <div className="relative size-full">
+ <button
+ type="button"
+ className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
+ aria-label={`Select ${track.name} track`}
+ onMouseUp={(event) => {
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
+ <div
+ className="relative h-full min-w-full"
+ style={{ zIndex: TIMELINE_LAYERS.trackContent }}
+ onMouseUp={(event) => {
+ if (event.target !== event.currentTarget) return;
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ >
+ {track.elements.length === 0 ? (
+ <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
+ ) : (
+ track.elements.map((element) => {
+ const isSelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+
+ return (
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onSnapPointChange={onSnapPointChange}
+ onResizeStateChange={onResizeStateChange}
+ onElementMouseDown={(event, element) =>
+ onElementMouseDown({ event, element, track })
+ }
+ onElementClick={(event, element) =>
+ onElementClick({ event, element, track })
+ }
+ dragState={dragState}
+ isDropTarget={element.id === targetElementId}
+ />
+ );
+ })
+ )}
+ </div>
+ </div>
+ );
+}
|
