This commit is contained in:
Maze Winther
2025-12-25 21:15:36 +01:00
parent 2da84e7132
commit d5ca991501
102 changed files with 16028 additions and 6675 deletions
@@ -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>
@@ -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",
)}
/>
);
}
}
@@ -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" ? (
@@ -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}