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 { Image, Music, Plus, Upload, Video } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DragOverlay } from "@/components/ui/drag-overlay";
|
import { DragOverlay } from "@/components/ui/drag-overlay";
|
||||||
import {
|
import {
|
||||||
@ -24,6 +23,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||||
|
|
||||||
export function MediaView() {
|
export function MediaView() {
|
||||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||||
@ -98,19 +98,6 @@ export function MediaView() {
|
|||||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
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);
|
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -140,7 +127,7 @@ export function MediaView() {
|
|||||||
<img
|
<img
|
||||||
src={item.url}
|
src={item.url}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="max-w-full max-h-full object-contain rounded"
|
className="max-w-full max-h-full object-contain"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -293,33 +280,19 @@ export function MediaView() {
|
|||||||
>
|
>
|
||||||
{/* Render each media item as a draggable button */}
|
{/* Render each media item as a draggable button */}
|
||||||
{filteredMediaItems.map((item) => (
|
{filteredMediaItems.map((item) => (
|
||||||
<div key={item.id} className="relative group">
|
<ContextMenu key={item.id}>
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<Button
|
<DraggableMediaItem
|
||||||
variant="outline"
|
name={item.name}
|
||||||
className="flex flex-col gap-1 p-2 h-auto w-full relative border-none !bg-transparent cursor-default"
|
preview={renderPreview(item)}
|
||||||
>
|
dragData={{
|
||||||
<AspectRatio
|
id: item.id,
|
||||||
ratio={16 / 9}
|
type: item.type,
|
||||||
className="bg-accent"
|
name: item.name,
|
||||||
draggable={true}
|
}}
|
||||||
onDragStart={(e: React.DragEvent) =>
|
showPlusOnDrag={false}
|
||||||
startDrag(e, item)
|
rounded={false}
|
||||||
}
|
/>
|
||||||
>
|
|
||||||
{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>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||||
@ -331,7 +304,6 @@ export function MediaView() {
|
|||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -1,11 +1,25 @@
|
|||||||
import { Card } from "@/components/ui/card";
|
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||||
|
|
||||||
export function TextView() {
|
export function TextView() {
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<Card className="flex items-center justify-center size-24">
|
<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>
|
<span className="text-xs select-none">Default text</span>
|
||||||
</Card>
|
</div>
|
||||||
|
}
|
||||||
|
dragData={{
|
||||||
|
id: "default-text",
|
||||||
|
type: "text",
|
||||||
|
name: "Default text",
|
||||||
|
content: "Default text",
|
||||||
|
}}
|
||||||
|
aspectRatio={1}
|
||||||
|
className="w-24"
|
||||||
|
showLabel={false}
|
||||||
|
/>
|
||||||
</div>
|
</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