summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/assets
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
commit6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 (patch)
tree976d60dbe1ac8e44d6a9259f9d6e20fe6530c698 /apps/web/src/components/editor/panels/assets
parent0dffefdd59d8c70fece63436e8556f8ec1e43a49 (diff)
downloadopencut-6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692.tar.gz
refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor
Diffstat (limited to 'apps/web/src/components/editor/panels/assets')
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx479
1 files changed, 240 insertions, 239 deletions
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 2f8bc64a..7cf89fbe 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,240 @@
-"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 "@/editor/use-editor";
-import { clearDragData, setDragData } from "@/timeline/drag-data";
-import type { TimelineDragData } from "@/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 "@/editor/use-editor";
+import type { TimelineDragData } from "@/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 = (event: React.DragEvent) => {
+ event.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ editor.timeline.dragSource.begin({
+ dataTransfer: event.dataTransfer,
+ dragData,
+ });
+
+ setDragPosition({ x: event.clientX, y: event.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e: event });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ editor.timeline.dragSource.end();
+ };
+
+ 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;
+}