diff options
Diffstat (limited to 'apps/web/src/components')
16 files changed, 1912 insertions, 1786 deletions
diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx index d970f86d..25d040db 100644 --- a/apps/web/src/components/editor-provider.tsx +++ b/apps/web/src/components/editor-provider.tsx @@ -3,7 +3,11 @@ import { useEffect } from "react";
import { Loader2 } from "lucide-react";
import { useEditorStore } from "@/stores/editor-store";
-import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
+import {
+ useKeybindingsListener,
+ useKeybindingDisabler,
+} from "@/hooks/use-keybindings";
+import { useEditorActions } from "@/hooks/use-editor-actions";
interface EditorProviderProps {
children: React.ReactNode;
@@ -11,11 +15,22 @@ interface EditorProviderProps { export function EditorProvider({ children }: EditorProviderProps) {
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
+ const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
- useKeyboardShortcuts({
- enabled: !isInitializing && isPanelsReady,
- context: "editor",
- });
+ // Set up action handlers
+ useEditorActions();
+
+ // Set up keybinding listener
+ useKeybindingsListener();
+
+ // Disable keybindings when initializing
+ useEffect(() => {
+ if (isInitializing || !isPanelsReady) {
+ disableKeybindings();
+ } else {
+ enableKeybindings();
+ }
+ }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
useEffect(() => {
initializeApp();
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 9f7322c7..90ac84f0 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -4,15 +4,14 @@ import { TabBar } from "./tabbar"; import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
+import { AudioView } from "./views/audio";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
- audio: (
- <div className="p-4 text-muted-foreground">Audio view coming soon...</div>
- ),
+ audio: <AudioView />,
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx new file mode 100644 index 00000000..7ddf465c --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/audio.tsx @@ -0,0 +1,18 @@ +"use client";
+
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+
+export function AudioView() {
+ const [search, setSearch] = useState("");
+ return (
+ <div className="h-full flex flex-col gap-2 p-4">
+ <Input
+ placeholder="Search songs and artists"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ <div className="flex flex-col gap-2"></div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 631fe39a..0189720f 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -208,10 +208,10 @@ export function MediaView() { {/* Search and filter controls */}
<div className="flex gap-2">
<Select value={mediaFilter} onValueChange={setMediaFilter}>
- <SelectTrigger className="w-[80px] h-full text-xs">
+ <SelectTrigger className="w-[80px] h-9 text-xs">
<SelectValue />
</SelectTrigger>
- <SelectContent className="">
+ <SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
@@ -221,7 +221,7 @@ export function MediaView() { <Input
type="text"
placeholder="Search media..."
- className="min-w-[60px] flex-1 h-full text-xs"
+ className="min-w-[60px] flex-1 h-9 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
@@ -230,7 +230,7 @@ export function MediaView() { size="lg"
onClick={handleFileSelect}
disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
+ className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index dc0dd4b2..9e28850f 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -83,11 +83,7 @@ export function PropertiesPanel() { ); if (mediaItem?.type === "audio") { - return ( - <div key={elementId}> - <AudioProperties element={element} /> - </div> - ); + return <AudioProperties element={element} />; } return ( diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx deleted file mode 100644 index c0efc19e..00000000 --- a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { TimelineCanvasRulerWrapperProps } from "@/types/timeline"; -import React, { forwardRef } from "react"; - -/** - * Wrapper div around the canvas-based timeline ruler. - * Captures mouse events for scrubbing and ensures pointer events are properly enabled. - */ -const TimelineCanvasRulerWrapper = forwardRef< - HTMLDivElement, - TimelineCanvasRulerWrapperProps ->(({ children, onMouseDown, className = "" }, ref) => { - return ( - <div - ref={ref} - className={`relative overflow-hidden h-5 w-full select-none cursor-pointer ${className}`} - onMouseDown={onMouseDown} - data-ruler-wrapper - > - {children} - </div> - ); -}); - -export default TimelineCanvasRulerWrapper; diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx deleted file mode 100644 index c40cdbda..00000000 --- a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { TimelineCanvasRulerProps } from "@/types/timeline"; -import React, { useRef, useEffect } from "react"; - -/** - * TimelineCanvasRuler renders the timeline ticks and labels using Canvas. - * Should be wrapped by TimelineCanvasRulerWrapper for interaction handling. - */ -export default function TimelineCanvasRuler({ - zoomLevel, - duration, - width, - height = 20, -}: TimelineCanvasRulerProps) { - const canvasRef = useRef<HTMLCanvasElement>(null); - - useEffect(() => { - const ctx = canvasRef.current?.getContext("2d"); - if (!ctx) return; - - ctx.clearRect(0, 0, width, height); - - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - - const getTimeInterval = () => { - if (pixelsPerSecond >= 200) return 0.1; - if (pixelsPerSecond >= 100) return 0.5; - if (pixelsPerSecond >= 50) return 1; - if (pixelsPerSecond >= 25) return 2; - if (pixelsPerSecond >= 12) return 5; - if (pixelsPerSecond >= 6) return 10; - return 30; - }; - - const mainInterval = getTimeInterval(); - const tickPerMain = 5; - const subInterval = mainInterval / tickPerMain; - - const totalTicks = Math.ceil(duration / subInterval) + 1; - - for (let i = 0; i < totalTicks; i++) { - const time = i * subInterval; - const x = time * pixelsPerSecond; - - const isMain = i % tickPerMain === 0; - - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, isMain ? height * 0.3 : height * 0.1); - ctx.strokeStyle = isMain ? "#999" : "#ccc"; - ctx.lineWidth = isMain ? 1 : 0.5; - ctx.stroke(); - - // Label - if (isMain) { - ctx.fillStyle = "#666"; - ctx.font = "10px sans-serif"; - const mins = Math.floor(time / 60); - const secs = Math.floor(time % 60); - let label = ""; - let xTranslate = 10; - if (mainInterval < 1) { - label = `${time.toFixed(1)}s`; - } else if (mins > 0) { - label = `${mins}:${secs.toString().padStart(2, "0")}`; - } else { - label = `${secs}s`; - xTranslate = 5; - } - ctx.fillText(label, x - xTranslate, height - 4); - } - } - }, [zoomLevel, duration, width, height]); - - return ( - <canvas ref={canvasRef} width={width} height={height} className="block" /> - ); -} diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 71b2cd26..eb9f1c01 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -1,470 +1,392 @@ -"use client";
-
-import { useState } from "react";
-import { Button } from "../ui/button";
-import {
- MoreVertical,
- Scissors,
- Trash2,
- SplitSquareHorizontal,
- Music,
- ChevronRight,
- ChevronLeft,
- Type,
- Copy,
- RefreshCw,
-} 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 { toast } from "sonner";
-import { TimelineElementProps, TrackType } from "@/types/timeline";
-import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
-import {
- getTrackElementClasses,
- TIMELINE_CONSTANTS,
- getTrackHeight,
-} from "@/constants/timeline-constants";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
-} from "../ui/dropdown-menu";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "../ui/context-menu";
-
-export function TimelineElement({
- element,
- track,
- zoomLevel,
- isSelected,
- onElementMouseDown,
- onElementClick,
-}: TimelineElementProps) {
- const { mediaItems } = useMediaStore();
- const {
- updateElementTrim,
- updateElementDuration,
- removeElementFromTrack,
- dragState,
- splitElement,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- addElementToTrack,
- replaceElementMedia,
- } = useTimelineStore();
- const { currentTime } = usePlaybackStore();
-
- const [elementMenuOpen, setElementMenuOpen] = useState(false);
-
- const {
- resizing,
- isResizing,
- handleResizeStart,
- handleResizeMove,
- handleResizeEnd,
- } = useTimelineElementResize({
- element,
- track,
- zoomLevel,
- onUpdateTrim: updateElementTrim,
- onUpdateDuration: updateElementDuration,
- });
-
- const effectiveDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementWidth = Math.max(
- TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
- effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
- );
-
- // Use real-time position during drag, otherwise use stored position
- const isBeingDragged = dragState.elementId === element.id;
- const elementStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime
- : element.startTime;
- const elementLeft = elementStartTime * 50 * zoomLevel;
-
- const handleDeleteElement = () => {
- removeElementFromTrack(track.id, element.id);
- setElementMenuOpen(false);
- };
-
- const handleSplitElement = () => {
- 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 element to split");
- return;
- }
-
- const secondElementId = splitElement(track.id, element.id, currentTime);
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- setElementMenuOpen(false);
- };
-
- const handleSplitAndKeepLeft = () => {
- 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 element");
- return;
- }
-
- splitAndKeepLeft(track.id, element.id, currentTime);
- setElementMenuOpen(false);
- };
-
- const handleSplitAndKeepRight = () => {
- 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 element");
- return;
- }
-
- splitAndKeepRight(track.id, element.id, currentTime);
- setElementMenuOpen(false);
- };
-
- const handleSeparateAudio = () => {
- if (element.type !== "media") {
- toast.error("Audio separation only available for media elements");
- return;
- }
-
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- if (!mediaItem || mediaItem.type !== "video") {
- toast.error("Audio separation only available for video elements");
- return;
- }
-
- const audioElementId = separateAudio(track.id, element.id);
- if (!audioElementId) {
- toast.error("Failed to separate audio");
- }
- setElementMenuOpen(false);
- };
-
- const canSplitAtPlayhead = () => {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- return currentTime > effectiveStart && currentTime < effectiveEnd;
- };
-
- const canSeparateAudio = () => {
- if (element.type !== "media") return false;
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- return mediaItem?.type === "video" && track.type === "media";
- };
-
- const handleElementSplitContext = () => {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const secondElementId = splitElement(track.id, element.id, currentTime);
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- } else {
- toast.error("Playhead must be within element to split");
- }
- };
-
- const handleElementDuplicateContext = () => {
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
- };
-
- const handleElementDeleteContext = () => {
- removeElementFromTrack(track.id, element.id);
- };
-
- const handleReplaceClip = () => {
- if (element.type !== "media") {
- toast.error("Replace is only available for media clips");
- return;
- }
-
- // Create a file input to select replacement media
- 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) return;
-
- try {
- const success = await replaceElementMedia(track.id, element.id, file);
- if (success) {
- toast.success("Clip replaced successfully");
- } else {
- toast.error("Failed to replace clip");
- }
- } catch (error) {
- toast.error("Failed to replace clip");
- console.log(
- JSON.stringify({ error: "Failed to replace clip", details: error })
- );
- }
- };
- input.click();
- };
-
- const renderElementContent = () => {
- if (element.type === "text") {
- return (
- <div className="w-full h-full flex items-center justify-start pl-2">
- <span className="text-xs text-foreground/80 truncate">
- {element.content}
- </span>
- </div>
- );
- }
-
- // Render media element ->
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- if (!mediaItem) {
- return (
- <span className="text-xs text-foreground/80 truncate">
- {element.name}
- </span>
- );
- }
-
- const TILE_ASPECT_RATIO = 16 / 9;
-
- if (mediaItem.type === "image") {
- // Calculate tile size based on 16:9 aspect ratio
- const trackHeight = getTrackHeight(track.type);
- const tileHeight = trackHeight - 8; // Account for padding
- const tileWidth = tileHeight * TILE_ASPECT_RATIO;
-
- return (
- <div className="w-full h-full flex items-center justify-center">
- <div className="bg-[#004D52] py-3 w-full h-full relative">
- {/* Background with tiled images */}
- <div
- className="absolute top-3 bottom-3 left-0 right-0"
- style={{
- backgroundImage: mediaItem.url
- ? `url(${mediaItem.url})`
- : "none",
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${tileHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- aria-label={`Tiled background of ${mediaItem.name}`}
- />
- {/* Overlay with vertical borders */}
- <div
- className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
- style={{
- backgroundImage: `repeating-linear-gradient(
- to right,
- transparent 0px,
- transparent ${tileWidth - 1}px,
- rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
- rgba(255, 255, 255, 0.6) ${tileWidth}px
- )`,
- backgroundPosition: "left center",
- }}
- />
- </div>
- </div>
- );
- }
-
- const VIDEO_TILE_PADDING = 16;
- const OVERLAY_SPACE_MULTIPLIER = 1.5;
-
- if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
- const trackHeight = getTrackHeight(track.type);
- const tileHeight = trackHeight - VIDEO_TILE_PADDING;
- const tileWidth = tileHeight * TILE_ASPECT_RATIO;
-
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="flex-1 h-full relative overflow-hidden">
- {/* Background with tiled thumbnails */}
- <div
- className="absolute inset-0"
- style={{
- backgroundImage: mediaItem.thumbnailUrl
- ? `url(${mediaItem.thumbnailUrl})`
- : "none",
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${tileHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- aria-label={`Tiled thumbnail of ${mediaItem.name}`}
- />
- {/* Overlay with vertical borders */}
- <div
- className="absolute inset-0 pointer-events-none"
- style={{
- backgroundImage: `repeating-linear-gradient(
- to right,
- transparent 0px,
- transparent ${tileWidth - 1}px,
- rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
- rgba(255, 255, 255, 0.6) ${tileWidth}px
- )`,
- backgroundPosition: "left center",
- }}
- />
- </div>
- {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
- <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
- {element.name}
- </div>
- ) : (
- <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
- {element.name}
- </span>
- )}
- </div>
- );
- }
-
- // Render audio element ->
- if (mediaItem.type === "audio") {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="flex-1 min-w-0">
- <AudioWaveform
- audioUrl={mediaItem.url || ""}
- height={24}
- className="w-full"
- />
- </div>
- </div>
- );
- }
-
- return (
- <span className="text-xs text-foreground/80 truncate">
- {element.name}
- </span>
- );
- };
-
- const handleElementMouseDown = (e: React.MouseEvent) => {
- if (onElementMouseDown) {
- onElementMouseDown(e, element);
- }
- };
-
- return (
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- className={`absolute top-0 h-full select-none timeline-element ${
- isBeingDragged ? "z-50" : "z-10"
- }`}
- style={{
- left: `${elementLeft}px`,
- width: `${elementWidth}px`,
- }}
- data-element-id={element.id}
- data-track-id={track.id}
- onMouseMove={resizing ? handleResizeMove : undefined}
- onMouseUp={resizing ? handleResizeEnd : undefined}
- onMouseLeave={resizing ? handleResizeEnd : undefined}
- >
- <div
- className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
- track.type
- )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
- isBeingDragged ? "z-50" : "z-10"
- }`}
- onClick={(e) => onElementClick && onElementClick(e, element)}
- onMouseDown={handleElementMouseDown}
- onContextMenu={(e) =>
- onElementMouseDown && onElementMouseDown(e, element)
- }
- >
- <div className="absolute inset-0 flex items-center h-full">
- {renderElementContent()}
- </div>
-
- {isSelected && (
- <>
- <div
- className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
- />
- <div
- className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
- />
- </>
- )}
- </div>
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem onClick={handleElementSplitContext}>
- <Scissors className="h-4 w-4 mr-2" />
- Split at playhead
- </ContextMenuItem>
- <ContextMenuItem onClick={handleElementDuplicateContext}>
- <Copy className="h-4 w-4 mr-2" />
- Duplicate {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- {element.type === "media" && (
- <ContextMenuItem onClick={handleReplaceClip}>
- <RefreshCw className="h-4 w-4 mr-2" />
- Replace clip
- </ContextMenuItem>
- )}
- <ContextMenuSeparator />
- <ContextMenuItem
- onClick={handleElementDeleteContext}
- className="text-destructive focus:text-destructive"
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- );
-}
+"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; +import { + MoreVertical, + Scissors, + Trash2, + SplitSquareHorizontal, + Music, + ChevronRight, + ChevronLeft, + Type, + Copy, + RefreshCw, +} 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 { toast } from "sonner"; +import { TimelineElementProps, TrackType } from "@/types/timeline"; +import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; +import { + getTrackElementClasses, + TIMELINE_CONSTANTS, + getTrackHeight, +} from "@/constants/timeline-constants"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "../ui/dropdown-menu"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; + +export function TimelineElement({ + element, + track, + zoomLevel, + isSelected, + onElementMouseDown, + onElementClick, +}: TimelineElementProps) { + const { mediaItems } = useMediaStore(); + const { + updateElementTrim, + updateElementDuration, + removeElementFromTrack, + removeElementFromTrackWithRipple, + dragState, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + addElementToTrack, + replaceElementMedia, + rippleEditingEnabled, + } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); + + const [elementMenuOpen, setElementMenuOpen] = useState(false); + + const { + resizing, + isResizing, + handleResizeStart, + handleResizeMove, + handleResizeEnd, + } = useTimelineElementResize({ + element, + track, + zoomLevel, + onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, + }); + + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + const elementWidth = Math.max( + TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, + effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel + ); + + // Use real-time position during drag, otherwise use stored position + const isBeingDragged = dragState.elementId === element.id; + const elementStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + : element.startTime; + + // Element should always be positioned at startTime - trimStart only affects content, not position + const elementLeft = elementStartTime * 50 * zoomLevel; + + const handleElementSplitContext = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const secondElementId = splitElement(track.id, element.id, currentTime); + if (!secondElementId) { + toast.error("Failed to split element"); + } + } else { + toast.error("Playhead must be within element to split"); + } + }; + + const handleElementDuplicateContext = () => { + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; + + const handleElementDeleteContext = () => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(track.id, element.id); + } else { + removeElementFromTrack(track.id, element.id); + } + }; + + const handleReplaceClip = () => { + if (element.type !== "media") { + toast.error("Replace is only available for media clips"); + return; + } + + // Create a file input to select replacement media + 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) return; + + try { + const success = await replaceElementMedia(track.id, element.id, file); + if (success) { + toast.success("Clip replaced successfully"); + } else { + toast.error("Failed to replace clip"); + } + } catch (error) { + toast.error("Failed to replace clip"); + console.log( + JSON.stringify({ error: "Failed to replace clip", details: error }) + ); + } + }; + input.click(); + }; + + const renderElementContent = () => { + if (element.type === "text") { + return ( + <div className="w-full h-full flex items-center justify-start pl-2"> + <span className="text-xs text-foreground/80 truncate"> + {element.content} + </span> + </div> + ); + } + + // Render media element -> + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem) { + return ( + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> + ); + } + + const TILE_ASPECT_RATIO = 16 / 9; + + if (mediaItem.type === "image") { + // Calculate tile size based on 16:9 aspect ratio + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - 8; // Account for padding + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + + return ( + <div className="w-full h-full flex items-center justify-center"> + <div className="bg-[#004D52] py-3 w-full h-full relative"> + {/* Background with tiled images */} + <div + className="absolute top-3 bottom-3 left-0 right-0" + style={{ + backgroundImage: mediaItem.url + ? `url(${mediaItem.url})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled background of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( + to right, + transparent 0px, + transparent ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth}px + )`, + backgroundPosition: "left center", + }} + /> + </div> + </div> + ); + } + + const VIDEO_TILE_PADDING = 16; + const OVERLAY_SPACE_MULTIPLIER = 1.5; + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - VIDEO_TILE_PADDING; + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="flex-1 h-full relative overflow-hidden"> + {/* Background with tiled thumbnails */} + <div + className="absolute inset-0" + style={{ + backgroundImage: mediaItem.thumbnailUrl + ? `url(${mediaItem.thumbnailUrl})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled thumbnail of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute inset-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( + to right, + transparent 0px, + transparent ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth}px + )`, + backgroundPosition: "left center", + }} + /> + </div> + {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( + <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> + {element.name} + </div> + ) : ( + <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> + {element.name} + </span> + )} + </div> + ); + } + + // Render audio element -> + if (mediaItem.type === "audio") { + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="flex-1 min-w-0"> + <AudioWaveform + audioUrl={mediaItem.url || ""} + height={24} + className="w-full" + /> + </div> + </div> + ); + } + + return ( + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> + ); + }; + + const handleElementMouseDown = (e: React.MouseEvent) => { + if (onElementMouseDown) { + onElementMouseDown(e, element); + } + }; + + return ( + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className={`absolute top-0 h-full select-none timeline-element ${ + isBeingDragged ? "z-50" : "z-10" + }`} + style={{ + left: `${elementLeft}px`, + width: `${elementWidth}px`, + }} + data-element-id={element.id} + data-track-id={track.id} + onMouseMove={resizing ? handleResizeMove : undefined} + onMouseUp={resizing ? handleResizeEnd : undefined} + onMouseLeave={resizing ? handleResizeEnd : undefined} + > + <div + className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( + track.type + )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ + isBeingDragged ? "z-50" : "z-10" + }`} + onClick={(e) => onElementClick && onElementClick(e, element)} + onMouseDown={handleElementMouseDown} + onContextMenu={(e) => + onElementMouseDown && onElementMouseDown(e, element) + } + > + <div className="absolute inset-0 flex items-center h-full"> + {renderElementContent()} + </div> + + {isSelected && ( + <> + <div + className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "left")} + /> + <div + className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "right")} + /> + </> + )} + </div> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onClick={handleElementSplitContext}> + <Scissors className="h-4 w-4 mr-2" /> + Split at playhead + </ContextMenuItem> + <ContextMenuItem onClick={handleElementDuplicateContext}> + <Copy className="h-4 w-4 mr-2" /> + Duplicate {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + {element.type === "media" && ( + <ContextMenuItem onClick={handleReplaceClip}> + <RefreshCw className="h-4 w-4 mr-2" /> + Replace clip + </ContextMenuItem> + )} + <ContextMenuSeparator /> + <ContextMenuItem + onClick={handleElementDeleteContext} + className="text-destructive focus:text-destructive" + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); +} diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx index 390abc3d..7a357067 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -2,7 +2,10 @@ import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ TIMELINE_CONSTANTS,
+ getTotalTracksHeight,
+} from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
@@ -13,6 +16,7 @@ interface TimelinePlayheadProps { seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
@@ -27,6 +31,7 @@ export function TimelinePlayhead({ seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
@@ -34,13 +39,15 @@ export function TimelinePlayhead({ }: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handleRulerMouseDown } = useTimelinePlayhead({
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
});
// Use timeline container height minus a few pixels for breathing room
@@ -59,14 +66,14 @@ export function TimelinePlayhead({ return (
<div
ref={playheadRef}
- className="absolute pointer-events-auto z-[50]"
+ className="absolute pointer-events-auto z-[150]"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
}}
- onMouseDown={handleRulerMouseDown}
+ onMouseDown={handlePlayheadMouseDown}
>
{/* The playhead line spanning full height */}
<div
@@ -89,24 +96,21 @@ export function useTimelinePlayheadRuler({ seek,
rulerRef,
rulerScrollRef,
-}: Omit<
- TimelinePlayheadProps,
- | "tracks"
- | "trackLabelsRef"
- | "timelineRef"
- | "tracksScrollRef"
- | "playheadRef"
->) {
- const { handleRulerMouseDown } = useTimelinePlayhead({
+ tracksScrollRef,
+ playheadRef,
+}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
+ const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
});
- return { handleRulerMouseDown };
+ return { handleRulerMouseDown, isDraggingRuler };
}
export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 2aa1c2b0..82ca0b98 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -39,6 +39,7 @@ export function TimelineTrackContent({ addTrack,
moveElementToTrack,
updateElementStartTime,
+ updateElementStartTimeWithRipple,
addElementToTrack,
selectedElements,
selectElement,
@@ -49,6 +50,7 @@ export function TimelineTrackContent({ clearSelectedElements,
insertTrackAt,
snappingEnabled,
+ rippleEditingEnabled,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -221,11 +223,19 @@ export function TimelineTrackContent({ const timelineRect = timelineRef.current?.getBoundingClientRect();
if (!timelineRect) {
if (isTrackThatStartedDrag) {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- dragState.currentTime
- );
+ 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);
@@ -272,7 +282,19 @@ export function TimelineTrackContent({ if (!hasOverlap) {
if (dragState.trackId === track.id) {
- updateElementStartTime(track.id, dragState.elementId, finalTime);
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId,
+ finalTime
+ );
+ } else {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId,
+ finalTime
+ );
+ }
} else {
moveElementToTrack(
dragState.trackId,
@@ -280,11 +302,19 @@ export function TimelineTrackContent({ dragState.elementId
);
requestAnimationFrame(() => {
- updateElementStartTime(
- track.id,
- dragState.elementId!,
- finalTime
- );
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId!,
+ finalTime
+ );
+ } else {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId!,
+ finalTime
+ );
+ }
});
}
}
@@ -318,7 +348,15 @@ export function TimelineTrackContent({ });
if (!hasOverlap) {
- updateElementStartTime(track.id, dragState.elementId, finalTime);
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId,
+ finalTime
+ );
+ } else {
+ updateElementStartTime(track.id, dragState.elementId, finalTime);
+ }
}
}
}
@@ -743,12 +781,28 @@ export function TimelineTrackContent({ if (fromTrackId === track.id) {
// Moving within same track
- updateElementStartTime(track.id, elementId, finalStartTime);
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ elementId,
+ finalStartTime
+ );
+ } else {
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ }
} else {
// Moving to different track
moveElementToTrack(fromTrackId, track.id, elementId);
requestAnimationFrame(() => {
- updateElementStartTime(track.id, elementId, finalStartTime);
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ elementId,
+ finalStartTime
+ );
+ } else {
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ }
});
}
} else if (hasMediaItem) {
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 5130c753..6e8c453c 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1,948 +1,1092 @@ -"use client";
-
-import { ScrollArea } from "../ui/scroll-area";
-import { Button } from "../ui/button";
-import {
- Scissors,
- ArrowLeftToLine,
- ArrowRightToLine,
- Trash2,
- Snowflake,
- Copy,
- SplitSquareHorizontal,
- Pause,
- Play,
- Video,
- Music,
- TypeIcon,
- Lock,
- ZoomIn,
- ZoomOut,
- LockOpen,
-} from "lucide-react";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- TooltipProvider,
-} from "../ui/tooltip";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "../ui/context-menu";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
-import { processMediaFiles } from "@/lib/media-processing";
-import { toast } from "sonner";
-import { useState, useRef, useEffect, useCallback } from "react";
-import { TimelineTrackContent } from "./timeline-track";
-import {
- TimelinePlayhead,
- useTimelinePlayheadRuler,
-} from "./timeline-playhead";
-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 type { DragData, TimelineTrack } from "@/types/timeline";
-import {
- getTrackHeight,
- getCumulativeHeightBefore,
- getTotalTracksHeight,
- TIMELINE_CONSTANTS,
- snapTimeToFrame,
-} from "@/constants/timeline-constants";
-import { Slider } from "../ui/slider";
-import TimelineCanvasRuler from "./timeline-canvas/timeline-canvas-ruler";
-import TimelineCanvasRulerWrapper from "./timeline-canvas/timeline-canvas-ruler-wrapper";
-
-export function Timeline() {
- // Timeline shows all tracks (video, audio, effects) and their elements.
- // You can drag media here to add it to your project.
- // elements can be trimmed, deleted, and moved.
-
- const {
- tracks,
- addTrack,
- addElementToTrack,
- removeElementFromTrack,
- getTotalDuration,
- selectedElements,
- clearSelectedElements,
- setSelectedElements,
- splitElement,
- splitAndKeepLeft,
- splitAndKeepRight,
- toggleTrackMute,
- separateAudio,
- undo,
- redo,
- snappingEnabled,
- toggleSnapping,
- dragState,
- } = useTimelineStore();
- const { mediaItems, addMediaItem } = useMediaStore();
- const { activeProject } = useProjectStore();
- const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
- usePlaybackStore();
- const [isDragOver, setIsDragOver] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const [progress, setProgress] = useState(0);
- const dragCounterRef = useRef(0);
- const timelineRef = useRef<HTMLDivElement>(null);
- const rulerRef = useRef<HTMLDivElement>(null);
- const [isInTimeline, setIsInTimeline] = useState(false);
-
- // Timeline zoom functionality
- const {
- zoomLevel,
- zoomStep,
- handleChangeZoomLevel,
- handleChangeZoomStep,
- handleWheel,
- } = useTimelineZoom();
-
- // Old marquee selection removed - using new SelectionBox component instead
-
- // Dynamic timeline width calculation based on playhead position and duration
- const dynamicTimelineWidth = Math.max(
- (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration
- (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer
- timelineRef.current?.clientWidth || 1000 // Minimum width
- );
-
- // Scroll synchronization and auto-scroll to playhead
- const rulerScrollRef = useRef<HTMLDivElement>(null);
- const trackLabelsRef = useRef<HTMLDivElement>(null);
- const playheadRef = useRef<HTMLDivElement>(null);
- const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
- const isUpdatingRef = useRef(false);
- const lastRulerSync = useRef(0);
- const lastTracksSync = useRef(0);
- const lastVerticalSync = useRef(0);
-
- // Timeline playhead ruler handlers
- const { handleRulerMouseDown } = useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- });
-
- // Selection box functionality
- const tracksContainerRef = useRef<HTMLDivElement>(null);
- const {
- selectionBox,
- handleMouseDown: handleSelectionMouseDown,
- isSelecting,
- justFinishedSelecting,
- } = useSelectionBox({
- containerRef: tracksContainerRef,
- playheadRef,
- onSelectionComplete: (elements) => {
- console.log(JSON.stringify({ onSelectionComplete: elements.length }));
- setSelectedElements(elements);
- },
- });
-
- // Calculate snap indicator state
- const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
- null
- );
- const showSnapIndicator =
- dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
-
- // Callback to handle snap point changes from TimelineTrackContent
- const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
- setCurrentSnapPoint(snapPoint);
- }, []);
-
- // Timeline content click to seek handler
- const handleTimelineContentClick = useCallback(
- (e: React.MouseEvent) => {
- console.log(
- JSON.stringify({
- timelineClick: {
- isSelecting,
- justFinishedSelecting,
- willReturn: isSelecting || justFinishedSelecting,
- },
- })
- );
-
- // Don't seek if this was a selection box operation
- if (isSelecting || justFinishedSelecting) {
- return;
- }
-
- // Don't seek if clicking on timeline elements, but still deselect
- if ((e.target as HTMLElement).closest(".timeline-element")) {
- return;
- }
-
- // Don't seek if clicking on playhead
- if (playheadRef.current?.contains(e.target as Node)) {
- return;
- }
-
- // Don't seek if clicking on track labels
- if ((e.target as HTMLElement).closest("[data-track-labels]")) {
- clearSelectedElements();
- return;
- }
-
- // Clear selected elements when clicking empty timeline area
- console.log(JSON.stringify({ clearingSelectedElements: true }));
- clearSelectedElements();
-
- // Determine if we're clicking in ruler or tracks area
- const isTracksAreaClick = (e.target as HTMLElement).closest(
- "[data-tracks-area]"
- );
-
- let mouseX: number;
- let scrollLeft = 0;
-
- if (isTracksAreaClick) {
- // Calculate based on ruler position
- const rulerContent = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerContent) return;
- const rect = rulerContent.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- scrollLeft = rulerContent.scrollLeft;
- } else {
- return;
- }
-
- const rawTime = Math.max(
- 0,
- Math.min(
- duration,
- (mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- )
- );
-
- // Use frame snapping for timeline clicking
- const projectFps = activeProject?.fps || 30;
- const time = snapTimeToFrame(rawTime, projectFps);
-
- seek(time);
- },
- [
- duration,
- zoomLevel,
- seek,
- rulerScrollRef,
- clearSelectedElements,
- isSelecting,
- justFinishedSelecting,
- ]
- );
-
- // Update timeline duration when tracks change
- useEffect(() => {
- const totalDuration = getTotalDuration();
- setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
- }, [tracks, setDuration, getTotalDuration]);
-
- // Old marquee system removed - using new SelectionBox component instead
-
- const handleDragEnter = (e: React.DragEvent) => {
- // When something is dragged over the timeline, show overlay
- e.preventDefault();
- // Don't show overlay for timeline elements - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-element")) {
- return;
- }
- dragCounterRef.current += 1;
- if (!isDragOver) {
- setIsDragOver(true);
- }
- };
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- };
-
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Don't update state for timeline elements - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-element")) {
- return;
- }
-
- dragCounterRef.current -= 1;
- if (dragCounterRef.current === 0) {
- setIsDragOver(false);
- }
- };
-
- const handleDrop = async (e: React.DragEvent) => {
- // When media is dropped, add it as a new track/element
- e.preventDefault();
- setIsDragOver(false);
- dragCounterRef.current = 0;
-
- // Ignore timeline element drags - they're handled by track-specific handlers
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- if (hasTimelineElement) {
- return;
- }
-
- const itemData = e.dataTransfer.getData("application/x-media-item");
- if (itemData) {
- try {
- const dragData: DragData = JSON.parse(itemData);
-
- if (dragData.type === "text") {
- // Always create new text track to avoid overlaps
- useTimelineStore.getState().addTextToNewTrack(dragData);
- } else {
- // Handle media items
- const mediaItem = mediaItems.find(
- (item: any) => item.id === dragData.id
- );
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- useTimelineStore.getState().addMediaToNewTrack(mediaItem);
- }
- } catch (error) {
- console.error("Error parsing dropped item data:", error);
- toast.error("Failed to add item to timeline");
- }
- } else if (e.dataTransfer.files?.length > 0) {
- // Handle file drops by creating new tracks
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- setIsProcessing(true);
- setProgress(0);
- try {
- const processedItems = await processMediaFiles(
- e.dataTransfer.files,
- (p) => setProgress(p)
- );
- for (const processedItem of processedItems) {
- await addMediaItem(activeProject.id, processedItem);
- const currentMediaItems = useMediaStore.getState().mediaItems;
- const addedItem = currentMediaItems.find(
- (item) =>
- item.name === processedItem.name && item.url === processedItem.url
- );
- if (addedItem) {
- useTimelineStore.getState().addMediaToNewTrack(addedItem);
- }
- }
- } catch (error) {
- // Show error if file processing fails
- console.error("Error processing external files:", error);
- toast.error("Failed to process dropped files");
- } finally {
- setIsProcessing(false);
- setProgress(0);
- }
- }
- };
-
- const dragProps = {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- };
-
- // Action handlers for toolbar
- const handleSplitSelected = () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- let splitCount = 0;
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (element && track) {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const newElementId = splitElement(trackId, elementId, currentTime);
- if (newElementId) splitCount++;
- }
- }
- });
- if (splitCount === 0) {
- toast.error("Playhead must be within selected elements to split");
- }
- };
-
- const handleDuplicateSelected = () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- const canDuplicate = selectedElements.length === 1;
- if (!canDuplicate) return;
-
- const newSelections: { trackId: string; elementId: string }[] = [];
-
- 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;
-
- // Create element without id (will be generated by store)
- const { id, ...elementWithoutId } = element;
-
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
-
- // We can't predict the new id, so just clear selection for now
- // TODO: addElementToTrack could return the new element id
- }
- });
-
- clearSelectedElements();
- };
-
- const handleFreezeSelected = () => {
- toast.info("Freeze frame functionality coming soon!");
- };
-
- 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);
- };
-
- 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);
- };
-
- 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 handleDeleteSelected = () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- selectedElements.forEach(({ trackId, elementId }) => {
- removeElementFromTrack(trackId, elementId);
- });
- clearSelectedElements();
- };
-
- // --- Scroll synchronization effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
-
- if (!rulerViewport) return;
-
- // Horizontal scroll synchronization between ruler and tracks
- const handleRulerScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
- lastRulerSync.current = now;
- isUpdatingRef.current = true;
- isUpdatingRef.current = false;
- };
- const handleTracksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
- lastTracksSync.current = now;
- isUpdatingRef.current = true;
- isUpdatingRef.current = false;
- };
-
- rulerViewport.addEventListener("scroll", handleRulerScroll);
-
- // Vertical scroll synchronization between track labels and tracks content
- if (trackLabelsViewport) {
- const handleTrackLabelsScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- isUpdatingRef.current = false;
- };
- const handleTracksVerticalScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- isUpdatingRef.current = false;
- };
-
- trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
-
- return () => {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- trackLabelsViewport.removeEventListener(
- "scroll",
- handleTrackLabelsScroll
- );
- };
- }
-
- return () => {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- };
- }, []);
-
- return (
- <div
- className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`}
- {...dragProps}
- onMouseEnter={() => setIsInTimeline(true)}
- onMouseLeave={() => setIsInTimeline(false)}
- >
- {/* Toolbar */}
- <div className="border-b flex items-center justify-between px-2 py-1">
- <div className="flex items-center gap-1 w-full">
- <TooltipProvider delayDuration={500}>
- {/* Play/Pause Button */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- className="mr-2"
- >
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- {/* Time Display */}
- <div
- className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: "18ch", textAlign: "center" }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
- </div>
- {/* Test Clip Button - for debugging */}
- {tracks.length === 0 && (
- <>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("media");
- addElementToTrack(trackId, {
- type: "media",
- mediaId: "test",
- name: "Test Clip",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- Add a test clip to try playback
- </TooltipContent>
- </Tooltip>
- </>
- )}
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitSelected}
- >
- <Scissors className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split element (Ctrl+S)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
- <ArrowLeftToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
- <ArrowRightToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSeparateAudio}
- >
- <SplitSquareHorizontal className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDuplicateSelected}
- >
- <Copy className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleFreezeSelected}
- >
- <Snowflake className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Freeze frame (F)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDeleteSelected}
- >
- <Trash2 className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Delete element (Delete)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- className="ml-auto"
- variant="text"
- size="icon"
- onClick={() => handleChangeZoomLevel(zoomLevel - 0.15)}
- >
- <ZoomOut className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Zoom Out</TooltipContent>
- </Tooltip>
- <Slider
- className="max-w-24"
- max={TIMELINE_CONSTANTS.MAX_ZOOM_STEP}
- min={0}
- step={1}
- value={[zoomStep]}
- onValueChange={(value) => handleChangeZoomStep(value[0])}
- />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={() => handleChangeZoomLevel(zoomLevel + 0.15)}
- >
- <ZoomIn className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Zoom In</TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggleSnapping}>
- {snappingEnabled ? (
- <Lock className="h-4 w-4" />
- ) : (
- <LockOpen className="h-4 w-4 text-primary" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>Auto snapping</TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </div>
-
- {/* Timeline Container */}
- <div
- className="flex-1 flex flex-col overflow-hidden relative"
- ref={timelineRef}
- >
- <TimelinePlayhead
- currentTime={currentTime}
- duration={duration}
- zoomLevel={zoomLevel}
- tracks={tracks}
- seek={seek}
- rulerRef={rulerRef}
- rulerScrollRef={rulerScrollRef}
- trackLabelsRef={trackLabelsRef}
- timelineRef={timelineRef}
- playheadRef={playheadRef}
- isSnappingToPlayhead={
- showSnapIndicator && currentSnapPoint?.type === "playhead"
- }
- />
- <SnapIndicator
- snapPoint={currentSnapPoint}
- zoomLevel={zoomLevel}
- tracks={tracks}
- timelineRef={timelineRef}
- trackLabelsRef={trackLabelsRef}
- isVisible={showSnapIndicator}
- />
- {/* Timeline Header with Ruler */}
- <div className="flex bg-panel sticky top-0 z-10">
- <div className="w-48 flex-shrink-0 bg-muted/30 border-r h-5 px-3" />
-
- <div className="flex-1 overflow-hidden h-5">
- <ScrollArea className="w-full" ref={rulerScrollRef}>
- <TimelineCanvasRulerWrapper
- ref={rulerRef}
- onMouseDown={handleRulerMouseDown}
- >
- <TimelineCanvasRuler
- zoomLevel={zoomLevel}
- duration={duration}
- width={dynamicTimelineWidth}
- />
- </TimelineCanvasRulerWrapper>
- </ScrollArea>
- </div>
- </div>
-
- {/* Tracks Area */}
- <ScrollArea className="w-full h-full">
- <div
- className="flex-1 flex overflow-hidden overflow-y-auto"
- data-tracks-area
- >
- {/* Track Labels */}
- {tracks.length > 0 && (
- <div
- ref={trackLabelsRef}
- className="w-48 flex-shrink-0 border-r bg-panel-accent "
- data-track-labels
- >
- <div className="flex flex-col gap-1" ref={trackLabelsScrollRef}>
- {tracks.map((track) => (
- <div
- key={track.id}
- className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
- style={{ height: `${getTrackHeight(track.type)}px` }}
- >
- <div className="flex items-center flex-1 min-w-0">
- <TrackIcon track={track} />
- </div>
- {track.muted && (
- <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
- Muted
- </span>
- )}
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* Timeline Tracks Content */}
- <div
- className="flex-1 relative overflow-hidden"
- onWheel={handleWheel}
- onMouseDown={handleSelectionMouseDown}
- onClick={handleTimelineContentClick}
- ref={tracksContainerRef}
- >
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
- <div
- className="relative flex-1"
- style={{
- height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`,
- width: `${dynamicTimelineWidth}px`,
- }}
- >
- {tracks.length === 0 ? (
- <div></div>
- ) : (
- <>
- {tracks.map((track, index) => (
- <ContextMenu key={track.id}>
- <ContextMenuTrigger asChild>
- <div
- className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
- style={{
- top: `${getCumulativeHeightBefore(tracks, index)}px`,
- height: `${getTrackHeight(track.type)}px`,
- }}
- onClick={(e) => {
- // If clicking empty area (not on a element), deselect all elements
- if (
- !(e.target as HTMLElement).closest(
- ".timeline-element"
- )
- ) {
- clearSelectedElements();
- }
- }}
- >
- <TimelineTrackContent
- track={track}
- zoomLevel={zoomLevel}
- onSnapPointChange={handleSnapPointChange}
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem
- onClick={() => toggleTrackMute(track.id)}
- >
- {track.muted ? "Unmute Track" : "Mute Track"}
- </ContextMenuItem>
- <ContextMenuItem>
- Track settings (soon)
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </>
- )}
- </div>
- </div>
- </div>
- </ScrollArea>
- </div>
- </div>
- );
-}
-
-function TrackIcon({ track }: { track: TimelineTrack }) {
- return (
- <>
- {track.type === "media" && (
- <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
- )}
- {track.type === "text" && (
- <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
- )}
- {track.type === "audio" && (
- <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
- )}
- </>
- );
-}
+"use client"; + +import { ScrollArea } from "../ui/scroll-area"; +import { Button } from "../ui/button"; +import { + Scissors, + ArrowLeftToLine, + ArrowRightToLine, + Trash2, + Snowflake, + Copy, + SplitSquareHorizontal, + Pause, + Play, + Video, + Music, + TypeIcon, + Lock, + LockOpen, + Link, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "../ui/tooltip"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "../ui/context-menu"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; +import { processMediaFiles } from "@/lib/media-processing"; +import { toast } from "sonner"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { TimelineTrackContent } from "./timeline-track"; +import { + TimelinePlayhead, + useTimelinePlayheadRuler, +} from "./timeline-playhead"; +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 type { DragData, TimelineTrack } from "@/types/timeline"; +import { + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, + TIMELINE_CONSTANTS, + snapTimeToFrame, +} from "@/constants/timeline-constants"; + +export function Timeline() { + // Timeline shows all tracks (video, audio, effects) and their elements. + // You can drag media here to add it to your project. + // elements can be trimmed, deleted, and moved. + + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + getTotalDuration, + selectedElements, + clearSelectedElements, + setSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + toggleTrackMute, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + dragState, + } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const { currentTime, duration, seek, setDuration, isPlaying, toggle } = + usePlaybackStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const dragCounterRef = useRef(0); + const timelineRef = useRef<HTMLDivElement>(null); + const rulerRef = useRef<HTMLDivElement>(null); + const [isInTimeline, setIsInTimeline] = useState(false); + + // Track mouse down/up for distinguishing clicks from drag/resize ends + const mouseTrackingRef = useRef({ + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }); + + // Timeline zoom functionality + const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ + containerRef: timelineRef, + isInTimeline, + }); + + // Old marquee selection removed - using new SelectionBox component instead + + // Dynamic timeline width calculation based on playhead position and duration + const dynamicTimelineWidth = Math.max( + (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration + (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer + timelineRef.current?.clientWidth || 1000 // Minimum width + ); + + // Scroll synchronization and auto-scroll to playhead + const rulerScrollRef = useRef<HTMLDivElement>(null); + const tracksScrollRef = useRef<HTMLDivElement>(null); + const trackLabelsRef = useRef<HTMLDivElement>(null); + const playheadRef = useRef<HTMLDivElement>(null); + const trackLabelsScrollRef = useRef<HTMLDivElement>(null); + const isUpdatingRef = useRef(false); + const lastRulerSync = useRef(0); + const lastTracksSync = useRef(0); + const lastVerticalSync = useRef(0); + + // Timeline playhead ruler handlers + const { handleRulerMouseDown } = useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + // Selection box functionality + const tracksContainerRef = useRef<HTMLDivElement>(null); + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + justFinishedSelecting, + } = useSelectionBox({ + containerRef: tracksContainerRef, + playheadRef, + onSelectionComplete: (elements) => { + console.log(JSON.stringify({ onSelectionComplete: elements.length })); + setSelectedElements(elements); + }, + }); + + // Calculate snap indicator state + const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( + null + ); + const showSnapIndicator = + dragState.isDragging && snappingEnabled && currentSnapPoint !== null; + + // Callback to handle snap point changes from TimelineTrackContent + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + + // Track mouse down to distinguish real clicks from drag/resize ends + const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { + // Only track mouse down on timeline background areas (not elements) + const target = e.target as HTMLElement; + const isTimelineBackground = + !target.closest(".timeline-element") && + !playheadRef.current?.contains(target) && + !target.closest("[data-track-labels]"); + + if (isTimelineBackground) { + mouseTrackingRef.current = { + isMouseDown: true, + downX: e.clientX, + downY: e.clientY, + downTime: e.timeStamp, + }; + } + }, []); + + // Timeline content click to seek handler + const handleTimelineContentClick = useCallback( + (e: React.MouseEvent) => { + const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; + + // Reset mouse tracking + mouseTrackingRef.current = { + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }; + + // Only process as click if we tracked a mouse down on timeline background + if (!isMouseDown) { + console.log( + JSON.stringify({ + ignoredClickWithoutMouseDown: true, + timeStamp: e.timeStamp, + }) + ); + return; + } + + // Check if mouse moved significantly (indicates drag, not click) + const deltaX = Math.abs(e.clientX - downX); + const deltaY = Math.abs(e.clientY - downY); + const deltaTime = e.timeStamp - downTime; + + if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { + console.log( + JSON.stringify({ + ignoredDragNotClick: true, + deltaX, + deltaY, + deltaTime, + timeStamp: e.timeStamp, + }) + ); + return; + } + + // Don't seek if this was a selection box operation + if (isSelecting || justFinishedSelecting) { + return; + } + + // Don't seek if clicking on timeline elements, but still deselect + if ((e.target as HTMLElement).closest(".timeline-element")) { + return; + } + + // Don't seek if clicking on playhead + if (playheadRef.current?.contains(e.target as Node)) { + return; + } + + // Don't seek if clicking on track labels + if ((e.target as HTMLElement).closest("[data-track-labels]")) { + clearSelectedElements(); + return; + } + + // Clear selected elements when clicking empty timeline area + console.log(JSON.stringify({ clearingSelectedElements: true })); + clearSelectedElements(); + + // Determine if we're clicking in ruler or tracks area + const isRulerClick = (e.target as HTMLElement).closest( + "[data-ruler-area]" + ); + + let mouseX: number; + let scrollLeft = 0; + + if (isRulerClick) { + // Calculate based on ruler position + const rulerContent = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!rulerContent) return; + const rect = rulerContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = rulerContent.scrollLeft; + } else { + // Calculate based on tracks content position + const tracksContent = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!tracksContent) return; + const rect = tracksContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = tracksContent.scrollLeft; + } + + const rawTime = Math.max( + 0, + Math.min( + duration, + (mouseX + scrollLeft) / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) + ) + ); + + // Use frame snapping for timeline clicking + const projectFps = activeProject?.fps || 30; + const time = snapTimeToFrame(rawTime, projectFps); + + seek(time); + }, + [ + duration, + zoomLevel, + seek, + rulerScrollRef, + tracksScrollRef, + clearSelectedElements, + isSelecting, + justFinishedSelecting, + ] + ); + + // Update timeline duration when tracks change + useEffect(() => { + const totalDuration = getTotalDuration(); + setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline + }, [tracks, setDuration, getTotalDuration]); + + // Old marquee system removed - using new SelectionBox component instead + + const handleDragEnter = (e: React.DragEvent) => { + // When something is dragged over the timeline, show overlay + e.preventDefault(); + // Don't show overlay for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Don't update state for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/element + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + // Ignore timeline element drags - they're handled by track-specific handlers + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + if (hasTimelineElement) { + return; + } + + const itemData = e.dataTransfer.getData("application/x-media-item"); + if (itemData) { + try { + const dragData: DragData = JSON.parse(itemData); + + if (dragData.type === "text") { + // Always create new text track to avoid overlaps + useTimelineStore.getState().addTextToNewTrack(dragData); + } else { + // Handle media items + const mediaItem = mediaItems.find( + (item: any) => item.id === dragData.id + ); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + useTimelineStore.getState().addMediaToNewTrack(mediaItem); + } + } catch (error) { + console.error("Error parsing dropped item data:", error); + toast.error("Failed to add item to timeline"); + } + } else if (e.dataTransfer.files?.length > 0) { + // Handle file drops by creating new tracks + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p) + ); + for (const processedItem of processedItems) { + await addMediaItem(activeProject.id, processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && item.url === processedItem.url + ); + if (addedItem) { + useTimelineStore.getState().addMediaToNewTrack(addedItem); + } + } + } catch (error) { + // Show error if file processing fails + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + } + }; + + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; + + // Action handlers for toolbar + const handleSplitSelected = () => { + if (selectedElements.length === 0) { + return; + } + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) { + return; + } + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + const newSelections: { trackId: string; elementId: string }[] = []; + + 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; + + // Create element without id (will be generated by store) + const { id, ...elementWithoutId } = element; + + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + + // We can't predict the new id, so just clear selection for now + // TODO: addElementToTrack could return the new element id + } + }); + + clearSelectedElements(); + }; + + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; + + 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); + }; + + 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); + }; + + 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 handleDeleteSelected = () => { + if (selectedElements.length === 0) { + return; + } + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; + + // --- Scroll synchronization effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + + if (!rulerViewport || !tracksViewport) return; + + // Horizontal scroll synchronization between ruler and tracks + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; + lastRulerSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport.scrollLeft; + isUpdatingRef.current = false; + }; + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + rulerViewport.scrollLeft = tracksViewport.scrollLeft; + isUpdatingRef.current = false; + }; + + rulerViewport.addEventListener("scroll", handleRulerScroll); + tracksViewport.addEventListener("scroll", handleTracksScroll); + + // Vertical scroll synchronization between track labels and tracks content + if (trackLabelsViewport) { + const handleTrackLabelsScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollTop = trackLabelsViewport.scrollTop; + isUpdatingRef.current = false; + }; + const handleTracksVerticalScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + trackLabelsViewport.scrollTop = tracksViewport.scrollTop; + isUpdatingRef.current = false; + }; + + trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); + tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + trackLabelsViewport.removeEventListener( + "scroll", + handleTrackLabelsScroll + ); + tracksViewport.removeEventListener( + "scroll", + handleTracksVerticalScroll + ); + }; + } + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + }; + }, []); + + return ( + <div + className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} + {...dragProps} + onMouseEnter={() => setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + > + {/* Toolbar */} + <div className="border-b flex items-center justify-between px-2 py-1"> + <div className="flex items-center gap-1 w-full"> + <TooltipProvider delayDuration={500}> + {/* Play/Pause Button */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={toggle} + className="mr-2" + > + {isPlaying ? ( + <Pause className="h-4 w-4" /> + ) : ( + <Play className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + {isPlaying ? "Pause (Space)" : "Play (Space)"} + </TooltipContent> + </Tooltip> + <div className="w-px h-6 bg-border mx-1" /> + {/* Time Display */} + <div + className="text-xs text-muted-foreground font-mono px-2" + style={{ minWidth: "18ch", textAlign: "center" }} + > + {currentTime.toFixed(1)}s / {duration.toFixed(1)}s + </div> + {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + <> + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + const trackId = addTrack("media"); + addElementToTrack(trackId, { + type: "media", + mediaId: "test", + name: "Test Clip", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + }} + className="text-xs" + > + Add Test Clip + </Button> + </TooltipTrigger> + <TooltipContent> + Add a test clip to try playback + </TooltipContent> + </Tooltip> + </> + )} + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitSelected} + > + <Scissors className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split element (Ctrl+S)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepLeft} + > + <ArrowLeftToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepRight} + > + <ArrowRightToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSeparateAudio} + > + <SplitSquareHorizontal className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleDuplicateSelected} + > + <Copy className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleFreezeSelected} + > + <Snowflake className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Freeze frame (F)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleDeleteSelected} + > + <Trash2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Delete element (Delete)</TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggleSnapping}> + {snappingEnabled ? ( + <LockOpen className="h-4 w-4 text-primary" /> + ) : ( + <Lock className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent>Auto snapping</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={toggleRippleEditing} + > + <Link + className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`} + /> + </Button> + </TooltipTrigger> + <TooltipContent> + {rippleEditingEnabled + ? "Disable Ripple Editing" + : "Enable Ripple Editing"} + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </div> + + {/* Timeline Container */} + <div + className="flex-1 flex flex-col overflow-hidden relative" + ref={timelineRef} + > + <TimelinePlayhead + currentTime={currentTime} + duration={duration} + zoomLevel={zoomLevel} + tracks={tracks} + seek={seek} + rulerRef={rulerRef} + rulerScrollRef={rulerScrollRef} + tracksScrollRef={tracksScrollRef} + trackLabelsRef={trackLabelsRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + tracks={tracks} + timelineRef={timelineRef} + trackLabelsRef={trackLabelsRef} + isVisible={showSnapIndicator} + /> + {/* Timeline Header with Ruler */} + <div className="flex bg-panel sticky top-0 z-10"> + {/* Track Labels Header */} + <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2"> + {/* Empty space */} + <span className="text-sm font-medium text-muted-foreground opacity-0"> + . + </span> + </div> + + {/* Timeline Ruler */} + <div + className="flex-1 relative overflow-hidden h-4" + onWheel={handleWheel} + onMouseDown={handleSelectionMouseDown} + onClick={handleTimelineContentClick} + data-ruler-area + > + <ScrollArea className="w-full" ref={rulerScrollRef}> + <div + ref={rulerRef} + className="relative h-4 select-none cursor-default" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + onMouseDown={handleRulerMouseDown} + > + {/* Time markers */} + {(() => { + // Calculate appropriate time interval based on zoom level + const getTimeInterval = (zoom: number) => { + const pixelsPerSecond = + TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; + if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in + if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in + if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom + if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out + if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out + if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out + return 30; // Every 30s when extremely zoomed out + }; + + const interval = getTimeInterval(zoomLevel); + const markerCount = Math.ceil(duration / interval) + 1; + + return Array.from({ length: markerCount }, (_, i) => { + const time = i * interval; + if (time > duration) return null; + + const isMainMarker = + time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + + return ( + <div + key={i} + className={`absolute top-0 bottom-0 ${ + isMainMarker + ? "border-l border-muted-foreground/40" + : "border-l border-muted-foreground/20" + }`} + style={{ + left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + > + <span + className={`absolute top-1 left-1 text-[0.6rem] ${ + isMainMarker + ? "text-muted-foreground font-medium" + : "text-muted-foreground/70" + }`} + > + {(() => { + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (minutes > 0) { + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (interval >= 1) { + return `${Math.floor(secs)}s`; + } else { + return `${secs.toFixed(1)}s`; + } + }; + return formatTime(time); + })()} + </span> + </div> + ); + }).filter(Boolean); + })()} + </div> + </ScrollArea> + </div> + </div> + + {/* Tracks Area */} + <div className="flex-1 flex overflow-hidden"> + {/* Track Labels */} + {tracks.length > 0 && ( + <div + ref={trackLabelsRef} + className="w-48 flex-shrink-0 border-r border-black overflow-y-auto" + data-track-labels + > + <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> + <div className="flex flex-col gap-1"> + {tracks.map((track) => ( + <div + key={track.id} + className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" + style={{ height: `${getTrackHeight(track.type)}px` }} + > + <div className="flex items-center flex-1 min-w-0"> + <TrackIcon track={track} /> + </div> + {track.muted && ( + <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> + Muted + </span> + )} + </div> + ))} + </div> + </ScrollArea> + </div> + )} + + {/* Timeline Tracks Content */} + <div + className="flex-1 relative overflow-hidden" + onWheel={handleWheel} + onMouseDown={(e) => { + handleTimelineMouseDown(e); + handleSelectionMouseDown(e); + }} + onClick={handleTimelineContentClick} + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <ScrollArea className="w-full h-full" ref={tracksScrollRef}> + <div + className="relative flex-1" + style={{ + height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, + width: `${dynamicTimelineWidth}px`, + }} + > + {tracks.length === 0 ? ( + <div></div> + ) : ( + <> + {tracks.map((track, index) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" + style={{ + top: `${getCumulativeHeightBefore(tracks, index)}px`, + height: `${getTrackHeight(track.type)}px`, + }} + onClick={(e) => { + // If clicking empty area (not on a element), deselect all elements + if ( + !(e.target as HTMLElement).closest( + ".timeline-element" + ) + ) { + clearSelectedElements(); + } + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + onSnapPointChange={handleSnapPointChange} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem + onClick={() => toggleTrackMute(track.id)} + > + {track.muted ? "Unmute Track" : "Mute Track"} + </ContextMenuItem> + <ContextMenuItem> + Track settings (soon) + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + </> + )} + </div> + </ScrollArea> + </div> + </div> + </div> + </div> + ); +} + +function TrackIcon({ track }: { track: TimelineTrack }) { + return ( + <> + {track.type === "media" && ( + <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + {track.type === "text" && ( + <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + {track.type === "audio" && ( + <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + </> + ); +} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index bfe314d6..720abd3d 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -4,28 +4,9 @@ import Link from "next/link"; import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
-import { useSession } from "@opencut/auth/client";
-import { getStars } from "@/lib/fetch-github-stars";
-import { useEffect, useState } from "react";
import Image from "next/image";
export function Header() {
- const { data: session } = useSession();
- const [star, setStar] = useState<string>("");
-
- useEffect(() => {
- const fetchStars = async () => {
- try {
- const data = await getStars();
- setStar(data);
- } catch (err) {
- console.error("Failed to fetch GitHub stars", err);
- }
- };
-
- fetchStars();
- }, []);
-
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
@@ -40,21 +21,12 @@ export function Header() { Contributors
</Button>
</Link>
- {process.env.NODE_ENV === "development" ? (
- <Link href="/projects">
- <Button size="sm" className="text-sm ml-4">
- Projects
- <ArrowRight className="h-4 w-4" />
- </Button>
- </Link>
- ) : (
- <Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
- <Button size="sm" className="text-sm ml-4">
- GitHub {star}+
- <ArrowRight className="h-4 w-4" />
- </Button>
- </Link>
- )}
+ <Link href="/projects">
+ <Button size="sm" className="text-sm ml-4">
+ Projects
+ <ArrowRight className="h-4 w-4" />
+ </Button>
+ </Link>
</nav>
);
diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 936254be..4c8bfff5 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "./ui/button"; import { Dialog, @@ -10,44 +10,48 @@ import { DialogTitle, DialogTrigger, } from "./ui/dialog"; -import { Badge } from "./ui/badge"; +import { getPlatformSpecialKey } from "@/lib/utils"; import { Keyboard } from "lucide-react"; -import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; - -const KeyBadge = ({ keyName }: { keyName: string }) => { - // Replace common key names with symbols or friendly names - const displayKey = keyName - .replace("Cmd", "⌘") - .replace("Shift", "Shift") - .replace("ArrowLeft", "Arrow Left") - .replace("ArrowRight", "Arrow Right") - .replace("ArrowUp", "Arrow Up") - .replace("ArrowDown", "Arrow Down") - .replace("←", "◀") - .replace("→", "▶") - .replace("Space", "Space"); +import { + useKeyboardShortcutsHelp, + KeyboardShortcut, +} from "@/hooks/use-keyboard-shortcuts-help"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { toast } from "sonner"; - return ( - <Badge variant="secondary" className="font-mono text-xs px-1 py-1"> - {displayKey} - </Badge> - ); +const modifier: { + [key: string]: string; +} = { + Shift: "Shift", + Alt: "Alt", + ArrowLeft: "←", + ArrowRight: "→", + ArrowUp: "↑", + ArrowDown: "↓", + Space: "Space", }; -const ShortcutItem = ({ shortcut }: { shortcut: any }) => { +function getKeyWithModifier(key: string) { + if (key === "Ctrl") return getPlatformSpecialKey(); + return modifier[key] || key; +} + +const ShortcutItem = ({ + shortcut, + recordingKey, + onStartRecording, +}: { + shortcut: KeyboardShortcut; + recordingKey: string | null; + onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void; +}) => { // Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J" const displayKeys = shortcut.keys.filter((key: string) => { - const lowerKey = key.toLowerCase(); - const upperKey = key.toUpperCase(); - - // If this is a lowercase letter and the uppercase version exists, skip it if ( - key === lowerKey && - key !== upperKey && - shortcut.keys.includes(upperKey) - ) { + key.includes("Cmd") && + shortcut.keys.includes(key.replace("Cmd", "Ctrl")) + ) return false; - } return true; }); @@ -64,14 +68,21 @@ const ShortcutItem = ({ shortcut }: { shortcut: any }) => { {displayKeys.map((key: string, index: number) => ( <div key={index} className="flex items-center gap-1"> <div className="flex items-center"> - {key.split("+").map((keyPart: string, partIndex: number) => ( - <div key={partIndex} className="flex items-center gap-1"> - <KeyBadge keyName={keyPart} /> - {partIndex < key.split("+").length - 1 && ( - <span className="text-xs text-muted-foreground">+</span> - )} - </div> - ))} + {key.split("+").map((keyPart: string, partIndex: number) => { + const keyId = `${shortcut.id}-${index}-${partIndex}`; + return ( + <EditableShortcutKey + key={partIndex} + keyId={keyId} + originalKey={key} + shortcut={shortcut} + isRecording={recordingKey === keyId} + onStartRecording={() => onStartRecording(keyId, shortcut)} + > + {getKeyWithModifier(keyPart)} + </EditableShortcutKey> + ); + })} </div> {index < displayKeys.length - 1 && ( <span className="text-xs text-muted-foreground">or</span> @@ -83,14 +94,125 @@ const ShortcutItem = ({ shortcut }: { shortcut: any }) => { ); }; +const EditableShortcutKey = ({ + children, + keyId, + originalKey, + shortcut, + isRecording, + onStartRecording, +}: { + children: React.ReactNode; + keyId: string; + originalKey: string; + shortcut: KeyboardShortcut; + isRecording: boolean; + onStartRecording: () => void; +}) => { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onStartRecording(); + }; + + return ( + <kbd + className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${ + isRecording + ? "border-primary bg-primary/10" + : "border-white/10 bg-black/20" + }`} + onClick={handleClick} + title={ + isRecording ? "Press any key combination..." : "Click to edit shortcut" + } + > + {children} + </kbd> + ); +}; + export const KeyboardShortcutsHelp = () => { const [open, setOpen] = useState(false); + const [recordingKey, setRecordingKey] = useState<string | null>(null); + const [recordingShortcut, setRecordingShortcut] = + useState<KeyboardShortcut | null>(null); - // Get shortcuts from centralized hook (disabled so it doesn't add event listeners) - const { shortcuts } = useKeyboardShortcuts({ enabled: false }); + const { + updateKeybinding, + removeKeybinding, + getKeybindingString, + validateKeybinding, + getKeybindingsForAction, + } = useKeybindingsStore(); + + // Get shortcuts from centralized hook + const { shortcuts } = useKeyboardShortcutsHelp(); const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + useEffect(() => { + if (!recordingKey || !recordingShortcut) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const keyString = getKeybindingString(e); + if (keyString) { + // Auto-save the new keybinding + const conflict = validateKeybinding( + keyString, + recordingShortcut.action + ); + if (conflict) { + toast.error( + `Key "${keyString}" is already bound to "${conflict.existingAction}"` + ); + setRecordingKey(null); + setRecordingShortcut(null); + return; + } + + // Remove old keybindings for this action + const oldKeys = getKeybindingsForAction(recordingShortcut.action); + oldKeys.forEach((key) => removeKeybinding(key)); + + // Add new keybinding + updateKeybinding(keyString, recordingShortcut.action); + + setRecordingKey(null); + setRecordingShortcut(null); + } + }; + + const handleClickOutside = (e: MouseEvent) => { + setRecordingKey(null); + setRecordingShortcut(null); + }; + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("click", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("click", handleClickOutside); + }; + }, [ + recordingKey, + recordingShortcut, + getKeybindingString, + updateKeybinding, + removeKeybinding, + validateKeybinding, + getKeybindingsForAction, + ]); + + const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => { + setRecordingKey(keyId); + setRecordingShortcut(shortcut); + }; + return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> @@ -99,7 +221,7 @@ export const KeyboardShortcutsHelp = () => { Shortcuts </Button> </DialogTrigger> - <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-hidden flex"> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Keyboard className="w-5 h-5" /> @@ -107,7 +229,7 @@ export const KeyboardShortcutsHelp = () => { </DialogTitle> <DialogDescription> Speed up your video editing workflow with these keyboard shortcuts. - Most shortcuts work when the timeline is focused. + Click any shortcut key to edit it. </DialogDescription> </DialogHeader> @@ -121,7 +243,12 @@ export const KeyboardShortcutsHelp = () => { {shortcuts .filter((shortcut) => shortcut.category === category) .map((shortcut, index) => ( - <ShortcutItem key={index} shortcut={shortcut} /> + <ShortcutItem + key={index} + shortcut={shortcut} + recordingKey={recordingKey} + onStartRecording={handleStartRecording} + /> ))} </div> </div> diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index e8091fa3..dba958ac 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -2,104 +2,15 @@ import { motion } from "motion/react"; import { Button } from "../ui/button"; -import { Input } from "../ui/input"; import { SponsorButton } from "../ui/sponsor-button"; import { VercelIcon } from "../icons"; import { ArrowRight } from "lucide-react"; -import { useState, useEffect } from "react"; -import { toast } from "sonner"; import Image from "next/image"; import { Handlebars } from "./handlebars"; +import Link from "next/link"; export function Hero() { - const [email, setEmail] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [csrfToken, setCsrfToken] = useState<string | null>(null); - - useEffect(() => { - let isMounted = true; - fetch("/api/waitlist/token", { - credentials: "include", - }) - .then((res) => res.json()) - .then((data) => { - if (isMounted && data.token) { - setCsrfToken(data.token); - } - }) - .catch((err) => { - console.error("Failed to fetch CSRF token:", err); - if (isMounted) { - toast.error("Security initialization failed", { - description: "Please refresh the page to continue.", - }); - } - }); - }, []); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!email.trim()) { - toast.error("Email required", { - description: "Please enter your email address.", - }); - return; - } - - if (!csrfToken) { - toast.error("Security error", { - description: "Please refresh the page and try again.", - }); - return; - } - - setIsSubmitting(true); - - try { - const response = await fetch("/api/waitlist", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken, - }, - credentials: "include", - body: JSON.stringify({ email: email.trim() }), - }); - - const data = (await response.json()) as { error: string }; - - if (response.ok) { - toast.success("Welcome to the waitlist! 🎉", { - description: "You'll be notified when we launch.", - }); - setEmail(""); - - fetch("/api/waitlist/token", { credentials: "include" }) - .then((res) => res.json()) - .then((data) => { - if (data.token) setCsrfToken(data.token); - }) - .catch((err) => { - console.error("Failed to refresh CSRF token:", err); - }); - } else { - toast.error("Oops!", { - description: - (data as { error: string }).error || - "Something went wrong. Please try again.", - }); - } - } catch (error) { - toast.error("Network error", { - description: "Please check your connection and try again.", - }); - } finally { - setIsSubmitting(false); - } - }; - return ( <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4"> <Image @@ -148,47 +59,21 @@ export function Hero() { </motion.p> <motion.div - className="mt-12 flex gap-8 justify-center" + className="mt-8 flex gap-8 justify-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6, duration: 0.8 }} > - <form - onSubmit={handleSubmit} - className="flex gap-3 w-full max-w-lg flex-col sm:flex-row" - > - <div className="relative w-full"> - <Input - type="email" - placeholder="Enter your email" - className="h-11 text-base flex-1" - value={email} - onChange={(e) => setEmail(e.target.value)} - disabled={isSubmitting || !csrfToken} - required - /> - </div> + <Link href="/projects"> <Button type="submit" size="lg" - className="px-6 h-11 text-base !bg-foreground" - disabled={isSubmitting || !csrfToken} + className="px-6 h-11 text-base bg-foreground" > - <span className="relative z-10"> - {isSubmitting ? "Joining..." : "Join waitlist"} - </span> + Try early beta <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" /> </Button> - </form> - </motion.div> - <motion.div - initial={{ opacity: 0, y: 10 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.8, duration: 0.6 }} - className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center" - > - <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> - <span>50k+ people already joined</span> + </Link> </motion.div> </motion.div> </div> diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx index a2943677..80b5f99c 100644 --- a/apps/web/src/components/onboarding.tsx +++ b/apps/web/src/components/onboarding.tsx @@ -1,29 +1,121 @@ "use client";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "./ui/dialog";
+import { Dialog, DialogContent } from "./ui/dialog";
+import { Button } from "./ui/button";
+import { ArrowRightIcon } from "lucide-react";
+import { useState, useEffect } from "react";
+import ReactMarkdown from "react-markdown";
-interface OnboardingProps {
- isOpen: boolean;
- onClose: () => void;
-}
+export function Onboarding() {
+ const [step, setStep] = useState(0);
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
+ if (!hasSeenOnboarding) {
+ setIsOpen(true);
+ }
+ }, []);
+
+ const handleNext = () => {
+ setStep(step + 1);
+ };
+
+ const handleClose = () => {
+ setIsOpen(false);
+ localStorage.setItem("hasSeenOnboarding", "true");
+ };
+
+ const renderStepContent = () => {
+ switch (step) {
+ case 0:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title="Welcome to OpenCut Beta! 🎉" />
+ <Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
+ </div>
+ <NextButton onClick={handleNext}>Next</NextButton>
+ </div>
+ );
+ case 1:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title="⚠️ This is a super early beta!" />
+ <Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
+ <Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
+ </div>
+ <NextButton onClick={handleNext}>Next</NextButton>
+ </div>
+ );
+ case 2:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title="🦋 Have fun testing!" />
+ <Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
+ </div>
+ <NextButton onClick={handleClose}>Finish</NextButton>
+ </div>
+ );
+ default:
+ return null;
+ }
+ };
-export function Onboarding({ isOpen, onClose }: OnboardingProps) {
return (
- <Dialog open={isOpen} onOpenChange={onClose}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Welcome to Clipture</DialogTitle>
- <DialogDescription>
- Let's get you started with the basics.
- </DialogDescription>
- </DialogHeader>
+ <Dialog open={isOpen} onOpenChange={handleClose}>
+ <DialogContent className="sm:max-w-[425px] !outline-none">
+ {renderStepContent()}
</DialogContent>
</Dialog>
);
}
+
+function Title({ title }: { title: string }) {
+ return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
+}
+
+function Subtitle({ subtitle }: { subtitle: string }) {
+ return <h3 className="text-lg font-medium">{subtitle}</h3>;
+}
+
+function Description({ description }: { description: string }) {
+ return (
+ <div className="text-muted-foreground">
+ <ReactMarkdown
+ components={{
+ p: ({ children }) => <p className="mb-0">{children}</p>,
+ a: ({ href, children }) => (
+ <a
+ href={href}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-foreground hover:text-foreground/80 underline"
+ >
+ {children}
+ </a>
+ ),
+ }}
+ >
+ {description}
+ </ReactMarkdown>
+ </div>
+ );
+}
+
+function NextButton({
+ children,
+ onClick,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+}) {
+ return (
+ <Button onClick={onClick} variant="default" className="w-full">
+ {children}
+ <ArrowRightIcon className="w-4 h-4" />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index ebfd8907..e8decbd7 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -39,7 +39,7 @@ const DialogContent = React.forwardRef< <DialogPrimitive.Content ref={ref} className={cn( - "fixed left-[50%] top-[50%] z-[150] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + "fixed left-[50%] top-[50%] z-[150] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg", className )} onCloseAutoFocus={(e) => { @@ -55,7 +55,7 @@ const DialogContent = React.forwardRef< <ScrollArea className="max-h-[85vh]"> <div className="p-6 space-y-4">{children}</div> </ScrollArea> - <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <X className="h-4 w-4" /> <span className="sr-only">Close</span> </DialogPrimitive.Close> |
