mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
yes
This commit is contained in:
+8
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
import { TabBar } from "./tabbar";
|
||||
import { MediaView } from "./views/media";
|
||||
import { useMediaPanelStore, Tab } from "./store";
|
||||
import { useAssetsPanelStore, Tab } from "../../../stores/assets-panel-store";
|
||||
import { TextView } from "./views/text";
|
||||
import { SoundsView } from "./views/sounds";
|
||||
import { StickersView } from "./views/stickers";
|
||||
@@ -10,8 +10,8 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { SettingsView } from "./views/settings";
|
||||
import { Captions } from "./views/captions";
|
||||
|
||||
export function MediaPanel() {
|
||||
const { activeTab } = useMediaPanelStore();
|
||||
export function AssetsPanel() {
|
||||
const { activeTab } = useAssetsPanelStore();
|
||||
|
||||
const viewMap: Record<Tab, React.ReactNode> = {
|
||||
media: <MediaView />,
|
||||
@@ -19,23 +19,23 @@ export function MediaPanel() {
|
||||
text: <TextView />,
|
||||
stickers: <StickersView />,
|
||||
effects: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
<div className="text-muted-foreground p-4">
|
||||
Effects view coming soon...
|
||||
</div>
|
||||
),
|
||||
transitions: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
<div className="text-muted-foreground p-4">
|
||||
Transitions view coming soon...
|
||||
</div>
|
||||
),
|
||||
captions: <Captions />,
|
||||
filters: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
<div className="text-muted-foreground p-4">
|
||||
Filters view coming soon...
|
||||
</div>
|
||||
),
|
||||
adjustment: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
<div className="text-muted-foreground p-4">
|
||||
Adjustment view coming soon...
|
||||
</div>
|
||||
),
|
||||
@@ -43,7 +43,7 @@ export function MediaPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex bg-panel">
|
||||
<div className="bg-panel flex h-full">
|
||||
<TabBar />
|
||||
<Separator orientation="vertical" />
|
||||
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
|
||||
+26
-16
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tab, tabs, useMediaPanelStore } from "./store";
|
||||
import {
|
||||
Tab,
|
||||
tabs,
|
||||
useAssetsPanelStore,
|
||||
} from "../../../stores/assets-panel-store";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -10,7 +14,7 @@ import {
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useMediaPanelStore();
|
||||
const { activeTab, setActiveTab } = useAssetsPanelStore();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const [showBottomFade, setShowBottomFade] = useState(false);
|
||||
@@ -30,7 +34,7 @@ export function TabBar() {
|
||||
|
||||
checkScrollPosition();
|
||||
element.addEventListener("scroll", checkScrollPosition);
|
||||
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
@@ -41,20 +45,20 @@ export function TabBar() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex relative">
|
||||
<div
|
||||
<div className="relative flex">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4"
|
||||
className="scrollbar-hidden relative flex h-full w-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4"
|
||||
>
|
||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex z-[100] flex-col gap-0.5 items-center cursor-pointer",
|
||||
"z-[100] flex cursor-pointer flex-col items-center gap-0.5",
|
||||
activeTab === tabKey
|
||||
? "text-primary !opacity-100"
|
||||
: "text-muted-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
key={tabKey}
|
||||
@@ -69,7 +73,7 @@ export function TabBar() {
|
||||
variant="sidebar"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="dark:text-base-gray-950 text-black text-sm font-medium leading-none dark:text-white">
|
||||
<div className="dark:text-base-gray-950 text-sm font-medium leading-none text-black dark:text-white">
|
||||
{tab.label}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@@ -78,22 +82,28 @@ export function TabBar() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
<FadeOverlay direction="top" show={showTopFade} />
|
||||
<FadeOverlay direction="bottom" show={showBottomFade} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) {
|
||||
function FadeOverlay({
|
||||
direction,
|
||||
show,
|
||||
}: {
|
||||
direction: "top" | "bottom";
|
||||
show: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200",
|
||||
"pointer-events-none absolute left-0 right-0 z-[101] h-6 transition-opacity duration-200",
|
||||
direction === "top" && show
|
||||
? "top-0 bg-gradient-to-b from-panel to-transparent"
|
||||
: "bottom-0 bg-gradient-to-t from-panel to-transparent"
|
||||
? "from-panel top-0 bg-gradient-to-b to-transparent"
|
||||
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
-34
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { useFileUpload } from "@opencut/hooks/use-file-upload";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { MediaFile } from "@/types/media";
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
Music,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState, useMemo } from "react";
|
||||
import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useRevealItem } from "@/hooks/use-reveal-item";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
||||
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useMediaPanelStore } from "../store";
|
||||
import { useAssetsPanelStore } from "../../../../stores/assets-panel-store";
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
@@ -72,15 +72,14 @@ export function MediaView() {
|
||||
const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { mediaViewMode, setMediaViewMode } = usePanelStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
||||
"name",
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const { highlightMediaId, clearHighlight } = useMediaPanelStore();
|
||||
const { highlightedId, registerElement } = useHighlightScroll(
|
||||
const { highlightMediaId, clearHighlight } = useAssetsPanelStore();
|
||||
const { highlightedId, registerElement } = useRevealItem(
|
||||
highlightMediaId,
|
||||
clearHighlight,
|
||||
);
|
||||
@@ -95,9 +94,10 @@ export function MediaView() {
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p),
|
||||
);
|
||||
const processedItems = await processMediaFiles({
|
||||
files: files as FileList,
|
||||
onProgress: (p: { progress: number }) => setProgress(p.progress),
|
||||
});
|
||||
for (const item of processedItems) {
|
||||
await addMediaFile(activeProject.id, item);
|
||||
}
|
||||
@@ -110,17 +110,12 @@ export function MediaView() {
|
||||
}
|
||||
};
|
||||
|
||||
const { isDragOver, dragProps } = useDragDrop({
|
||||
// When files are dropped, process them
|
||||
onDrop: processFiles,
|
||||
});
|
||||
|
||||
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) processFiles(e.target.files);
|
||||
e.target.value = ""; // Reset input
|
||||
};
|
||||
const { isDragOver, dragProps, openFilePicker, fileInputProps } =
|
||||
useFileUpload({
|
||||
accept: "image/*,video/*,audio/*",
|
||||
multiple: true,
|
||||
onFilesSelected: processFiles,
|
||||
});
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
@@ -260,27 +255,19 @@ export function MediaView() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden file input for uploading media */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/* native file picker, visually hidden */}
|
||||
<input {...fileInputProps} />
|
||||
|
||||
<div
|
||||
className={`relative flex h-full flex-col gap-1 transition-colors ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="bg-panel p-3 pb-2">
|
||||
{/* Search and filter controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleFileSelect}
|
||||
onClick={openFilePicker}
|
||||
disabled={isProcessing}
|
||||
className="!bg-background h-9 flex-1 items-center justify-center px-4 opacity-100 transition-opacity hover:opacity-75"
|
||||
>
|
||||
@@ -427,7 +414,7 @@ export function MediaView() {
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={handleFileSelect}
|
||||
onClick={openFilePicker}
|
||||
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
||||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
-6
@@ -2,10 +2,6 @@
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useStickersStore } from "@/stores/stickers-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import {
|
||||
Loader2,
|
||||
Grid3X3,
|
||||
@@ -35,9 +31,7 @@ import {
|
||||
POPULAR_COLLECTIONS,
|
||||
} from "@/lib/iconify-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import Image from "next/image";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import { StickerCategory } from "@/stores/stickers-store";
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
CaptionsIcon,
|
||||
ArrowLeftRightIcon,
|
||||
SparklesIcon,
|
||||
StickerIcon,
|
||||
MusicIcon,
|
||||
VideoIcon,
|
||||
BlendIcon,
|
||||
SlidersHorizontalIcon,
|
||||
LucideIcon,
|
||||
TypeIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Tab =
|
||||
| "media"
|
||||
| "sounds"
|
||||
| "text"
|
||||
| "stickers"
|
||||
| "effects"
|
||||
| "transitions"
|
||||
| "captions"
|
||||
| "filters"
|
||||
| "adjustment"
|
||||
| "settings";
|
||||
|
||||
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
||||
media: {
|
||||
icon: VideoIcon,
|
||||
label: "Media",
|
||||
},
|
||||
sounds: {
|
||||
icon: MusicIcon,
|
||||
label: "Sounds",
|
||||
},
|
||||
text: {
|
||||
icon: TypeIcon,
|
||||
label: "Text",
|
||||
},
|
||||
stickers: {
|
||||
icon: StickerIcon,
|
||||
label: "Stickers",
|
||||
},
|
||||
effects: {
|
||||
icon: SparklesIcon,
|
||||
label: "Effects",
|
||||
},
|
||||
transitions: {
|
||||
icon: ArrowLeftRightIcon,
|
||||
label: "Transitions",
|
||||
},
|
||||
captions: {
|
||||
icon: CaptionsIcon,
|
||||
label: "Captions",
|
||||
},
|
||||
filters: {
|
||||
icon: BlendIcon,
|
||||
label: "Filters",
|
||||
},
|
||||
adjustment: {
|
||||
icon: SlidersHorizontalIcon,
|
||||
label: "Adjustment",
|
||||
},
|
||||
settings: {
|
||||
icon: SettingsIcon,
|
||||
label: "Settings",
|
||||
},
|
||||
};
|
||||
|
||||
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 }),
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { useState, useEffect } from "react";
|
||||
@@ -63,7 +63,7 @@ export function SnapIndicator({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none z-90"
|
||||
className="z-90 pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
@@ -71,7 +71,7 @@ export function SnapIndicator({
|
||||
width: "2px",
|
||||
}}
|
||||
>
|
||||
<div className={"w-0.5 h-full bg-primary/40 opacity-80"} />
|
||||
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../../ui/context-menu";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import {
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { SelectionBox } from "../selection-box";
|
||||
import { useSelectionBox } from "@/hooks/use-selection-box";
|
||||
import { SnapIndicator } from "../snap-indicator";
|
||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
} from "@/lib/timeline";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useScrollSync } from "@/hooks/use-scroll-sync";
|
||||
import { useTimelineInteractions } from "@/hooks/use-timeline-interactions";
|
||||
import { useTimelineDragDrop } from "@/hooks/use-timeline-drag-drop";
|
||||
import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { TimelineRuler } from "./timeline-ruler";
|
||||
|
||||
export function Timeline() {
|
||||
@@ -45,10 +45,7 @@ export function Timeline() {
|
||||
dragState,
|
||||
} = useTimelineStore();
|
||||
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
|
||||
const { addElementToNewTrack } = useTimelineStore();
|
||||
const { dragProps } = useTimelineDragDrop({
|
||||
addElementToNewTrack,
|
||||
});
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const rulerRef = useRef<HTMLDivElement>(null);
|
||||
const [isInTimeline, setIsInTimeline] = useState(false);
|
||||
@@ -59,6 +56,10 @@ export function Timeline() {
|
||||
isInTimeline,
|
||||
});
|
||||
|
||||
const { dragProps } = useTimelineDragDrop({
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
// Dynamic timeline width calculation based on playhead position and duration
|
||||
const dynamicTimelineWidth = Math.max(
|
||||
(duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
Eye,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import AudioWaveform from "../audio-waveform";
|
||||
import { TimelineElementProps } from "@/types/timeline";
|
||||
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/use-timeline-element-resize";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getTrackElementClasses, getTrackHeight } from "@/lib/timeline";
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
import { useAssetsPanelStore } from "../../../stores/assets-panel-store";
|
||||
|
||||
export function TimelineElement({
|
||||
element,
|
||||
@@ -36,6 +38,7 @@ export function TimelineElement({
|
||||
onElementClick,
|
||||
}: TimelineElementProps) {
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const { requestRevealMedia } = useAssetsPanelStore();
|
||||
const {
|
||||
dragState,
|
||||
copySelected,
|
||||
@@ -45,8 +48,6 @@ export function TimelineElement({
|
||||
toggleSelectedHidden,
|
||||
toggleSelectedMuted,
|
||||
duplicateElement,
|
||||
revealElementInMedia,
|
||||
replaceElementWithFile,
|
||||
getContextMenuState,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
@@ -129,24 +130,11 @@ export function TimelineElement({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceClip = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "video/*,audio/*,image/*";
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
await replaceElementWithFile(track.id, element.id, file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleRevealInMedia = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
revealElementInMedia(element.id);
|
||||
if (element.type === "media") {
|
||||
requestRevealMedia(element.mediaId);
|
||||
}
|
||||
};
|
||||
|
||||
const renderElementContent = () => {
|
||||
@@ -354,15 +342,20 @@ export function TimelineElement({
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
<ContextMenuItem disabled>
|
||||
<ArrowUpDown className="mr-2 h-4 w-4" />
|
||||
Move to track (Coming soon)
|
||||
</ContextMenuItem>
|
||||
|
||||
{!isMultipleSelected && element.type === "media" && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleRevealInMedia}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Reveal in media
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleReplaceClip}>
|
||||
<ContextMenuItem disabled>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Replace clip
|
||||
Replace clip (Coming soon)
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
@@ -91,12 +91,12 @@ export function TimelinePlayhead({
|
||||
const leftBoundary = trackLabelsWidth;
|
||||
const rightBoundary = Math.min(
|
||||
trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
|
||||
trackLabelsWidth + viewportWidth // Don't go beyond viewport
|
||||
trackLabelsWidth + viewportWidth, // Don't go beyond viewport
|
||||
);
|
||||
|
||||
const leftPosition = Math.max(
|
||||
leftBoundary,
|
||||
Math.min(rightBoundary, rawLeftPosition)
|
||||
Math.min(rightBoundary, rawLeftPosition),
|
||||
);
|
||||
|
||||
// Debug logging when playhead might go outside
|
||||
@@ -115,14 +115,14 @@ export function TimelinePlayhead({
|
||||
timelineContentWidth,
|
||||
viewportWidth,
|
||||
zoomLevel,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-40"
|
||||
className="pointer-events-auto absolute z-40"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
@@ -133,12 +133,12 @@ export function TimelinePlayhead({
|
||||
>
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
|
||||
className={`absolute left-0 h-full w-0.5 cursor-col-resize ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||
<div
|
||||
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
className={`shadow-xs absolute left-1/2 top-1 h-3 w-3 -translate-x-1/2 transform rounded-full border-2 ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,6 @@ export function TimelineToolbar({
|
||||
splitSelected,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
snappingEnabled,
|
||||
toggleSnapping,
|
||||
rippleEditingEnabled,
|
||||
@@ -140,20 +139,6 @@ export function TimelineToolbar({
|
||||
splitAndKeepRight(trackId, elementId, currentTime);
|
||||
};
|
||||
|
||||
const handleSeparateAudio = () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one media element to separate audio");
|
||||
return;
|
||||
}
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
if (!track || track.type !== "media") {
|
||||
toast.error("Select a media element to separate audio");
|
||||
return;
|
||||
}
|
||||
separateAudio(trackId, elementId);
|
||||
};
|
||||
|
||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||
const newZoomLevel =
|
||||
direction === "in"
|
||||
@@ -211,7 +196,11 @@ export function TimelineToolbar({
|
||||
/
|
||||
</div>
|
||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
||||
{formatTimeCode(duration, "HH:MM:SS:FF")}
|
||||
{formatTimeCode({ timeInSeconds: duration })}
|
||||
{formatTimeCode({
|
||||
timeInSeconds: duration,
|
||||
format: "HH:MM:SS:FF",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{tracks.length === 0 && (
|
||||
@@ -278,11 +267,11 @@ export function TimelineToolbar({
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
|
||||
<Button variant="text" size="icon" disabled>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
|
||||
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -2,25 +2,18 @@
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { toast } from "sonner";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { TimelineElement } from "./timeline-element";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { getMainTrack, canElementGoOnTrack } from "@/lib/timeline/track-utils";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
DragData,
|
||||
TrackType,
|
||||
} from "@/types/timeline";
|
||||
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
|
||||
export function TimelineTrackContent({
|
||||
track,
|
||||
@@ -35,14 +28,10 @@ export function TimelineTrackContent({
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const {
|
||||
tracks,
|
||||
addTrack,
|
||||
moveElementToTrack,
|
||||
updateElementStartTime,
|
||||
updateElementStartTimeWithRipple,
|
||||
addElementToTrack,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
dragState,
|
||||
@@ -50,78 +39,18 @@ export function TimelineTrackContent({
|
||||
updateDragTime,
|
||||
endDrag: endDragAction,
|
||||
clearSelectedElements,
|
||||
insertTrackAt,
|
||||
snappingEnabled,
|
||||
rippleEditingEnabled,
|
||||
} = useTimelineStore();
|
||||
|
||||
const { currentTime, duration } = usePlaybackStore();
|
||||
const { duration } = usePlaybackStore();
|
||||
|
||||
// Initialize snapping hook
|
||||
const { snapElementEdge } = useTimelineSnapping({
|
||||
snapThreshold: 10,
|
||||
enableElementSnapping: snappingEnabled,
|
||||
enablePlayheadSnapping: snappingEnabled,
|
||||
const { isDragOver, wouldOverlap, dragProps } = useTimelineDragDrop({
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
});
|
||||
|
||||
// Helper function for drop snapping that tries both edges
|
||||
const getDropSnappedTime = (
|
||||
dropTime: number,
|
||||
elementDuration: number,
|
||||
excludeElementId?: string,
|
||||
) => {
|
||||
// Always apply frame snapping first
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
let finalTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
|
||||
|
||||
// Additionally apply element snapping if enabled
|
||||
if (snappingEnabled) {
|
||||
// Try snapping both start and end edges for drops
|
||||
const startSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
true, // snap to start edge
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
false, // snap to end edge
|
||||
);
|
||||
|
||||
// Choose the snap result with the smaller distance (closer snap)
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
|
||||
if (bestSnapResult.snapPoint) {
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
}
|
||||
}
|
||||
|
||||
return finalTime;
|
||||
};
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
||||
const [wouldOverlap, setWouldOverlap] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [mouseDownLocation, setMouseDownLocation] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -159,70 +88,12 @@ export function TimelineTrackContent({
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
|
||||
// Always apply frame snapping first
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
let finalTime = snapTimeToFrame({ time: adjustedTime, fps: projectFps });
|
||||
let snapPoint = null;
|
||||
|
||||
// Additionally apply element snapping if enabled
|
||||
if (snappingEnabled) {
|
||||
// Find the element being dragged to get its duration
|
||||
let elementDuration = 5; // fallback duration
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const element = sourceTrack?.elements.find(
|
||||
(e) => e.id === dragState.elementId,
|
||||
);
|
||||
if (element) {
|
||||
elementDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Try snapping both start and end edges
|
||||
const startSnapResult = snapElementEdge(
|
||||
adjustedTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
dragState.elementId || undefined,
|
||||
true, // snap to start edge
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
adjustedTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
dragState.elementId || undefined,
|
||||
false, // snap to end edge
|
||||
);
|
||||
|
||||
// Choose the snap result with the smaller distance (closer snap)
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
|
||||
if (bestSnapResult.snapPoint) {
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
snapPoint = bestSnapResult.snapPoint;
|
||||
}
|
||||
|
||||
// Notify parent component about snap point change
|
||||
onSnapPointChange?.(snapPoint);
|
||||
} else {
|
||||
// Clear snap point when element snapping is disabled
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
const finalTime = snapTimeToFrame({
|
||||
time: adjustedTime,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
updateDragTime(finalTime);
|
||||
};
|
||||
@@ -309,26 +180,7 @@ export function TimelineTrackContent({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
moveElementToTrack(
|
||||
dragState.trackId,
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
dragState.elementId!,
|
||||
finalTime,
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId!,
|
||||
finalTime,
|
||||
);
|
||||
}
|
||||
});
|
||||
toast.info("Moving elements between tracks is coming soon!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,7 +251,6 @@ export function TimelineTrackContent({
|
||||
track.id,
|
||||
updateDragTime,
|
||||
updateElementStartTime,
|
||||
moveElementToTrack,
|
||||
endDragAction,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
@@ -495,620 +346,6 @@ export function TimelineTrackContent({
|
||||
// If element is already selected, keep it selected (do nothing)
|
||||
};
|
||||
|
||||
const handleTrackDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Handle both timeline elements and media items
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
// Calculate drop position for overlap checking
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-elements-container",
|
||||
) as HTMLElement;
|
||||
let dropTime = 0;
|
||||
if (trackContainer) {
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
}
|
||||
|
||||
// Check for potential overlaps and show appropriate feedback
|
||||
let wouldOverlap = false;
|
||||
|
||||
if (hasMediaItem) {
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
|
||||
if (dragData.type === "text") {
|
||||
// Text elements have default duration of 5 seconds
|
||||
const newElementDuration = 5;
|
||||
const snappedTime = getDropSnappedTime(
|
||||
dropTime,
|
||||
newElementDuration,
|
||||
);
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return snappedTime < existingEnd && newElementEnd > existingStart;
|
||||
});
|
||||
} else {
|
||||
// Media elements
|
||||
const mediaItem = mediaFiles.find(
|
||||
(item) => item.id === dragData.id,
|
||||
);
|
||||
if (mediaItem) {
|
||||
const newElementDuration = mediaItem.duration || 5;
|
||||
const snappedTime = getDropSnappedTime(
|
||||
dropTime,
|
||||
newElementDuration,
|
||||
);
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return (
|
||||
snappedTime < existingEnd && newElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
} else if (hasTimelineElement) {
|
||||
try {
|
||||
const timelineElementData = e.dataTransfer.getData(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
if (timelineElementData) {
|
||||
const { elementId, trackId: fromTrackId } =
|
||||
JSON.parse(timelineElementData);
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId,
|
||||
);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c: any) => c.id === elementId,
|
||||
);
|
||||
|
||||
if (movingElement) {
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const snappedTime = getDropSnappedTime(
|
||||
dropTime,
|
||||
movingElementDuration,
|
||||
elementId,
|
||||
);
|
||||
const movingElementEnd = snappedTime + movingElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
if (fromTrackId === track.id && existingElement.id === elementId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return (
|
||||
snappedTime < existingEnd && movingElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
}
|
||||
|
||||
if (wouldOverlap) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
setWouldOverlap(true);
|
||||
// Use default duration for position indicator
|
||||
setDropPosition(getDropSnappedTime(dropTime, 5));
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
|
||||
setWouldOverlap(false);
|
||||
// Use default duration for position indicator
|
||||
setDropPosition(getDropSnappedTime(dropTime, 5));
|
||||
};
|
||||
|
||||
const handleTrackDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
setIsDropping(true);
|
||||
};
|
||||
|
||||
const handleTrackDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Debug logging
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
message: "Drop event started in timeline track",
|
||||
dataTransferTypes: Array.from(e.dataTransfer.types),
|
||||
trackId: track.id,
|
||||
trackType: track.type,
|
||||
}),
|
||||
);
|
||||
|
||||
// Reset all drag states
|
||||
dragCounterRef.current = 0;
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem && !hasFiles) return;
|
||||
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-elements-container",
|
||||
) as HTMLElement;
|
||||
if (!trackContainer) return;
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const mouseY = e.clientY - rect.top; // Get Y position relative to this track
|
||||
const newStartTime =
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
const snappedTime = snapTimeToFrame({
|
||||
time: newStartTime,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
// Calculate drop position relative to tracks
|
||||
const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
|
||||
|
||||
// Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
|
||||
let dropPosition: "above" | "on" | "below";
|
||||
if (mouseY < 20) {
|
||||
dropPosition = "above";
|
||||
} else if (mouseY > 40) {
|
||||
dropPosition = "below";
|
||||
} else {
|
||||
dropPosition = "on";
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasTimelineElement) {
|
||||
// Handle timeline element movement
|
||||
const timelineElementData = e.dataTransfer.getData(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
if (!timelineElementData) return;
|
||||
|
||||
const {
|
||||
elementId,
|
||||
trackId: fromTrackId,
|
||||
clickOffsetTime = 0,
|
||||
} = JSON.parse(timelineElementData);
|
||||
|
||||
// Find the element being moved
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId,
|
||||
);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c: TimelineElementType) => c.id === elementId,
|
||||
);
|
||||
|
||||
if (!movingElement) {
|
||||
toast.error("Element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for overlaps with existing elements (excluding the moving element itself)
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
|
||||
// Adjust position based on where user clicked on the element
|
||||
const adjustedStartTime = newStartTime - clickOffsetTime;
|
||||
const snappedStartTime = getDropSnappedTime(
|
||||
adjustedStartTime,
|
||||
movingElementDuration,
|
||||
elementId,
|
||||
);
|
||||
const finalStartTime = Math.max(0, snappedStartTime);
|
||||
const movingElementEnd = finalStartTime + movingElementDuration;
|
||||
|
||||
const hasOverlap = track.elements.some((existingElement) => {
|
||||
// Skip the element being moved if it's on the same track
|
||||
if (fromTrackId === track.id && existingElement.id === elementId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return (
|
||||
finalStartTime < existingEnd && movingElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot move element here - it would overlap with existing elements",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromTrackId === track.id) {
|
||||
// Moving within same track
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
elementId,
|
||||
finalStartTime,
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
}
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveElementToTrack(fromTrackId, track.id, elementId);
|
||||
requestAnimationFrame(() => {
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
elementId,
|
||||
finalStartTime,
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (hasMediaItem) {
|
||||
// Handle media item drop
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (!mediaItemData) return;
|
||||
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
|
||||
if (dragData.type === "text") {
|
||||
let targetTrackId = track.id;
|
||||
let targetTrack = track;
|
||||
|
||||
// Handle position-aware track creation for text
|
||||
if (track.type !== "text" || dropPosition !== "on") {
|
||||
// Text tracks should go above the main track
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
insertIndex = currentTrackIndex;
|
||||
} else if (dropPosition === "below") {
|
||||
insertIndex = currentTrackIndex + 1;
|
||||
} else {
|
||||
// dropPosition === "on" but track is not text type
|
||||
// Insert above main track if main track exists, otherwise at top
|
||||
if (mainTrack) {
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id,
|
||||
);
|
||||
insertIndex = mainTrackIndex;
|
||||
} else {
|
||||
insertIndex = 0; // Top of timeline
|
||||
}
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("text", insertIndex);
|
||||
// Get the updated tracks array after creating the new track
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId,
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
|
||||
// Check for overlaps with existing elements in target track
|
||||
const newElementDuration = 5; // Default text duration
|
||||
const textSnappedTime = getDropSnappedTime(
|
||||
newStartTime,
|
||||
newElementDuration,
|
||||
);
|
||||
const newElementEnd = textSnappedTime + newElementDuration;
|
||||
|
||||
const hasOverlap = targetTrack.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return (
|
||||
textSnappedTime < existingEnd && newElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot place element here - it would overlap with existing elements",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
|
||||
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
|
||||
startTime: textSnappedTime,
|
||||
});
|
||||
} else {
|
||||
// Handle media items
|
||||
const mediaItem = mediaFiles.find((item) => item.id === dragData.id);
|
||||
|
||||
if (!mediaItem) {
|
||||
toast.error("Media item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let targetTrackId = track.id;
|
||||
|
||||
// Check if track type is compatible
|
||||
const isVideoOrImage =
|
||||
dragData.type === "video" || dragData.type === "image";
|
||||
const isAudio = dragData.type === "audio";
|
||||
const isCompatible = isVideoOrImage
|
||||
? canElementGoOnTrack({
|
||||
elementType: "media",
|
||||
trackType: track.type,
|
||||
})
|
||||
: isAudio
|
||||
? canElementGoOnTrack({
|
||||
elementType: "media",
|
||||
trackType: track.type,
|
||||
})
|
||||
: false;
|
||||
|
||||
let targetTrack = tracks.find((t) => t.id === targetTrackId);
|
||||
|
||||
// Handle position-aware track creation for media elements
|
||||
if (!isCompatible || dropPosition !== "on") {
|
||||
if (isVideoOrImage) {
|
||||
// For video/image, check if we need a main track or additional media track
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
|
||||
if (!mainTrack) {
|
||||
// No main track exists, create it
|
||||
targetTrackId = addTrack("media");
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId,
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
} else if (
|
||||
mainTrack.elements.length === 0 &&
|
||||
dropPosition === "on"
|
||||
) {
|
||||
// Main track exists and is empty, use it
|
||||
targetTrackId = mainTrack.id;
|
||||
targetTrack = mainTrack;
|
||||
} else {
|
||||
// Create new media track
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
insertIndex = currentTrackIndex;
|
||||
} else if (dropPosition === "below") {
|
||||
insertIndex = currentTrackIndex + 1;
|
||||
} else {
|
||||
// Insert above main track
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id,
|
||||
);
|
||||
insertIndex = mainTrackIndex;
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("media", insertIndex);
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId,
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
} else if (isAudio) {
|
||||
// Audio tracks go at the bottom
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
insertIndex = currentTrackIndex;
|
||||
} else if (dropPosition === "below") {
|
||||
insertIndex = currentTrackIndex + 1;
|
||||
} else {
|
||||
// Insert after main track (bottom area)
|
||||
if (mainTrack) {
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id,
|
||||
);
|
||||
insertIndex = mainTrackIndex + 1;
|
||||
} else {
|
||||
insertIndex = tracks.length; // Bottom of timeline
|
||||
}
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("audio", insertIndex);
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId,
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTrack) return;
|
||||
|
||||
// Check for overlaps with existing elements in target track
|
||||
const newElementDuration = mediaItem.duration || 5;
|
||||
const mediaSnappedTime = getDropSnappedTime(
|
||||
newStartTime,
|
||||
newElementDuration,
|
||||
);
|
||||
const newElementEnd = mediaSnappedTime + newElementDuration;
|
||||
|
||||
const hasOverlap = targetTrack.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return (
|
||||
mediaSnappedTime < existingEnd && newElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot place element here - it would overlap with existing elements",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration: mediaItem.duration || 5,
|
||||
startTime: mediaSnappedTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
// External file drops
|
||||
const { activeProject } = useProjectStore.getState();
|
||||
const { addMediaFile } = useMediaStore.getState();
|
||||
const { addElementToTrack } = useTimelineStore.getState();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process and add files to new timeline tracks at playhead position
|
||||
processMediaFiles(e.dataTransfer.files)
|
||||
.then(async (processedItems) => {
|
||||
for (const processedItem of processedItems) {
|
||||
await addMediaFile(activeProject.id, processedItem);
|
||||
const currentMediaFiles = mediaFiles;
|
||||
const addedItem = currentMediaFiles.find(
|
||||
(item) =>
|
||||
item.name === processedItem.name &&
|
||||
item.url === processedItem.url,
|
||||
);
|
||||
|
||||
if (addedItem) {
|
||||
const trackType: TrackType =
|
||||
addedItem.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = insertTrackAt(trackType, 0);
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: addedItem.id,
|
||||
name: addedItem.name,
|
||||
duration: addedItem.duration || 5,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error processing external files:", error);
|
||||
toast.error("Failed to process dropped files");
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling drop:", error);
|
||||
toast.error("Failed to add media to track");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hover:bg-muted/20 h-full w-full"
|
||||
@@ -1118,10 +355,7 @@ export function TimelineTrackContent({
|
||||
clearSelectedElements();
|
||||
}
|
||||
}}
|
||||
onDragOver={handleTrackDragOver}
|
||||
onDragEnter={handleTrackDragEnter}
|
||||
onDragLeave={handleTrackDragLeave}
|
||||
onDrop={handleTrackDrop}
|
||||
{...dragProps}
|
||||
>
|
||||
<div
|
||||
ref={timelineRef}
|
||||
@@ -1130,14 +364,14 @@ export function TimelineTrackContent({
|
||||
{track.elements.length === 0 ? (
|
||||
<div
|
||||
className={`text-muted-foreground flex h-full w-full items-center justify-center rounded-sm border-2 border-dashed text-xs transition-colors ${
|
||||
isDropping
|
||||
isDragOver
|
||||
? wouldOverlap
|
||||
? "border-red-500 bg-red-500/10 text-red-600"
|
||||
: "border-blue-500 bg-blue-500/10 text-blue-600"
|
||||
: "border-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isDropping
|
||||
{isDragOver
|
||||
? wouldOverlap
|
||||
? "Cannot drop - would overlap"
|
||||
: "Drop element here"
|
||||
@@ -1150,40 +384,6 @@ export function TimelineTrackContent({
|
||||
(c) => c.trackId === track.id && c.elementId === element.id,
|
||||
);
|
||||
|
||||
const handleElementSplit = () => {
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { splitSelected } = useTimelineStore();
|
||||
const splitTime = currentTime;
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||
splitSelected(splitTime, track.id, element.id);
|
||||
} else {
|
||||
toast.error("Playhead must be within element to split");
|
||||
}
|
||||
};
|
||||
|
||||
const handleElementDuplicate = () => {
|
||||
const { addElementToTrack } = useTimelineStore.getState();
|
||||
const { id, ...elementWithoutId } = element;
|
||||
addElementToTrack(track.id, {
|
||||
...elementWithoutId,
|
||||
name: element.name + " (copy)",
|
||||
startTime:
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleElementDelete = () => {
|
||||
const { deleteSelected } = useTimelineStore.getState();
|
||||
deleteSelected(track.id, element.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<TimelineElement
|
||||
key={element.id}
|
||||
|
||||
Reference in New Issue
Block a user