diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7a48aa37 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# AGENTS.md + +## Overview + +Privacy-first video editor, with a focus on simplicity and ease of use. + +## Core Editor System + +The editor uses a **singleton EditorCore** that manages all editor state through specialized managers. + +### Architecture + +``` +EditorCore (singleton) +├── playback: PlaybackManager +├── timeline: TimelineManager +├── scene: SceneManager +├── project: ProjectManager +├── media: MediaManager +└── renderer: RendererManager +``` + +### When to Use What + +#### In React Components + +**Always use the `useEditor()` hook:** + +```typescript +import { useEditor } from '@/hooks/use-editor'; + +function MyComponent() { + const editor = useEditor(); + const tracks = editor.timeline.getTracks(); + + // Call methods + editor.timeline.addTrack({ type: 'media' }); + + // Display data (auto re-renders on changes) + return
{tracks.length} tracks
; +} +``` + +The hook: +- Returns the singleton instance +- Subscribes to all manager changes +- Automatically re-renders when state changes + +#### Outside React Components + +**Use `EditorCore.getInstance()` directly:** + +```typescript +// In utilities, event handlers, or non-React code +import { EditorCore } from '@/core'; + +const editor = EditorCore.getInstance(); +await editor.export({ format: 'mp4', quality: 'high' }); +``` diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..1594a6a6 --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +[x] Delete all stores we can +[x] Create commands for each action +[x] Finish timeline store +[x] Migrate final timeline store to manager +[x] Delete timeline store +[ ] Update usage of all stores to use the new managers \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 44c63a77..dc069c57 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -3,4 +3,6 @@ # Env vars .env* -!.env.example \ No newline at end of file +!.env.example + +.next/ \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index bed46576..fde3d37f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,6 @@ "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^3.9.1", "@opencut/env": "workspace:*", - "@opencut/hooks": "workspace:*", "@opencut/ui": "workspace:*", "@radix-ui/react-separator": "^1.1.7", "@upstash/ratelimit": "^2.0.6", diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 8d318925..759b400c 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -24,6 +24,8 @@ --accent-foreground: hsl(0 0% 2%); --destructive: hsl(0, 83%, 50%); --destructive-foreground: hsl(0, 0%, 100%); + --constructive: hsl(141, 71%, 48%); + --constructive-foreground: hsl(0, 0%, 100%); --border: hsl(0 0% 83%); --input: hsl(0 0% 85.1%); --ring: hsl(0, 0%, 55%); @@ -62,6 +64,8 @@ --accent-foreground: hsl(0 0% 98%); --destructive: hsl(0 100% 60%); --destructive-foreground: hsl(0 0% 98%); + --constructive: hsl(141, 71%, 48%); + --constructive-foreground: hsl(0, 0%, 100%); --border: hsl(0 0% 17%); --input: hsl(0 0% 14.9%); --ring: hsl(0 0% 83.1%); @@ -152,6 +156,9 @@ --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); + --color-constructive: var(--constructive); + --color-constructive-foreground: var(--constructive-foreground); + --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 5729857c..1bd70ecc 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -36,25 +36,12 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { Skeleton } from "@/components/ui/skeleton"; -import { useProjectStore } from "@/stores/project-store"; +import { EditorCore } from "@/core"; import { useTimelineStore } from "@/stores/timeline-store"; import type { TProject } from "@/types/project"; +import { toast } from "sonner"; export default function ProjectsPage() { - const { - savedProjects, - isLoading, - isInitialized, - deleteProject, - createNewProject, - getFilteredAndSortedProjects, - } = useProjectStore(); - const [thumbnailCache, setThumbnailCache] = useState< - Record - >({}); - const [_loadingThumbnails, setLoadingThumbnails] = useState>( - new Set(), - ); const [isSelectionMode, setIsSelectionMode] = useState(false); const [selectedProjects, setSelectedProjects] = useState>( new Set(), @@ -62,36 +49,21 @@ export default function ProjectsPage() { const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [sortOption, setSortOption] = useState("createdAt-desc"); - const { getProjectThumbnail } = useTimelineStore(); const router = useRouter(); - - const getProjectThumbnail = useCallback( - async (projectId: string): Promise => { - if (thumbnailCache[projectId] !== undefined) { - return thumbnailCache[projectId]; - } - - setLoadingThumbnails((prev) => new Set(prev).add(projectId)); - - try { - const thumbnail = await getProjectThumbnail(projectId); - setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail })); - return thumbnail; - } finally { - setLoadingThumbnails((prev) => { - const newSet = new Set(prev); - newSet.delete(projectId); - return newSet; - }); - } - }, - [], - ); + const editor = EditorCore.getInstance(); const handleCreateProject = async () => { - const projectId = await createNewProject("New Project"); - console.log("projectId", projectId); - router.push(`/editor/${projectId}`); + try { + const projectId = await editor.project.createNewProject({ + name: "New project", + }); + router.push(`/editor/${projectId}`); + } catch (error) { + toast.error("Failed to create project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } }; const handleSelectProject = (projectId: string, checked: boolean) => { @@ -118,15 +90,28 @@ export default function ProjectsPage() { }; const handleBulkDelete = async () => { - await Promise.all( - Array.from(selectedProjects).map((projectId) => deleteProject(projectId)), - ); - setSelectedProjects(new Set()); - setIsSelectionMode(false); - setIsBulkDeleteDialogOpen(false); + try { + await Promise.all( + Array.from(selectedProjects).map((projectId) => + editor.project.deleteProject({ id: projectId }), + ), + ); + } catch (error) { + toast.error("Failed to delete projects", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } finally { + setSelectedProjects(new Set()); + setIsSelectionMode(false); + setIsBulkDeleteDialogOpen(false); + } }; - const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption); + const sortedProjects = editor.project.getFilteredAndSortedProjects({ + searchQuery, + sortOption, + }); const allSelected = sortedProjects.length > 0 && @@ -178,8 +163,8 @@ export default function ProjectsPage() { Your Projects

- {savedProjects.length}{" "} - {savedProjects.length === 1 ? "project" : "projects"} + {sortedProjects.length}{" "} + {sortedProjects.length === 1 ? "project" : "projects"} {isSelectionMode && selectedProjects.size > 0 && ( • {selectedProjects.size} selected @@ -209,9 +194,9 @@ export default function ProjectsPage() { @@ -317,7 +302,7 @@ export default function ProjectsPage() { )} - {isLoading || !isInitialized ? ( + {editor.project.isLoading || !editor.project.isInitialized ? (

{Array.from({ length: 8 }, (_, index) => (
))}
- ) : savedProjects.length === 0 ? ( + ) : sortedProjects.length === 0 ? ( ) : sortedProjects.length === 0 ? ( ))}
@@ -372,7 +356,6 @@ interface ProjectCardProps { isSelectionMode?: boolean; isSelected?: boolean; onSelect?: (projectId: string, checked: boolean) => void; - getProjectThumbnail: (projectId: string) => Promise; } function ProjectCard({ @@ -380,28 +363,12 @@ function ProjectCard({ isSelectionMode = false, isSelected = false, onSelect, - getProjectThumbnail, }: ProjectCardProps) { const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const [dynamicThumbnail, setDynamicThumbnail] = useState(null); - const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true); const { deleteProject, renameProject, duplicateProject } = useProjectStore(); - useEffect(() => { - const loadThumbnail = async () => { - setIsLoadingThumbnail(true); - try { - const thumbnail = await getProjectThumbnail(project.id); - setDynamicThumbnail(thumbnail); - } finally { - setIsLoadingThumbnail(false); - } - }; - loadThumbnail(); - }, [project.id, getProjectThumbnail]); - const formatDate = (date: Date): string => { return date.toLocaleDateString("en-US", { month: "short", @@ -466,13 +433,9 @@ function ProjectCard({ )}
- {isLoadingThumbnail ? ( -
- -
- ) : dynamicThumbnail ? ( + {project.thumbnail ? ( Project thumbnail { - console.log("handleNameSave", newName); + const handleExit = async () => { + if (isExiting) return; + setIsExiting(true); + + try { + await editor.project.prepareExit(); + editor.project.closeProject(); + } catch (error) { + console.error("Failed to prepare project exit:", error); + } finally { + editor.project.closeProject(); + router.push("/projects"); + } + }; + + const handleSaveProjectName = async (newName: string) => { if (activeProject && newName.trim() && newName !== activeProject.name) { try { - await renameProject(activeProject.id, newName.trim()); - setIsRenameDialogOpen(false); + await editor.project.renameProject({ + id: activeProject.id, + name: newName.trim(), + }); } catch (error) { - console.error("Failed to rename project:", error); + toast.error("Failed to rename project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } finally { + setIsRenameDialogOpen(false); } } }; - const handleDelete = () => { + const handleDeleteProject = async () => { if (activeProject) { - deleteProject(activeProject.id); - setIsDeleteDialogOpen(false); - router.push("/projects"); + try { + await editor.project.deleteProject({ id: activeProject.id }); + router.push("/projects"); + } catch (error) { + toast.error("Failed to delete project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } finally { + setIsDeleteDialogOpen(false); + } } }; @@ -77,12 +115,18 @@ function ProjectDropdown() { - - + + {isExiting ? ( + + ) : ( - Projects - - + )} + Projects + setIsRenameDialogOpen(true)} @@ -115,13 +159,13 @@ function ProjectDropdown() { handleSaveProjectName(newName)} projectName={activeProject?.name || ""} /> diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index 21a9fd39..57a49fb0 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { TransitionUpIcon } from "../icons"; +import { TransitionUpIcon } from "@opencut/ui/icons"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Button } from "../ui/button"; import { Label } from "../ui/label"; @@ -15,20 +15,20 @@ import { getExportFileExtension, DEFAULT_EXPORT_OPTIONS, } from "@/lib/export-utils"; -import { useProjectStore } from "@/stores/project-store"; import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; import { PropertyGroup } from "./properties-panel/property-item"; +import { useEditor } from "@/hooks/use-editor"; export function ExportButton() { const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); - const { activeProject } = useProjectStore(); + const editor = useEditor(); const handleExport = () => { setIsExportPopoverOpen(true); }; - const hasProject = !!activeProject; + const hasProject = !!editor.project.activeProject; return ( @@ -36,10 +36,10 @@ export function ExportButton() { @@ -69,15 +69,16 @@ function ExportPopover({ }: { onOpenChange: (open: boolean) => void; }) { - const { activeProject } = useProjectStore(); + const editor = useEditor(); + const activeProject = editor.project.activeProject; const [format, setFormat] = useState( - DEFAULT_EXPORT_OPTIONS.format + DEFAULT_EXPORT_OPTIONS.format, ); const [quality, setQuality] = useState( - DEFAULT_EXPORT_OPTIONS.quality + DEFAULT_EXPORT_OPTIONS.quality, ); const [includeAudio, setIncludeAudio] = useState( - DEFAULT_EXPORT_OPTIONS.includeAudio || true + DEFAULT_EXPORT_OPTIONS.includeAudio || true, ); const [isExporting, setIsExporting] = useState(false); const [progress, setProgress] = useState(0); @@ -104,8 +105,8 @@ function ExportPopover({ if (result.success && result.buffer) { // Download the file - const mimeType = getExportMimeType(format); - const extension = getExportFileExtension(format); + const mimeType = getExportMimeType({ format }); + const extension = getExportFileExtension({ format }); const blob = new Blob([result.buffer], { type: mimeType }); const url = URL.createObjectURL(blob); @@ -132,7 +133,7 @@ function ExportPopover({ }; return ( - + <> {exportResult && !exportResult.success ? (
-

+

{isExporting ? "Exporting project" : "Export project"}

@@ -233,7 +234,7 @@ function ExportPopover({
@@ -242,18 +243,18 @@ function ExportPopover({ {isExporting && (
-
-

+

+

{Math.round(progress * 100)}%

-

100%

+

100%

- ))} -
-
- - setSpeed(value[0])} - className="mt-2" - /> -
-
- - ); -} diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/timeline/audio-waveform.tsx similarity index 100% rename from apps/web/src/components/editor/audio-waveform.tsx rename to apps/web/src/components/editor/timeline/audio-waveform.tsx diff --git a/apps/web/src/components/editor/timeline/drag-line.tsx b/apps/web/src/components/editor/timeline/drag-line.tsx new file mode 100644 index 00000000..b7a5287b --- /dev/null +++ b/apps/web/src/components/editor/timeline/drag-line.tsx @@ -0,0 +1,21 @@ +import { getDropLineY } from "@/lib/timeline/drop-utils"; +import type { TimelineTrack, DropTarget } from "@/types/timeline"; + +interface DragLineProps { + dropTarget: DropTarget | null; + tracks: TimelineTrack[]; + isVisible: boolean; +} + +export function DragLine({ dropTarget, tracks, isVisible }: DragLineProps) { + if (!isVisible || !dropTarget) return null; + + const y = getDropLineY({ dropTarget, tracks }); + + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 51e8a551..589e97af 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -8,10 +8,8 @@ import { ContextMenuItem, ContextMenuTrigger, } from "../../ui/context-menu"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead, @@ -19,10 +17,11 @@ import { } from "./timeline-playhead"; import { SelectionBox } from "../selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; -import { SnapIndicator } from "../snap-indicator"; +import { SnapIndicator } from "./snap-indicator"; import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; import type { TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useElementInteraction } from "@/hooks/timeline/use-element-interaction"; import { getTrackHeight, getCumulativeHeightBefore, @@ -30,37 +29,59 @@ import { } from "@/lib/timeline"; import { TimelineToolbar } from "./timeline-toolbar"; import { useScrollSync } from "@/hooks/use-scroll-sync"; +import { useElementSelection } from "@/hooks/use-element-selection"; import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions"; import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; import { TimelineRuler } from "./timeline-ruler"; +import { DragLine } from "./drag-line"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useEditor } from "@/hooks/use-editor"; export function Timeline() { - const { - tracks, - getTotalDuration, - clearSelectedElements, - snappingEnabled, - setSelectedElements, - toggleTrackMute, - dragState, - } = useTimelineStore(); - const { currentTime, duration, seek, setDuration } = usePlaybackStore(); - + const editor = useEditor(); + const tracks = editor.timeline.sortedTracks; + const currentTime = editor.playback.currentTime; + const duration = editor.timeline.getTotalDuration(); + const seek = (time: number) => editor.playback.seek({ time }); + + const { snappingEnabled } = useTimelineStore(); + const { clearSelection, setSelection } = useElementSelection(); + const timelineRef = useRef(null); const rulerRef = useRef(null); - const [isInTimeline, setIsInTimeline] = useState(false); + const tracksContainerRef = useRef(null); + const rulerScrollRef = useRef(null); + const tracksScrollRef = useRef(null); + const trackLabelsRef = useRef(null); + const playheadRef = useRef(null); + const trackLabelsScrollRef = useRef(null); + + const [isInTimeline, setIsInTimeline] = useState(false); + const [currentSnapPoint, setCurrentSnapPoint] = useState( + null, + ); + + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); - // Timeline zoom functionality const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ containerRef: timelineRef, isInTimeline, }); - const { dragProps } = useTimelineDragDrop({ + const { + dragState, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + } = useElementInteraction({ zoomLevel, + timelineRef, + tracksContainerRef, + onSnapPointChange: handleSnapPointChange, }); - // Dynamic timeline width calculation based on playhead position and duration const dynamicTimelineWidth = Math.max( (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, (currentTime + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) * @@ -69,14 +90,6 @@ export function Timeline() { timelineRef.current?.clientWidth || 1000, ); - // Scroll synchronization and auto-scroll to playhead - const rulerScrollRef = useRef(null); - const tracksScrollRef = useRef(null); - const trackLabelsRef = useRef(null); - const playheadRef = useRef(null); - const trackLabelsScrollRef = useRef(null); - - // Timeline playhead ruler handlers const { handleRulerMouseDown } = useTimelinePlayheadRuler({ currentTime, duration, @@ -88,8 +101,11 @@ export function Timeline() { playheadRef, }); - // Selection box functionality - const tracksContainerRef = useRef(null); + const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ + containerRef: tracksContainerRef, + zoomLevel, + }); + const { selectionBox, handleMouseDown: handleSelectionMouseDown, @@ -99,23 +115,13 @@ export function Timeline() { containerRef: tracksContainerRef, playheadRef, onSelectionComplete: (elements) => { - console.log(JSON.stringify({ onSelectionComplete: elements.length })); - setSelectedElements(elements); + setSelection(elements); }, }); - // Calculate snap indicator state - const [currentSnapPoint, setCurrentSnapPoint] = useState( - null, - ); const showSnapIndicator = dragState.isDragging && snappingEnabled && currentSnapPoint !== null; - // Callback to handle snap point changes from TimelineTrackContent - const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { - setCurrentSnapPoint(snapPoint); - }, []); - const { handleTimelineMouseDown, handleTimelineContentClick } = useTimelineInteractions({ playheadRef, @@ -126,19 +132,10 @@ export function Timeline() { duration, isSelecting, justFinishedSelecting, - clearSelectedElements, + clearSelectedElements: clearSelection, seek, }); - // Update timeline duration when tracks change - useEffect(() => { - const totalDuration = getTotalDuration(); - setDuration( - Math.max(totalDuration, TIMELINE_CONSTANTS.MIN_DURATION_SECONDS), - ); - }, [tracks, setDuration, getTotalDuration]); - - // --- Scroll synchronization effect --- useScrollSync({ rulerScrollRef, tracksScrollRef, @@ -154,7 +151,10 @@ export function Timeline() { onMouseEnter={() => setIsInTimeline(true)} onMouseLeave={() => setIsInTimeline(false)} > - + setZoomLevel(zoom)} + />
- {/* Timeline Header with Ruler */}
- {/* Track Labels Header */}
- {/* Empty space */} - - . - + .
- {/* Timeline Ruler */}
- {/* Tracks Area */}
- {/* Track Labels */} {tracks.length > 0 && (
toggleTrackMute(track.id)} + onClick={() => + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } /> ) : ( toggleTrackMute(track.id)} + onClick={() => + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } /> )} @@ -250,13 +250,11 @@ export function Timeline() {
)} - {/* Timeline Tracks Content */}
{ - // Check if this is horizontal scrolling - if so, don't handle it here if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - return; // Let ScrollArea handle horizontal scrolling + return; } handleWheel(e); }} @@ -273,6 +271,11 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> +
{ - // If clicking empty area (not on a element), deselect all elements if ( !(e.target as HTMLElement).closest( ".timeline-element", ) ) { - clearSelectedElements(); + clearSelection(); } }} >
@@ -324,7 +329,9 @@ export function Timeline() { { e.stopPropagation(); - toggleTrackMute(track.id); + editor.timeline.toggleTrackMute({ + trackId: track.id, + }); }} > {track.muted ? "Unmute Track" : "Mute Track"} diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/timeline/snap-indicator.tsx similarity index 100% rename from apps/web/src/components/editor/snap-indicator.tsx rename to apps/web/src/components/editor/timeline/snap-indicator.tsx diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index 1326d43b..572d08da 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -15,11 +15,14 @@ import { 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/timeline/use-timeline-element-resize"; +import AudioWaveform from "./audio-waveform"; +import { useTimelineElementResize } from "@/hooks/timeline/use-element-resize"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { getTrackElementClasses, getTrackHeight } from "@/lib/timeline"; +import { + getTrackColor, + getTrackHeight, + isMutableElement, +} from "@/lib/timeline"; import { ContextMenu, ContextMenuContent, @@ -28,6 +31,24 @@ import { ContextMenuTrigger, } from "../../ui/context-menu"; import { useAssetsPanelStore } from "../../../stores/assets-panel-store"; +import { + TimelineElement as TimelineElementType, + TimelineTrack, +} from "@/types/timeline"; +import { ElementDragState } from "@/types/timeline"; + +interface TimelineElementProps { + element: TimelineElementType; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onElementMouseDown: ( + e: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + dragState: ElementDragState; +} export function TimelineElement({ element, @@ -36,11 +57,11 @@ export function TimelineElement({ isSelected, onElementMouseDown, onElementClick, + dragState, }: TimelineElementProps) { const { mediaFiles } = useMediaStore(); const { requestRevealMedia } = useAssetsPanelStore(); const { - dragState, copySelected, selectedElements, deleteSelected, @@ -58,12 +79,11 @@ export function TimelineElement({ : null; const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video"; - const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = - useTimelineElementResize({ - element, - track, - zoomLevel, - }); + const { handleResizeStart } = useTimelineElementResize({ + element, + track, + zoomLevel, + }); const { isMultipleSelected, @@ -215,7 +235,7 @@ export function TimelineElement({ } }; - const isMuted = element.type === "media" && element.muted; + const isMuted = isMutableElement(element) && element.muted; return ( @@ -230,12 +250,9 @@ export function TimelineElement({ }} data-element-id={element.id} data-track-id={track.id} - onMouseMove={resizing ? handleResizeMove : undefined} - onMouseUp={resizing ? handleResizeEnd : undefined} - onMouseLeave={resizing ? handleResizeEnd : undefined} >
handleResizeStart(e, element.id, "left")} + onMouseDown={(e) => + handleResizeStart({ + e, + elementId: element.id, + side: "left", + }) + } >
handleResizeStart(e, element.id, "right")} + onMouseDown={(e) => + handleResizeStart({ + e, + elementId: element.id, + side: "right", + }) + } >
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index b3c5a8b6..c98794c4 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -1,7 +1,5 @@ -import { usePlaybackStore } from "@/stores/playback-store"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useSceneStore } from "@/stores/scene-store"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementSelection } from "@/hooks/use-element-selection"; import { toast } from "sonner"; import { TooltipProvider, @@ -46,97 +44,41 @@ export function TimelineToolbar({ setZoomLevel, }: { zoomLevel: number; - setZoomLevel: (zoom: number) => void; + setZoomLevel: ({ zoom }: { zoom: number }) => void; }) { - const { - tracks, - addTrack, - addElementToTrack, - selectedElements, - clearSelectedElements, - deleteSelected, - splitSelected, - splitAndKeepLeft, - splitAndKeepRight, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { activeProject } = useProjectStore(); - const { toggleBookmark, isBookmarked } = useSceneStore(); - const { scenes, currentScene } = useSceneStore(); + const editor = useEditor(); + const { selectedElements, clearSelection } = useElementSelection(); const handleSplitSelected = () => { - splitSelected(currentTime); + editor.timeline.splitElements({ + elements: selectedElements, + splitTime: editor.playback.currentTime, + }); }; const handleDuplicateSelected = () => { - if (selectedElements.length === 0) return; - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; - - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }); - clearSelectedElements(); - }; - - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + editor.timeline.duplicateElements({ elements: selectedElements }); + clearSelection(); }; const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); + editor.timeline.splitElements({ + elements: selectedElements, + splitTime: editor.playback.currentTime, + retainSide: "left", + }); }; const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); + editor.timeline.splitElements({ + elements: selectedElements, + splitTime: editor.playback.currentTime, + retainSide: "right", + }); }; const handleZoom = ({ direction }: { direction: "in" | "out" }) => { @@ -150,248 +92,329 @@ export function TimelineToolbar({ TIMELINE_CONSTANTS.ZOOM_MIN, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP, ); - setZoomLevel(newZoomLevel); + setZoomLevel({ zoom: newZoomLevel }); }; - const currentBookmarked = isBookmarked({ time: currentTime }); + const hasNoTracks = editor.timeline.getTracks().length === 0; return (
-
- - - - - - - {isPlaying ? "Pause (Space)" : "Play (Space)"} - - - - - - - Return to Start (Home / Enter) - -
- {/* Time Display */} -
- -
- / -
-
- {formatTimeCode({ timeInSeconds: duration })} - {formatTimeCode({ - timeInSeconds: duration, - format: "HH:MM:SS:FF", - })} -
-
- {tracks.length === 0 && ( - <> -
- - - - - Add a test clip to try playback - - - )} -
- - - - - Split element (Ctrl+S) - - - - - - Split and keep left (Ctrl+Q) - - - - - - Split and keep right (Ctrl+W) - - - - - - Separate audio (Coming soon) - - - - - - Duplicate element (Ctrl+D) - - - - - - Freeze frame (F) - - - - - - Delete element (Delete) - -
- - - - - - {currentBookmarked ? "Remove bookmark" : "Add bookmark"} - - - -
-
- - {currentScene?.name || "No Scene"} - - - {}}> - - - - -
-
- - - - - - Auto snapping - - - - - - - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - - - + + + + + setZoomLevel({ zoom })} + onZoom={handleZoom} + /> +
+ ); +} + +function ToolbarLeftSection({ + hasNoTracks, + onSplit, + onSplitLeft, + onSplitRight, + onDuplicate, +}: { + hasNoTracks: boolean; + onSplit: () => void; + onSplitLeft: () => void; + onSplitRight: () => void; + onDuplicate: () => void; +}) { + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + + const currentTime = editor.playback.currentTime; + const duration = editor.timeline.getTotalDuration(); + const isPlaying = editor.playback.isPlaying; + const activeProject = editor.project.getActive(); + const fps = activeProject?.fps ?? DEFAULT_FPS; + const currentBookmarked = editor.scene.isBookmarked({ time: currentTime }); + + return ( +
+ + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + + + + + + + Return to Start (Home / Enter) +
-
- - setZoomLevel(values[0])} - min={TIMELINE_CONSTANTS.ZOOM_MIN} - max={TIMELINE_CONSTANTS.ZOOM_MAX} - step={TIMELINE_CONSTANTS.ZOOM_STEP} - /> - -
+ + + +
+ + + + + + Split element (Ctrl+S) + + + + + + + Split and keep left (Ctrl+Q) + + + + + + + Split and keep right (Ctrl+W) + + + + + + + Separate audio (Coming soon) + + + + + + + Duplicate element (Ctrl+D) + + + + + + + Freeze frame (F) + + + + + + + Delete element (Delete) + + +
+ + + + + + + {currentBookmarked ? "Remove bookmark" : "Add bookmark"} + + + +
+ ); +} + +function TimeDisplay({ + currentTime, + duration, + fps, +}: { + currentTime: number; + duration: number; + fps: number; +}) { + const editor = useEditor(); + + return ( +
+ editor.playback.seek({ time })} + className="text-center" + /> +
/
+
+ {formatTimeCode({ timeInSeconds: duration })} + {formatTimeCode({ + timeInSeconds: duration, + format: "HH:MM:SS:FF", + })} +
+
+ ); +} + +function SceneSelector() { + const editor = useEditor(); + const currentScene = editor.scene.getCurrentScene(); + const scenesCount = editor.scene.getScenes().length; + + return ( +
+ + {currentScene?.name || "No Scene"} + + + {}} + type="button" + > + + + + +
+ ); +} + +function ToolbarRightSection({ + zoomLevel, + onZoomChange, + onZoom, +}: { + zoomLevel: number; + onZoomChange: (zoom: number) => void; + onZoom: (options: { direction: "in" | "out" }) => void; +}) { + return ( +
+ + + + + + Auto snapping + + + + + + + Enable Ripple Editing + + + +
+ +
+ + onZoomChange(values[0])} + min={TIMELINE_CONSTANTS.ZOOM_MIN} + max={TIMELINE_CONSTANTS.ZOOM_MAX} + step={TIMELINE_CONSTANTS.ZOOM_STEP} + /> +
); diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 01b5df18..8e61688d 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -1,388 +1,68 @@ "use client"; -import { useRef, useState, useEffect } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { toast } from "sonner"; +import { useElementSelection } from "@/hooks/use-element-selection"; import { TimelineElement } from "./timeline-element"; import { TimelineTrack } from "@/types/timeline"; -import { usePlaybackStore } from "@/stores/playback-store"; import type { TimelineElement as TimelineElementType } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -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"; +import { ElementDragState } from "@/types/timeline"; +import { useEditor } from "@/hooks/use-editor"; + +interface TimelineTrackContentProps { + track: TimelineTrack; + zoomLevel: number; + dragState: ElementDragState; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + lastMouseXRef: React.RefObject; + onElementMouseDown: (params: { + e: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onElementClick: (params: { + e: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; +} export function TimelineTrackContent({ track, zoomLevel, - onSnapPointChange, + dragState, rulerScrollRef, tracksScrollRef, -}: { - track: TimelineTrack; - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; -}) { - const { - tracks, - updateElementStartTime, - updateElementStartTimeWithRipple, - selectedElements, - selectElement, - dragState, - startDrag: startDragAction, - updateDragTime, - endDrag: endDragAction, - clearSelectedElements, - rippleEditingEnabled, - } = useTimelineStore(); + lastMouseXRef, + onElementMouseDown, + onElementClick, +}: TimelineTrackContentProps) { + const editor = useEditor(); + const { isSelected, clearSelection } = useElementSelection(); - const { duration } = usePlaybackStore(); - - const { isDragOver, wouldOverlap, dragProps } = useTimelineDragDrop({ - track, - zoomLevel, - onSnapPointChange, - }); - - const timelineRef = useRef(null); - const [mouseDownLocation, setMouseDownLocation] = useState<{ - x: number; - y: number; - } | null>(null); - - const lastMouseXRef = useRef(0); - - // Set up mouse event listeners for drag - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseMove = (e: MouseEvent) => { - if (!timelineRef.current) return; - lastMouseXRef.current = e.clientX; - - // On first mouse move during drag, ensure the element is selected - if (dragState.elementId && dragState.trackId) { - const isSelected = selectedElements.some( - (c) => - c.trackId === dragState.trackId && - c.elementId === dragState.elementId, - ); - - if (!isSelected) { - // Select this element (replacing other selections) since we're dragging it - selectElement(dragState.trackId, dragState.elementId, false); - } - } - - const timelineRect = timelineRef.current.getBoundingClientRect(); - const mouseX = e.clientX - timelineRect.left; - const mouseTime = Math.max( - 0, - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), - ); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS; - const finalTime = snapTimeToFrame({ - time: adjustedTime, - fps: projectFps, - }); - - updateDragTime(finalTime); - }; - - const handleMouseUp = (e: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - // If this track initiated the drag, we should handle the mouse up regardless of where it occurs - const isTrackThatStartedDrag = dragState.trackId === track.id; - - const timelineRect = timelineRef.current?.getBoundingClientRect(); - if (!timelineRect) { - if (isTrackThatStartedDrag) { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId, - dragState.currentTime, - ); - } else { - updateElementStartTime( - track.id, - dragState.elementId, - dragState.currentTime, - ); - } - endDragAction(); - // Clear snap point when drag ends - onSnapPointChange?.(null); - } - return; - } - - const isMouseOverThisTrack = - e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; - - if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return; - - const finalTime = dragState.currentTime; - - if (isMouseOverThisTrack) { - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - (c) => c.id === dragState.elementId, - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; - - const targetTrack = tracks.find((t) => t.id === track.id); - const hasOverlap = targetTrack?.elements.some((existingElement) => { - if ( - dragState.trackId === track.id && - existingElement.id === dragState.elementId - ) { - return false; - } - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return finalTime < existingEnd && movingElementEnd > existingStart; - }); - - if (!hasOverlap) { - if (dragState.trackId === track.id) { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId, - finalTime, - ); - } else { - updateElementStartTime( - track.id, - dragState.elementId, - finalTime, - ); - } - } else { - toast.info("Moving elements between tracks is coming soon!"); - } - } - } - } else if (isTrackThatStartedDrag) { - // Mouse is not over this track, but this track started the drag - // This means user released over ruler/outside - update position within same track - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - (c) => c.id === dragState.elementId, - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; - - const hasOverlap = track.elements.some((existingElement) => { - if (existingElement.id === dragState.elementId) { - return false; - } - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return finalTime < existingEnd && movingElementEnd > existingStart; - }); - - if (!hasOverlap) { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId, - finalTime, - ); - } else { - updateElementStartTime(track.id, dragState.elementId, finalTime); - } - } - } - } - - if (isTrackThatStartedDrag) { - endDragAction(); - // Clear snap point when drag ends - onSnapPointChange?.(null); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.trackId, - dragState.currentTime, - zoomLevel, - tracks, - track.id, - updateDragTime, - updateElementStartTime, - endDragAction, - selectedElements, - selectElement, - onSnapPointChange, - ]); + const duration = editor.timeline.getTotalDuration(); useEdgeAutoScroll({ isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current, + getMouseClientX: () => lastMouseXRef.current ?? 0, rulerScrollRef, tracksScrollRef, contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, }); - const handleElementMouseDown = ( - e: React.MouseEvent, - element: TimelineElementType, - ) => { - setMouseDownLocation({ x: e.clientX, y: e.clientY }); - - // Detect right-click (button 2) and handle selection without starting drag - const isRightClick = e.button === 2; - const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; - - if (isRightClick) { - // Handle right-click selection - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id, - ); - - // If element is not selected, select it (keep other selections if multi-select) - if (!isSelected) { - selectElement(track.id, element.id, isMultiSelect); - } - // If element is already selected, keep it selected - - // Don't start drag action for right-clicks - return; - } - - // Handle multi-selection for left-click with modifiers - if (isMultiSelect) { - selectElement(track.id, element.id, true); - } - - // Calculate the offset from the left edge of the element to where the user clicked - const elementElement = e.currentTarget as HTMLElement; - const elementRect = elementElement.getBoundingClientRect(); - const clickOffsetX = e.clientX - elementRect.left; - const clickOffsetTime = - clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - - startDragAction( - element.id, - track.id, - e.clientX, - element.startTime, - clickOffsetTime, - ); - }; - - const handleElementClick = ( - e: React.MouseEvent, - element: TimelineElementType, - ) => { - e.stopPropagation(); - - // Check if mouse moved significantly - if (mouseDownLocation) { - const deltaX = Math.abs(e.clientX - mouseDownLocation.x); - const deltaY = Math.abs(e.clientY - mouseDownLocation.y); - // If it moved more than a few pixels, consider it a drag and not a click. - if (deltaX > 5 || deltaY > 5) { - setMouseDownLocation(null); // Reset for next interaction - return; - } - } - - // Skip selection logic for multi-selection (handled in mousedown) - if (e.metaKey || e.ctrlKey || e.shiftKey) { - return; - } - - // Handle single selection - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id, - ); - - if (!isSelected) { - // If element is not selected, select it (replacing other selections) - selectElement(track.id, element.id, false); - } - // If element is already selected, keep it selected (do nothing) - }; - return ( -
{ - // If clicking empty area (not on an element), deselect all elements - if (!(e.target as HTMLElement).closest(".timeline-element")) { - clearSelectedElements(); - } - }} - {...dragProps} - > -
+
+
{track.elements.length === 0 ? ( -
- {isDragOver - ? wouldOverlap - ? "Cannot drop - would overlap" - : "Drop element here" - : ""} -
+
) : ( <> {track.elements.map((element) => { - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id, - ); + const isElementSelected = isSelected({ + trackId: track.id, + elementId: element.id, + }); return ( + onElementMouseDown({ e, element: el, track }) + } + onElementClick={(e, el) => + onElementClick({ e, element: el, track }) + } + dragState={dragState} /> ); })} diff --git a/apps/web/src/components/gitHub-contribute-section.tsx b/apps/web/src/components/gitHub-contribute-section.tsx index 347af71e..8866d94a 100644 --- a/apps/web/src/components/gitHub-contribute-section.tsx +++ b/apps/web/src/components/gitHub-contribute-section.tsx @@ -1,5 +1,5 @@ import { Button } from "./ui/button"; -import { GithubIcon } from "./icons"; +import { GithubIcon } from "@opencut/ui/icons"; import { ExternalLink } from "lucide-react"; import Link from "next/link"; import { SOCIAL_LINKS } from "@/constants/site-constants"; diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx index dc576b00..bb82cbe5 100644 --- a/apps/web/src/components/rename-project-dialog.tsx +++ b/apps/web/src/components/rename-project-dialog.tsx @@ -18,7 +18,7 @@ export function RenameProjectDialog({ }: { isOpen: boolean; onOpenChange: (open: boolean) => void; - onConfirm: (name: string) => void; + onConfirm: (newName: string) => void; projectName: string; }) { const [name, setName] = useState(projectName); diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 9f5c7887..dcaeaf91 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -12,12 +12,13 @@ import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { usePlaybackStore } from "@/stores/playback-store"; -import { DragData } from "@/types/timeline"; +import { setAssetDragData } from "@/lib/asset-drag"; +import type { AssetDragData } from "@/types/assets"; export interface DraggableMediaItemProps { name: string; preview: ReactNode; - dragData: DragData; + dragData: AssetDragData; onDragStart?: (e: React.DragEvent) => void; onAddToTimeline?: (currentTime: number) => void; aspectRatio?: number; @@ -80,14 +81,9 @@ export function DraggableMediaItem({ const handleDragStart = (e: React.DragEvent) => { e.dataTransfer.setDragImage(emptyImg, 0, 0); - // Set drag data - e.dataTransfer.setData( - "application/x-media-item", - JSON.stringify(dragData) - ); + setAssetDragData({ dataTransfer: e.dataTransfer, dragData }); e.dataTransfer.effectAllowed = "copy"; - // Set initial position and show custom drag preview setDragPosition({ x: e.clientX, y: e.clientY }); setIsDragging(true); @@ -103,13 +99,13 @@ export function DraggableMediaItem({ {variant === "card" ? (
{showLabel && ( @@ -148,24 +144,24 @@ export function DraggableMediaItem({
-
+
{preview}
- {name} + {name}
)} @@ -176,7 +172,7 @@ export function DraggableMediaItem({ typeof document !== "undefined" && createPortal(
-
+
{preview}
{showPlusOnDrag && ( @@ -199,7 +195,7 @@ export function DraggableMediaItem({
, - document.body + document.body, )} ); @@ -218,8 +214,8 @@ function PlusButton({