summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-08-18 10:54:28 +0200
committerMaze Winther <mazewinther@gmail.com>2025-08-18 10:54:28 +0200
commit756a7c7caaeada4fff9a51f2ec4fe2968ac3b01e (patch)
tree1194f58254f2450395df18fb8d03469cd3ac3cd9
parent13dff382c452be96e1adb05d26bc30fa6de38298 (diff)
downloadopencut-756a7c7caaeada4fff9a51f2ec4fe2968ac3b01e.tar.gz
feat: implement reveal in media feature
-rw-r--r--apps/web/src/components/editor/media-panel/store.ts7
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx110
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx28
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx21
-rw-r--r--apps/web/src/hooks/use-highlight-scroll.ts36
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 };
+}