diff options
Diffstat (limited to 'apps/web/src/components/editor')
11 files changed, 479 insertions, 99 deletions
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index a673f995..372a31c5 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -19,22 +19,21 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ useEffect(() => { let mounted = true; - + let ws = wavesurfer.current; + const initWaveSurfer = async () => { if (!waveformRef.current || !audioUrl) return; try { - // Clean up any existing instance - if (wavesurfer.current) { - try { - wavesurfer.current.destroy(); - } catch (e) { - // Silently ignore destroy errors - } + // Clear any existing instance safely + if (ws) { + // Instead of immediately destroying, just set to null + // We'll destroy it outside this function wavesurfer.current = null; } - wavesurfer.current = WaveSurfer.create({ + // Create a fresh instance + const newWaveSurfer = WaveSurfer.create({ container: waveformRef.current, waveColor: "rgba(255, 255, 255, 0.6)", progressColor: "rgba(255, 255, 255, 0.9)", @@ -46,15 +45,28 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ interact: false, }); + // Assign to ref only if component is still mounted + if (mounted) { + wavesurfer.current = newWaveSurfer; + } else { + // Component unmounted during initialization, clean up + try { + newWaveSurfer.destroy(); + } catch (e) { + // Ignore destroy errors + } + return; + } + // Event listeners - wavesurfer.current.on("ready", () => { + newWaveSurfer.on("ready", () => { if (mounted) { setIsLoading(false); setError(false); } }); - wavesurfer.current.on("error", (err) => { + newWaveSurfer.on("error", (err) => { console.error("WaveSurfer error:", err); if (mounted) { setError(true); @@ -62,7 +74,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ } }); - await wavesurfer.current.load(audioUrl); + await newWaveSurfer.load(audioUrl); } catch (err) { console.error("Failed to initialize WaveSurfer:", err); if (mounted) { @@ -72,17 +84,50 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ } }; - initWaveSurfer(); - - return () => { - mounted = false; - if (wavesurfer.current) { + // First safely destroy previous instance if it exists + if (ws) { + // Use this pattern to safely destroy the previous instance + const wsToDestroy = ws; + // Detach from ref immediately + wavesurfer.current = null; + + // Wait a tick to destroy so any pending operations can complete + requestAnimationFrame(() => { try { - wavesurfer.current.destroy(); + wsToDestroy.destroy(); } catch (e) { - // Silently ignore destroy errors + // Ignore errors during destroy + } + // Only initialize new instance after destroying the old one + if (mounted) { + initWaveSurfer(); } - wavesurfer.current = null; + }); + } else { + // No previous instance to clean up, initialize directly + initWaveSurfer(); + } + + return () => { + // Mark component as unmounted + mounted = false; + + // Store reference to current wavesurfer instance + const wsToDestroy = wavesurfer.current; + + // Immediately clear the ref to prevent accessing it after unmount + wavesurfer.current = null; + + // If we have an instance to clean up, do it safely + if (wsToDestroy) { + // Delay destruction to avoid race conditions + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch (e) { + // Ignore destroy errors - they're expected + } + }); } }; }, [audioUrl, height]); diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index bce84a46..92482559 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -82,7 +82,7 @@ export function TabBar() { onClick={() => setActiveTab(tabKey)} key={tabKey} > - <tab.icon className="!size-[1.1rem]" /> + <tab.icon className="size-[1.1rem]!" /> <span className="text-[0.65rem]">{tab.label}</span> </div> ); @@ -114,10 +114,10 @@ function ScrollButton({ <div className="bg-panel-accent w-12 h-full flex items-center justify-center"> <Button size="icon" - className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10" + className="rounded-[0.4rem] w-4 h-7 bg-foreground/10!" onClick={onClick} > - <Icon className="!size-4 text-foreground" /> + <Icon className="size-4! text-foreground" /> </Button> </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 f218dc14..d1533736 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -169,7 +169,7 @@ export function MediaView() { if (item.type === "audio") { return ( - <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> + <div className="w-full h-full bg-linear-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> <Music className="h-6 w-6 mb-1" /> <span className="text-xs">Audio</span> {item.duration && ( diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 6cb06061..c6f4e159 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -20,6 +20,7 @@ import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; +import { EditableTimecode } from "@/components/ui/editable-timecode"; import { FONT_CLASS_MAP } from "@/lib/font-config"; import { BackgroundSettings } from "../background-settings"; import { useProjectStore } from "@/stores/project-store"; @@ -233,6 +234,7 @@ export function PreviewPanel() { tracks.forEach((track) => { track.elements.forEach((element) => { + if (element.hidden) return; const elementStart = element.startTime; const elementEnd = element.startTime + @@ -357,20 +359,18 @@ export function PreviewPanel() { style={{ left: `${ 50 + - ( - (dragState.isDragging && dragState.elementId === element.id - ? dragState.currentX - : element.x) / canvasSize.width - ) * + ((dragState.isDragging && dragState.elementId === element.id + ? dragState.currentX + : element.x) / + canvasSize.width) * 100 }%`, top: `${ 50 + - ( - (dragState.isDragging && dragState.elementId === element.id - ? dragState.currentY - : element.y) / canvasSize.height - ) * + ((dragState.isDragging && dragState.elementId === element.id + ? dragState.currentY + : element.y) / + canvasSize.height) * 100 }%`, transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, @@ -408,7 +408,7 @@ export function PreviewPanel() { return ( <div key={element.id} - className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" + className="absolute inset-0 bg-linear-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" > <div className="text-center"> <div className="text-2xl mb-2">🎬</div> @@ -564,7 +564,7 @@ function FullscreenToolbar({ toggle: () => void; getTotalDuration: () => number; }) { - const { isPlaying } = usePlaybackStore(); + const { isPlaying, seek } = usePlaybackStore(); const { activeProject } = useProjectStore(); const [isDragging, setIsDragging] = useState(false); @@ -624,9 +624,15 @@ function FullscreenToolbar({ className="flex items-center gap-2 p-1 pt-2 w-full text-white" > <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90"> - <span className="text-primary"> - {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} - </span> + <EditableTimecode + time={currentTime} + duration={totalDuration} + format="HH:MM:SS:FF" + fps={activeProject?.fps || 30} + onTimeChange={seek} + disabled={!hasAnyElements} + className="text-white/90 hover:bg-white/10" + /> <span className="opacity-50">/</span> <span> {formatTimeCode( @@ -691,7 +697,7 @@ function FullscreenToolbar({ style={{ width: `${progress}%` }} /> <div - className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-sm bg-white border border-black/20" + className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-xs bg-white border border-black/20" style={{ left: `${progress}%` }} /> </div> @@ -700,11 +706,11 @@ function FullscreenToolbar({ <Button variant="text" size="icon" - className="!size-4 text-white/80 hover:text-white" + className="size-4! text-white/80 hover:text-white" onClick={onToggleExpanded} title="Exit fullscreen (Esc)" > - <Expand className="!size-4" /> + <Expand className="size-4!" /> </Button> </div> ); @@ -738,7 +744,7 @@ function FullscreenPreview({ getTotalDuration: () => number; }) { return ( - <div className="fixed inset-0 z-[9999] flex flex-col"> + <div className="fixed inset-0 z-9999 flex flex-col"> <div className="flex-1 flex items-center justify-center bg-background"> <div className="relative overflow-hidden border border-border m-3" @@ -801,7 +807,7 @@ function PreviewToolbar({ toggle: () => void; getTotalDuration: () => number; }) { - const { isPlaying } = usePlaybackStore(); + const { isPlaying, seek } = usePlaybackStore(); const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); const { activeProject } = useProjectStore(); const { @@ -821,6 +827,18 @@ function PreviewToolbar({ setCanvasSizeToOriginal(aspectRatio); }; + const totalDuration = getTotalDuration(); + + const skipBackward = () => { + const newTime = Math.max(0, currentTime - 1); + setCurrentTime(newTime); + }; + + const skipForward = () => { + const newTime = Math.min(totalDuration, currentTime + 1); + setCurrentTime(newTime); + }; + if (isExpanded) { return ( <FullscreenToolbar @@ -844,17 +862,18 @@ function PreviewToolbar({ <div> <p className={cn( - "text-[0.75rem] text-muted-foreground flex items-center gap-1", + "text-[0.75rem] text-muted-foreground flex items-center gap-1 w-[10rem]", !hasAnyElements && "opacity-50" )} > - <span className="text-primary tabular-nums"> - {formatTimeCode( - currentTime, - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - </span> + <EditableTimecode + time={currentTime} + duration={getTotalDuration()} + format="HH:MM:SS:FF" + fps={activeProject?.fps || 30} + onTimeChange={seek} + disabled={!hasAnyElements} + /> <span className="opacity-50">/</span> <span className="tabular-nums"> {formatTimeCode( @@ -865,26 +884,48 @@ function PreviewToolbar({ </span> </p> </div> - <Button - variant="text" - size="icon" - onClick={toggle} - disabled={!hasAnyElements} - className="h-auto p-0" - > - {isPlaying ? ( - <Pause className="h-3 w-3" /> - ) : ( - <Play className="h-3 w-3" /> - )} - </Button> + <div className="flex items-center gap-1"> + <Button + variant="text" + size="icon" + onClick={skipBackward} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + title="Skip backward 1s" + > + <SkipBack className="h-3 w-3" /> + </Button> + <Button + variant="text" + size="icon" + onClick={toggle} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + > + {isPlaying ? ( + <Pause className="h-3 w-3" /> + ) : ( + <Play className="h-3 w-3" /> + )} + </Button> + <Button + variant="text" + size="icon" + onClick={skipForward} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + title="Skip forward 1s" + > + <SkipForward className="h-3 w-3" /> + </Button> + </div> <div className="flex items-center gap-3"> <BackgroundSettings /> <DropdownMenu> <DropdownMenuTrigger asChild> <Button size="sm" - className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" + className="bg-panel-accent! text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" disabled={!hasAnyElements} > {getDisplayName()} @@ -915,11 +956,11 @@ function PreviewToolbar({ <Button variant="text" size="icon" - className="!size-4 text-muted-foreground" + className="size-4! text-muted-foreground" onClick={onToggleExpanded} title="Enter fullscreen" > - <Expand className="!size-4" /> + <Expand className="size-4!" /> </Button> </div> </div> diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 720c4a26..f1b3f6a3 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -67,7 +67,7 @@ export function PropertiesPanel() { <div className="flex flex-col gap-1"> <Label className="text-xs text-muted-foreground">Frame rate:</Label> <Select - value={(activeProject?.fps || 30).toString()} + value={(activeProject?.fps || "N/A").toString()} onValueChange={handleFpsChange} > <SelectTrigger className="w-32 h-6 text-xs"> diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index e147ea65..a06624eb 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -5,6 +5,8 @@ import { TextElement } from "@/types/timeline"; import { useTimelineStore } from "@/stores/timeline-store"; import { Slider } from "@/components/ui/slider"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useState } from "react"; import { PropertyItem, PropertyItemLabel, @@ -20,12 +22,76 @@ export function TextProperties({ }) { const { updateTextElement } = useTimelineStore(); + // Local state for input values to allow temporary empty/invalid states + const [fontSizeInput, setFontSizeInput] = useState( + element.fontSize.toString() + ); + const [opacityInput, setOpacityInput] = useState( + Math.round(element.opacity * 100).toString() + ); + + const parseAndValidateNumber = ( + value: string, + min: number, + max: number, + fallback: number + ): number => { + const parsed = parseInt(value, 10); + if (isNaN(parsed)) return fallback; + return Math.max(min, Math.min(max, parsed)); + }; + + const handleFontSizeChange = (value: string) => { + setFontSizeInput(value); + + if (value.trim() !== "") { + const fontSize = parseAndValidateNumber(value, 8, 300, element.fontSize); + updateTextElement(trackId, element.id, { fontSize }); + } + }; + + const handleFontSizeBlur = () => { + const fontSize = parseAndValidateNumber( + fontSizeInput, + 8, + 300, + element.fontSize + ); + setFontSizeInput(fontSize.toString()); + updateTextElement(trackId, element.id, { fontSize }); + }; + + const handleOpacityChange = (value: string) => { + setOpacityInput(value); + + if (value.trim() !== "") { + const opacityPercent = parseAndValidateNumber( + value, + 0, + 100, + Math.round(element.opacity * 100) + ); + updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 }); + } + }; + + const handleOpacityBlur = () => { + const opacityPercent = parseAndValidateNumber( + opacityInput, + 0, + 100, + Math.round(element.opacity * 100) + ); + setOpacityInput(opacityPercent.toString()); + updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 }); + }; + return ( <div className="space-y-6 p-5"> <Textarea placeholder="Name" defaultValue={element.content} - className="min-h-[4.5rem] resize-none bg-background/50" + className="min-h-18 resize-none bg-background/50" onChange={(e) => updateTextElement(trackId, element.id, { content: e.target.value }) } @@ -42,27 +108,96 @@ export function TextProperties({ </PropertyItemValue> </PropertyItem> <PropertyItem direction="column"> + <PropertyItem direction="row"> + <PropertyItemLabel>Style</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Button + variant={element.fontWeight === "bold" ? "default" : "outline"} + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + fontWeight: + element.fontWeight === "bold" ? "normal" : "bold", + }) + } + className="h-8 px-3 font-bold" + > + B + </Button> + <Button + variant={element.fontStyle === "italic" ? "default" : "outline"} + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + fontStyle: + element.fontStyle === "italic" ? "normal" : "italic", + }) + } + className="h-8 px-3 italic" + > + I + </Button> + <Button + variant={ + element.textDecoration === "underline" ? "default" : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", + }) + } + className="h-8 px-3 underline" + > + U + </Button> + <Button + variant={ + element.textDecoration === "line-through" + ? "default" + : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", + }) + } + className="h-8 px-3 line-through" + > + S + </Button> + </div> + </PropertyItemValue> + </PropertyItem> <PropertyItemLabel>Font size</PropertyItemLabel> <PropertyItemValue> <div className="flex items-center gap-2"> <Slider - defaultValue={[element.fontSize]} + value={[element.fontSize]} min={8} max={300} step={1} - onValueChange={([value]) => - updateTextElement(trackId, element.id, { fontSize: value }) - } + onValueChange={([value]) => { + updateTextElement(trackId, element.id, { fontSize: value }); + setFontSizeInput(value.toString()); + }} className="w-full" /> <Input type="number" - value={element.fontSize} - onChange={(e) => - updateTextElement(trackId, element.id, { - fontSize: parseInt(e.target.value), - }) - } + value={fontSizeInput} + min={8} + max={300} + onChange={(e) => handleFontSizeChange(e.target.value)} + onBlur={handleFontSizeBlur} className="w-12 !text-xs h-7 rounded-sm text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none @@ -85,6 +220,56 @@ export function TextProperties({ /> </PropertyItemValue> </PropertyItem> + <PropertyItem direction="row"> + <PropertyItemLabel>Background</PropertyItemLabel> + <PropertyItemValue> + <Input + type="color" + value={ + element.backgroundColor === "transparent" + ? "#000000" + : element.backgroundColor || "#000000" + } + onChange={(e) => { + const backgroundColor = e.target.value; + updateTextElement(trackId, element.id, { backgroundColor }); + }} + className="w-full cursor-pointer rounded-full" + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Opacity</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.opacity * 100]} + min={0} + max={100} + step={1} + onValueChange={([value]) => { + updateTextElement(trackId, element.id, { + opacity: value / 100, + }); + setOpacityInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={opacityInput} + min={0} + max={100} + onChange={(e) => handleOpacityChange(e.target.value)} + onBlur={handleOpacityBlur} + className="w-12 !text-xs h-7 rounded-sm text-center + [appearance:textfield] + [&::-webkit-outer-spin-button]:appearance-none + [&::-webkit-inner-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> </div> ); } diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx index 0a97c6f8..ca9d799f 100644 --- a/apps/web/src/components/editor/selection-box.tsx +++ b/apps/web/src/components/editor/selection-box.tsx @@ -49,10 +49,7 @@ export function SelectionBox({ return ( <div ref={selectionBoxRef} - className="absolute pointer-events-none z-50" - style={{ - backgroundColor: "hsl(var(--foreground) / 0.1)", - }} + className="absolute pointer-events-none z-50 bg-foreground/10" /> ); } diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 053d119e..0a51e107 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -65,7 +65,7 @@ export function SnapIndicator({ return ( <div - className="absolute pointer-events-none z-[90]" + className="absolute pointer-events-none z-90" style={{ left: `${leftPosition}px`, top: 0, diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 7943af06..85d6a11e 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -19,6 +19,7 @@ import { Link, ZoomIn, ZoomOut, + Bookmark, } from "lucide-react"; import { Tooltip, @@ -543,7 +544,7 @@ export function Timeline() { {/* 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-panel border-r flex items-center justify-between px-3 py-2"> + <div className="w-48 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2"> {/* Empty space */} <span className="text-sm font-medium text-muted-foreground opacity-0"> . @@ -552,7 +553,7 @@ export function Timeline() { {/* Timeline Ruler */} <div - className="flex-1 relative overflow-hidden h-4" + className="flex-1 relative overflow-hidden h-10" onWheel={(e) => { // Check if this is horizontal scrolling - if so, don't handle it here if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { @@ -567,7 +568,7 @@ export function Timeline() { <ScrollArea className="w-full" ref={rulerScrollRef}> <div ref={rulerRef} - className="relative h-4 select-none cursor-default" + className="relative h-10 select-none cursor-default" style={{ width: `${dynamicTimelineWidth}px`, }} @@ -601,7 +602,7 @@ export function Timeline() { return ( <div key={i} - className={`absolute top-0 bottom-0 ${ + className={`absolute top-0 h-4 ${ isMainMarker ? "border-l border-muted-foreground/40" : "border-l border-muted-foreground/20" @@ -651,6 +652,30 @@ export function Timeline() { ); }).filter(Boolean); })()} + + {/* Bookmark markers */} + {(() => { + const { activeProject } = useProjectStore.getState(); + if (!activeProject?.bookmarks?.length) return null; + + return activeProject.bookmarks.map((bookmarkTime, i) => ( + <div + key={`bookmark-${i}`} + className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer" + style={{ + left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + onClick={(e) => { + e.stopPropagation(); + usePlaybackStore.getState().seek(bookmarkTime); + }} + > + <div className="absolute top-[-1px] left-[-5px] text-primary"> + <Bookmark className="h-3 w-3 fill-primary" /> + </div> + </div> + )); + })()} </div> </ScrollArea> </div> @@ -662,7 +687,7 @@ export function Timeline() { {tracks.length > 0 && ( <div ref={trackLabelsRef} - className="w-48 flex-shrink-0 border-r border-black overflow-y-auto z-[200] bg-panel" + className="w-48 shrink-0 border-r border-black overflow-y-auto z-200 bg-panel" data-track-labels > <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> @@ -677,7 +702,7 @@ export function Timeline() { <TrackIcon track={track} /> </div> {track.muted && ( - <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> + <span className="ml-2 text-xs text-red-500 font-semibold shrink-0"> Muted </span> )} @@ -761,7 +786,7 @@ export function Timeline() { /> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-[200]"> + <ContextMenuContent className="z-200"> <ContextMenuItem onClick={(e) => { e.stopPropagation(); @@ -773,6 +798,23 @@ export function Timeline() { <ContextMenuItem onClick={(e) => e.stopPropagation()}> Track settings (soon) </ContextMenuItem> + {activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && ( + <> + <ContextMenuItem disabled>Bookmarks</ContextMenuItem> + {activeProject.bookmarks.map((bookmarkTime, i) => ( + <ContextMenuItem + key={`bookmark-menu-${i}`} + onClick={(e) => { + e.stopPropagation(); + seek(bookmarkTime); + }} + > + <Bookmark className="h-3 w-3 mr-2 inline-block" /> + {bookmarkTime.toFixed(1)}s + </ContextMenuItem> + ))} + </> + )} </ContextMenuContent> </ContextMenu> ))} @@ -791,13 +833,13 @@ function TrackIcon({ track }: { track: TimelineTrack }) { return ( <> {track.type === "media" && ( - <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + <Video className="w-4 h-4 shrink-0 text-muted-foreground" /> )} {track.type === "text" && ( - <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + <TypeIcon className="w-4 h-4 shrink-0 text-muted-foreground" /> )} {track.type === "audio" && ( - <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + <Music className="w-4 h-4 shrink-0 text-muted-foreground" /> )} </> ); @@ -828,6 +870,7 @@ function TimelineToolbar({ toggleRippleEditing, } = useTimelineStore(); const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + const { toggleBookmark, isBookmarked } = useProjectStore(); // Action handlers const handleSplitSelected = () => { @@ -957,6 +1000,13 @@ function TimelineToolbar({ const handleZoomSliderChange = (values: number[]) => { setZoomLevel(values[0]); }; + + const handleToggleBookmark = async () => { + await toggleBookmark(currentTime); + }; + + // Check if the current time is bookmarked + const currentBookmarked = isBookmarked(currentTime); return ( <div className="border-b flex items-center justify-between px-2 py-1"> <div className="flex items-center gap-1 w-full"> @@ -1088,6 +1138,17 @@ function TimelineToolbar({ </TooltipTrigger> <TooltipContent>Delete element (Delete)</TooltipContent> </Tooltip> + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleToggleBookmark}> + <Bookmark className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} /> + </Button> + </TooltipTrigger> + <TooltipContent> + {currentBookmarked ? "Remove bookmark" : "Add bookmark"} + </TooltipContent> + </Tooltip> </TooltipProvider> </div> <div className="flex items-center gap-1"> @@ -1121,6 +1182,8 @@ function TimelineToolbar({ </TooltipContent> </Tooltip> </TooltipProvider> + + <div className="h-6 w-px bg-border mx-1" /> <div className="flex items-center gap-1"> <Button variant="text" size="icon" onClick={handleZoomOut}> <ZoomOut className="h-4 w-4" /> diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index f581bbf0..c7ed833e 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -13,6 +13,10 @@ import { Type, Copy, RefreshCw, + EyeOff, + Eye, + Volume2, + VolumeX, } from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; @@ -66,11 +70,18 @@ export function TimelineElement({ addElementToTrack, replaceElementMedia, rippleEditingEnabled, + toggleElementHidden, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); const [elementMenuOpen, setElementMenuOpen] = useState(false); + const mediaItem = + element.type === "media" + ? mediaItems.find((item) => item.id === element.mediaId) + : null; + const isAudio = mediaItem?.type === "audio"; + const { resizing, isResizing, @@ -141,6 +152,11 @@ export function TimelineElement({ } }; + const handleToggleElementHidden = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleElementHidden(track.id, element.id); + }; + const handleReplaceClip = (e: React.MouseEvent) => { e.stopPropagation(); if (element.type !== "media") { @@ -332,7 +348,7 @@ export function TimelineElement({ track.type )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ isBeingDragged ? "z-50" : "z-10" - }`} + } ${element.hidden ? "opacity-50" : ""}`} onClick={(e) => onElementClick && onElementClick(e, element)} onMouseDown={handleElementMouseDown} onContextMenu={(e) => @@ -343,6 +359,16 @@ export function TimelineElement({ {renderElementContent()} </div> + {element.hidden && ( + <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none"> + {isAudio ? ( + <VolumeX className="h-6 w-6 text-white" /> + ) : ( + <EyeOff className="h-6 w-6 text-white" /> + )} + </div> + )} + {isSelected && ( <> <div @@ -358,11 +384,34 @@ export function TimelineElement({ </div> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-[200]"> + <ContextMenuContent className="z-200"> <ContextMenuItem onClick={handleElementSplitContext}> <Scissors className="h-4 w-4 mr-2" /> Split at playhead </ContextMenuItem> + <ContextMenuItem onClick={handleToggleElementHidden}> + {isAudio ? ( + element.hidden ? ( + <Volume2 className="h-4 w-4 mr-2" /> + ) : ( + <VolumeX className="h-4 w-4 mr-2" /> + ) + ) : element.hidden ? ( + <Eye className="h-4 w-4 mr-2" /> + ) : ( + <EyeOff className="h-4 w-4 mr-2" /> + )} + <span> + {isAudio + ? element.hidden + ? "Unmute" + : "Mute" + : element.hidden + ? "Show" + : "Hide"}{" "} + {element.type === "text" ? "text" : "clip"} + </span> + </ContextMenuItem> <ContextMenuItem onClick={handleElementDuplicateContext}> <Copy className="h-4 w-4 mr-2" /> Duplicate {element.type === "text" ? "text" : "clip"} diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index b33166c0..ea70419b 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -126,7 +126,7 @@ export function TimelinePlayhead({ return ( <div ref={playheadRef} - className="absolute pointer-events-auto z-[150]" + className="absolute pointer-events-auto z-150" style={{ left: `${leftPosition}px`, top: 0, @@ -142,7 +142,7 @@ export function TimelinePlayhead({ {/* Playhead dot indicator at the top (in ruler area) */} <div - className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} + className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} /> </div> ); |
