Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/web/src/components/editor/media-panel/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,16 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
interface MediaPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
highlightMediaId: string | null;
requestRevealMedia: (mediaId: string) => void;
clearHighlight: () => void;
}

export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
activeTab: "media",
setActiveTab: (tab) => set({ activeTab: tab }),
highlightMediaId: null,
requestRevealMedia: (mediaId) =>
set({ activeTab: "media", highlightMediaId: mediaId }),
clearHighlight: () => set({ highlightMediaId: null }),
}));
110 changes: 61 additions & 49 deletions apps/web/src/components/editor/media-panel/views/media.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import {
List,
Loader2,
Music,
Search,
Video,
} from "lucide-react";
import { useEffect, useRef, useState, useMemo } from "react";
import { useRef, useState, useMemo, useEffect } from "react";
import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
Expand All @@ -33,14 +33,14 @@ import {
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePanelStore } from "@/stores/panel-store";
import { useMediaPanelStore } from "../store";

function MediaItemWithContextMenu({
item,
Expand Down Expand Up @@ -80,6 +80,11 @@ export function MediaView() {
"name"
);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const { highlightMediaId, clearHighlight } = useMediaPanelStore();
const { highlightedId, registerElement } = useHighlightScroll(
highlightMediaId,
clearHighlight
);

const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
Expand Down Expand Up @@ -206,7 +211,7 @@ export function MediaView() {
<img
src={item.url}
alt={item.name}
className="max-w-full max-h-full object-contain"
className="w-full max-h-full object-cover"
loading="lazy"
/>
</div>
Expand Down Expand Up @@ -435,7 +440,7 @@ export function MediaView() {
</div>
</div>

<div className="h-full w-full overflow-y-auto scrollbar-thin">
<div className="h-full w-full overflow-y-auto scrollbar-thin pt-1">
<div className="flex-1 p-3 pt-0 w-full">
{isDragOver || filteredMediaItems.length === 0 ? (
<MediaDragOverlay
Expand All @@ -450,13 +455,16 @@ export function MediaView() {
filteredMediaItems={filteredMediaItems}
renderPreview={renderPreview}
handleRemove={handleRemove}
highlightedId={highlightedId}
registerElement={registerElement}
/>
) : (
<ListView
filteredMediaItems={filteredMediaItems}
renderPreview={renderPreview}
handleRemove={handleRemove}
formatDuration={formatDuration}
highlightedId={highlightedId}
registerElement={registerElement}
/>
)}
</div>
Expand All @@ -470,10 +478,14 @@ function GridView({
filteredMediaItems,
renderPreview,
handleRemove,
highlightedId,
registerElement,
}: {
filteredMediaItems: MediaItem[];
renderPreview: (item: MediaItem) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
return (
<div
Expand All @@ -483,27 +495,26 @@ function GridView({
}}
>
{filteredMediaItems.map((item) => (
<MediaItemWithContextMenu
key={item.id}
item={item}
onRemove={handleRemove}
>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addMediaAtTime(item, currentTime)
}
rounded={false}
variant="card"
/>
</MediaItemWithContextMenu>
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addMediaAtTime(item, currentTime)
}
rounded={false}
variant="card"
isHighlighted={highlightedId === item.id}
/>
</MediaItemWithContextMenu>
</div>
))}
</div>
);
Expand All @@ -513,36 +524,37 @@ function ListView({
filteredMediaItems,
renderPreview,
handleRemove,
formatDuration,
highlightedId,
registerElement,
}: {
filteredMediaItems: MediaItem[];
renderPreview: (item: MediaItem) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
formatDuration: (duration: number) => string;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
return (
<div className="space-y-1">
{filteredMediaItems.map((item) => (
<MediaItemWithContextMenu
key={item.id}
item={item}
onRemove={handleRemove}
>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addMediaAtTime(item, currentTime)
}
variant="compact"
/>
</MediaItemWithContextMenu>
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addMediaAtTime(item, currentTime)
}
variant="compact"
isHighlighted={highlightedId === item.id}
/>
</MediaItemWithContextMenu>
</div>
))}
</div>
);
Expand Down
28 changes: 24 additions & 4 deletions apps/web/src/components/editor/timeline/timeline-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Scissors,
Trash2,
Copy,
Search,
RefreshCw,
EyeOff,
Eye,
Expand All @@ -29,6 +30,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "../../ui/context-menu";
import { useMediaPanelStore } from "../media-panel/store";

export function TimelineElement({
element,
Expand Down Expand Up @@ -69,6 +71,8 @@ export function TimelineElement({
onUpdateDuration: updateElementDuration,
});

const { requestRevealMedia } = useMediaPanelStore.getState();

const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
const elementWidth = Math.max(
Expand Down Expand Up @@ -166,6 +170,16 @@ export function TimelineElement({
input.click();
};

const handleRevealInMedia = (e: React.MouseEvent) => {
e.stopPropagation();
if (element.type !== "media") {
toast.error("Reveal is only available for media clips");
return;
}

requestRevealMedia(element.mediaId);
};

const renderElementContent = () => {
if (element.type === "text") {
return (
Expand Down Expand Up @@ -348,10 +362,16 @@ export function TimelineElement({
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
{element.type === "media" && (
<ContextMenuItem onClick={handleReplaceClip}>
<RefreshCw className="h-4 w-4 mr-2" />
Replace clip
</ContextMenuItem>
<>
<ContextMenuItem onClick={handleRevealInMedia}>
<Search className="h-4 w-4 mr-2" />
Reveal in media
</ContextMenuItem>
<ContextMenuItem onClick={handleReplaceClip}>
<RefreshCw className="h-4 w-4 mr-2" />
Replace clip
</ContextMenuItem>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem
Expand Down
21 changes: 17 additions & 4 deletions apps/web/src/components/ui/draggable-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface DraggableMediaItemProps {
rounded?: boolean;
variant?: "card" | "compact";
isDraggable?: boolean;
isHighlighted?: boolean;
}

export function DraggableMediaItem({
Expand All @@ -43,13 +44,15 @@ export function DraggableMediaItem({
rounded = true,
variant = "card",
isDraggable = true,
isHighlighted = false,
}: DraggableMediaItemProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
const currentTime = isDraggable
? usePlaybackStore((state) => state.currentTime)
: 0;
const highlightClassName = "ring-2 ring-primary rounded-sm bg-primary/10";

const handleAddToTimeline = () => {
onAddToTimeline?.(currentTime);
Expand Down Expand Up @@ -102,7 +105,11 @@ export function DraggableMediaItem({
className={cn("relative group", containerClassName ?? "w-28 h-28")}
>
<div
className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`}
className={cn(
"flex flex-col gap-1 p-1 h-auto w-full relative cursor-default",
className,
isHighlighted && highlightClassName
)}
>
<AspectRatio
ratio={aspectRatio}
Expand Down Expand Up @@ -137,18 +144,24 @@ export function DraggableMediaItem({
</div>
</div>
) : (
<div ref={dragRef} className="relative group w-full">
<div
ref={dragRef}
className={cn(
"relative group w-full",
isHighlighted && highlightClassName
)}
>
<div
className={cn(
"h-10 flex items-center gap-3 cursor-default w-full",
"h-8 flex items-center gap-3 cursor-default w-full px-1",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
className
)}
draggable={isDraggable}
onDragStart={isDraggable ? handleDragStart : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
<div className="w-6 h-6 flex-shrink-0 rounded overflow-hidden">
<div className="w-6 h-6 flex-shrink-0 rounded-[0.35rem] overflow-hidden">
{preview}
</div>
<span className="text-sm truncate flex-1 w-full">{name}</span>
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/hooks/use-highlight-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useEffect, useState, useRef } from "react";

export function useHighlightScroll(
highlightId: string | null,
onClearHighlight: () => void,
highlightDuration = 1000
) {
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());

const registerElement = (id: string, element: HTMLElement | null) => {
if (element) {
elementRefs.current.set(id, element);
} else {
elementRefs.current.delete(id);
}
};

useEffect(() => {
if (!highlightId) return;

setHighlightedId(highlightId);

const target = elementRefs.current.get(highlightId);
target?.scrollIntoView({ block: "center" });

const timeout = setTimeout(() => {
setHighlightedId(null);
onClearHighlight();
}, highlightDuration);

return () => clearTimeout(timeout);
}, [highlightId, onClearHighlight, highlightDuration]);

return { highlightedId, registerElement };
}
Loading