diff options
| -rw-r--r-- | apps/web/src/components/editor/media-panel/store.ts | 7 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/media.tsx | 110 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-element.tsx | 28 | ||||
| -rw-r--r-- | apps/web/src/components/ui/draggable-item.tsx | 21 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-highlight-scroll.ts | 36 |
5 files changed, 145 insertions, 57 deletions
diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index e1212030..eb645ec3 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -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 }), })); diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 0f0ed92d..c84dde70 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -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"; @@ -33,7 +33,6 @@ 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, @@ -41,6 +40,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { usePanelStore } from "@/stores/panel-store"; +import { useMediaPanelStore } from "../store"; function MediaItemWithContextMenu({ item, @@ -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; @@ -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> @@ -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 @@ -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> @@ -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 @@ -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> ); @@ -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> ); diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index ca5d8c0a..a1a33837 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -4,6 +4,7 @@ import { Scissors, Trash2, Copy, + Search, RefreshCw, EyeOff, Eye, @@ -29,6 +30,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "../../ui/context-menu"; +import { useMediaPanelStore } from "../media-panel/store"; export function TimelineElement({ element, @@ -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( @@ -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 ( @@ -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 diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index f6d5c696..9e29c83b 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -27,6 +27,7 @@ export interface DraggableMediaItemProps { rounded?: boolean; variant?: "card" | "compact"; isDraggable?: boolean; + isHighlighted?: boolean; } export function DraggableMediaItem({ @@ -43,6 +44,7 @@ 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 }); @@ -50,6 +52,7 @@ export function DraggableMediaItem({ const currentTime = isDraggable ? usePlaybackStore((state) => state.currentTime) : 0; + const highlightClassName = "ring-2 ring-primary rounded-sm bg-primary/10"; const handleAddToTimeline = () => { onAddToTimeline?.(currentTime); @@ -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} @@ -137,10 +144,16 @@ 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 )} @@ -148,7 +161,7 @@ export function DraggableMediaItem({ 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> diff --git a/apps/web/src/hooks/use-highlight-scroll.ts b/apps/web/src/hooks/use-highlight-scroll.ts new file mode 100644 index 00000000..241bffb2 --- /dev/null +++ b/apps/web/src/hooks/use-highlight-scroll.ts @@ -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 };
+}
|
