refactor: new reusable draggable-item component and use it
This commit is contained in:
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
150
apps/web/src/components/ui/draggable-item.tsx
Normal file
150
apps/web/src/components/ui/draggable-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user