refactor: new reusable draggable-item component and use it

This commit is contained in:
Maze Winther
2025-07-04 01:30:24 +02:00
parent fb9f47117c
commit 4728884931
3 changed files with 194 additions and 58 deletions

View File

@ -7,7 +7,6 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { Image, Music, Plus, Upload, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { DragOverlay } from "@/components/ui/drag-overlay";
import {
@ -24,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
@ -98,19 +98,6 @@ export function MediaView() {
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const startDrag = (e: React.DragEvent, item: MediaItem) => {
// When dragging a media item, set drag data for timeline to read
e.dataTransfer.setData(
"application/x-media-item",
JSON.stringify({
id: item.id,
type: item.type,
name: item.name,
})
);
e.dataTransfer.effectAllowed = "copy";
};
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
useEffect(() => {
@ -140,7 +127,7 @@ export function MediaView() {
<img
src={item.url}
alt={item.name}
className="max-w-full max-h-full object-contain rounded"
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</div>
@ -293,45 +280,30 @@ export function MediaView() {
>
{/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<div key={item.id} className="relative group">
<ContextMenu>
<ContextMenuTrigger asChild>
<Button
variant="outline"
className="flex flex-col gap-1 p-2 h-auto w-full relative border-none !bg-transparent cursor-default"
>
<AspectRatio
ratio={16 / 9}
className="bg-accent"
draggable={true}
onDragStart={(e: React.DragEvent) =>
startDrag(e, item)
}
>
{renderPreview(item)}
</AspectRatio>
<span
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
aria-label={item.name}
title={item.name}
>
{item.name.length > 8
? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
: item.name}
</span>
</Button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={(e) => handleRemove(e, item.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
<ContextMenu key={item.id}>
<ContextMenuTrigger asChild>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
rounded={false}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={(e) => handleRemove(e, item.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}

View File

@ -1,11 +1,25 @@
import { Card } from "@/components/ui/card";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
export function TextView() {
return (
<div className="p-4">
<Card className="flex items-center justify-center size-24">
<span className="text-xs select-none">Default text</span>
</Card>
<DraggableMediaItem
name="Default text"
preview={
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
<span className="text-xs select-none">Default text</span>
</div>
}
dragData={{
id: "default-text",
type: "text",
name: "Default text",
content: "Default text",
}}
aspectRatio={1}
className="w-24"
showLabel={false}
/>
</div>
);
}

View File

@ -0,0 +1,150 @@
"use client";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { ReactNode, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
dragData: Record<string, any>;
onDragStart?: (e: React.DragEvent) => void;
aspectRatio?: number;
className?: string;
showPlusOnDrag?: boolean;
showLabel?: boolean;
rounded?: boolean;
}
export function DraggableMediaItem({
name,
preview,
dragData,
onDragStart,
aspectRatio = 16 / 9,
className = "",
showPlusOnDrag = true,
showLabel = true,
rounded = true,
}: DraggableMediaItemProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
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) => {
// Hide the default ghost image
const emptyImg = new Image();
emptyImg.src =
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
e.dataTransfer.setDragImage(emptyImg, 0, 0);
// Set drag data
e.dataTransfer.setData(
"application/x-media-item",
JSON.stringify(dragData)
);
e.dataTransfer.effectAllowed = "copy";
// Set initial position and show custom drag preview
setDragPosition({ x: e.clientX, y: e.clientY });
setIsDragging(true);
onDragStart?.(e);
};
const handleDragEnd = () => {
setIsDragging(false);
};
return (
<>
<div ref={dragRef} className="relative group">
<Button
variant="outline"
className={`flex flex-col gap-1 p-0 h-auto w-full relative border-none !bg-transparent cursor-default ${className}`}
>
<AspectRatio
ratio={aspectRatio}
className={cn(
"bg-accent relative overflow-hidden",
rounded && "rounded-md"
)}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{preview}
{!isDragging && (
<PlusButton className="opacity-0 group-hover:opacity-100" />
)}
</AspectRatio>
{showLabel && (
<span
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
aria-label={name}
title={name}
>
{name.length > 8
? `${name.slice(0, 16)}...${name.slice(-3)}`
: name}
</span>
)}
</Button>
</div>
{/* Custom drag preview */}
{isDragging &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed pointer-events-none z-[9999]"
style={{
left: dragPosition.x - 40, // Center the preview (half of 80px)
top: dragPosition.y - 40, // Center the preview (half of 80px)
}}
>
<div className="w-[80px]">
<AspectRatio
ratio={1}
className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary"
>
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
{preview}
</div>
{showPlusOnDrag && <PlusButton />}
</AspectRatio>
</div>
</div>,
document.body
)}
</>
);
}
function PlusButton({ className }: { className?: string }) {
return (
<Button
size="icon"
className={cn("absolute bottom-2 right-2 size-4", className)}
>
<Plus className="!size-3" />
</Button>
);
}