diff options
Diffstat (limited to 'apps/web/src')
18 files changed, 404 insertions, 263 deletions
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 6e9af186..837bd97a 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -10,6 +10,7 @@ import { useProjectStore } from "@/stores/project-store"; import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; import { useState, useRef } from "react"; import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; export function EditorHeader() { const { getTotalDuration } = useTimelineStore(); @@ -22,6 +23,7 @@ export function EditorHeader() { // TODO: Implement export functionality // NOTE: This is already being worked on console.log("Export project"); + window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank"); }; const handleNameClick = () => { @@ -55,11 +57,11 @@ export function EditorHeader() { > <ChevronLeft className="h-4 w-4" /> </Link> - <div className="w-[14rem] h-9 flex items-center"> + <div className="w-40 xl:w-60 h-9 flex items-center"> {isEditing ? ( <Input ref={inputRef} - className="text-sm font-medium px-2 py-1 h-9 truncate" + className="text-sm font-medium w-full px-2 py-1 h-9 truncate" value={newName} onChange={(e) => setNewName(e.target.value)} onBlur={handleNameSave} @@ -78,7 +80,7 @@ export function EditorHeader() { onClick={handleNameClick} onKeyDown={(e) => e.key === "Enter" && handleNameClick()} > - <div className="truncate text-ellipsis overflow-clip w-40"> + <div className="truncate text-ellipsis overflow-clip max-w-40 xl:max-w-60"> {activeProject?.name} </div> </span> diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 2c743618..359612f8 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -46,9 +46,9 @@ export function MediaPanel() { }; return ( - <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden"> + <div className="h-full flex flex-col bg-panel rounded-sm"> <TabBar /> - <div className="flex-1">{viewMap[activeTab]}</div> + <div className="flex-1 overflow-y-auto">{viewMap[activeTab]}</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 66d0d00d..f218dc14 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -25,6 +25,7 @@ import { import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; +import { ScrollArea } from "@/components/ui/scroll-area"; export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); @@ -204,7 +205,7 @@ export function MediaView() { className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} {...dragProps} > - <div className="p-3 pb-2"> + <div className="p-3 pb-2 bg-panel"> {/* Search and filter controls */} <div className="flex gap-2"> <Select value={mediaFilter} onValueChange={setMediaFilter}> @@ -241,57 +242,59 @@ export function MediaView() { </div> </div> - <div className="flex-1 overflow-y-auto p-3 pt-0"> - {isDragOver || filteredMediaItems.length === 0 ? ( - <MediaDragOverlay - isVisible={true} - isProcessing={isProcessing} - progress={progress} - onClick={handleFileSelect} - isEmptyState={filteredMediaItems.length === 0 && !isDragOver} - /> - ) : ( - <div - className="grid gap-2" - style={{ - gridTemplateColumns: "repeat(auto-fill, 160px)", - }} - > - {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( - <ContextMenu key={item.id}> - <ContextMenuTrigger> - <DraggableMediaItem - name={item.name} - preview={renderPreview(item)} - dragData={{ - id: item.id, - type: item.type, - name: item.name, - }} - showPlusOnDrag={false} - onAddToTimeline={(currentTime) => - useTimelineStore - .getState() - .addMediaAtTime(item, currentTime) - } - rounded={false} - /> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem>Export clips</ContextMenuItem> - <ContextMenuItem - variant="destructive" - onClick={(e) => handleRemove(e, item.id)} - > - Delete - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ))} - </div> - )} - </div> + <ScrollArea className="h-full"> + <div className="flex-1 p-3 pt-0"> + {isDragOver || filteredMediaItems.length === 0 ? ( + <MediaDragOverlay + isVisible={true} + isProcessing={isProcessing} + progress={progress} + onClick={handleFileSelect} + isEmptyState={filteredMediaItems.length === 0 && !isDragOver} + /> + ) : ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > + {/* Render each media item as a draggable button */} + {filteredMediaItems.map((item) => ( + <ContextMenu key={item.id}> + <ContextMenuTrigger> + <DraggableMediaItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: item.type, + name: item.name, + }} + showPlusOnDrag={false} + onAddToTimeline={(currentTime) => + useTimelineStore + .getState() + .addMediaAtTime(item, currentTime) + } + rounded={false} + /> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(e) => handleRemove(e, item.id)} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + </div> + )} + </div> + </ScrollArea> </div> </> ); diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index f09e8462..639858d0 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -23,6 +23,7 @@ import { formatTimeCode } from "@/lib/time"; import { FONT_CLASS_MAP } from "@/lib/font-config"; import { BackgroundSettings } from "../background-settings"; import { useProjectStore } from "@/stores/project-store"; +import { TextElementDragState } from "@/types/editor"; interface ActiveElement { element: TimelineElement; @@ -31,7 +32,7 @@ interface ActiveElement { } export function PreviewPanel() { - const { tracks, getTotalDuration } = useTimelineStore(); + const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); const { mediaItems } = useMediaStore(); const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore(); const { canvasSize } = useEditorStore(); @@ -43,6 +44,19 @@ export function PreviewPanel() { }); const [isExpanded, setIsExpanded] = useState(false); const { activeProject } = useProjectStore(); + const [dragState, setDragState] = useState<TextElementDragState>({ + isDragging: false, + elementId: null, + trackId: null, + startX: 0, + startY: 0, + initialElementX: 0, + initialElementY: 0, + currentX: 0, + currentY: 0, + elementWidth: 0, + elementHeight: 0, + }); useEffect(() => { const updatePreviewSize = () => { @@ -129,6 +143,86 @@ export function PreviewPanel() { }; }, [isExpanded]); + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!dragState.isDragging) return; + + const deltaX = e.clientX - dragState.startX; + const deltaY = e.clientY - dragState.startY; + + const scaleRatio = previewDimensions.width / canvasSize.width; + const newX = dragState.initialElementX + deltaX / scaleRatio; + const newY = dragState.initialElementY + deltaY / scaleRatio; + + const halfWidth = dragState.elementWidth / scaleRatio / 2; + const halfHeight = dragState.elementHeight / scaleRatio / 2; + + const constrainedX = Math.max( + -canvasSize.width / 2 + halfWidth, + Math.min(canvasSize.width / 2 - halfWidth, newX) + ); + const constrainedY = Math.max( + -canvasSize.height / 2 + halfHeight, + Math.min(canvasSize.height / 2 - halfHeight, newY) + ); + + setDragState((prev) => ({ + ...prev, + currentX: constrainedX, + currentY: constrainedY, + })); + }; + + const handleMouseUp = () => { + if (dragState.isDragging && dragState.trackId && dragState.elementId) { + updateTextElement(dragState.trackId, dragState.elementId, { + x: dragState.currentX, + y: dragState.currentY, + }); + } + setDragState((prev) => ({ ...prev, isDragging: false })); + }; + + if (dragState.isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "grabbing"; + document.body.style.userSelect = "none"; + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [dragState, previewDimensions, canvasSize, updateTextElement]); + + const handleTextMouseDown = ( + e: React.MouseEvent<HTMLDivElement>, + element: any, + trackId: string + ) => { + e.preventDefault(); + e.stopPropagation(); + + const rect = e.currentTarget.getBoundingClientRect(); + + setDragState({ + isDragging: true, + elementId: element.id, + trackId: trackId, + startX: e.clientX, + startY: e.clientY, + initialElementX: element.x, + initialElementY: element.y, + currentX: element.x, + currentY: element.y, + elementWidth: rect.width, + elementHeight: rect.height, + }); + }; + const toggleExpanded = useCallback(() => { setIsExpanded((prev) => !prev); }, []); @@ -256,10 +350,13 @@ export function PreviewPanel() { return ( <div key={element.id} - className="absolute flex items-center justify-center" + className="absolute flex items-center justify-center cursor-grab" + onMouseDown={(e) => + handleTextMouseDown(e, element, elementData.track.id) + } style={{ - left: `${50 + (element.x / canvasSize.width) * 100}%`, - top: `${50 + (element.y / canvasSize.height) * 100}%`, + left: `${50 + ((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) * 100}%`, transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, opacity: element.opacity, zIndex: 100 + index, // Text elements on top diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 4fc5d89d..720c4a26 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -1,14 +1,12 @@ "use client"; -import { useProjectStore } from "@/stores/project-store"; +import { FPS_PRESETS } from "@/constants/timeline-constants"; import { useAspectRatio } from "@/hooks/use-aspect-ratio"; +import { useMediaStore } from "@/stores/media-store"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; import { Label } from "../../ui/label"; import { ScrollArea } from "../../ui/scroll-area"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; -import { AudioProperties } from "./audio-properties"; -import { MediaProperties } from "./media-properties"; -import { TextProperties } from "./text-properties"; import { Select, SelectContent, @@ -16,7 +14,14 @@ import { SelectTrigger, SelectValue, } from "../../ui/select"; -import { FPS_PRESETS } from "@/constants/timeline-constants"; +import { AudioProperties } from "./audio-properties"; +import { MediaProperties } from "./media-properties"; +import { + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "./property-item"; +import { TextProperties } from "./text-properties"; export function PropertiesPanel() { const { activeProject, updateProjectFps } = useProjectStore(); @@ -35,13 +40,31 @@ export function PropertiesPanel() { <div className="space-y-4 p-5"> {/* Media Properties */} <div className="flex flex-col gap-3"> - <PropertyItem label="Name:" value={activeProject?.name || ""} /> - <PropertyItem label="Aspect ratio:" value={getDisplayName()} /> - <PropertyItem - label="Resolution:" - value={`${canvasSize.width} × ${canvasSize.height}`} - /> - <div className="flex justify-between items-center"> + <PropertyItem direction="column"> + <PropertyItemLabel className="text-xs text-muted-foreground"> + Name: + </PropertyItemLabel> + <PropertyItemValue className="text-xs truncate"> + {activeProject?.name || ""} + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel className="text-xs text-muted-foreground"> + Aspect ratio: + </PropertyItemLabel> + <PropertyItemValue className="text-xs truncate"> + {getDisplayName()} + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel className="text-xs text-muted-foreground"> + Resolution: + </PropertyItemLabel> + <PropertyItemValue className="text-xs truncate"> + {`${canvasSize.width} × ${canvasSize.height}`} + </PropertyItemValue> + </PropertyItem> + <div className="flex flex-col gap-1"> <Label className="text-xs text-muted-foreground">Frame rate:</Label> <Select value={(activeProject?.fps || 30).toString()} @@ -83,7 +106,7 @@ export function PropertiesPanel() { ); if (mediaItem?.type === "audio") { - return <AudioProperties element={element} />; + return <AudioProperties key={elementId} element={element} />; } return ( @@ -98,14 +121,3 @@ export function PropertiesPanel() { </ScrollArea> ); } - -function PropertyItem({ label, value }: { label: string; value: string }) { - return ( - <div className="flex justify-between"> - <Label className="text-xs text-muted-foreground">{label}</Label> - <span className="text-xs text-right truncate w-40" title={value}> - {value} - </span> - </div> - ); -} 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 c55c3dae..e147ea65 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -71,6 +71,20 @@ export function TextProperties({ </div> </PropertyItemValue> </PropertyItem> + <PropertyItem direction="row"> + <PropertyItemLabel>Color</PropertyItemLabel> + <PropertyItemValue> + <Input + type="color" + value={element.color || "#ffffff"} + onChange={(e) => { + const color = e.target.value; + updateTextElement(trackId, element.id, { color }); + }} + className="w-full cursor-pointer rounded-full" + /> + </PropertyItemValue> + </PropertyItem> </div> ); } diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index fd2b2a11..bcaece89 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -744,13 +744,16 @@ export function Timeline() { /> </div> </ContextMenuTrigger> - <ContextMenuContent> + <ContextMenuContent className="z-[200]"> <ContextMenuItem - onClick={() => toggleTrackMute(track.id)} + onClick={(e) => { + e.stopPropagation(); + toggleTrackMute(track.id); + }} > {track.muted ? "Unmute Track" : "Mute Track"} </ContextMenuItem> - <ContextMenuItem> + <ContextMenuItem onClick={(e) => e.stopPropagation()}> Track settings (soon) </ContextMenuItem> </ContextMenuContent> diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index 8aadde82..c66e9fcb 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -102,7 +102,8 @@ export function TimelineElement({ // Element should always be positioned at startTime - trimStart only affects content, not position const elementLeft = elementStartTime * 50 * zoomLevel; - const handleElementSplitContext = () => { + const handleElementSplitContext = (e: React.MouseEvent) => { + e.stopPropagation(); const effectiveStart = element.startTime; const effectiveEnd = element.startTime + @@ -118,7 +119,8 @@ export function TimelineElement({ } }; - const handleElementDuplicateContext = () => { + const handleElementDuplicateContext = (e: React.MouseEvent) => { + e.stopPropagation(); const { id, ...elementWithoutId } = element; addElementToTrack(track.id, { ...elementWithoutId, @@ -130,7 +132,8 @@ export function TimelineElement({ }); }; - const handleElementDeleteContext = () => { + const handleElementDeleteContext = (e: React.MouseEvent) => { + e.stopPropagation(); if (rippleEditingEnabled) { removeElementFromTrackWithRipple(track.id, element.id); } else { @@ -138,7 +141,8 @@ export function TimelineElement({ } }; - const handleReplaceClip = () => { + const handleReplaceClip = (e: React.MouseEvent) => { + e.stopPropagation(); if (element.type !== "media") { toast.error("Replace is only available for media clips"); return; @@ -354,7 +358,7 @@ export function TimelineElement({ </div> </div> </ContextMenuTrigger> - <ContextMenuContent> + <ContextMenuContent className="z-[200]"> <ContextMenuItem onClick={handleElementSplitContext}> <Scissors className="h-4 w-4 mr-2" /> Split at playhead diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index 509ce9d7..d0aea7fa 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -72,6 +72,7 @@ export function TimelinePlayhead({ top: 0, height: `${totalHeight}px`, width: "2px", // Slightly wider for better click target + zIndex: 100, }} onMouseDown={handlePlayheadMouseDown} > diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 2e6de534..f5f847be 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -15,17 +15,19 @@ export function Header() { ); const rightContent = ( - <nav className="flex items-center gap-3"> - <Link href="/blog"> - <Button variant="text" className="text-sm p-0"> - Blog - </Button> - </Link> - <Link href="/contributors"> - <Button variant="text" className="text-sm p-0"> - Contributors - </Button> - </Link> + <nav className="flex items-center gap-1"> + <div className="flex items-center gap-4"> + <Link href="/blog"> + <Button variant="text" className="text-sm p-0"> + Blog + </Button> + </Link> + <Link href="/contributors"> + <Button variant="text" className="text-sm p-0"> + Contributors + </Button> + </Link> + </div> <Link href="/projects"> <Button size="sm" className="text-sm ml-4"> Projects diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 65a73b90..96d4705f 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -91,8 +91,8 @@ const EditableShortcutKey = ({ return ( <Button - variant='text' - size='sm' + variant="text" + size="sm" 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" @@ -141,11 +141,11 @@ export const KeyboardShortcutsHelp = () => { // Auto-save the new keybinding const conflict = validateKeybinding( keyString, - recordingShortcut.action, + recordingShortcut.action ); if (conflict) { toast.error( - `Key "${keyString}" is already bound to "${conflict.existingAction}"`, + `Key "${keyString}" is already bound to "${conflict.existingAction}"` ); setRecordingShortcut(null); return; @@ -199,7 +199,7 @@ export const KeyboardShortcutsHelp = () => { Shortcuts </Button> </DialogTrigger> - <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogContent className="max-w-2xl overflow-y-auto"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Keyboard className="w-5 h-5" /> @@ -235,7 +235,12 @@ export const KeyboardShortcutsHelp = () => { ))} </div> <DialogFooter> - <Button size="sm" className="mt-4" onClick={resetToDefaults}> + <Button + size="sm" + className="mt-4" + variant="destructive" + onClick={resetToDefaults} + > Reset to Default </Button> </DialogFooter> diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx index 94764dbe..26cf9254 100644 --- a/apps/web/src/components/landing/handlebars.tsx +++ b/apps/web/src/components/landing/handlebars.tsx @@ -1,163 +1,104 @@ "use client"; -import React, { useState, useRef, useEffect } from "react"; -import { motion, useMotionValue, useTransform, PanInfo } from "motion/react"; +import { + motion, + useMotionTemplate, + useMotionValue, + useMotionValueEvent, + useTransform, +} from "motion/react"; +import { type PropsWithChildren, useEffect, useRef, useState } from "react"; -interface HandlebarsProps { - children: React.ReactNode; - minWidth?: number; - maxWidth?: number; - onRangeChange?: (left: number, right: number) => void; -} - -export function Handlebars({ - children, - minWidth = 50, - maxWidth = 400, - onRangeChange, -}: HandlebarsProps) { - const [leftHandle, setLeftHandle] = useState(0); - const [rightHandle, setRightHandle] = useState(maxWidth); - const [contentWidth, setContentWidth] = useState(maxWidth); - - const leftHandleX = useMotionValue(0); - const rightHandleX = useMotionValue(maxWidth); - - const visibleWidth = useTransform( - [leftHandleX, rightHandleX], - (values: number[]) => values[1] - values[0] - ); - - const contentLeft = useTransform(leftHandleX, (left: number) => -left); +type HandlebarsProps = PropsWithChildren; +export function Handlebars({ children }: HandlebarsProps) { const containerRef = useRef<HTMLDivElement>(null); - const measureRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - if (!measureRef.current) return; - - const measureContent = () => { - if (measureRef.current) { - const width = measureRef.current.scrollWidth; - const paddedWidth = width + 32; - setContentWidth(paddedWidth); - setRightHandle(paddedWidth); - rightHandleX.set(paddedWidth); - } - }; - measureContent(); - const timer = setTimeout(measureContent, 50); + const [width, setWidth] = useState(0); - return () => clearTimeout(timer); - }, [children, rightHandleX]); + const [leftHandle, setLeftHandle] = useState(0); + const [rightHandle, setRightHandle] = useState(width); - useEffect(() => { - leftHandleX.set(leftHandle); - }, [leftHandle, leftHandleX]); + const leftHandleX = useMotionValue(0); + const rightHandleX = useMotionValue(width); useEffect(() => { - rightHandleX.set(rightHandle); - }, [rightHandle, rightHandleX]); + const el = containerRef.current; + if (!el) return; - useEffect(() => { - onRangeChange?.(leftHandle, rightHandle); - }, [leftHandle, rightHandle, onRangeChange]); + const observer = new ResizeObserver(() => { + setWidth(el.offsetWidth); + setRightHandle(el.offsetWidth); + rightHandleX.set(el.offsetWidth); + }); - const handleLeftDrag = (event: any, info: PanInfo) => { - const newLeft = Math.max( - 0, - Math.min(leftHandle + info.offset.x, rightHandle - minWidth) - ); - setLeftHandle(newLeft); - }; + observer.observe(el); + return () => observer.disconnect(); + }, [rightHandleX.set]); - const handleRightDrag = (event: any, info: PanInfo) => { - const newRight = Math.max( - leftHandle + minWidth, - Math.min(contentWidth, rightHandle + info.offset.x) - ); - setRightHandle(newRight); - }; + useMotionValueEvent(leftHandleX, "change", () => { + setLeftHandle(leftHandleX.get()); + }); + useMotionValueEvent(rightHandleX, "change", () => { + setRightHandle(rightHandleX.get()); + }); + const leftGradient = useTransform(leftHandleX, [0, width - 10], [0, 100]); + const rightGradient = useTransform(rightHandleX, [0, width + 10], [0, 100]); return ( <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2"> - <div - ref={measureRef} - className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]" - > - {children} - </div> - - <div - ref={containerRef} - className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2" - style={{ width: contentWidth }} - > - <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between"> + <div ref={containerRef} className="relative -rotate-[2.76deg] mt-2"> + <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between z-[1]"> <motion.div - className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" + className="absolute z-10 left-0 h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none" style={{ - position: "absolute", x: leftHandleX, - left: 0, - zIndex: 10, }} drag="x" - dragConstraints={{ left: 0, right: rightHandle - minWidth }} + dragConstraints={{ left: 0, right: rightHandle - 60 }} dragElastic={0} dragMomentum={false} - onDrag={handleLeftDrag} - whileHover={{ scale: 1.05 }} - whileDrag={{ scale: 1.1 }} + whileHover={{ scale: 1.05, cursor: "grab" }} + whileDrag={{ scale: 1.1, cursor: "grabbing" }} transition={{ type: "spring", stiffness: 400, damping: 30 }} > - <div className="w-2 h-8 rounded-full bg-yellow-500" /> + <div className="w-2 h-8 rounded-full bg-yellow-500"></div> </motion.div> <motion.div - className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" + className="absolute z-10 -left-[30px] h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none" style={{ - position: "absolute", x: rightHandleX, - left: -30, - zIndex: 10, }} drag="x" dragConstraints={{ - left: leftHandle + minWidth, - right: contentWidth, + left: leftHandle + 60, + right: width, }} dragElastic={0} dragMomentum={false} - onDrag={handleRightDrag} - whileHover={{ scale: 1.05 }} - whileDrag={{ scale: 1.1 }} + whileHover={{ scale: 1.05, cursor: "grab" }} + whileDrag={{ scale: 1.1, cursor: "grabbing" }} transition={{ type: "spring", stiffness: 400, damping: 30 }} > - <div className="w-2 h-8 rounded-full bg-yellow-500" /> + <div className="w-2 h-8 rounded-full bg-yellow-500"></div> </motion.div> </div> - <motion.div - className="relative overflow-hidden rounded-2xl" + <motion.span + className="inline-flex items-center justify-center w-full h-full px-9 relative rounded-2xl will-change-auto" style={{ - width: visibleWidth, - x: leftHandleX, - height: "100%", + mask: useMotionTemplate`linear-gradient(90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0) ${leftGradient}%, + rgba(0, 0, 0) ${leftGradient}%, + rgba(0, 0, 0) ${rightGradient}%, + rgba(255, 255, 255, 0) ${rightGradient}%, + rgba(255, 255, 255, 0) 100%)`, }} > - <motion.div - className="w-full h-full flex items-center justify-center px-4" - style={{ - x: contentLeft, - width: contentWidth, - whiteSpace: "nowrap", - }} - > - {children} - </motion.div> - </motion.div> + {children} + </motion.span> </div> </div> ); diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index e8decbd7..35843a5f 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -52,7 +52,7 @@ const DialogContent = React.forwardRef< }} {...props} > - <ScrollArea className="max-h-[85vh]"> + <ScrollArea className="max-h-[75vh]"> <div className="p-6 space-y-4">{children}</div> </ScrollArea> <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"> diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts index 4e8f6aef..b2fad55f 100644 --- a/apps/web/src/hooks/use-editor-actions.ts +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -28,52 +28,52 @@ export function useEditorActions() { // Playback actions useActionHandler("toggle-play", () => { toggle(); - }); + }, undefined); useActionHandler("stop-playback", () => { if (isPlaying) { toggle(); } seek(0); - }); + }, undefined); useActionHandler("seek-forward", (args) => { const seconds = args?.seconds ?? 1; seek(Math.min(duration, currentTime + seconds)); - }); + }, undefined); useActionHandler("seek-backward", (args) => { const seconds = args?.seconds ?? 1; seek(Math.max(0, currentTime - seconds)); - }); + }, undefined); useActionHandler("frame-step-forward", () => { const projectFps = activeProject?.fps || 30; seek(Math.min(duration, currentTime + 1 / projectFps)); - }); + }, undefined); useActionHandler("frame-step-backward", () => { const projectFps = activeProject?.fps || 30; seek(Math.max(0, currentTime - 1 / projectFps)); - }); + }, undefined); useActionHandler("jump-forward", (args) => { const seconds = args?.seconds ?? 5; seek(Math.min(duration, currentTime + seconds)); - }); + }, undefined); useActionHandler("jump-backward", (args) => { const seconds = args?.seconds ?? 5; seek(Math.max(0, currentTime - seconds)); - }); + }, undefined); useActionHandler("goto-start", () => { seek(0); - }); + }, undefined); useActionHandler("goto-end", () => { seek(duration); - }); + }, undefined); // Timeline editing actions useActionHandler("split-element", () => { @@ -98,7 +98,7 @@ export function useEditorActions() { toast.error("Playhead must be within selected element"); } } - }); + }, undefined); useActionHandler("delete-selected", () => { if (selectedElements.length === 0) { @@ -110,7 +110,7 @@ export function useEditorActions() { } ); clearSelectedElements(); - }); + }, undefined); useActionHandler("select-all", () => { const allElements = tracks.flatMap((track: any) => @@ -120,7 +120,7 @@ export function useEditorActions() { })) ); setSelectedElements(allElements); - }); + }, undefined); useActionHandler("duplicate-selected", () => { if (selectedElements.length !== 1) { @@ -144,18 +144,18 @@ export function useEditorActions() { startTime: newStartTime, }); } - }); + }, undefined); useActionHandler("toggle-snapping", () => { toggleSnapping(); - }); + }, undefined); // History actions useActionHandler("undo", () => { undo(); - }); + }, undefined); useActionHandler("redo", () => { redo(); - }); + }, undefined); } diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index ed1d6b8c..97cba23f 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -54,7 +54,7 @@ interface KeybindingsState { // Validation validateKeybinding: ( key: ShortcutKey, - action: ActionWithOptionalArgs, + action: ActionWithOptionalArgs ) => KeybindingConflict | null; getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[]; @@ -129,7 +129,7 @@ export const useKeybindingsStore = create<KeybindingsState>()( validateKeybinding: ( key: ShortcutKey, - action: ActionWithOptionalArgs, + action: ActionWithOptionalArgs ) => { const { keybindings } = get(); const existingAction = keybindings[key]; @@ -151,7 +151,7 @@ export const useKeybindingsStore = create<KeybindingsState>()( getKeybindingsForAction: (action: ActionWithOptionalArgs) => { const { keybindings } = get(); return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action, + (key) => keybindings[key as ShortcutKey] === action ) as ShortcutKey[]; }, @@ -162,8 +162,8 @@ export const useKeybindingsStore = create<KeybindingsState>()( { name: "opencut-keybindings", version: 1, - }, - ), + } + ) ); // Utility functions diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 029307d9..650c0540 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -183,24 +183,58 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ } }, - removeMediaItem: async (projectId, id: string) => { + removeMediaItem: async (projectId: string, id: string) => { const state = get(); const item = state.mediaItems.find((media) => media.id === id); // Cleanup object URLs to prevent memory leaks - if (item && item.url) { + if (item?.url) { URL.revokeObjectURL(item.url); if (item.thumbnailUrl) { URL.revokeObjectURL(item.thumbnailUrl); } } - // Remove from local state immediately + // 1) Remove from local state immediately set((state) => ({ mediaItems: state.mediaItems.filter((media) => media.id !== id), })); - // Remove from persistent storage + // 2) Cascade into the timeline: remove any elements using this media ID + const timeline = useTimelineStore.getState(); + const { + tracks, + removeElementFromTrack, + removeElementFromTrackWithRipple, + rippleEditingEnabled, + pushHistory, + } = timeline; + + // Find all elements that reference this media + const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; + for (const track of tracks) { + for (const el of track.elements) { + if (el.type === "media" && el.mediaId === id) { + elementsToRemove.push({ trackId: track.id, elementId: el.id }); + } + } + } + + // If there are elements to remove, push history once before batch removal + if (elementsToRemove.length > 0) { + pushHistory(); + + // Remove all elements without pushing additional history entries + for (const { trackId, elementId } of elementsToRemove) { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId, false); + } else { + removeElementFromTrack(trackId, elementId, false); + } + } + } + + // 3) Remove from persistent storage try { await storageService.deleteMediaItem(projectId, id); } catch (error) { diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 820025cc..6f9dfbd6 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -96,7 +96,11 @@ interface TimelineStore { removeTrack: (trackId: string) => void; removeTrackWithRipple: (trackId: string) => void; addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; - removeElementFromTrack: (trackId: string, elementId: string) => void; + removeElementFromTrack: ( + trackId: string, + elementId: string, + pushHistory?: boolean + ) => void; moveElementToTrack: ( fromTrackId: string, toTrackId: string, @@ -156,7 +160,8 @@ interface TimelineStore { ) => void; removeElementFromTrackWithRipple: ( trackId: string, - elementId: string + elementId: string, + pushHistory?: boolean ) => void; // Computed values @@ -553,13 +558,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { get().selectElement(trackId, newElement.id); }, - removeElementFromTrack: (trackId, elementId) => { + removeElementFromTrack: (trackId, elementId, pushHistory = true) => { const { rippleEditingEnabled } = get(); if (rippleEditingEnabled) { - get().removeElementFromTrackWithRipple(trackId, elementId); + get().removeElementFromTrackWithRipple(trackId, elementId, pushHistory); } else { - get().pushHistory(); + if (pushHistory) get().pushHistory(); updateTracksAndSave( get() ._tracks.map((track) => @@ -577,12 +582,16 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { } }, - removeElementFromTrackWithRipple: (trackId, elementId) => { + removeElementFromTrackWithRipple: ( + trackId, + elementId, + pushHistory = true + ) => { const { _tracks, rippleEditingEnabled } = get(); if (!rippleEditingEnabled) { // If ripple editing is disabled, use regular removal - get().removeElementFromTrack(trackId, elementId); + get().removeElementFromTrack(trackId, elementId, pushHistory); return; } @@ -591,7 +600,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { if (!element || !track) return; - get().pushHistory(); + if (pushHistory) get().pushHistory(); const elementStartTime = element.startTime; const elementDuration = diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts index b0449991..12b474fd 100644 --- a/apps/web/src/types/editor.ts +++ b/apps/web/src/types/editor.ts @@ -10,3 +10,17 @@ export interface CanvasPreset { width: number; height: number; } + +export interface TextElementDragState { + isDragging: boolean; + elementId: string | null; + trackId: string | null; + startX: number; + startY: number; + initialElementX: number; + initialElementY: number; + currentX: number; + currentY: number; + elementWidth: number; + elementHeight: number; +} |
