mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: implement reveal in media feature
This commit is contained in:
@@ -71,9 +71,16 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
|||||||
interface MediaPanelStore {
|
interface MediaPanelStore {
|
||||||
activeTab: Tab;
|
activeTab: Tab;
|
||||||
setActiveTab: (tab: Tab) => void;
|
setActiveTab: (tab: Tab) => void;
|
||||||
|
highlightMediaId: string | null;
|
||||||
|
requestRevealMedia: (mediaId: string) => void;
|
||||||
|
clearHighlight: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
|
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
|
||||||
activeTab: "media",
|
activeTab: "media",
|
||||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||||
|
highlightMediaId: null,
|
||||||
|
requestRevealMedia: (mediaId) =>
|
||||||
|
set({ activeTab: "media", highlightMediaId: mediaId }),
|
||||||
|
clearHighlight: () => set({ highlightMediaId: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
List,
|
List,
|
||||||
Loader2,
|
Loader2,
|
||||||
Music,
|
Music,
|
||||||
Search,
|
|
||||||
Video,
|
Video,
|
||||||
} from "lucide-react";
|
} 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 { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -41,6 +40,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import { useMediaPanelStore } from "../store";
|
||||||
|
|
||||||
function MediaItemWithContextMenu({
|
function MediaItemWithContextMenu({
|
||||||
item,
|
item,
|
||||||
@@ -80,6 +80,11 @@ export function MediaView() {
|
|||||||
"name"
|
"name"
|
||||||
);
|
);
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||||
|
const { highlightMediaId, clearHighlight } = useMediaPanelStore();
|
||||||
|
const { highlightedId, registerElement } = useHighlightScroll(
|
||||||
|
highlightMediaId,
|
||||||
|
clearHighlight
|
||||||
|
);
|
||||||
|
|
||||||
const processFiles = async (files: FileList | File[]) => {
|
const processFiles = async (files: FileList | File[]) => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
@@ -206,7 +211,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"
|
className="w-full max-h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +440,7 @@ export function MediaView() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex-1 p-3 pt-0 w-full">
|
||||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||||
<MediaDragOverlay
|
<MediaDragOverlay
|
||||||
@@ -450,13 +455,16 @@ export function MediaView() {
|
|||||||
filteredMediaItems={filteredMediaItems}
|
filteredMediaItems={filteredMediaItems}
|
||||||
renderPreview={renderPreview}
|
renderPreview={renderPreview}
|
||||||
handleRemove={handleRemove}
|
handleRemove={handleRemove}
|
||||||
|
highlightedId={highlightedId}
|
||||||
|
registerElement={registerElement}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ListView
|
<ListView
|
||||||
filteredMediaItems={filteredMediaItems}
|
filteredMediaItems={filteredMediaItems}
|
||||||
renderPreview={renderPreview}
|
renderPreview={renderPreview}
|
||||||
handleRemove={handleRemove}
|
handleRemove={handleRemove}
|
||||||
formatDuration={formatDuration}
|
highlightedId={highlightedId}
|
||||||
|
registerElement={registerElement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -470,10 +478,14 @@ function GridView({
|
|||||||
filteredMediaItems,
|
filteredMediaItems,
|
||||||
renderPreview,
|
renderPreview,
|
||||||
handleRemove,
|
handleRemove,
|
||||||
|
highlightedId,
|
||||||
|
registerElement,
|
||||||
}: {
|
}: {
|
||||||
filteredMediaItems: MediaItem[];
|
filteredMediaItems: MediaItem[];
|
||||||
renderPreview: (item: MediaItem) => React.ReactNode;
|
renderPreview: (item: MediaItem) => React.ReactNode;
|
||||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||||
|
highlightedId: string | null;
|
||||||
|
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -483,27 +495,26 @@ function GridView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filteredMediaItems.map((item) => (
|
{filteredMediaItems.map((item) => (
|
||||||
<MediaItemWithContextMenu
|
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||||
key={item.id}
|
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
|
||||||
item={item}
|
<DraggableMediaItem
|
||||||
onRemove={handleRemove}
|
name={item.name}
|
||||||
>
|
preview={renderPreview(item)}
|
||||||
<DraggableMediaItem
|
dragData={{
|
||||||
name={item.name}
|
id: item.id,
|
||||||
preview={renderPreview(item)}
|
type: item.type,
|
||||||
dragData={{
|
name: item.name,
|
||||||
id: item.id,
|
}}
|
||||||
type: item.type,
|
showPlusOnDrag={false}
|
||||||
name: item.name,
|
onAddToTimeline={(currentTime) =>
|
||||||
}}
|
useTimelineStore.getState().addMediaAtTime(item, currentTime)
|
||||||
showPlusOnDrag={false}
|
}
|
||||||
onAddToTimeline={(currentTime) =>
|
rounded={false}
|
||||||
useTimelineStore.getState().addMediaAtTime(item, currentTime)
|
variant="card"
|
||||||
}
|
isHighlighted={highlightedId === item.id}
|
||||||
rounded={false}
|
/>
|
||||||
variant="card"
|
</MediaItemWithContextMenu>
|
||||||
/>
|
</div>
|
||||||
</MediaItemWithContextMenu>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -513,36 +524,37 @@ function ListView({
|
|||||||
filteredMediaItems,
|
filteredMediaItems,
|
||||||
renderPreview,
|
renderPreview,
|
||||||
handleRemove,
|
handleRemove,
|
||||||
formatDuration,
|
highlightedId,
|
||||||
|
registerElement,
|
||||||
}: {
|
}: {
|
||||||
filteredMediaItems: MediaItem[];
|
filteredMediaItems: MediaItem[];
|
||||||
renderPreview: (item: MediaItem) => React.ReactNode;
|
renderPreview: (item: MediaItem) => React.ReactNode;
|
||||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||||
formatDuration: (duration: number) => string;
|
highlightedId: string | null;
|
||||||
|
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{filteredMediaItems.map((item) => (
|
{filteredMediaItems.map((item) => (
|
||||||
<MediaItemWithContextMenu
|
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||||
key={item.id}
|
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
|
||||||
item={item}
|
<DraggableMediaItem
|
||||||
onRemove={handleRemove}
|
name={item.name}
|
||||||
>
|
preview={renderPreview(item)}
|
||||||
<DraggableMediaItem
|
dragData={{
|
||||||
name={item.name}
|
id: item.id,
|
||||||
preview={renderPreview(item)}
|
type: item.type,
|
||||||
dragData={{
|
name: item.name,
|
||||||
id: item.id,
|
}}
|
||||||
type: item.type,
|
showPlusOnDrag={false}
|
||||||
name: item.name,
|
onAddToTimeline={(currentTime) =>
|
||||||
}}
|
useTimelineStore.getState().addMediaAtTime(item, currentTime)
|
||||||
showPlusOnDrag={false}
|
}
|
||||||
onAddToTimeline={(currentTime) =>
|
variant="compact"
|
||||||
useTimelineStore.getState().addMediaAtTime(item, currentTime)
|
isHighlighted={highlightedId === item.id}
|
||||||
}
|
/>
|
||||||
variant="compact"
|
</MediaItemWithContextMenu>
|
||||||
/>
|
</div>
|
||||||
</MediaItemWithContextMenu>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Scissors,
|
Scissors,
|
||||||
Trash2,
|
Trash2,
|
||||||
Copy,
|
Copy,
|
||||||
|
Search,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "../../ui/context-menu";
|
} from "../../ui/context-menu";
|
||||||
|
import { useMediaPanelStore } from "../media-panel/store";
|
||||||
|
|
||||||
export function TimelineElement({
|
export function TimelineElement({
|
||||||
element,
|
element,
|
||||||
@@ -69,6 +71,8 @@ export function TimelineElement({
|
|||||||
onUpdateDuration: updateElementDuration,
|
onUpdateDuration: updateElementDuration,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { requestRevealMedia } = useMediaPanelStore.getState();
|
||||||
|
|
||||||
const effectiveDuration =
|
const effectiveDuration =
|
||||||
element.duration - element.trimStart - element.trimEnd;
|
element.duration - element.trimStart - element.trimEnd;
|
||||||
const elementWidth = Math.max(
|
const elementWidth = Math.max(
|
||||||
@@ -166,6 +170,16 @@ export function TimelineElement({
|
|||||||
input.click();
|
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 = () => {
|
const renderElementContent = () => {
|
||||||
if (element.type === "text") {
|
if (element.type === "text") {
|
||||||
return (
|
return (
|
||||||
@@ -348,10 +362,16 @@ export function TimelineElement({
|
|||||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{element.type === "media" && (
|
{element.type === "media" && (
|
||||||
<ContextMenuItem onClick={handleReplaceClip}>
|
<>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<ContextMenuItem onClick={handleRevealInMedia}>
|
||||||
Replace clip
|
<Search className="h-4 w-4 mr-2" />
|
||||||
</ContextMenuItem>
|
Reveal in media
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={handleReplaceClip}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Replace clip
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface DraggableMediaItemProps {
|
|||||||
rounded?: boolean;
|
rounded?: boolean;
|
||||||
variant?: "card" | "compact";
|
variant?: "card" | "compact";
|
||||||
isDraggable?: boolean;
|
isDraggable?: boolean;
|
||||||
|
isHighlighted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DraggableMediaItem({
|
export function DraggableMediaItem({
|
||||||
@@ -43,6 +44,7 @@ export function DraggableMediaItem({
|
|||||||
rounded = true,
|
rounded = true,
|
||||||
variant = "card",
|
variant = "card",
|
||||||
isDraggable = true,
|
isDraggable = true,
|
||||||
|
isHighlighted = false,
|
||||||
}: DraggableMediaItemProps) {
|
}: DraggableMediaItemProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||||
@@ -50,6 +52,7 @@ export function DraggableMediaItem({
|
|||||||
const currentTime = isDraggable
|
const currentTime = isDraggable
|
||||||
? usePlaybackStore((state) => state.currentTime)
|
? usePlaybackStore((state) => state.currentTime)
|
||||||
: 0;
|
: 0;
|
||||||
|
const highlightClassName = "ring-2 ring-primary rounded-sm bg-primary/10";
|
||||||
|
|
||||||
const handleAddToTimeline = () => {
|
const handleAddToTimeline = () => {
|
||||||
onAddToTimeline?.(currentTime);
|
onAddToTimeline?.(currentTime);
|
||||||
@@ -102,7 +105,11 @@ export function DraggableMediaItem({
|
|||||||
className={cn("relative group", containerClassName ?? "w-28 h-28")}
|
className={cn("relative group", containerClassName ?? "w-28 h-28")}
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<AspectRatio
|
||||||
ratio={aspectRatio}
|
ratio={aspectRatio}
|
||||||
@@ -137,10 +144,16 @@ export function DraggableMediaItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div ref={dragRef} className="relative group w-full">
|
<div
|
||||||
|
ref={dragRef}
|
||||||
|
className={cn(
|
||||||
|
"relative group w-full",
|
||||||
|
isHighlighted && highlightClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -148,7 +161,7 @@ export function DraggableMediaItem({
|
|||||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||||
onDragEnd={isDraggable ? handleDragEnd : 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}
|
{preview}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm truncate flex-1 w-full">{name}</span>
|
<span className="text-sm truncate flex-1 w-full">{name}</span>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user