diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components')
88 files changed, 7717 insertions, 3583 deletions
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 61f23372..40390f07 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { formatTimeCode, parseTimeCode } from "@/lib/time"; -import type { TTimeCode } from "@/types/time"; +import type { TTimeCode } from "@/lib/time"; import { cn } from "@/utils/ui"; interface EditableTimecodeProps { @@ -66,16 +66,13 @@ export function EditableTimecode({ enterPressedRef.current = false; }; - const handleKeyDown = ({ - key, - preventDefault, - }: React.KeyboardEvent<HTMLInputElement>) => { - if (key === "Enter") { - preventDefault(); + const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { + if (event.key === "Enter") { + event.preventDefault(); enterPressedRef.current = true; applyEdit(); - } else if (key === "Escape") { - preventDefault(); + } else if (event.key === "Escape") { + event.preventDefault(); cancelEditing(); } }; @@ -93,14 +90,13 @@ export function EditableTimecode({ } }; - const handleDisplayKeyDown = ({ - key, - preventDefault, - }: React.KeyboardEvent<HTMLButtonElement>) => { + const handleDisplayKeyDown = ( + event: React.KeyboardEvent<HTMLButtonElement>, + ) => { if (disabled) return; - if (key === "Enter" || key === " ") { - preventDefault(); + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); startEditing(); } }; diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx index 7b736b23..42f99c79 100644 --- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx @@ -6,7 +6,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import type { TProjectMetadata } from "@/types/project"; +import type { TProjectMetadata } from "@/lib/project/types"; import { formatDate } from "@/utils/date"; import { formatTimeCode } from "@/lib/time"; import { Button } from "@/components/ui/button"; diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx index 565fe705..9a353a1c 100644 --- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx @@ -110,7 +110,7 @@ export function ShortcutsDialog({ <DialogTitle>Keyboard shortcuts</DialogTitle> </DialogHeader> - <DialogBody className="scrollbar-thin flex-grow overflow-y-auto"> + <DialogBody className="scrollbar-thin grow overflow-y-auto"> <div className="flex flex-col gap-6"> {categories.map((category) => ( <div key={category} className="flex flex-col gap-1"> diff --git a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx new file mode 100644 index 00000000..d391928c --- /dev/null +++ b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useStoragePersistence } from "@/hooks/use-storage-persistence"; + +export function StoragePersistenceDialog() { + const { showDialog, onConfirm, onDismiss } = useStoragePersistence(); + + return ( + <Dialog open={showDialog} onOpenChange={(open) => !open && onDismiss()}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Don't lose your projects</DialogTitle> + </DialogHeader> + <DialogBody> + <p className="text-base text-muted-foreground"> + Your browser can automatically delete your projects when storage + runs low. + </p> + <p className="text-base text-muted-foreground"> + Allow OpenCut to protect them? + </p> + </DialogBody> + <DialogFooter> + <Button variant="outline" onClick={onDismiss}> + Not now + </Button> + <Button onClick={onConfirm}>Allow</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index 2dea73e2..8dd730b5 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -47,7 +47,7 @@ function ProjectDropdown() { const [isExiting, setIsExiting] = useState(false); const router = useRouter(); const editor = useEditor(); - const activeProject = editor.project.getActive(); + const activeProject = useEditor((e) => e.project.getActive()); const handleExit = async () => { if (isExiting) return; @@ -136,7 +136,7 @@ function ProjectDropdown() { <DropdownMenuSeparator /> - <DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}> + <DropdownMenuItem asChild icon={<FaDiscord className="size-4!" />}> <Link href={SOCIAL_LINKS.discord} target="_blank" @@ -169,7 +169,7 @@ function ProjectDropdown() { function EditableProjectName() { const editor = useEditor(); - const activeProject = editor.project.getActive(); + const activeProject = useEditor((e) => e.project.getActive()); const [isEditing, setIsEditing] = useState(false); const inputRef = useRef<HTMLInputElement>(null); const originalNameRef = useRef(""); @@ -219,6 +219,7 @@ function EditableProjectName() { event.preventDefault(); if (inputRef.current) { inputRef.current.value = originalNameRef.current; + inputRef.current.setSelectionRange(0, 0); } setIsEditing(false); inputRef.current?.blur(); diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index ddf8308d..ec39e7e6 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -14,20 +14,24 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Progress } from "@/components/ui/progress"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/utils/ui"; -import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export"; +import { + getExportMimeType, + getExportFileExtension, + downloadBuffer, +} from "@/lib/export"; import { Check, Copy, Download, RotateCcw } from "lucide-react"; import { EXPORT_FORMAT_VALUES, EXPORT_QUALITY_VALUES, type ExportFormat, type ExportQuality, -} from "@/types/export"; +} from "@/lib/export"; import { Section, SectionContent, SectionHeader, SectionTitle, -} from "@/components/editor/panels/properties/section"; +} from "@/components/section"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; @@ -42,8 +46,8 @@ function isExportQuality(value: string): value is ExportQuality { export function ExportButton() { const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); const editor = useEditor(); - - const hasProject = !!editor.project.getActiveOrNull(); + const activeProject = useEditor((e) => e.project.getActiveOrNull()); + const hasProject = !!activeProject; const handlePopoverOpenChange = ({ open }: { open: boolean }) => { if (!open) { @@ -54,7 +58,10 @@ export function ExportButton() { }; return ( - <Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}> + <Popover + open={isExportPopoverOpen} + onOpenChange={(open) => handlePopoverOpenChange({ open })} + > <PopoverTrigger asChild> <button type="button" @@ -91,9 +98,9 @@ function ExportPopover({ onOpenChange: (open: boolean) => void; }) { const editor = useEditor(); - const activeProject = editor.project.getActive(); - const { isExporting, progress, result: exportResult } = - editor.project.getExportState(); + const activeProject = useEditor((e) => e.project.getActive()); + const exportState = useEditor((e) => e.project.getExportState()); + const { isExporting, progress, result: exportResult } = exportState; const [format, setFormat] = useState<ExportFormat>( DEFAULT_EXPORT_OPTIONS.format, ); @@ -109,10 +116,10 @@ function ExportPopover({ const result = await editor.project.export({ options: { - format, - quality, - fps: activeProject.settings.fps, - includeAudio: shouldIncludeAudio, + format, + quality, + fps: activeProject.settings.fps, + includeAudio: shouldIncludeAudio, }, }); @@ -156,7 +163,11 @@ function ExportPopover({ {!isExporting && ( <> <div className="flex flex-col"> - <Section collapsible defaultOpen={false} showTopBorder={false}> + <Section + collapsible + defaultOpen={false} + showTopBorder={false} + > <SectionHeader> <SectionTitle>Format</SectionTitle> </SectionHeader> @@ -228,10 +239,10 @@ function ExportPopover({ <div className="flex items-center space-x-2"> <Checkbox id="include-audio" - checked={shouldIncludeAudio} - onCheckedChange={(checked) => - setShouldIncludeAudio(!!checked) - } + checked={shouldIncludeAudio} + onCheckedChange={(checked) => + setShouldIncludeAudio(!!checked) + } /> <Label htmlFor="include-audio"> Include audio in export @@ -252,15 +263,15 @@ function ExportPopover({ {isExporting && ( <div className="space-y-4 p-3"> - <div className="flex flex-col gap-2"> - <div className="flex items-center justify-between text-center"> - <p className="text-muted-foreground text-sm"> - {Math.round(progress * 100)}% - </p> - <p className="text-muted-foreground text-sm">100%</p> + <div className="flex flex-col gap-2"> + <div className="flex items-center justify-between text-center"> + <p className="text-muted-foreground text-sm"> + {Math.round(progress * 100)}% + </p> + <p className="text-muted-foreground text-sm">100%</p> + </div> + <Progress value={progress * 100} className="w-full" /> </div> - <Progress value={progress * 100} className="w-full" /> - </div> <Button variant="outline" diff --git a/apps/web/src/components/editor/mobile-gate.tsx b/apps/web/src/components/editor/mobile-gate.tsx index 8fecf3cb..6483e438 100644 --- a/apps/web/src/components/editor/mobile-gate.tsx +++ b/apps/web/src/components/editor/mobile-gate.tsx @@ -36,7 +36,7 @@ export function MobileGate({ children }: MobileGateProps) { }; return ( - <div className="dark bg-background relative flex h-screen w-screen flex-col overflow-hidden"> + <div className="bg-background relative flex h-screen w-screen flex-col overflow-hidden"> <Button variant="text" className="absolute top-6 left-6 flex items-center gap-1 text-muted-foreground" @@ -58,9 +58,7 @@ export function MobileGate({ children }: MobileGateProps) { </p> </div> <div className="flex items-center gap-3"> - <Button onClick={handleContinue}> - Take a look anyway - </Button> + <Button onClick={handleContinue}>Take a look anyway</Button> <Button variant="ghost" asChild> <Link href="/roadmap" className="flex items-center gap-1"> Roadmap diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx index c6b0861c..9d9ae903 100644 --- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx +++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx @@ -29,7 +29,7 @@ export function MediaDragOverlay({ return ( <button - className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center" + className="bg-foreground/5 hover:bg-foreground/10 flex flex-col items-center justify-center gap-4 rounded-lg p-8 text-center" type="button" disabled={isProcessing || !onClick} onClick={(event) => handleClick({ event })} diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index fae74e84..8c9acce8 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -12,7 +12,7 @@ import { } from "@/components/ui/tooltip"; import { useEditor } from "@/hooks/use-editor"; import { clearDragData, setDragData } from "@/lib/drag-data"; -import type { TimelineDragData } from "@/types/drag"; +import type { TimelineDragData } from "@/lib/timeline/drag"; import { cn } from "@/utils/ui"; export interface DraggableItemProps { @@ -29,7 +29,6 @@ export interface DraggableItemProps { isRounded?: boolean; variant?: "card" | "compact"; isDraggable?: boolean; - isHighlighted?: boolean; } export function DraggableItem({ @@ -46,13 +45,11 @@ export function DraggableItem({ isRounded = true, variant = "card", isDraggable = true, - isHighlighted = false, }: DraggableItemProps) { const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const dragRef = useRef<HTMLDivElement>(null); const editor = useEditor(); - const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`; const handleAddToTimeline = () => { onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); @@ -102,9 +99,8 @@ export function DraggableItem({ > <div className={cn( - "relative flex h-auto w-full cursor-default flex-col gap-1 p-1", + "relative flex h-auto w-full cursor-default flex-col gap-1 p-", className, - isHighlighted && highlightClassName, )} > <AspectRatio @@ -144,15 +140,12 @@ export function DraggableItem({ ) : ( <div ref={dragRef} - className={cn( - "group relative w-full", - isHighlighted && highlightClassName, - )} + className={cn("group relative w-full", containerClassName)} > <button type="button" className={cn( - "flex h-8 w-full cursor-default items-center gap-3 px-1", + "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none", isDraggable && "[&::-webkit-drag-ghost]:opacity-0", className, )} @@ -160,7 +153,7 @@ export function DraggableItem({ onDragStart={isDraggable ? handleDragStart : undefined} onDragEnd={isDraggable ? handleDragEnd : undefined} > - <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]"> + <div className="size-6 shrink-0 overflow-hidden rounded-sm"> {preview} </div> <span className="w-full flex-1 truncate text-sm text-left"> diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index 61777a7c..a4e072f1 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -49,7 +49,7 @@ export function TabBar() { <div className="relative flex"> <div ref={scrollRef} - className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto" + className="scrollbar-hidden relative flex size-full p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto" > {TAB_KEYS.map((tabKey) => { const tab = tabs[tabKey]; @@ -57,12 +57,13 @@ export function TabBar() { <Tooltip key={tabKey} delayDuration={10}> <TooltipTrigger asChild> <Button - variant={activeTab === tabKey ? "secondary" : "text"} + variant={activeTab === tabKey ? "secondary" : "ghost"} + size="icon" aria-label={tab.label} className={cn( - "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5", - activeTab !== tabKey && - "border border-transparent text-muted-foreground", + "shrink-0", + "h-8 w-8", + activeTab !== tabKey && "text-muted-foreground", )} onClick={() => setActiveTab(tabKey)} > @@ -102,8 +103,8 @@ function FadeOverlay({ className={cn( "pointer-events-none absolute right-0 left-0 h-6", direction === "top" && show - ? "from-background top-0 bg-gradient-to-b to-transparent" - : "from-background bottom-0 bg-gradient-to-t to-transparent", + ? "from-background top-0 bg-linear-to-b to-transparent" + : "from-background bottom-0 bg-linear-to-t to-transparent", )} /> ); diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 3b35c022..e17e4d4a 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -2,11 +2,11 @@ import Image from "next/image"; import { useMemo, useState } from "react"; -import { toast } from "sonner"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; import { ContextMenu, ContextMenuContent, @@ -28,8 +28,15 @@ import { import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useEditor } from "@/hooks/use-editor"; import { useFileUpload } from "@/hooks/use-file-upload"; -import { useRevealItem } from "@/hooks/use-reveal-item"; +import { invokeAction } from "@/lib/actions"; import { processMediaAssets } from "@/lib/media/processing"; +import { showMediaUploadToast } from "@/lib/media/upload-toast"; +import { + SelectableItem, + SelectableSurface, + useSelection, + useSelectionScope, +} from "@/lib/selection"; import { buildElementFromMedia } from "@/lib/timeline/element-utils"; import { type MediaSortKey, @@ -37,7 +44,8 @@ import { type MediaViewMode, useAssetsPanelStore, } from "@/stores/assets-panel-store"; -import type { MediaAsset } from "@/types/assets"; +import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; import { cn } from "@/utils/ui"; import { CloudUploadIcon, @@ -52,8 +60,8 @@ import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; export function MediaView() { const editor = useEditor(); - const mediaFiles = editor.media.getAssets(); - const activeProject = editor.project.getActive(); + const mediaFiles = useEditor((e) => e.media.getAssets()); + const activeProject = useEditor((e) => e.project.getActive()); const { mediaViewMode, @@ -64,15 +72,11 @@ export function MediaView() { mediaSortOrder, setMediaSort, } = useAssetsPanelStore(); - const { highlightedId, registerElement } = useRevealItem( - highlightMediaId, - clearHighlight, - ); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); - const processFiles = async ({ files }: { files: FileList }) => { + const processFiles = async ({ files }: { files: File[] }) => { if (!files || files.length === 0) return; if (!activeProject) { toast.error("No active project"); @@ -82,20 +86,28 @@ export function MediaView() { setIsProcessing(true); setProgress(0); try { - const processedAssets = await processMediaAssets({ - files, - onProgress: (progress: { progress: number }) => - setProgress(progress.progress), + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ + files, + onProgress: (progress: { progress: number }) => + setProgress(progress.progress), + }); + for (const asset of processedAssets) { + await editor.media.addMediaAsset({ + projectId: activeProject.metadata.id, + asset, + }); + } + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, }); - for (const asset of processedAssets) { - await editor.media.addMediaAsset({ - projectId: activeProject.metadata.id, - asset, - }); - } } catch (error) { console.error("Error processing files:", error); - toast.error("Failed to process files"); } finally { setIsProcessing(false); setProgress(0); @@ -109,23 +121,18 @@ export function MediaView() { onFilesSelected: (files) => processFiles({ files }), }); - const handleRemove = async ({ + const handleRemove = ({ event, - id, + ids, }: { event: React.MouseEvent; - id: string; + ids: string[]; }) => { event.stopPropagation(); - if (!activeProject) { - toast.error("No active project"); - return; - } - - await editor.media.removeMediaAsset({ + invokeAction("remove-media-assets", { projectId: activeProject.metadata.id, - id, + assetIds: ids, }); }; @@ -172,6 +179,9 @@ export function MediaView() { return filtered; }, [mediaFiles, mediaSortBy, mediaSortOrder]); + const orderedMediaIds = useMemo(() => { + return filteredMediaItems.map((item) => item.id); + }, [filteredMediaItems]); return ( <> @@ -191,6 +201,7 @@ export function MediaView() { /> } className={cn(isDragOver && "bg-accent/30")} + contentClassName="h-full" {...dragProps} > {isDragOver || filteredMediaItems.length === 0 ? ( @@ -201,29 +212,38 @@ export function MediaView() { onClick={openFilePicker} /> ) : ( - <MediaItemList - items={filteredMediaItems} - mode={mediaViewMode} - onRemove={handleRemove} - highlightedId={highlightedId} - registerElement={registerElement} - /> + <SelectableSurface + ariaLabel="Assets" + orderedIds={orderedMediaIds} + revealId={highlightMediaId} + onRevealComplete={clearHighlight} + > + <MediaScopeRegistrar /> + <MediaItemList + items={filteredMediaItems} + mode={mediaViewMode} + onRemove={handleRemove} + /> + </SelectableSurface> )} </PanelView> </> ); } +function MediaScopeRegistrar() { + useSelectionScope(); + return null; +} + function MediaAssetDraggable({ item, preview, - isHighlighted, variant, isRounded, }: { item: MediaAsset; preview: React.ReactNode; - isHighlighted: boolean; variant: "card" | "compact"; isRounded?: boolean; }) { @@ -261,7 +281,7 @@ function MediaAssetDraggable({ mediaType: item.type, name: item.name, ...(item.type !== "audio" && { - targetElementTypes: ["video", "image"] as const, + targetElementTypes: [...MASKABLE_ELEMENT_TYPES], }), }} shouldShowPlusOnDrag={false} @@ -270,7 +290,6 @@ function MediaAssetDraggable({ } variant={variant} isRounded={isRounded} - isHighlighted={isHighlighted} /> ); } @@ -282,18 +301,31 @@ function MediaItemWithContextMenu({ }: { item: MediaAsset; children: React.ReactNode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; + onRemove: ({ + event, + ids, + }: { + event: React.MouseEvent; + ids: string[]; + }) => void; }) { + const { isSelected, selectedIds } = useSelection(); + const idsToDelete = isSelected(item.id) ? selectedIds : [item.id]; + const deleteLabel = + idsToDelete.length > 1 ? `Delete ${idsToDelete.length} items` : "Delete"; + return ( <ContextMenu> - <ContextMenuTrigger>{children}</ContextMenuTrigger> + <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem>Export clips</ContextMenuItem> <ContextMenuItem variant="destructive" - onClick={(event) => onRemove({ event, id: item.id })} + onClick={(event: React.MouseEvent<HTMLDivElement>) => + onRemove({ event, ids: idsToDelete }) + } > - Delete + {deleteLabel} </ContextMenuItem> </ContextMenuContent> </ContextMenu> @@ -304,27 +336,29 @@ function MediaItemList({ items, mode, onRemove, - highlightedId, - registerElement, }: { items: MediaAsset[]; mode: MediaViewMode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; + onRemove: ({ + event, + ids, + }: { + event: React.MouseEvent; + ids: string[]; + }) => void; }) { const isGrid = mode === "grid"; return ( <div - className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1")} + className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1.5")} style={ isGrid ? { gridTemplateColumns: "repeat(auto-fill, 7rem)" } : undefined } > {items.map((item) => ( - <div key={item.id} ref={(element) => registerElement(item.id, element)}> - <MediaItemWithContextMenu item={item} onRemove={onRemove}> + <MediaItemWithContextMenu item={item} onRemove={onRemove} key={item.id}> + <SelectableItem className={cn(!isGrid && "w-full")} id={item.id}> <MediaAssetDraggable item={item} preview={ @@ -335,16 +369,15 @@ function MediaItemList({ } variant={isGrid ? "card" : "compact"} isRounded={isGrid ? false : undefined} - isHighlighted={highlightedId === item.id} /> - </MediaItemWithContextMenu> - </div> + </SelectableItem> + </MediaItemWithContextMenu> ))} </div> ); } -export function formatDuration({ duration }: { duration: number }) { +function formatDuration({ duration }: { duration: number }) { const min = Math.floor(duration / 60); const sec = Math.floor(duration % 60); return `${min}:${sec.toString().padStart(2, "0")}`; @@ -406,7 +439,7 @@ function MediaPreview({ if (item.type === "image") { return ( - <div className="relative flex size-full items-center justify-center"> + <div className="relative flex size-full items-center justify-center bg-muted"> <Image src={item.url ?? ""} alt={item.name} diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx index c613bc2a..0ece9b19 100644 --- a/apps/web/src/components/editor/panels/assets/views/base-view.tsx +++ b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx @@ -5,6 +5,7 @@ interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> { actions?: React.ReactNode; children: React.ReactNode; contentClassName?: string; + scrollClassName?: string; hideHeader?: boolean; ref?: React.Ref<HTMLDivElement>; onScroll?: React.UIEventHandler<HTMLDivElement>; @@ -17,6 +18,7 @@ export function PanelView({ children, className, contentClassName, + scrollClassName, hideHeader = false, ref, onScroll, @@ -30,15 +32,18 @@ export function PanelView({ {...rest} > {!hideHeader && ( - <div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b"> - <span className="text-muted-foreground text-sm">{title}</span> + <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b"> + {title && ( + <span className="text-muted-foreground text-sm">{title}</span> + )} {actions} </div> )} <div className={cn( - "scrollbar-thin size-full overflow-y-auto", + "scrollbar-hidden size-full overflow-y-auto", hideHeader ? "pt-4" : "pt-2", + scrollClassName, )} ref={scrollRef} onScroll={onScroll} diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index a73fd693..023e1578 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { Select, SelectContent, @@ -10,7 +10,6 @@ import { import { useState, useRef } from "react"; import { extractTimelineAudio } from "@/lib/media/mediabunny"; import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE, TRANSCRIPTION_LANGUAGES, @@ -18,12 +17,23 @@ import { import type { TranscriptionLanguage, TranscriptionProgress, -} from "@/types/transcription"; +} from "@/lib/transcription/types"; import { transcriptionService } from "@/services/transcription/service"; import { decodeAudioToFloat32 } from "@/lib/media/audio"; import { buildCaptionChunks } from "@/lib/transcription/caption"; import { Spinner } from "@/components/ui/spinner"; -import { Label } from "@/components/ui/label"; +import { + Section, + SectionContent, + SectionField, + SectionFields, +} from "@/components/section"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { + AddTrackCommand, + BatchCommand, + InsertElementCommand, +} from "@/lib/commands"; export function Captions() { const [selectedLanguage, setSelectedLanguage] = @@ -69,26 +79,29 @@ export function Captions() { setProcessingStep("Generating captions..."); const captionChunks = buildCaptionChunks({ segments: result.segments }); - const captionTrackId = editor.timeline.addTrack({ - type: "text", - index: 0, - }); + const addTrackCommand = new AddTrackCommand("text", 0); + const insertCommands = captionChunks.map( + (caption, i) => + new InsertElementCommand({ + placement: { + mode: "explicit", + trackId: addTrackCommand.getTrackId(), + }, + element: { + ...DEFAULTS.text.element, + name: `Caption ${i + 1}`, + content: caption.text, + duration: caption.duration, + startTime: caption.startTime, + fontSize: 65, + fontWeight: "bold", + }, + }), + ); - for (let i = 0; i < captionChunks.length; i++) { - const caption = captionChunks[i]; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: captionTrackId }, - element: { - ...DEFAULT_TEXT_ELEMENT, - name: `Caption ${i + 1}`, - content: caption.text, - duration: caption.duration, - startTime: caption.startTime, - fontSize: 65, - fontWeight: "bold", - }, - }); - } + editor.command.execute({ + command: new BatchCommand([addTrackCommand, ...insertCommands]), + }); } catch (error) { console.error("Transcription failed:", error); setError( @@ -114,43 +127,53 @@ export function Captions() { }; return ( - <PanelView title="Captions" ref={containerRef}> - <div className="flex flex-col gap-3"> - <Label>Language</Label> - <Select - value={selectedLanguage} - onValueChange={(value) => handleLanguageChange({ value })} - > - <SelectTrigger> - <SelectValue placeholder="Select a language" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="auto">Auto detect</SelectItem> - {TRANSCRIPTION_LANGUAGES.map((language) => ( - <SelectItem key={language.code} value={language.code}> - {language.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - <div className="flex flex-col gap-4"> - {error && ( - <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3"> - <p className="text-destructive text-sm">{error}</p> - </div> - )} + <PanelView + title="Captions" + contentClassName="px-0 flex flex-col h-full" + ref={containerRef} + > + <Section showTopBorder={false} showBottomBorder={false} className="flex-1"> + <SectionContent className="flex flex-col gap-4 h-full pt-1"> + <SectionFields> + <SectionField label="Language"> + <Select + value={selectedLanguage} + onValueChange={(value) => handleLanguageChange({ value })} + > + <SelectTrigger> + <SelectValue placeholder="Select a language" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="auto">Auto detect</SelectItem> + {TRANSCRIPTION_LANGUAGES.map((language) => ( + <SelectItem key={language.code} value={language.code}> + {language.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </SectionField> + </SectionFields> - <Button - className="w-full" - onClick={handleGenerateTranscript} - disabled={isProcessing} - > - {isProcessing && <Spinner className="mr-1" />} - {isProcessing ? processingStep : "Generate transcript"} - </Button> - </div> + {error && ( + <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3"> + <p className="text-destructive text-sm">{error}</p> + </div> + )} + </SectionContent> + </Section> + <Section showBottomBorder={false} showTopBorder={false}> + <SectionContent> + <Button + className="w-full" + onClick={handleGenerateTranscript} + disabled={isProcessing} + > + {isProcessing && <Spinner className="mr-1" />} + {isProcessing ? processingStep : "Generate transcript"} + </Button> + </SectionContent> + </Section> </PanelView> ); } diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx index c9b8b2dd..74128df6 100644 --- a/apps/web/src/components/editor/panels/assets/views/effects.tsx +++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx @@ -1,19 +1,16 @@ "use client"; import { useEffect, useRef, useCallback } from "react"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects"; -import { - effectPreviewService, - onPreviewImageReady, -} from "@/services/renderer/effect-preview"; +import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects"; +import { effectPreviewService } from "@/services/renderer/effect-preview"; import { useEditor } from "@/hooks/use-editor"; import { buildEffectElement } from "@/lib/timeline/element-utils"; -import type { EffectDefinition } from "@/types/effects"; +import type { EffectDefinition } from "@/lib/effects/types"; export function EffectsView() { - const effects = getAllEffects(); + const effects = effectsRegistry.getAll(); return ( <PanelView title="Effects"> @@ -50,7 +47,7 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) { }; render(); - return onPreviewImageReady({ callback: render }); + return effectPreviewService.onPreviewImageReady({ callback: render }); }, [effectType]); return <canvas ref={canvasRef} className="size-full" />; diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx deleted file mode 100644 index 457c08a9..00000000 --- a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx +++ /dev/null @@ -1,332 +0,0 @@ -// @ts-nocheck - -"use client"; - -import Image from "next/image"; -import { memo, useCallback, useMemo } from "react"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - BLUR_INTENSITY_PRESETS, - DEFAULT_BLUR_INTENSITY, - DEFAULT_COLOR, - FPS_PRESETS, -} from "@/constants/project-constants"; -import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { colors } from "@/data/colors/solid"; -import { syntaxUIGradients } from "@/data/colors/syntax-ui"; -import { useEditor } from "@/hooks/use-editor"; -import { useEditorStore } from "@/stores/editor-store"; -import { dimensionToAspectRatio } from "@/utils/geometry"; -import { cn } from "@/utils/ui"; -import { - PropertyGroup, - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "@/components/editor/panels/properties/section"; - -export function SettingsView() { - return ( - <PanelView title="Project settings" contentClassName="px-0"> - <div className="flex flex-col gap-8"> - <ProjectInfoView /> - <BackgroundView /> - </div> - </PanelView> - ); -} - -function ProjectInfoView() { - const editor = useEditor(); - const activeProject = editor.project.getActive(); - const { canvasPresets } = useEditorStore(); - - const findPresetIndexByAspectRatio = ({ - presets, - targetAspectRatio, - }: { - presets: Array<{ width: number; height: number }>; - targetAspectRatio: string; - }) => { - for (let index = 0; index < presets.length; index++) { - const preset = presets[index]; - const presetAspectRatio = dimensionToAspectRatio({ - width: preset.width, - height: preset.height, - }); - if (presetAspectRatio === targetAspectRatio) { - return index; - } - } - return -1; - }; - - const currentCanvasSize = activeProject.settings.canvasSize; - const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); - const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; - const presetIndex = findPresetIndexByAspectRatio({ - presets: canvasPresets, - targetAspectRatio: currentAspectRatio, - }); - const originalPresetValue = "original"; - const selectedPresetValue = - presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; - - const handleAspectRatioChange = ({ value }: { value: string }) => { - if (value === originalPresetValue) { - const canvasSize = originalCanvasSize ?? currentCanvasSize; - editor.project.updateSettings({ - settings: { canvasSize }, - }); - return; - } - const index = parseInt(value, 10); - const preset = canvasPresets[index]; - if (preset) { - editor.project.updateSettings({ settings: { canvasSize: preset } }); - } - }; - - const handleFpsChange = (value: string) => { - const fps = parseFloat(value); - editor.project.updateSettings({ settings: { fps } }); - }; - - return ( - <div className="flex flex-col gap-4"> - <PropertyItem direction="column"> - <PropertyItemLabel>Name</PropertyItemLabel> - <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue> - </PropertyItem> - - <PropertyItem direction="column"> - <PropertyItemLabel>Aspect ratio</PropertyItemLabel> - <PropertyItemValue> - <Select - value={selectedPresetValue} - onValueChange={(value) => handleAspectRatioChange({ value })} - > - <SelectTrigger> - <SelectValue placeholder="Select an aspect ratio" /> - </SelectTrigger> - <SelectContent> - <SelectItem value={originalPresetValue}>Original</SelectItem> - {canvasPresets.map((preset, index) => { - const label = dimensionToAspectRatio({ - width: preset.width, - height: preset.height, - }); - return ( - <SelectItem key={label} value={index.toString()}> - {label} - </SelectItem> - ); - })} - </SelectContent> - </Select> - </PropertyItemValue> - </PropertyItem> - - <PropertyItem direction="column"> - <PropertyItemLabel>Frame rate</PropertyItemLabel> - <PropertyItemValue> - <Select - value={activeProject.settings.fps.toString()} - onValueChange={handleFpsChange} - > - <SelectTrigger> - <SelectValue placeholder="Select a frame rate" /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map((preset) => ( - <SelectItem key={preset.value} value={preset.value}> - {preset.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </PropertyItemValue> - </PropertyItem> - </div> - ); -} - -const BlurPreview = memo( - ({ - blur, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - isSelected: boolean; - onSelect: () => void; - }) => ( - <button - className={cn( - "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border", - isSelected && "border-primary border-2", - )} - onClick={onSelect} - type="button" - aria-label={`Select ${blur.label} blur`} - > - <Image - src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" - alt={`Blur preview ${blur.label}`} - fill - className="object-cover" - style={{ filter: `blur(${blur.value}px)` }} - loading="eager" - /> - <div className="absolute right-1 bottom-1 left-1 text-center"> - <span className="rounded bg-black/50 px-1 text-xs text-white"> - {blur.label} - </span> - </div> - </button> - ), -); - -BlurPreview.displayName = "BlurPreview"; - -const BackgroundPreviews = memo( - ({ - backgrounds, - currentBackgroundColor, - isColorBackground, - handleColorSelect, - useBackgroundColor = false, - }: { - backgrounds: string[]; - currentBackgroundColor: string; - isColorBackground: boolean; - handleColorSelect: ({ bg }: { bg: string }) => void; - useBackgroundColor?: boolean; - }) => { - return useMemo( - () => - backgrounds.map((bg, index) => ( - <button - key={`${index}-${bg}`} - className={cn( - "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", - isColorBackground && - bg === currentBackgroundColor && - "border-primary border-2", - )} - style={ - useBackgroundColor - ? { backgroundColor: bg } - : { - background: bg, - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", - } - } - onClick={() => handleColorSelect({ bg })} - type="button" - aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`} - /> - )), - [ - backgrounds, - isColorBackground, - currentBackgroundColor, - handleColorSelect, - useBackgroundColor, - ], - ); - }, -); - -BackgroundPreviews.displayName = "BackgroundPreviews"; - -function BackgroundView() { - const editor = useEditor(); - const activeProject = editor.project.getActive(); - const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []); - - const handleBlurSelect = useCallback( - async ({ blurIntensity }: { blurIntensity: number }) => { - await editor.project.updateSettings({ - settings: { background: { type: "blur", blurIntensity } }, - }); - }, - [editor.project], - ); - - const handleColorSelect = useCallback( - async ({ color }: { color: string }) => { - await editor.project.updateSettings({ - settings: { background: { type: "color", color } }, - }); - }, - [editor.project], - ); - - const currentBlurIntensity = - activeProject.settings.background.type === "blur" - ? activeProject.settings.background.blurIntensity - : DEFAULT_BLUR_INTENSITY; - - const currentBackgroundColor = - activeProject.settings.background.type === "color" - ? activeProject.settings.background.color - : DEFAULT_COLOR; - - const isBlurBackground = activeProject.settings.background.type === "blur"; - const isColorBackground = activeProject.settings.background.type === "color"; - - const blurPreviews = useMemo( - () => - blurLevels.map((blur) => ( - <BlurPreview - key={blur.value} - blur={blur} - isSelected={isBlurBackground && currentBlurIntensity === blur.value} - onSelect={() => handleBlurSelect({ blurIntensity: blur.value })} - /> - )), - [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect], - ); - - const backgroundSections = [ - { title: "Colors", backgrounds: colors, useBackgroundColor: true }, - { title: "Pattern craft", backgrounds: patternCraftGradients }, - { title: "Syntax UI", backgrounds: syntaxUIGradients }, - ]; - - return ( - <div className="flex flex-col"> - <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}> - <div className="flex flex-wrap gap-2">{blurPreviews}</div> - </PropertyGroup> - - {backgroundSections.map((section) => ( - <PropertyGroup - key={section.title} - title={section.title} - defaultExpanded={false} - > - <div className="flex flex-wrap gap-2"> - <BackgroundPreviews - backgrounds={section.backgrounds} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} - useBackgroundColor={section.useBackgroundColor} - /> - </div> - </PropertyGroup> - ))} - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx deleted file mode 100644 index d46e9d1f..00000000 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ /dev/null @@ -1,168 +0,0 @@ -"use client"; - -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { FPS_PRESETS } from "@/constants/project-constants"; -import { useEditor } from "@/hooks/use-editor"; -import { useEditorStore } from "@/stores/editor-store"; -import { dimensionToAspectRatio } from "@/utils/geometry"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/editor/panels/properties/section"; -import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; - -const ORIGINAL_PRESET_VALUE = "original"; - -export function findPresetIndexByAspectRatio({ - presets, - targetAspectRatio, -}: { - presets: Array<{ width: number; height: number }>; - targetAspectRatio: string; -}) { - for (let index = 0; index < presets.length; index++) { - const preset = presets[index]; - const presetAspectRatio = dimensionToAspectRatio({ - width: preset.width, - height: preset.height, - }); - if (presetAspectRatio === targetAspectRatio) { - return index; - } - } - return -1; -} - -export function SettingsView() { - return ( - <PanelView contentClassName="px-0" hideHeader> - <div className="flex flex-col"> - <Section showTopBorder={false}> - <SectionContent> - <ProjectInfoContent /> - </SectionContent> - </Section> - <Popover> - <Section className="cursor-pointer"> - <PopoverTrigger asChild> - <div> - <SectionHeader - trailing={<div className="size-4 rounded-sm bg-red-500" />} - > - <SectionTitle>Background</SectionTitle> - </SectionHeader> - </div> - </PopoverTrigger> - </Section> - <PopoverContent> - <div className="size-4 rounded-sm bg-red-500" /> - </PopoverContent> - </Popover> - </div> - </PanelView> - ); -} - -function ProjectInfoContent() { - const editor = useEditor(); - const activeProject = editor.project.getActive(); - const { canvasPresets } = useEditorStore(); - - const currentCanvasSize = activeProject.settings.canvasSize; - const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); - const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; - const presetIndex = findPresetIndexByAspectRatio({ - presets: canvasPresets, - targetAspectRatio: currentAspectRatio, - }); - const selectedPresetValue = - presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE; - - const handleAspectRatioChange = ({ value }: { value: string }) => { - if (value === ORIGINAL_PRESET_VALUE) { - const canvasSize = originalCanvasSize ?? currentCanvasSize; - editor.project.updateSettings({ - settings: { canvasSize }, - }); - return; - } - const index = parseInt(value, 10); - const preset = canvasPresets[index]; - if (preset) { - editor.project.updateSettings({ settings: { canvasSize: preset } }); - } - }; - - const handleFpsChange = ({ value }: { value: string }) => { - const fps = parseFloat(value); - editor.project.updateSettings({ settings: { fps } }); - }; - - return ( - <div className="flex flex-col gap-4"> - <div className="flex flex-col gap-2"> - <Label>Name</Label> - <span className="leading-none text-sm"> - {activeProject.metadata.name} - </span> - </div> - <div className="flex flex-col gap-2"> - <Label>Aspect ratio</Label> - <Select - value={selectedPresetValue} - onValueChange={(value) => handleAspectRatioChange({ value })} - > - <SelectTrigger className="w-fit"> - <SelectValue placeholder="Select an aspect ratio" /> - </SelectTrigger> - <SelectContent> - <SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem> - {canvasPresets.map((preset, index) => { - const label = dimensionToAspectRatio({ - width: preset.width, - height: preset.height, - }); - return ( - <SelectItem key={label} value={index.toString()}> - {label} - </SelectItem> - ); - })} - </SelectContent> - </Select> - </div> - <div className="flex flex-col gap-2"> - <Label>Frame rate</Label> - <Select - value={activeProject.settings.fps.toString()} - onValueChange={(value) => handleFpsChange({ value })} - > - <SelectTrigger className="w-fit"> - <SelectValue placeholder="Select a frame rate" /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map((preset) => ( - <SelectItem key={preset.value} value={preset.value}> - {preset.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx new file mode 100644 index 00000000..b3ef1eb1 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { + BLUR_INTENSITY_PRESETS, + DEFAULT_BLUR_INTENSITY, + DEFAULT_COLOR, +} from "@/constants/project-constants"; +import { patternCraftGradients } from "@/data/colors/pattern-craft"; +import { colors } from "@/data/colors/solid"; +import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { useEditor } from "@/hooks/use-editor"; +import { effectPreviewService } from "@/services/renderer/effect-preview"; +import type { TCanvasSize } from "@/lib/project/types"; +import { cn } from "@/utils/ui"; + +const BlurPreview = memo( + ({ + blur, + canvasSize, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + canvasSize: TCanvasSize; + isSelected: boolean; + onSelect: () => void; + }) => { + const canvasRef = useRef<HTMLCanvasElement>(null); + const { width, height } = canvasSize; + + useEffect(() => { + const renderPreview = () => { + if (!canvasRef.current) return; + + effectPreviewService.renderPreview({ + effectType: "blur", + params: { intensity: blur.value }, + targetCanvas: canvasRef.current, + uniformDimensions: { width, height }, + }); + }; + + renderPreview(); + return effectPreviewService.onPreviewImageReady({ callback: renderPreview }); + }, [blur.value, width, height]); + + return ( + <button + className={cn( + "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border", + isSelected && "border-primary border-2", + )} + onClick={onSelect} + type="button" + aria-label={`Select ${blur.label} blur`} + > + <canvas + ref={canvasRef} + className="absolute inset-0 h-full w-full object-cover" + /> + <div className="absolute right-1 bottom-1 left-1 text-center"> + <span className="rounded bg-black/50 px-1 text-xs text-white"> + {blur.label} + </span> + </div> + </button> + ); + }, +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + onSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + onSelect: (bg: string) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg) => ( + <button + key={bg} + className={cn( + "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", + isColorBackground && + bg === currentBackgroundColor && + "border-primary border-2", + )} + style={ + useBackgroundColor + ? { backgroundColor: bg } + : { + background: bg, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + } + onClick={() => onSelect(bg)} + type="button" + aria-label={`Select background ${bg}`} + /> + )), + [ + backgrounds, + isColorBackground, + currentBackgroundColor, + onSelect, + useBackgroundColor, + ], + ); + }, +); + +BackgroundPreviews.displayName = "BackgroundPreviews"; + +const COLOR_SECTIONS = [ + { title: "Colors", backgrounds: colors, useBackgroundColor: true }, + { title: "Pattern craft", backgrounds: patternCraftGradients }, + { title: "Syntax UI", backgrounds: syntaxUIGradients }, +] as const; + +export function BackgroundContent() { + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + + const handleBlurSelect = useCallback( + async (blurIntensity: number) => { + await editor.project.updateSettings({ + settings: { background: { type: "blur", blurIntensity } }, + }); + }, + [editor.project], + ); + + const handleColorSelect = useCallback( + async (color: string) => { + await editor.project.updateSettings({ + settings: { background: { type: "color", color } }, + }); + }, + [editor.project], + ); + + const isBlurBackground = activeProject.settings.background.type === "blur"; + const isColorBackground = activeProject.settings.background.type === "color"; + + const currentBlurIntensity = isBlurBackground + ? (activeProject.settings.background as { blurIntensity: number }) + .blurIntensity + : DEFAULT_BLUR_INTENSITY; + + const currentBackgroundColor = isColorBackground + ? (activeProject.settings.background as { color: string }).color + : DEFAULT_COLOR; + const canvasSize = activeProject.settings.canvasSize; + + const blurPreviews = useMemo( + () => + BLUR_INTENSITY_PRESETS.map((blur) => ( + <BlurPreview + key={blur.value} + blur={blur} + canvasSize={canvasSize} + isSelected={isBlurBackground && currentBlurIntensity === blur.value} + onSelect={() => handleBlurSelect(blur.value)} + /> + )), + [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect], + ); + + return ( + <div className="flex flex-col"> + <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}> + <SectionHeader> + <SectionTitle>Blur</SectionTitle> + </SectionHeader> + <SectionContent> + <div className="flex flex-wrap gap-2">{blurPreviews}</div> + </SectionContent> + </Section> + {COLOR_SECTIONS.map((section) => ( + <Section + key={section.title} + collapsible + defaultOpen={false} + sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`} + > + <SectionHeader> + <SectionTitle>{section.title}</SectionTitle> + </SectionHeader> + <SectionContent> + <div className="flex flex-wrap gap-2"> + <BackgroundPreviews + backgrounds={section.backgrounds as string[]} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + onSelect={handleColorSelect} + useBackgroundColor={ + "useBackgroundColor" in section + ? section.useBackgroundColor + : false + } + /> + </div> + </SectionContent> + </Section> + ))} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx new file mode 100644 index 00000000..e0229ea9 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -0,0 +1,369 @@ +"use client"; + +import { useRef, useState } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FPS_PRESETS } from "@/constants/project-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { BackgroundContent } from "./background"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { NumberField } from "@/components/ui/number-field"; +import { useEditorStore } from "@/stores/editor-store"; +import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Tick02Icon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; +import { dimensionToAspectRatio } from "@/utils/geometry"; +import { formatNumberForDisplay } from "@/utils/math"; +import { OcSquarePlusIcon } from "@opencut/ui/icons"; +import type { TCanvasSize } from "@/lib/project/types"; + +type SettingsView = "project-info" | "background"; + +const PRESET_LABELS: Record<string, string> = { + "1:1": "1:1", + "16:9": "16:9", + "9:16": "9:16", + "4:3": "4:3", +}; + +function areCanvasSizesEqual({ + left, + right, +}: { + left: TCanvasSize; + right: TCanvasSize; +}) { + return left.width === right.width && left.height === right.height; +} + +function formatCanvasDimension({ value }: { value: number }) { + return formatNumberForDisplay({ value, maxFractionDigits: 0 }); +} + +function parseCanvasDimension({ input }: { input: string }): number | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return null; + + const rounded = Math.round(parsed); + return rounded > 0 ? rounded : null; +} + +function useCanvasDimensionDraft({ + value, + onCommit, +}: { + value: number; + onCommit: (value: number) => void; +}) { + const pendingValueRef = useRef(value); + const syncedValueRef = useRef(value); + + if (syncedValueRef.current !== value) { + syncedValueRef.current = value; + pendingValueRef.current = value; + } + + return usePropertyDraft({ + displayValue: formatCanvasDimension({ value }), + parse: (input) => parseCanvasDimension({ input }), + onPreview: (nextValue) => { + pendingValueRef.current = nextValue; + }, + onCommit: () => { + if (pendingValueRef.current !== value) { + onCommit(pendingValueRef.current); + } + }, + }); +} + +export function SettingsView() { + const [view, setView] = useState<SettingsView>("project-info"); + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + const { canvasPresets } = useEditorStore(); + const currentCanvasSize = activeProject.settings.canvasSize; + const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset"; + const lastCustomCanvasSize = + activeProject.settings.lastCustomCanvasSize ?? null; + + const presetItems = canvasPresets.map((preset, index) => { + const ratio = dimensionToAspectRatio(preset); + return { + id: index.toString(), + label: PRESET_LABELS[ratio] ?? ratio, + ratio, + canvasSize: preset, + }; + }); + + const selectedPresetId = canvasSizeMode === "preset" + ? (presetItems.find((preset) => + areCanvasSizesEqual({ + left: preset.canvasSize, + right: currentCanvasSize, + }), + )?.id ?? null) + : null; + + const updateCustomCanvasSize = ({ + canvasSize, + }: { + canvasSize: TCanvasSize; + }) => { + const shouldUpdateCanvasSize = !areCanvasSizesEqual({ + left: canvasSize, + right: currentCanvasSize, + }); + const shouldUpdateLastCustomCanvasSize = + lastCustomCanvasSize === null || + !areCanvasSizesEqual({ + left: canvasSize, + right: lastCustomCanvasSize, + }); + const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom"; + + if ( + !shouldUpdateCanvasSize && + !shouldUpdateLastCustomCanvasSize && + !shouldUpdateCanvasSizeMode + ) { + return; + } + + editor.project.updateSettings({ + settings: { + ...(shouldUpdateCanvasSize ? { canvasSize } : {}), + ...(shouldUpdateCanvasSizeMode + ? { canvasSizeMode: "custom" as const } + : {}), + lastCustomCanvasSize: canvasSize, + }, + }); + }; + + const selectPresetCanvasSize = ({ + canvasSize, + }: { + canvasSize: TCanvasSize; + }) => { + const shouldUpdateCanvasSize = !areCanvasSizesEqual({ + left: canvasSize, + right: currentCanvasSize, + }); + const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset"; + + if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return; + + editor.project.updateSettings({ + settings: { + ...(shouldUpdateCanvasSize ? { canvasSize } : {}), + ...(shouldUpdateCanvasSizeMode + ? { canvasSizeMode: "preset" as const } + : {}), + }, + }); + }; + + const selectCustomCanvasSize = () => { + updateCustomCanvasSize({ + canvasSize: lastCustomCanvasSize ?? currentCanvasSize, + }); + }; + + const widthDraft = useCanvasDimensionDraft({ + value: currentCanvasSize.width, + onCommit: (width) => + updateCustomCanvasSize({ + canvasSize: { width, height: currentCanvasSize.height }, + }), + }); + + const heightDraft = useCanvasDimensionDraft({ + value: currentCanvasSize.height, + onCommit: (height) => + updateCustomCanvasSize({ + canvasSize: { width: currentCanvasSize.width, height }, + }), + }); + + const isCustomSelected = canvasSizeMode === "custom"; + + return ( + <PanelView + contentClassName="px-0" + scrollClassName="pt-0" + actions={ + <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> + <TabsList> + <TabsTrigger value="project-info">Project info</TabsTrigger> + <TabsTrigger value="background">Background</TabsTrigger> + </TabsList> + </Tabs> + } + > + {view === "project-info" && ( + <div className="flex flex-col"> + <Section showTopBorder={false}> + <SectionHeader> + <SectionTitle className="flex-1">Name</SectionTitle> + <span className="text-sm truncate"> + {activeProject.metadata.name} + </span> + </SectionHeader> + </Section> + <Section showTopBorder={false}> + <SectionHeader className="justify-between"> + <SectionTitle className="flex-1">Frame rate</SectionTitle> + <Select + value={activeProject.settings.fps.toString()} + onValueChange={(value) => { + const fps = parseFloat(value); + editor.project.updateSettings({ settings: { fps } }); + }} + > + <SelectTrigger className="bg-transparent border-none p-1 h-auto"> + <SelectValue placeholder="Select a frame rate" /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map((preset) => ( + <SelectItem key={preset.value} value={preset.value}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </SectionHeader> + </Section> + <Section + showTopBorder={false} + collapsible + sectionKey="settings:aspect-ratio" + > + <SectionHeader> + <SectionTitle className="flex-1">Aspect ratio</SectionTitle> + </SectionHeader> + <SectionContent className="px-2 flex flex-col gap-1 pb-2"> + {presetItems.map((preset) => ( + <AspectRatioItem + key={preset.id} + label={preset.label} + previewIcon={<AspectRatioPreview ratio={preset.ratio} />} + isSelected={selectedPresetId === preset.id} + onClick={() => { + selectPresetCanvasSize({ + canvasSize: preset.canvasSize, + }); + }} + /> + ))} + <div className="pb-2"> + <AspectRatioItem + key="custom" + label="Custom" + previewIcon={<OcSquarePlusIcon />} + isSelected={isCustomSelected} + onClick={selectCustomCanvasSize} + uiOptions={ + <div className=" flex items-center gap-2 text-foreground"> + <NumberField + value={widthDraft.displayValue} + className="w-full" + aria-label="Canvas width" + onFocus={widthDraft.onFocus} + onChange={widthDraft.onChange} + onBlur={widthDraft.onBlur} + /> + <NumberField + value={heightDraft.displayValue} + className="w-full" + aria-label="Canvas height" + onFocus={heightDraft.onFocus} + onChange={heightDraft.onChange} + onBlur={heightDraft.onBlur} + /> + </div> + } + /> + </div> + </SectionContent> + </Section> + </div> + )} + {view === "background" && <BackgroundContent />} + </PanelView> + ); +} + +function AspectRatioItem({ + label, + previewIcon, + isSelected, + onClick, + uiOptions, +}: { + label: string; + previewIcon: React.ReactNode; + isSelected: boolean; + onClick: () => void; + uiOptions?: React.ReactNode; +}) { + return ( + <Button + variant={isSelected ? "secondary" : "ghost"} + className={cn( + "px-2 py-0 flex flex-col h-fit w-full", + !isSelected && "border border-transparent opacity-75!", + )} + onClick={onClick} + > + <div className="w-full flex justify-between items-center h-8"> + <div className="flex-1 flex items-center gap-2"> + <div className="flex items-center justify-center size-5"> + {previewIcon} + </div> + <span className="text-sm truncate">{label}</span> + </div> + <div> + {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />} + </div> + </div> + {uiOptions && isSelected && ( + <div className="w-full pb-2">{uiOptions}</div> + )} + </Button> + ); +} + +function AspectRatioPreview({ ratio }: { ratio?: string }) { + if (!ratio) return null; + + const [w, h] = ratio.split(":").map(Number); + const maxSize = 16; + const width = w >= h ? maxSize : (w / h) * maxSize; + const height = h >= w ? maxSize : (h / w) * maxSize; + + return ( + <div + style={{ width, height, borderWidth: 1.5 }} + className="rounded-xs border-current opacity-60" + /> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index b1c57c3d..4fb43ac2 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -24,7 +24,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { useSoundSearch } from "@/hooks/use-sound-search"; import { useSoundsStore } from "@/stores/sounds-store"; -import type { SavedSound, SoundEffect } from "@/types/sounds"; +import type { SavedSound, SoundEffect } from "@/lib/sounds/types"; import { cn } from "@/utils/ui"; import { FavouriteIcon, diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 671effec..af489630 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -2,81 +2,110 @@ import Image from "next/image"; import type { CSSProperties } from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { useEditor } from "@/hooks/use-editor"; +import { resolveStickerIntrinsicSize } from "@/lib/stickers"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - resolveStickerId, - type StickerItem as StickerData, + buildGraphicElement, + buildStickerElement, +} from "@/lib/timeline/element-utils"; +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers"; +import { parseShapeStickerId } from "@/lib/stickers/providers/shapes"; +import type { TimelineDragData } from "@/lib/timeline/drag"; +import type { + StickerBrowseSection, + StickerCategory, + StickerItem as StickerData, } from "@/lib/stickers"; import { useStickersStore } from "@/stores/stickers-store"; import { cn } from "@/utils/ui"; import { HappyIcon, - ClockIcon, - MultiplicationSignIcon, - Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Spinner } from "@/components/ui/spinner"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { OcSlidersVerticalIcon } from "@opencut/ui/icons"; -import type { StickerCategory } from "@/types/stickers"; -import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; -import { parseStickerId } from "@/lib/stickers/sticker-id"; export function StickersView() { - const { selectedCategory, setSelectedCategory } = useStickersStore(); + const { + browseContent, + browseStickers, + searchQuery, + searchStickers, + selectedCategory, + setSearchQuery, + setSelectedCategory, + viewMode, + } = useStickersStore(); + + useEffect(() => { + if (viewMode === "browse" && !browseContent) { + void browseStickers(); + } + }, [browseContent, browseStickers, viewMode]); return ( - <PanelView - title="Stickers" - actions={ - <div className="flex items-center"> - <Select - value={selectedCategory} - onValueChange={(value: StickerCategory) => - setSelectedCategory({ category: value }) - } + <div className="flex h-full flex-col py-2"> + <div className="px-2"> + <Input + size="sm" + variant="default" + placeholder="Search..." + value={searchQuery} + onChange={(e) => { + setSearchQuery({ query: e.target.value }); + void searchStickers({ query: e.target.value }); + }} + showClearIcon + onClear={() => { + setSearchQuery({ query: "" }); + void searchStickers({ query: "" }); + }} + className="w-full" + containerClassName="w-full" + /> + </div> + + <div className="mt-2 flex min-h-0 flex-1 flex-col"> + <div className="border-b border-border px-2"> + <div + role="tablist" + aria-label="Sticker categories" + className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0" > - <SelectTrigger variant="outline" size="sm" className="mr-1.5"> - <SelectValue placeholder="All" /> - </SelectTrigger> - <SelectContent> - {Object.entries(STICKER_CATEGORIES).map(([category, label]) => ( - <SelectItem key={category} value={category}> + {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { + const isActive = key === selectedCategory; + return ( + <button + key={key} + type="button" + role="tab" + aria-selected={isActive} + onClick={() => + setSelectedCategory({ + category: key as StickerCategory, + }) + } + className={cn( + "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px", + isActive && "text-primary border-primary", + )} + > {label} - </SelectItem> - ))} - </SelectContent> - </Select> - - <Button variant="ghost" size="icon"> - <HugeiconsIcon icon={Search01Icon} className="!size-3.5" /> - </Button> - - <Button variant="ghost" size="icon"> - <OcSlidersVerticalIcon className="!size-3.5" /> - </Button> + </button> + ); + })} + </div> </div> - } - > - <StickersContentView /> - </PanelView> + <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4"> + <StickersContentView /> + </div> + </div> + </div> ); } @@ -92,10 +121,10 @@ function StickerGrid({ "--sticker-max"?: string; } = { gridTemplateColumns: shouldCapSize - ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" - : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", - "--sticker-min": "96px", - ...(shouldCapSize ? { "--sticker-max": "160px" } : {}), + ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))" + : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))", + "--sticker-min": "80px", + ...(shouldCapSize ? { "--sticker-max": "140px" } : {}), }; return ( @@ -107,6 +136,18 @@ function StickerGrid({ ); } +function StickerRow({ items }: { items: StickerData[] }) { + return ( + <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden"> + {items.map((item) => ( + <div key={item.id} className="w-20 shrink-0"> + <StickerItem item={item} shouldCapSize containerClassName="w-full" /> + </div> + ))} + </div> + ); +} + function EmptyView({ message }: { message: string }) { return ( <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> @@ -122,88 +163,170 @@ function EmptyView({ message }: { message: string }) { ); } +function RegionBanner({ region }: { region: string }) { + return ( + <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2"> + <svg + width="12" + height="12" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="shrink-0 text-sky-600" + aria-hidden="true" + > + <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /> + <circle cx="12" cy="10" r="3" /> + </svg> + <span className="text-xs font-semibold text-sky-600">{region}</span> + </div> + ); +} + function StickersContentView() { const { + browseContent, + clearRecentStickers, + isBrowsing, + isSearching, searchQuery, - viewMode, searchResults, - recentStickers, - isSearching, - clearRecentStickers, + selectedCategory, + setSelectedCategory, + viewMode, } = useStickersStore(); - const itemsToDisplay = useMemo(() => { - if (viewMode === "search" && searchResults) { - return searchResults.items; + if (viewMode === "search") { + if (isSearching) { + return ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ); } - return []; - }, [viewMode, searchResults]); + if (searchResults?.items.length) { + const normalizedQuery = searchQuery.trim().toLowerCase(); + const isRegionSearch = + selectedCategory === "flags" && + resolveQueryToRegions({ query: normalizedQuery }) !== null; + const regionLabel = getRegionLabel({ query: normalizedQuery }); - const recentStickerItems = useMemo(() => { - const items: StickerData[] = []; - for (const stickerId of recentStickers) { - const recentStickerItem = toRecentStickerItem({ stickerId }); - if (recentStickerItem) { - items.push(recentStickerItem); - } + return ( + <div className="flex flex-col gap-3 pb-4"> + {isRegionSearch && <RegionBanner region={regionLabel} />} + <div className="flex items-center justify-between"> + <span className="text-muted-foreground text-sm"> + {searchResults.total} results + </span> + </div> + <StickerGrid items={searchResults.items} /> + </div> + ); } - return items; - }, [recentStickers]); + + // "all" tab search — sections are in browseContent, fall through to section rendering below + if (selectedCategory !== "all" && searchQuery) { + return <EmptyView message={`No stickers found for "${searchQuery}"`} />; + } + } + + if (isBrowsing && !browseContent) { + return ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ); + } + + if (!browseContent?.sections.length) { + const categoryLabel = STICKER_CATEGORIES[selectedCategory]; + return ( + <EmptyView + message={ + viewMode === "search" + ? `No stickers found for "${searchQuery}"` + : selectedCategory === "all" + ? "No stickers available yet." + : `No stickers available in ${categoryLabel.toLowerCase()} yet.` + } + /> + ); + } return ( - <div className="flex h-full flex-col gap-4"> - {recentStickerItems.length > 0 && viewMode === "browse" && ( - <div className="flex h-full flex-col gap-2"> - <div className="flex items-center gap-2"> - <HugeiconsIcon - icon={ClockIcon} - className="text-muted-foreground size-4" - /> - <span className="text-sm font-medium">Recent</span> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <button - type="button" - onClick={clearRecentStickers} - className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0" - > - <HugeiconsIcon - icon={MultiplicationSignIcon} - className="text-muted-foreground size-3" - /> - </button> - </TooltipTrigger> - <TooltipContent> - <p>Clear recent stickers</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> + <div className="flex flex-col gap-4 pb-4"> + {browseContent.sections.map((section) => ( + <StickerSection + key={section.id} + section={section} + onClearRecent={clearRecentStickers} + onSeeAll={(category) => { + setSelectedCategory({ category }); + }} + /> + ))} + </div> + ); +} + +function StickerSection({ + section, + onClearRecent, + onSeeAll, +}: { + section: StickerBrowseSection; + onClearRecent: () => void; + onSeeAll: (category: StickerCategory) => void; +}) { + const hasHeader = + Boolean(section.title) || section.id === "recent" || section.action; + + return ( + <div className="flex flex-col gap-2"> + {hasHeader && ( + <div className="flex items-center justify-between gap-2"> + {section.title ? ( + <p className="text-xs text-muted-foreground">{section.title}</p> + ) : ( + <div /> + )} + + <div className="ml-auto flex items-center gap-2"> + {section.id === "recent" && ( + <Button + onClick={onClearRecent} + variant="text" + size="sm" + className="h-auto gap-1 p-0 text-xs text-muted-foreground" + > + Clear + </Button> + )} + + {section.action?.type === "see-all" && section.action.category && ( + <Button + variant="text" + size="sm" + className="h-auto gap-1 p-0 text-xs text-primary" + onClick={() => { + onSeeAll(section.action?.category as StickerCategory); + }} + > + See all + </Button> + )} </div> - <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize /> </div> )} - {viewMode === "search" && ( - <div className="h-full"> - {isSearching ? ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ) : searchResults?.items.length ? ( - <div className="flex flex-col gap-3"> - <div className="flex items-center justify-between"> - <span className="text-muted-foreground text-sm"> - {searchResults.total} results - </span> - </div> - <StickerGrid items={itemsToDisplay} shouldCapSize /> - </div> - ) : searchQuery ? ( - <EmptyView message={`No stickers found for "${searchQuery}"`} /> - ) : null} - </div> + {section.layout === "row" ? ( + <StickerRow items={section.items} /> + ) : ( + <StickerGrid items={section.items} /> )} </div> ); @@ -212,65 +335,116 @@ function StickersContentView() { interface StickerItemProps { item: StickerData; shouldCapSize?: boolean; + containerClassName?: string; } -function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { - const { addingSticker, addStickerToTimeline } = useStickersStore(); - const isAdding = addingSticker === item.id; +function StickerItem({ + item, + shouldCapSize = false, + containerClassName, +}: StickerItemProps) { + const editor = useEditor(); + const { addToRecentStickers } = useStickersStore(); + const [isAdding, setIsAdding] = useState(false); const [hasImageError, setHasImageError] = useState(false); useEffect(() => { if (!item.id) { return; } + setHasImageError(false); }, [item.id]); const displayName = item.name; + const shapePreset = + item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null; const handleAdd = async () => { + setIsAdding(true); try { - await addStickerToTimeline({ - stickerId: item.id, - name: item.name, + const currentTime = editor.playback.getCurrentTime(); + + let element: + | ReturnType<typeof buildGraphicElement> + | ReturnType<typeof buildStickerElement>; + if (shapePreset) { + element = buildGraphicElement({ + definitionId: shapePreset.definitionId, + name: shapePreset.name, + startTime: currentTime, + params: shapePreset.params, + }); + } else { + const { width: intrinsicWidth, height: intrinsicHeight } = + await resolveStickerIntrinsicSize({ stickerId: item.id }); + element = buildStickerElement({ + stickerId: item.id, + name: item.name, + startTime: currentTime, + intrinsicWidth, + intrinsicHeight, + }); + } + + editor.timeline.insertElement({ + placement: { mode: "auto" }, + element, }); + + addToRecentStickers({ stickerId: item.id }); } catch (error) { console.error("Failed to add sticker:", error); toast.error("Failed to add sticker to timeline"); + } finally { + setIsAdding(false); } }; - const preview = hasImageError ? ( - <div className="flex size-full items-center justify-center p-2"> - <span className="text-muted-foreground text-center text-xs break-all"> - {displayName} - </span> - </div> - ) : ( - <div className="flex size-full items-center justify-center p-4"> - <Image - src={item.previewUrl} - alt={displayName} - width={64} - height={64} - className="size-full object-contain" - style={ - shouldCapSize - ? { - maxWidth: "var(--sticker-max, 160px)", - maxHeight: "var(--sticker-max, 160px)", - } - : undefined - } - onError={() => { - setHasImageError(true); - }} - loading="lazy" - unoptimized - /> + const preview = ( + <div className="flex size-full items-center justify-center p-3"> + {hasImageError ? ( + <span className="text-muted-foreground text-center text-xs break-all"> + {displayName} + </span> + ) : ( + <Image + src={item.previewUrl} + alt={displayName} + width={64} + height={64} + className="size-full object-contain" + style={ + shouldCapSize + ? { + maxWidth: "var(--sticker-max, 160px)", + maxHeight: "var(--sticker-max, 160px)", + } + : undefined + } + onError={() => setHasImageError(true)} + loading="lazy" + unoptimized + /> + )} </div> ); + const dragData: TimelineDragData = shapePreset + ? { + id: item.id, + type: "graphic", + name: displayName, + definitionId: shapePreset.definitionId, + params: shapePreset.params ?? {}, + } + : { + id: item.id, + type: "sticker", + name: displayName, + stickerId: item.id, + }; + return ( <div className={cn("relative", isAdding && "pointer-events-none opacity-50")} @@ -278,18 +452,13 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { <DraggableItem name={displayName} preview={preview} - dragData={{ - id: item.id, - type: "sticker", - name: displayName, - stickerId: item.id, - }} + dragData={dragData} onAddToTimeline={handleAdd} aspectRatio={1} shouldShowLabel={false} isRounded variant="card" - containerClassName="w-full" + containerClassName={containerClassName ?? "w-full"} /> {isAdding && ( <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> @@ -299,36 +468,3 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { </div> ); } - -function getStickerNameFromId({ stickerId }: { stickerId: string }): string { - const stickerIdParts = stickerId.split(":"); - if (stickerIdParts.length <= 1) { - return stickerId; - } - return ( - stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ?? - stickerId - ); -} - -function toRecentStickerItem({ - stickerId, -}: { - stickerId: string; -}): StickerData | null { - try { - const { providerId } = parseStickerId({ stickerId }); - return { - id: stickerId, - provider: providerId, - name: getStickerNameFromId({ stickerId }), - previewUrl: resolveStickerId({ - stickerId, - options: { width: 64, height: 64 }, - }), - metadata: {}, - }; - } catch { - return null; - } -} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 3673acc3..075cc25b 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,7 +1,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; import { buildTextElement } from "@/lib/timeline/element-utils"; export function TextView() { @@ -12,7 +12,7 @@ export function TextView() { if (!activeScene) return; const element = buildTextElement({ - raw: DEFAULT_TEXT_ELEMENT, + raw: DEFAULTS.text.element, startTime: currentTime, }); @@ -33,9 +33,9 @@ export function TextView() { } dragData={{ id: "temp-text-id", - type: DEFAULT_TEXT_ELEMENT.type, - name: DEFAULT_TEXT_ELEMENT.name, - content: DEFAULT_TEXT_ELEMENT.content, + type: DEFAULTS.text.element.type, + name: DEFAULTS.text.element.name, + content: DEFAULTS.text.element.content, }} aspectRatio={1} onAddToTimeline={handleAddToTimeline} diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx index d4bcdcae..13226378 100644 --- a/apps/web/src/components/editor/panels/preview/context-menu.tsx +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -4,9 +4,12 @@ import { ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, } from "@/components/ui/context-menu"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useEditor } from "@/hooks/use-editor"; import { usePreviewStore } from "@/stores/preview-store"; +import { toast } from "sonner"; export function PreviewContextMenu({ onToggleFullscreen, @@ -16,25 +19,55 @@ export function PreviewContextMenu({ containerRef: React.RefObject<HTMLElement | null>; }) { const editor = useEditor(); + const viewport = usePreviewViewport(); const { overlays, setOverlayVisibility } = usePreviewStore(); + const handleCopySnapshot = async () => { + const result = await editor.renderer.copySnapshot(); + + if (!result.success) { + toast.error("Failed to copy snapshot", { + description: result.error ?? "Please try again", + }); + return; + } + }; + + const handleSaveSnapshot = async () => { + const result = await editor.renderer.saveSnapshot(); + + if (!result.success) { + toast.error("Failed to save snapshot", { + description: result.error ?? "Please try again", + }); + return; + } + }; + return ( <ContextMenuContent className="w-56" container={containerRef.current}> + <ContextMenuItem onClick={viewport.fitToScreen} inset> + Fit to screen + </ContextMenuItem> + <ContextMenuSeparator /> <ContextMenuItem onClick={onToggleFullscreen} inset> Full screen </ContextMenuItem> - <ContextMenuItem onClick={() => editor.renderer.saveSnapshot()} inset> + <ContextMenuItem onClick={handleSaveSnapshot} inset> Save snapshot </ContextMenuItem> + <ContextMenuItem onClick={handleCopySnapshot} inset> + Copy snapshot + </ContextMenuItem> + <ContextMenuSeparator /> <ContextMenuCheckboxItem checked={overlays.bookmarks} onCheckedChange={(checked) => setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) } > - Show bookmarks + Show bookmark notes </ContextMenuCheckboxItem> - <ContextMenuItem inset>Show grid</ContextMenuItem> </ContextMenuContent> ); } diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx new file mode 100644 index 00000000..ad7a621f --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { getGuideById } from "@/lib/guides"; +import { usePreviewStore } from "@/stores/preview-store"; +import { usePreviewViewport } from "./preview-viewport"; + +export function GuideOverlay() { + const activeGuide = usePreviewStore((state) => state.activeGuide); + const viewport = usePreviewViewport(); + const guide = getGuideById(activeGuide); + + if (!guide) { + return null; + } + + return ( + <div + className="pointer-events-none absolute" + style={{ + left: viewport.sceneLeft, + top: viewport.sceneTop, + width: viewport.sceneWidth, + height: viewport.sceneHeight, + }} + > + {guide.renderOverlay({ + width: viewport.sceneWidth, + height: viewport.sceneHeight, + })} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx new file mode 100644 index 00000000..e6d3df51 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/guide-popover.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides"; +import { usePreviewStore } from "@/stores/preview-store"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/utils/ui"; + +export function GridPopover({ children }: { children: React.ReactNode }) { + const activeGuide = usePreviewStore((state) => state.activeGuide); + const toggleGuide = usePreviewStore((state) => state.toggleGuide); + const activeGuideDef = getGuideById(activeGuide); + const options = activeGuideDef?.renderOptions?.(); + + return ( + <Popover> + <PopoverTrigger>{children}</PopoverTrigger> + <PopoverContent sideOffset={8} className="w-60 px-0"> + <div className="flex flex-col gap-2 px-4"> + <Label>Guides</Label> + <div className="grid grid-cols-3 gap-1"> + {GUIDE_REGISTRY.map((guide) => ( + <GridItem + key={guide.id} + label={guide.label} + preview={guide.renderPreview()} + isSelected={activeGuide === guide.id} + onClick={() => toggleGuide(guide.id)} + /> + ))} + </div> + </div> + <AnimatePresence> + {options && ( + <motion.div + initial={{ height: 0, opacity: 0 }} + animate={{ height: "auto", opacity: 1 }} + exit={{ height: 0, opacity: 0 }} + transition={{ + height: { type: "spring", duration: 0.2, bounce: 0 }, + opacity: { duration: 0.2 }, + }} + className="overflow-hidden" + > + <Separator className="my-3" /> + <div className="px-4">{options}</div> + </motion.div> + )} + </AnimatePresence> + </PopoverContent> + </Popover> + ); +} + +function GridItem({ + label, + preview, + isSelected, + onClick, +}: { + label: string; + preview: React.ReactNode; + isSelected?: boolean; + onClick: () => void; +}) { + return ( + <button + type="button" + onClick={onClick} + aria-pressed={isSelected} + className={cn( + "flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5", + isSelected && "bg-primary/5! text-primary", + )} + > + <div + className={cn( + "aspect-video bg-foreground/5 flex items-center justify-center rounded-sm", + isSelected && "bg-primary/5!", + )} + > + {preview} + </div> + <span + className={cn( + "text-xs", + isSelected ? "text-primary" : "text-muted-foreground", + )} + > + {label} + </span> + </button> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx new file mode 100644 index 00000000..44b8a7f1 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { IconSvgElement } from "@hugeicons/react"; + +export const HANDLE_SIZE = 10; +export const HANDLE_HIT_AREA_SIZE = 18; +export const ICON_HANDLE_RADIUS = 10; +export const EDGE_HANDLE_THIN_SIZE = 6; +export const EDGE_HANDLE_THICK_SIZE = 14; +export const LINE_HIT_AREA_SIZE = 48; + +export function getResizeCursor({ angleDeg }: { angleDeg: number }): string { + const normalized = ((angleDeg % 180) + 180) % 180; + if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize"; + if (normalized < 67.5) return "cursor-nwse-resize"; + if (normalized < 112.5) return "cursor-ns-resize"; + return "cursor-nesw-resize"; +} + +export function HandleButton({ + screen, + cursor, + hitAreaSize, + className, + onPointerDown, + onPointerMove, + onPointerUp, + children, +}: { + screen: { x: number; y: number }; + cursor?: string; + hitAreaSize: number; + className?: string; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + children: React.ReactNode; +}) { + return ( + <button + type="button" + className={cn( + "absolute flex items-center justify-center outline-none", + cursor, + className, + )} + style={{ + left: screen.x - hitAreaSize / 2, + top: screen.y - hitAreaSize / 2, + width: hitAreaSize, + height: hitAreaSize, + pointerEvents: "auto", + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} + onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} + > + {children} + </button> + ); +} + +export function CornerHandle({ + cursor, + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + cursor?: string; + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + <HandleButton + screen={screen} + cursor={cursor} + hitAreaSize={HANDLE_HIT_AREA_SIZE} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + > + <div + className="rounded-sm bg-white" + style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} + /> + </HandleButton> + ); +} + +export function EdgeHandle({ + edge, + screen, + rotation, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + edge: "right" | "left" | "bottom"; + screen: { x: number; y: number }; + rotation: number; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + const isHorizontalEdge = edge === "right" || edge === "left"; + const width = isHorizontalEdge + ? EDGE_HANDLE_THIN_SIZE + : EDGE_HANDLE_THICK_SIZE; + const height = isHorizontalEdge + ? EDGE_HANDLE_THICK_SIZE + : EDGE_HANDLE_THIN_SIZE; + const cursor = getResizeCursor({ + angleDeg: isHorizontalEdge ? rotation : rotation + 90, + }); + + return ( + <HandleButton + screen={screen} + cursor={cursor} + hitAreaSize={HANDLE_HIT_AREA_SIZE} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + > + <div + className="rounded-sm bg-white" + style={{ width, height, transform: `rotate(${rotation}deg)` }} + /> + </HandleButton> + ); +} + +export function IconHandle({ + icon, + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + icon: IconSvgElement; + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + <HandleButton + screen={screen} + hitAreaSize={ICON_HANDLE_RADIUS * 2} + className="rounded-full bg-white text-black shadow-sm" + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + > + <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} /> + </HandleButton> + ); +} + +export function BoundingBoxOutline({ + center, + outlineWidth, + outlineHeight, + rotation, + cursor, + dashed = false, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + center: { x: number; y: number }; + outlineWidth: number; + outlineHeight: number; + rotation: number; + cursor?: string; + dashed?: boolean; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + return ( + <svg + className={cn("absolute overflow-visible", cursor)} + aria-hidden="true" + focusable="false" + style={{ + left: center.x - outlineWidth / 2, + top: center.y - outlineHeight / 2, + width: outlineWidth, + height: outlineHeight, + transform: `rotate(${rotation}deg)`, + transformOrigin: "center center", + pointerEvents: onPointerDown ? "auto" : "none", + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + > + <rect + x={0.5} + y={0.5} + width={Math.max(outlineWidth - 1, 0)} + height={Math.max(outlineHeight - 1, 0)} + fill="transparent" + stroke="white" + strokeDasharray={dashed ? "4 4" : undefined} + strokeOpacity={0.75} + vectorEffect="non-scaling-stroke" + style={{ pointerEvents: onPointerDown ? "all" : "none" }} + /> + </svg> + ); +} + +export function ShapeOutline({ + center, + outlineWidth, + outlineHeight, + rotation, + pathData, + cursor, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + center: { x: number; y: number }; + outlineWidth: number; + outlineHeight: number; + rotation: number; + pathData: string; + cursor?: string; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + return ( + <svg + className={cn("absolute overflow-visible", cursor)} + aria-hidden="true" + focusable="false" + style={{ + left: center.x - outlineWidth / 2, + top: center.y - outlineHeight / 2, + width: outlineWidth, + height: outlineHeight, + transform: `rotate(${rotation}deg)`, + transformOrigin: "center center", + pointerEvents: onPointerDown ? "auto" : "none", + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + > + <path + d={pathData} + fill="transparent" + stroke="white" + strokeOpacity={0.75} + vectorEffect="non-scaling-stroke" + style={{ pointerEvents: onPointerDown ? "all" : "none" }} + /> + </svg> + ); +} + +export function LineOverlay({ + start, + end, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + start: { x: number; y: number }; + end: { x: number; y: number }; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + const dx = end.x - start.x; + const dy = end.y - start.y; + const length = Math.sqrt(dx * dx + dy * dy); + const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; + const cx = (start.x + end.x) / 2; + const cy = (start.y + end.y) / 2; + + const sharedStyle = { + left: cx - length / 2, + width: length, + transform: `rotate(${angleDeg}deg)`, + transformOrigin: "center center", + }; + + return ( + <> + {onPointerDown && ( + <div + className="absolute" + style={{ + ...sharedStyle, + top: cy - LINE_HIT_AREA_SIZE / 2, + height: LINE_HIT_AREA_SIZE, + pointerEvents: "auto", + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + /> + )} + <div + className="pointer-events-none absolute" + style={{ + ...sharedStyle, + top: cy - 0.5, + height: 1, + backgroundColor: "white", + opacity: 0.75, + }} + /> + </> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index df60db01..c625b9bf 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import useDeepCompareEffect from "use-deep-compare-effect"; import { useEditor } from "@/hooks/use-editor"; import { useRafLoop } from "@/hooks/use-raf-loop"; @@ -12,50 +12,70 @@ import { buildScene } from "@/services/renderer/scene-builder"; import { getLastFrameTime } from "@/lib/time"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; +import { GuideOverlay } from "./guide-overlay"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { usePreviewStore } from "@/stores/preview-store"; import { PreviewContextMenu } from "./context-menu"; import { PreviewToolbar } from "./toolbar"; +import { + PreviewViewportProvider, + usePreviewViewportState, +} from "./preview-viewport"; function usePreviewSize() { - const editor = useEditor(); - const activeProject = editor.project.getActive(); + const canvasSize = useEditor( + (e) => e.project.getActive()?.settings.canvasSize, + ); return { - width: activeProject?.settings.canvasSize.width, - height: activeProject?.settings.canvasSize.height, + width: canvasSize?.width, + height: canvasSize?.height, }; } +function normalizeWheelDelta({ + delta, + deltaMode, + pageSize, +}: { + delta: number; + deltaMode: number; + pageSize: number; +}): number { + if (deltaMode === WheelEvent.DOM_DELTA_LINE) { + return delta * 16; + } + + if (deltaMode === WheelEvent.DOM_DELTA_PAGE) { + return delta * pageSize; + } + + return delta; +} + export function PreviewPanel() { const containerRef = useRef<HTMLDivElement>(null); - const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); + const { toggleFullscreen } = useFullscreen({ containerRef }); return ( <div ref={containerRef} className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border" > - <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0"> - <PreviewCanvas - onToggleFullscreen={toggleFullscreen} - containerRef={containerRef} - /> - <RenderTreeController /> - </div> - <PreviewToolbar - isFullscreen={isFullscreen} + <PreviewCanvas + containerRef={containerRef} onToggleFullscreen={toggleFullscreen} /> + <RenderTreeController /> </div> ); } function RenderTreeController() { const editor = useEditor(); - const tracks = editor.timeline.getTracks(); - const mediaAssets = editor.media.getAssets(); - const activeProject = editor.project.getActive(); + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const activeProject = useEditor((e) => e.project.getActive()); const { width, height } = usePreviewSize(); @@ -79,23 +99,30 @@ function RenderTreeController() { } function PreviewCanvas({ - onToggleFullscreen, containerRef, + onToggleFullscreen, }: { - onToggleFullscreen: () => void; containerRef: React.RefObject<HTMLElement | null>; + onToggleFullscreen: () => void; }) { const canvasRef = useRef<HTMLCanvasElement>(null); - const outerContainerRef = useRef<HTMLDivElement>(null); - const canvasBoundsRef = useRef<HTMLDivElement>(null); + const viewportRef = useRef<HTMLDivElement>(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef<RootNode | null>(null); const renderingRef = useRef(false); const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); - const containerSize = useContainerSize({ containerRef: outerContainerRef }); + const viewportSize = useContainerSize({ containerRef: viewportRef }); const editor = useEditor(); - const activeProject = editor.project.getActive(); + const activeProject = useEditor((e) => e.project.getActive()); + const renderTree = useEditor((e) => e.renderer.getRenderTree()); const { overlays } = usePreviewStore(); + const viewport = usePreviewViewportState({ + canvasHeight: nativeHeight, + canvasWidth: nativeWidth, + viewportHeight: viewportSize.height, + viewportRef, + viewportWidth: viewportSize.width, + }); const renderer = useMemo(() => { return new CanvasRenderer({ @@ -105,37 +132,6 @@ function PreviewCanvas({ }); }, [nativeWidth, nativeHeight, activeProject.settings.fps]); - const displaySize = useMemo(() => { - if ( - !nativeWidth || - !nativeHeight || - containerSize.width === 0 || - containerSize.height === 0 - ) { - return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 }; - } - - const paddingBuffer = 4; - const availableWidth = containerSize.width - paddingBuffer; - const availableHeight = containerSize.height - paddingBuffer; - - const aspectRatio = nativeWidth / nativeHeight; - const containerAspect = availableWidth / availableHeight; - - const displayWidth = - containerAspect > aspectRatio - ? availableHeight * aspectRatio - : availableWidth; - const displayHeight = - containerAspect > aspectRatio - ? availableHeight - : availableWidth / aspectRatio; - - return { width: displayWidth, height: displayHeight }; - }, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]); - - const renderTree = editor.renderer.getRenderTree(); - const render = useCallback(() => { if (canvasRef.current && renderTree && !renderingRef.current) { const time = editor.playback.getCurrentTime(); @@ -168,44 +164,130 @@ function PreviewCanvas({ useRafLoop(render); + useEffect(() => { + const container = viewportRef.current; + if (!container) return; + + let pendingZoomDelta = 0; + let pendingPanDeltaX = 0; + let pendingPanDeltaY = 0; + let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null; + let panRafId: ReturnType<typeof requestAnimationFrame> | null = null; + + const onWheel = (event: WheelEvent) => { + const normalizedDeltaX = normalizeWheelDelta({ + delta: event.deltaX, + deltaMode: event.deltaMode, + pageSize: container.clientWidth, + }); + const normalizedDeltaY = normalizeWheelDelta({ + delta: event.deltaY, + deltaMode: event.deltaMode, + pageSize: container.clientHeight, + }); + const isZoomGesture = event.ctrlKey || event.metaKey; + if (isZoomGesture) { + event.preventDefault(); + pendingZoomDelta += normalizedDeltaY; + + if (zoomRafId === null) { + zoomRafId = requestAnimationFrame(() => { + const cappedDelta = + Math.sign(pendingZoomDelta) * + Math.min(Math.abs(pendingZoomDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + + viewport.scaleZoom({ factor: zoomFactor }); + pendingZoomDelta = 0; + zoomRafId = null; + }); + } + + return; + } + + if (!viewport.canPan) { + return; + } + + if (normalizedDeltaX === 0 && normalizedDeltaY === 0) { + return; + } + + event.preventDefault(); + pendingPanDeltaX += normalizedDeltaX; + pendingPanDeltaY += normalizedDeltaY; + + if (panRafId === null) { + panRafId = requestAnimationFrame(() => { + viewport.panByScreenDelta({ + deltaX: pendingPanDeltaX, + deltaY: pendingPanDeltaY, + }); + pendingPanDeltaX = 0; + pendingPanDeltaY = 0; + panRafId = null; + }); + } + }; + + container.addEventListener("wheel", onWheel, { + capture: true, + passive: false, + }); + + return () => { + container.removeEventListener("wheel", onWheel, { + capture: true, + }); + if (zoomRafId !== null) { + cancelAnimationFrame(zoomRafId); + } + if (panRafId !== null) { + cancelAnimationFrame(panRafId); + } + }; + }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]); + return ( - <div - ref={outerContainerRef} - className="relative flex size-full items-center justify-center" - > - <ContextMenu> - <ContextMenuTrigger asChild> - <div - ref={canvasBoundsRef} - className="relative" - style={{ width: displaySize.width, height: displaySize.height }} - > - <canvas - ref={canvasRef} - width={nativeWidth} - height={nativeHeight} - className="block border" - style={{ - width: displaySize.width, - height: displaySize.height, - background: - activeProject.settings.background.type === "blur" - ? "transparent" - : activeProject?.settings.background.color, - }} + <PreviewViewportProvider value={viewport}> + <div className="flex size-full min-h-0 min-w-0 flex-col"> + <div className="flex min-h-0 min-w-0 flex-1 p-2 pb-0"> + <ContextMenu> + <ContextMenuTrigger asChild> + <div + ref={viewportRef} + className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden" + > + <canvas + ref={canvasRef} + width={nativeWidth} + height={nativeHeight} + className="absolute block border" + style={{ + left: viewport.sceneLeft, + top: viewport.sceneTop, + width: viewport.sceneWidth, + height: viewport.sceneHeight, + background: + activeProject.settings.background.type === "blur" + ? "transparent" + : activeProject?.settings.background.color, + }} + /> + <GuideOverlay /> + <PreviewInteractionOverlay /> + {overlays.bookmarks && <BookmarkNoteOverlay />} + </div> + </ContextMenuTrigger> + <PreviewContextMenu + onToggleFullscreen={onToggleFullscreen} + containerRef={containerRef} /> - <PreviewInteractionOverlay - canvasRef={canvasRef} - containerRef={canvasBoundsRef} - /> - {overlays.bookmarks && <BookmarkNoteOverlay />} - </div> - </ContextMenuTrigger> - <PreviewContextMenu - onToggleFullscreen={onToggleFullscreen} - containerRef={containerRef} - /> - </ContextMenu> - </div> + </ContextMenu> + </div> + <PreviewToolbar onToggleFullscreen={onToggleFullscreen} /> + </div> + </PreviewViewportProvider> ); } diff --git a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx deleted file mode 100644 index 44efb4c8..00000000 --- a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { usePreviewStore } from "@/stores/preview-store"; -import Image from "next/image"; - -function TikTokGuide() { - return ( - <div className="pointer-events-none absolute inset-0"> - <Image - src="/platform-guides/tiktok-blueprint.png" - alt="TikTok layout guide" - className="absolute inset-0 size-full object-contain" - draggable={false} - fill - /> - </div> - ); -} - -export function LayoutGuideOverlay() { - const { layoutGuide } = usePreviewStore(); - - if (layoutGuide.platform === null) return null; - if (layoutGuide.platform === "tiktok") return <TikTokGuide />; - - return null; -} diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx new file mode 100644 index 00000000..0c11f27a --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/mask-handles.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useMaskHandles } from "@/hooks/use-mask-handles"; +import { masksRegistry } from "@/lib/masks"; +import type { SnapLine } from "@/lib/preview/preview-snap"; +import type { ParamValues } from "@/lib/params"; +import type { RectangleMaskParams } from "@/lib/masks/types"; +import { + CornerHandle, + EdgeHandle, + IconHandle, + LineOverlay, + BoundingBoxOutline, + ShapeOutline, +} from "./handle-primitives"; +import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons"; + +function hasRectangleOutlineParams( + params: ParamValues, +): params is RectangleMaskParams { + return ( + typeof params.centerX === "number" && + typeof params.centerY === "number" && + typeof params.width === "number" && + typeof params.height === "number" + ); +} + +export function MaskHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: (lines: SnapLine[]) => void; +}) { + const viewport = usePreviewViewport(); + const { + selectedWithMask, + handlePositions, + linePoints, + handlePointerDown, + handlePointerMove, + handlePointerUp, + } = useMaskHandles({ onSnapLinesChange }); + + if (!selectedWithMask) return null; + + const toOverlay = ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => + viewport.canvasToOverlay({ + canvasX, + canvasY, + }); + + const def = masksRegistry.get(selectedWithMask.mask.type); + const { bounds } = selectedWithMask; + const maskRotation = selectedWithMask.mask.params.rotation; + + const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); + + const rectangleOutlineProps = hasRectangleOutlineParams( + selectedWithMask.mask.params, + ) + ? { + center: toOverlay({ + canvasX: + bounds.cx + selectedWithMask.mask.params.centerX * bounds.width, + canvasY: + bounds.cy + selectedWithMask.mask.params.centerY * bounds.height, + }), + outlineWidth: + selectedWithMask.mask.params.width * bounds.width * scaleX, + outlineHeight: + selectedWithMask.mask.params.height * bounds.height * scaleY, + rotation: maskRotation, + } + : null; + + const onPointerMove = (event: React.PointerEvent) => + handlePointerMove({ event }); + const onPointerUp = () => handlePointerUp(); + + return ( + <div + className="pointer-events-none absolute inset-0 overflow-hidden" + aria-hidden + > + {def.overlayShape === "line" && linePoints && ( + <LineOverlay + start={toOverlay({ + canvasX: linePoints.start.x, + canvasY: linePoints.start.y, + })} + end={toOverlay({ + canvasX: linePoints.end.x, + canvasY: linePoints.end.y, + })} + onPointerDown={(event) => + handlePointerDown({ event, handleId: "position" }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + )} + {def.overlayShape === "box" && rectangleOutlineProps && ( + def.buildOverlayPath ? ( + <> + <BoundingBoxOutline {...rectangleOutlineProps} dashed /> + <ShapeOutline + {...rectangleOutlineProps} + pathData={def.buildOverlayPath({ + width: rectangleOutlineProps.outlineWidth, + height: rectangleOutlineProps.outlineHeight, + })} + onPointerDown={(event) => + handlePointerDown({ event, handleId: "position" }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + </> + ) : ( + <BoundingBoxOutline + {...rectangleOutlineProps} + cursor="cursor-move" + onPointerDown={(event) => + handlePointerDown({ event, handleId: "position" }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ) + )} + {handlePositions.map((handle) => { + const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); + + if (handle.id === "rotation") { + return ( + <IconHandle + key={handle.id} + icon={Rotate01Icon} + screen={screen} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.id === "feather") { + return ( + <IconHandle + key={handle.id} + icon={FeatherIcon} + screen={screen} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.id === "right" || handle.id === "left") { + return ( + <EdgeHandle + key={handle.id} + edge="right" + screen={screen} + rotation={maskRotation} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if (handle.id === "bottom" || handle.id === "top") { + return ( + <EdgeHandle + key={handle.id} + edge="bottom" + screen={screen} + rotation={maskRotation} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + if ( + handle.id === "top-left" || + handle.id === "top-right" || + handle.id === "bottom-left" || + handle.id === "bottom-right" || + handle.id === "scale" + ) { + return ( + <CornerHandle + key={handle.id} + screen={screen} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } + + return ( + <CornerHandle + key={handle.id} + cursor={handle.cursor} + screen={screen} + onPointerDown={(event) => + handlePointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index 4c33a1a1..65c11726 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,54 +1,117 @@ +import { useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; +import type { SnapLine } from "@/lib/preview/preview-snap"; import { TransformHandles } from "./transform-handles"; +import { MaskHandles } from "./mask-handles"; import { SnapGuides } from "./snap-guides"; import { TextEditOverlay } from "./text-edit-overlay"; +import { usePropertiesStore } from "../properties/stores/properties-store"; +import { useEditor } from "@/hooks/use-editor"; + +export function PreviewInteractionOverlay() { + const [snapLines, setSnapLines] = useState<SnapLine[]>([]); + const editor = useEditor(); + const viewport = usePreviewViewport(); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType); + + const selectedRef = + selectedElements.length === 1 ? selectedElements[0] : null; + const activeTrack = selectedRef + ? editor.timeline.getTrackById({ trackId: selectedRef.trackId }) + : null; + const activeElement = + activeTrack?.elements.find( + (element) => element.id === selectedRef?.elementId, + ) ?? null; + const isMaskMode = activeElement + ? activeTabPerType[activeElement.type] === "masks" + : false; -export function PreviewInteractionOverlay({ - canvasRef, - containerRef, -}: { - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; -}) { const { onPointerDown, onPointerMove, onPointerUp, onDoubleClick, - snapLines, editingText, commitTextEdit, - cancelTextEdit, - } = usePreviewInteraction({ canvasRef }); + } = usePreviewInteraction({ + onSnapLinesChange: setSnapLines, + isMaskMode, + }); + + const handlePointerDown = (event: React.PointerEvent) => { + if (viewport.handlePanPointerDown({ event })) { + return; + } + + onPointerDown(event); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + if (viewport.handlePanPointerMove({ event })) { + return; + } + + onPointerMove(event); + }; + + const handlePointerUp = (event: React.PointerEvent) => { + if (viewport.handlePanPointerUp({ event })) { + return; + } + + onPointerUp(event); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + const x = e.nativeEvent.offsetX; + const y = e.nativeEvent.offsetY; + const outsideCanvas = + x < viewport.sceneLeft || + x > viewport.sceneLeft + viewport.sceneWidth || + y < viewport.sceneTop || + y > viewport.sceneTop + viewport.sceneHeight; + if (outsideCanvas) { + e.stopPropagation(); + } + }; return ( <div className="absolute inset-0"> - {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */} <div className="absolute inset-0 pointer-events-auto" - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} + role="application" + aria-label="Preview canvas" + style={{ + cursor: viewport.isPanning + ? "grabbing" + : viewport.canPan + ? "default" + : undefined, + }} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} onDoubleClick={onDoubleClick} + onDragStart={(e) => e.preventDefault()} + onContextMenu={handleContextMenu} /> {editingText ? ( <TextEditOverlay - canvasRef={canvasRef} - containerRef={containerRef} trackId={editingText.trackId} elementId={editingText.elementId} element={editingText.element} onCommit={commitTextEdit} - onCancel={cancelTextEdit} /> + ) : isMaskMode ? ( + <MaskHandles onSnapLinesChange={setSnapLines} /> ) : ( - <TransformHandles canvasRef={canvasRef} containerRef={containerRef} /> + <TransformHandles onSnapLinesChange={setSnapLines} /> )} - <SnapGuides - lines={snapLines} - canvasRef={canvasRef} - containerRef={containerRef} - /> + <SnapGuides lines={snapLines} /> </div> ); } diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx new file mode 100644 index 00000000..e38183af --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx @@ -0,0 +1,576 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + canvasToOverlay, + getDisplayScale, + positionToOverlay, + screenPixelsToLogicalThreshold, + screenToCanvas, +} from "@/lib/preview/preview-coords"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import { PREVIEW_ZOOM } from "@/constants/editor-constants"; + +const MIDDLE_MOUSE_BUTTON = 1; +const IS_AT_FIT_EPSILON = 0.001; +const IS_AT_ACTUAL_SIZE_EPSILON = 0.01; + +interface PreviewViewportContextValue { + canPan: boolean; + isAtFit: boolean; + isAtActualSize: boolean; + isPanning: boolean; + sceneHeight: number; + sceneLeft: number; + sceneTop: number; + sceneWidth: number; + zoomPercent: number; + canvasToOverlay: ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => { x: number; y: number }; + fitToScreen: () => void; + getDisplayScale: () => { x: number; y: number }; + handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean; + handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean; + handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean; + positionToOverlay: ({ + positionX, + positionY, + }: { + positionX: number; + positionY: number; + }) => { x: number; y: number }; + panByScreenDelta: ({ + deltaX, + deltaY, + }: { + deltaX: number; + deltaY: number; + }) => void; + resetPan: () => void; + scaleZoom: ({ factor }: { factor: number }) => void; + screenPixelsToLogicalThreshold: ({ + screenPixels, + }: { + screenPixels: number; + }) => { x: number; y: number }; + screenToCanvas: ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => { x: number; y: number } | null; + setActualSize: () => void; + setViewportPercent: ({ percent }: { percent: number }) => void; + zoomIn: () => void; + zoomOut: () => void; +} + +const PreviewViewportContext = + createContext<PreviewViewportContextValue | null>(null); + +interface PreviewViewportProviderProps { + children: React.ReactNode; + value: PreviewViewportContextValue; +} + +interface PreviewViewportStateOptions { + canvasHeight: number; + canvasWidth: number; + viewportHeight: number; + viewportRef: React.RefObject<HTMLDivElement | null>; + viewportWidth: number; +} + +interface PanSession { + centerX: number; + centerY: number; + clientX: number; + clientY: number; + pointerId: number; +} + +function getClampedCenterAxis({ + axisSize, + center, + scale, + viewportSize, +}: { + axisSize: number; + center: number; + scale: number; + viewportSize: number; +}): number { + if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) { + return axisSize / 2; + } + + const visibleHalfSpan = viewportSize / (2 * scale); + if (visibleHalfSpan >= axisSize / 2) { + return axisSize / 2; + } + + return clamp({ + value: center, + min: visibleHalfSpan, + max: axisSize - visibleHalfSpan, + }); +} + +function clampViewportCenter({ + canvasHeight, + canvasWidth, + centerX, + centerY, + scale, + viewportHeight, + viewportWidth, +}: { + canvasHeight: number; + canvasWidth: number; + centerX: number; + centerY: number; + scale: number; + viewportHeight: number; + viewportWidth: number; +}): { x: number; y: number } { + return { + x: getClampedCenterAxis({ + center: centerX, + axisSize: canvasWidth, + scale, + viewportSize: viewportWidth, + }), + y: getClampedCenterAxis({ + center: centerY, + axisSize: canvasHeight, + scale, + viewportSize: viewportHeight, + }), + }; +} + +function getFitScale({ + canvasHeight, + canvasWidth, + viewportHeight, + viewportWidth, +}: { + canvasHeight: number; + canvasWidth: number; + viewportHeight: number; + viewportWidth: number; +}): number { + if ( + canvasHeight <= 0 || + canvasWidth <= 0 || + viewportHeight <= 0 || + viewportWidth <= 0 + ) { + return 1; + } + + return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight); +} + +function getClampedZoom({ zoom }: { zoom: number }): number { + return clamp({ + value: zoom, + min: PREVIEW_ZOOM.min, + max: PREVIEW_ZOOM.max, + }); +} + +export function PreviewViewportProvider({ + children, + value, +}: PreviewViewportProviderProps) { + return ( + <PreviewViewportContext.Provider value={value}> + {children} + </PreviewViewportContext.Provider> + ); +} + +export function usePreviewViewportState({ + canvasHeight, + canvasWidth, + viewportHeight, + viewportRef, + viewportWidth, +}: PreviewViewportStateOptions): PreviewViewportContextValue { + const [zoom, setZoomState] = useState(1); + const [center, setCenter] = useState(() => ({ + x: canvasWidth / 2, + y: canvasHeight / 2, + })); + const [isPanning, setIsPanning] = useState(false); + const panSessionRef = useRef<PanSession | null>(null); + const centerRef = useRef(center); + centerRef.current = center; + + const fitScale = useMemo( + () => + getFitScale({ + canvasHeight, + canvasWidth, + viewportHeight, + viewportWidth, + }), + [canvasHeight, canvasWidth, viewportHeight, viewportWidth], + ); + const viewportScale = fitScale * zoom; + const geometry = useMemo( + () => ({ + canvasHeight, + canvasWidth, + centerX: center.x, + centerY: center.y, + scale: viewportScale, + viewportHeight, + viewportWidth, + }), + [ + canvasHeight, + canvasWidth, + center.x, + center.y, + viewportHeight, + viewportScale, + viewportWidth, + ], + ); + + const scaleZoom = useCallback(({ factor }: { factor: number }) => { + setZoomState((previousZoom) => + getClampedZoom({ + zoom: previousZoom * factor, + }), + ); + }, []); + + const panByScreenDelta = useCallback( + ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => { + if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) { + return; + } + + setCenter((previousCenter) => + clampViewportCenter({ + canvasHeight, + canvasWidth, + centerX: previousCenter.x + deltaX / viewportScale, + centerY: previousCenter.y + deltaY / viewportScale, + scale: viewportScale, + viewportHeight, + viewportWidth, + }), + ); + }, + [ + canvasHeight, + canvasWidth, + viewportHeight, + viewportScale, + viewportWidth, + zoom, + ], + ); + + const resetPan = useCallback(() => { + setCenter({ + x: canvasWidth / 2, + y: canvasHeight / 2, + }); + }, [canvasHeight, canvasWidth]); + + const fitToScreen = useCallback(() => { + setZoomState(1); + setCenter({ + x: canvasWidth / 2, + y: canvasHeight / 2, + }); + }, [canvasHeight, canvasWidth]); + + const zoomIn = useCallback(() => { + setZoomState((previousZoom) => + getClampedZoom({ + zoom: previousZoom * PREVIEW_ZOOM.step, + }), + ); + }, []); + + const zoomOut = useCallback(() => { + setZoomState((previousZoom) => + getClampedZoom({ + zoom: previousZoom / PREVIEW_ZOOM.step, + }), + ); + }, []); + + const setActualSize = useCallback(() => { + const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1; + setZoomState( + getClampedZoom({ + zoom: actualSizeZoom, + }), + ); + }, [fitScale]); + + const setViewportPercent = useCallback( + ({ percent }: { percent: number }) => { + const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1; + setZoomState(getClampedZoom({ zoom: targetZoom })); + }, + [fitScale], + ); + + const getViewportRect = useCallback( + () => viewportRef.current?.getBoundingClientRect() ?? null, + [viewportRef], + ); + + const screenToCanvasWithViewport = useCallback( + ({ clientX, clientY }: { clientX: number; clientY: number }) => { + const viewportRect = getViewportRect(); + if (!viewportRect) return null; + + return screenToCanvas({ + clientX, + clientY, + geometry, + viewportRect, + }); + }, + [getViewportRect, geometry], + ); + + const canvasToOverlayWithViewport = useCallback( + ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) => + canvasToOverlay({ + canvasX, + canvasY, + geometry, + }), + [geometry], + ); + + const positionToOverlayWithViewport = useCallback( + ({ positionX, positionY }: { positionX: number; positionY: number }) => + positionToOverlay({ + geometry, + positionX, + positionY, + }), + [geometry], + ); + + const getDisplayScaleWithViewport = useCallback( + () => getDisplayScale({ geometry }), + [geometry], + ); + + const getLogicalThresholdForScreenPixels = useCallback( + ({ screenPixels }: { screenPixels: number }) => + screenPixelsToLogicalThreshold({ + geometry, + screenPixels, + }), + [geometry], + ); + + const handlePanPointerDown = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) { + return false; + } + + event.preventDefault(); + event.stopPropagation(); + + panSessionRef.current = { + centerX: centerRef.current.x, + centerY: centerRef.current.y, + clientX: event.clientX, + clientY: event.clientY, + pointerId: event.pointerId, + }; + setIsPanning(true); + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + return true; + }, + [zoom], + ); + + const handlePanPointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + const panSession = panSessionRef.current; + if (!panSession) { + return false; + } + + event.preventDefault(); + event.stopPropagation(); + + const deltaX = event.clientX - panSession.clientX; + const deltaY = event.clientY - panSession.clientY; + const nextCenter = clampViewportCenter({ + canvasHeight, + canvasWidth, + centerX: panSession.centerX - deltaX / viewportScale, + centerY: panSession.centerY - deltaY / viewportScale, + scale: viewportScale, + viewportHeight, + viewportWidth, + }); + + setCenter(nextCenter); + return true; + }, + [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth], + ); + + const handlePanPointerUp = useCallback( + ({ event }: { event: React.PointerEvent }) => { + const panSession = panSessionRef.current; + if (!panSession) { + return false; + } + + if ( + (event.currentTarget as HTMLElement).hasPointerCapture( + panSession.pointerId, + ) + ) { + (event.currentTarget as HTMLElement).releasePointerCapture( + panSession.pointerId, + ); + } + + panSessionRef.current = null; + setIsPanning(false); + return true; + }, + [], + ); + + useEffect(() => { + setZoomState(1); + setCenter({ + x: canvasWidth / 2, + y: canvasHeight / 2, + }); + panSessionRef.current = null; + setIsPanning(false); + }, [canvasHeight, canvasWidth]); + + useEffect(() => { + setCenter((previousCenter) => + clampViewportCenter({ + canvasHeight, + canvasWidth, + centerX: previousCenter.x, + centerY: previousCenter.y, + scale: viewportScale, + viewportHeight, + viewportWidth, + }), + ); + }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]); + + const sceneWidth = canvasWidth * viewportScale; + const sceneHeight = canvasHeight * viewportScale; + const sceneLeft = viewportWidth / 2 - center.x * viewportScale; + const sceneTop = viewportHeight / 2 - center.y * viewportScale; + const canPan = zoom > 1; + const zoomPercent = Math.round(viewportScale * 100); + + return useMemo( + () => ({ + canPan, + isAtActualSize: isNearlyEqual({ + leftValue: viewportScale, + rightValue: 1, + epsilon: IS_AT_ACTUAL_SIZE_EPSILON, + }), + isAtFit: isNearlyEqual({ + leftValue: zoom, + rightValue: 1, + epsilon: IS_AT_FIT_EPSILON, + }), + isPanning, + sceneHeight, + sceneLeft, + sceneTop, + sceneWidth, + zoomPercent, + canvasToOverlay: canvasToOverlayWithViewport, + fitToScreen, + getDisplayScale: getDisplayScaleWithViewport, + handlePanPointerDown, + handlePanPointerMove, + handlePanPointerUp, + panByScreenDelta, + positionToOverlay: positionToOverlayWithViewport, + resetPan, + scaleZoom, + screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels, + screenToCanvas: screenToCanvasWithViewport, + setActualSize, + setViewportPercent, + zoomIn, + zoomOut, + }), + [ + canPan, + viewportScale, + zoom, + isPanning, + sceneHeight, + sceneLeft, + sceneTop, + sceneWidth, + zoomPercent, + canvasToOverlayWithViewport, + fitToScreen, + getDisplayScaleWithViewport, + handlePanPointerDown, + handlePanPointerMove, + handlePanPointerUp, + panByScreenDelta, + positionToOverlayWithViewport, + resetPan, + scaleZoom, + getLogicalThresholdForScreenPixels, + screenToCanvasWithViewport, + setActualSize, + setViewportPercent, + zoomIn, + zoomOut, + ], + ); +} + +export function usePreviewViewport() { + const context = useContext(PreviewViewportContext); + if (!context) { + throw new Error( + "usePreviewViewport must be used within PreviewViewportProvider", + ); + } + + return context; +} diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx index 4aa714a1..744304ea 100644 --- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -1,43 +1,25 @@ "use client"; -import { useEditor } from "@/hooks/use-editor"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import type { SnapLine } from "@/lib/preview/preview-snap"; -import { positionToOverlay } from "@/lib/preview/preview-coords"; -export function SnapGuides({ - lines, - canvasRef, - containerRef, -}: { - lines: SnapLine[]; - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; -}) { - const editor = useEditor(); - const canvasSize = editor.project.getActive().settings.canvasSize; - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); +export function SnapGuides({ lines }: { lines: SnapLine[] }) { + const viewport = usePreviewViewport(); - if (!canvasRect || !containerRect || lines.length === 0) { + if (lines.length === 0) { return null; } const toOverlayX = (logicalX: number) => - positionToOverlay({ + viewport.positionToOverlay({ positionX: logicalX, positionY: 0, - canvasRect, - containerRect, - canvasSize, }).x; const toOverlayY = (logicalY: number) => - positionToOverlay({ + viewport.positionToOverlay({ positionX: 0, positionY: logicalY, - canvasRect, - containerRect, - canvasSize, }).y; return ( diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx index 26649b26..d3c61a24 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -1,38 +1,89 @@ "use client"; import { useCallback, useEffect, useRef } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useEditor } from "@/hooks/use-editor"; -import type { TextElement } from "@/types/timeline"; +import type { TextElement } from "@/lib/timeline"; import { - positionToOverlay, - getDisplayScale, -} from "@/lib/preview/preview-coords"; -import { - DEFAULT_LINE_HEIGHT, FONT_SIZE_SCALE_REFERENCE, } from "@/constants/text-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { + getMetricAscent, + getMetricDescent, + setCanvasLetterSpacing, +} from "@/lib/text/layout"; + +let cachedCanvas: HTMLCanvasElement | null = null; + +function getMeasurementContext(): CanvasRenderingContext2D | null { + if (!cachedCanvas) cachedCanvas = document.createElement("canvas"); + return cachedCanvas.getContext("2d"); +} + +/** + * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than + * actualBoundingBox to model where the browser places the baseline within the + * line box, then measures actual glyph bounds to find the visual center. + */ +function measureCSSVisualCenterOffset({ + lines, + fontString, + letterSpacingPx, + lineHeightPx, + displayFontSize, +}: { + lines: string[]; + fontString: string; + letterSpacingPx: number; + lineHeightPx: number; + displayFontSize: number; +}): number { + const ctx = getMeasurementContext(); + if (!ctx) return 0; + + ctx.font = fontString; + ctx.textBaseline = "alphabetic"; + setCanvasLetterSpacing({ ctx, letterSpacingPx }); + + const probe = ctx.measureText("M"); + const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8; + const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2; + const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2; -const TEXT_BACKGROUND_PADDING = "4px 8px"; -const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06; + let visualTop = Number.POSITIVE_INFINITY; + let visualBottom = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < lines.length; i++) { + const metrics = ctx.measureText(lines[i] || " "); + const baseline = i * lineHeightPx + halfLeading + fontAscent; + visualTop = Math.min( + visualTop, + baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }), + ); + visualBottom = Math.max( + visualBottom, + baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }), + ); + } + + const cssBlockHeight = lines.length * lineHeightPx; + return (visualTop + visualBottom) / 2 - cssBlockHeight / 2; +} export function TextEditOverlay({ - canvasRef, - containerRef, trackId, elementId, element, onCommit, - onCancel, }: { - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; trackId: string; elementId: string; element: TextElement; onCommit: () => void; - onCancel: () => void; }) { const editor = useEditor(); + const viewport = usePreviewViewport(); const divRef = useRef<HTMLDivElement>(null); useEffect(() => { @@ -60,59 +111,67 @@ export function TextEditOverlay({ const { key } = event; if (key === "Escape") { event.preventDefault(); - onCancel(); + onCommit(); return; } }, - [onCancel], + [onCommit], ); - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); const canvasSize = editor.project.getActive().settings.canvasSize; - if (!canvasRect || !containerRect || !canvasSize) return null; + if (!canvasSize) return null; - const { x: posX, y: posY } = positionToOverlay({ + const { x: posX, y: posY } = viewport.positionToOverlay({ positionX: element.transform.position.x, positionY: element.transform.position.y, - canvasRect, - containerRect, - canvasSize, }); - const { x: displayScaleX } = getDisplayScale({ - canvasRect, - canvasSize, - }); + const { x: displayScaleX } = viewport.getDisplayScale(); const displayFontSize = element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * displayScaleX; - const verticalAlignmentOffset = - displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM; - - const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT; + const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const letterSpacing = element.letterSpacing ?? 0; + const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX; + const lineHeightPx = displayFontSize * lineHeight; + const lines = (element.content || "").split("\n"); + const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`; + + const cssVisualCenterOffset = measureCSSVisualCenterOffset({ + lines, + fontString, + letterSpacingPx: displayLetterSpacing, + lineHeightPx, + displayFontSize, + }); + + const bg = element.background; const shouldShowBackground = - element.background.enabled && - element.background.color && - element.background.color !== "transparent"; - const backgroundColor = shouldShowBackground - ? element.background.color - : "transparent"; + bg.enabled && bg.color && bg.color !== "transparent"; + const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; + const displayPaddingX = shouldShowBackground + ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * + fontSizeRatio * + displayScaleX + : 0; + const displayPaddingY = shouldShowBackground + ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * + fontSizeRatio * + displayScaleX + : 0; return ( <div className="absolute" style={{ left: posX, - top: posY - verticalAlignmentOffset, - transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`, + top: posY - cssVisualCenterOffset, + transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`, transformOrigin: "center center", }} > @@ -131,13 +190,15 @@ export function TextEditOverlay({ fontWeight, fontStyle, textAlign: element.textAlign, - letterSpacing: `${letterSpacing}px`, + letterSpacing: `${displayLetterSpacing}px`, lineHeight, color: element.color, - backgroundColor, - minHeight: displayFontSize * lineHeight, + backgroundColor: shouldShowBackground ? bg.color : "transparent", + minHeight: lineHeightPx, textDecoration: element.textDecoration ?? "none", - padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0, + padding: shouldShowBackground + ? `${displayPaddingY}px ${displayPaddingX}px` + : 0, minWidth: 1, }} onInput={handleInput} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx index 7f5cd9bc..56cb784a 100644 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useEffect } from "react"; import { useEditor } from "@/hooks/use-editor"; import { formatTimeCode } from "@/lib/time"; import { invokeAction } from "@/lib/actions"; @@ -7,74 +8,144 @@ import { EditableTimecode } from "@/components/editable-timecode"; import { Button } from "@/components/ui/button"; import { FullScreenIcon, + GridTableIcon, PauseIcon, PlayIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { OcSocialIcon } from "@opencut/ui/icons"; +import { getGuideById } from "@/lib/guides"; import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectSeparator, +} from "@/components/ui/select"; +import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants"; +import { usePreviewViewport } from "./preview-viewport"; +import { GridPopover } from "./guide-popover"; +import { usePreviewStore } from "@/stores/preview-store"; export function PreviewToolbar({ - isFullscreen, onToggleFullscreen, }: { - isFullscreen: boolean; onToggleFullscreen: () => void; }) { - const editor = useEditor(); - const isPlaying = editor.playback.getIsPlaying(); - const currentTime = editor.playback.getCurrentTime(); - const totalDuration = editor.timeline.getTotalDuration(); - const fps = editor.project.getActive().settings.fps; + const activeGuide = usePreviewStore((state) => state.activeGuide); + const activeGuideDefinition = getGuideById(activeGuide); return ( <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5"> - <div className="flex items-center"> - <EditableTimecode - time={currentTime} - duration={totalDuration} - format="HH:MM:SS:FF" - fps={fps} - onTimeChange={({ time }) => editor.playback.seek({ time })} - className="text-center" - /> - <span className="text-muted-foreground px-2 font-mono text-xs">/</span> - <span className="text-muted-foreground font-mono text-xs"> - {formatTimeCode({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - </span> - </div> - - <Button - variant="text" - size="icon" - onClick={() => invokeAction("toggle-play")} - > - <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> - </Button> - + <TimecodeDisplay /> + <PlayPauseButton /> <div className="justify-self-end flex items-center gap-2.5"> - <Button - variant="secondary" - size="sm" - className="[&_svg]:size-auto px-1 h-7" - onClick={onToggleFullscreen} - title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} - > - <OcSocialIcon size={20} /> - </Button> + <ZoomSelect /> <Separator orientation="vertical" className="h-4" /> - <Button - variant="text" - onClick={onToggleFullscreen} - title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} - > + <GridPopover> + <Button + variant={activeGuideDefinition ? "secondary" : "text"} + size="icon" + > + {activeGuideDefinition ? ( + activeGuideDefinition.renderTriggerIcon() + ) : ( + <HugeiconsIcon icon={GridTableIcon} /> + )} + </Button> + </GridPopover> + <Button variant="text" onClick={onToggleFullscreen}> <HugeiconsIcon icon={FullScreenIcon} /> </Button> </div> </div> ); } + +function TimecodeDisplay() { + const editor = useEditor(); + const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); + const fps = useEditor((e) => e.project.getActive().settings.fps); + const [currentTime, setCurrentTime] = useState(() => + editor.playback.getCurrentTime(), + ); + + useEffect(() => { + const handler = (e: Event) => + setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); + window.addEventListener("playback-update", handler); + window.addEventListener("playback-seek", handler); + return () => { + window.removeEventListener("playback-update", handler); + window.removeEventListener("playback-seek", handler); + }; + }, []); + + return ( + <div className="flex items-center"> + <EditableTimecode + time={currentTime} + duration={totalDuration} + format="HH:MM:SS:FF" + fps={fps} + onTimeChange={({ time }) => editor.playback.seek({ time })} + className="text-center" + /> + <span className="text-muted-foreground px-2 font-mono text-xs">/</span> + <span className="text-muted-foreground font-mono text-xs"> + {formatTimeCode({ + timeInSeconds: totalDuration, + format: "HH:MM:SS:FF", + fps, + })} + </span> + </div> + ); +} + +function ZoomSelect() { + const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } = + usePreviewViewport(); + + const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`; + + const onValueChange = (value: string) => { + if (value === "fit") { + fitToScreen(); + } else { + setViewportPercent({ percent: Number(value) }); + } + }; + + return ( + <Select + value={isAtFit ? "fit" : String(zoomPercent)} + onValueChange={onValueChange} + > + <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger> + <SelectContent> + <SelectItem value="fit">Fit</SelectItem> + <SelectSeparator /> + {PREVIEW_ZOOM_PRESETS.map((preset) => ( + <SelectItem key={preset} value={String(preset)}> + {preset}% + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} + +function PlayPauseButton() { + const isPlaying = useEditor((e) => e.playback.getIsPlaying()); + + return ( + <Button + variant="text" + size="icon" + onClick={() => invokeAction("toggle-play")} + > + <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> + </Button> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx index 2fcf8096..88332625 100644 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -1,112 +1,55 @@ "use client"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useTransformHandles } from "@/hooks/use-transform-handles"; -import { useEditor } from "@/hooks/use-editor"; import { isVisualElement } from "@/lib/timeline/element-utils"; -import { SnapGuides } from "./snap-guides"; -import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; +import { + getCornerPosition, + getEdgeHandlePosition, + getRotationHandlePosition, + type Corner, + type Edge, +} from "@/lib/preview/element-bounds"; +import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; +import { + BoundingBoxOutline, + CornerHandle, + EdgeHandle, + IconHandle, + getResizeCursor, +} from "./handle-primitives"; import { Rotate01Icon } from "@hugeicons/core-free-icons"; -const HANDLE_SIZE = 10; -const ROTATION_HANDLE_OFFSET = 24; -const ROTATION_HANDLE_RADIUS = 10; -const CORNER_HIT_AREA_SIZE = 18; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; const CORNERS: Corner[] = [ "top-left", "top-right", "bottom-left", "bottom-right", ]; - -export function getCornerPosition({ - bounds, - corner, -}: { - bounds: ElementBounds; - corner: Corner; -}): { x: number; y: number } { - const halfW = bounds.width / 2; - const halfH = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; - const localY = - corner === "top-left" || corner === "top-right" ? -halfH : halfH; - - return { - x: bounds.cx + (localX * cos - localY * sin), - y: bounds.cy + (localX * sin + localY * cos), - }; -} - -export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { - x: number; - y: number; -} { - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET; - return { - x: bounds.cx - localY * sin, - y: bounds.cy + localY * cos, - }; -} - -export function getOverlayContext({ - canvasRef, - containerRef, -}: { - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; -}): { - canvasRect: DOMRect; - containerRect: DOMRect; -} | null { - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const containerRect = containerRef.current?.getBoundingClientRect(); - if (!canvasRect || !containerRect) return null; - return { canvasRect, containerRect }; -} +const EDGES: Edge[] = ["right", "left", "bottom"]; export function TransformHandles({ - canvasRef, - containerRef, + onSnapLinesChange, }: { - canvasRef: React.RefObject<HTMLCanvasElement | null>; - containerRef: React.RefObject<HTMLDivElement | null>; + onSnapLinesChange?: OnSnapLinesChange; }) { + const viewport = usePreviewViewport(); const { selectedWithBounds, hasVisualSelection, - snapLines, handleCornerPointerDown, + handleEdgePointerDown, handleRotationPointerDown, handlePointerMove, handlePointerUp, - } = useTransformHandles({ canvasRef }); - - const editor = useEditor(); - const canvasSize = editor.project.getActive().settings.canvasSize; + } = useTransformHandles({ onSnapLinesChange }); if (!hasVisualSelection || !selectedWithBounds) return null; - const overlayContext = getOverlayContext({ canvasRef, containerRef }); - if (!overlayContext) return null; - const { bounds, element } = selectedWithBounds; if (!isVisualElement(element)) return null; - const { canvasRect, containerRect } = overlayContext; - const displayScale = getDisplayScale({ canvasRect, canvasSize }); + const displayScale = viewport.getDisplayScale(); const toOverlay = ({ canvasX, @@ -115,31 +58,30 @@ export function TransformHandles({ canvasX: number; canvasY: number; }) => - canvasToOverlay({ + viewport.canvasToOverlay({ canvasX, canvasY, - canvasRect, - containerRect, - canvasSize, }); const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); - const outlineWidth = bounds.width * displayScale.x; - const outlineHeight = bounds.height * displayScale.y; + const outlineWidth = Math.abs(bounds.width) * displayScale.x; + const outlineHeight = Math.abs(bounds.height) * displayScale.y; - const rotationHandle = getRotationHandlePosition({ bounds }); + const rotationHandleCanvas = getRotationHandlePosition({ bounds }); const rotationHandleScreen = toOverlay({ - canvasX: rotationHandle.x, - canvasY: rotationHandle.y, + canvasX: rotationHandleCanvas.x, + canvasY: rotationHandleCanvas.y, }); + const onPointerMove = (event: React.PointerEvent) => + handlePointerMove({ event }); + const onPointerUp = () => handlePointerUp(); + return ( - <div className="pointer-events-none absolute inset-0" aria-hidden> - <SnapGuides - lines={snapLines} - canvasRef={canvasRef} - containerRef={containerRef} - /> + <div + className="pointer-events-none absolute inset-0 overflow-hidden" + aria-hidden + > <BoundingBoxOutline center={center} outlineWidth={outlineWidth} @@ -152,132 +94,47 @@ export function TransformHandles({ canvasX: cornerPosition.x, canvasY: cornerPosition.y, }); + const angleDeg = + Math.atan2(screen.y - center.y, screen.x - center.x) * + (180 / Math.PI); return ( <CornerHandle key={corner} - corner={corner} + cursor={getResizeCursor({ angleDeg })} screen={screen} onPointerDown={(event) => handleCornerPointerDown({ event, corner }) } - onPointerMove={(event) => handlePointerMove({ event })} - onPointerUp={(event) => handlePointerUp({ event })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} + {EDGES.map((edge) => { + const edgePosition = getEdgeHandlePosition({ bounds, edge }); + const screen = toOverlay({ + canvasX: edgePosition.x, + canvasY: edgePosition.y, + }); + return ( + <EdgeHandle + key={edge} + edge={edge} + screen={screen} + rotation={bounds.rotation} + onPointerDown={(event) => handleEdgePointerDown({ event, edge })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} /> ); })} - <RotationHandle + <IconHandle + icon={Rotate01Icon} screen={rotationHandleScreen} onPointerDown={(event) => handleRotationPointerDown({ event })} - onPointerMove={(event) => handlePointerMove({ event })} - onPointerUp={(event) => handlePointerUp({ event })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} /> </div> ); } - -function BoundingBoxOutline({ - center, - outlineWidth, - outlineHeight, - rotation, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; -}) { - return ( - <div - className="pointer-events-none absolute" - style={{ - left: center.x - outlineWidth / 2, - top: center.y - outlineHeight / 2, - width: outlineWidth, - height: outlineHeight, - transform: `rotate(${rotation}deg)`, - transformOrigin: "center center", - border: "1px dashed white", - boxSizing: "border-box", - opacity: 0.75, - }} - /> - ); -} - -function CornerHandle({ - corner, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - corner: Corner; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - const isNesw = corner === "top-right" || corner === "bottom-left"; - - return ( - <button - type="button" - className={cn( - "absolute flex items-center justify-center outline-none", - isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize", - )} - style={{ - left: screen.x - CORNER_HIT_AREA_SIZE / 2, - top: screen.y - CORNER_HIT_AREA_SIZE / 2, - width: CORNER_HIT_AREA_SIZE, - height: CORNER_HIT_AREA_SIZE, - pointerEvents: "auto", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} - onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} - > - <div - className="rounded-sm bg-white" - style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} - /> - </button> - ); -} - -function RotationHandle({ - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - <button - type="button" - className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none" - style={{ - left: screen.x - ROTATION_HANDLE_RADIUS, - top: screen.y - ROTATION_HANDLE_RADIUS, - width: ROTATION_HANDLE_RADIUS * 2, - height: ROTATION_HANDLE_RADIUS * 2, - pointerEvents: "auto", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} - onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} - > - <HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} /> - </button> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx deleted file mode 100644 index 2ad46497..00000000 --- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function AudioProperties() { - return <div className="space-y-4 p-5">Audio properties</div>; -} diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx deleted file mode 100644 index a1324bdf..00000000 --- a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx +++ /dev/null @@ -1,215 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import type { Effect } from "@/types/effects"; -import type { VisualElement } from "@/types/timeline"; -import { getEffect } from "@/lib/effects/registry"; -import { useEditor } from "@/hooks/use-editor"; -import { usePropertiesStore } from "@/stores/properties-store"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, - SectionFields, -} from "./section"; -import { EffectParamField } from "./effect-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowLeft01Icon, - Delete02Icon, - ViewIcon, - ViewOffSlashIcon, -} from "@hugeicons/core-free-icons"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { cn } from "@/utils/ui"; -export function ClipEffectsProperties({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const closeClipEffects = usePropertiesStore( - (state) => state.closeClipEffects, - ); - const editor = useEditor(); - const effects = element.effects ?? []; - - useEffect(() => { - if (effects.length === 0) closeClipEffects(); - }, [effects.length, closeClipEffects]); - - const [dragIndex, setDragIndex] = useState<number | null>(null); - const [dropIndex, setDropIndex] = useState<number | null>(null); - - const handleDragStart = ({ index }: { index: number }) => { - setDragIndex(index); - }; - - const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => { - event.preventDefault(); - if (index !== dropIndex) setDropIndex(index); - }; - - const handleDrop = ({ toIndex }: { toIndex: number }) => { - if (dragIndex !== null && dragIndex !== toIndex) { - editor.timeline.reorderClipEffects({ - trackId, - elementId: element.id, - fromIndex: dragIndex, - toIndex, - }); - } - setDragIndex(null); - setDropIndex(null); - }; - - const handleDragEnd = () => { - setDragIndex(null); - setDropIndex(null); - }; - - return ( - <div className="flex h-full flex-col"> - <div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5"> - <Button - variant="ghost" - size="icon" - onClick={closeClipEffects} - aria-label="Back to properties" - > - <HugeiconsIcon icon={ArrowLeft01Icon} /> - </Button> - <span className="text-sm font-medium">Effects</span> - </div> - <ScrollArea className="flex-1 scrollbar-hidden"> - {effects.map((effect, index) => ( - // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder - <div - key={effect.id} - draggable - onDragStart={() => handleDragStart({ index })} - onDragOver={(event) => handleDragOver({ event, index })} - onDrop={() => handleDrop({ toIndex: index })} - onDragEnd={handleDragEnd} - className={cn( - "group", - dragIndex === index && "opacity-40", - dropIndex === index && - dragIndex !== null && - dragIndex !== index && - (index < dragIndex - ? "border-t-2 border-primary" - : "border-b-2 border-primary"), - )} - > - <ClipEffectSection - effect={effect} - element={element} - trackId={trackId} - /> - </div> - ))} - </ScrollArea> - </div> - ); -} - -function ClipEffectSection({ - effect, - element, - trackId, -}: { - effect: Effect; - element: VisualElement; - trackId: string; -}) { - const editor = useEditor(); - const definition = getEffect({ effectType: effect.type }); - - const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => { - const updatedEffects = (element.effects ?? []).map((existing) => - existing.id !== effect.id - ? existing - : { ...existing, params: { ...existing.params, [key]: value } }, - ); - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { effects: updatedEffects }, - }, - ], - }); - }; - - const commitParam = () => editor.timeline.commitPreview(); - - const toggleEffect = () => - editor.timeline.toggleClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }); - - const removeEffect = () => - editor.timeline.removeClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }); - - return ( - <Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}> - <SectionHeader - className="cursor-move" - trailing={ - <div className="flex items-center gap-1"> - <Button - variant={effect.enabled ? "secondary" : "ghost"} - size="icon" - aria-label={`Toggle ${definition.name}`} - onClick={toggleEffect} - > - <HugeiconsIcon - icon={effect.enabled ? ViewIcon : ViewOffSlashIcon} - /> - </Button> - <Button - variant="ghost" - size="icon" - aria-label={`Remove ${definition.name}`} - onClick={removeEffect} - > - <HugeiconsIcon icon={Delete02Icon} /> - </Button> - </div> - } - > - <SectionTitle - className={cn(!effect.enabled && "text-muted-foreground")} - > - {definition.name} - </SectionTitle> - </SectionHeader> - {effect.enabled && ( - <SectionContent> - <SectionFields> - {definition.params.map((param) => ( - <EffectParamField - key={param.key} - param={param} - value={effect.params[param.key] ?? param.default} - onPreview={previewParam({ key: param.key })} - onCommit={commitParam} - /> - ))} - </SectionFields> - </SectionContent> - )} - </Section> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx index b5b8685f..b5b8685f 100644 --- a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx +++ b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx new file mode 100644 index 00000000..e69c6918 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx @@ -0,0 +1,194 @@ +"use client"; + +import type { ParamDefinition, NumberParamDefinition } from "@/lib/params"; +import { formatNumberForDisplay, snapToStep } from "@/utils/math"; +import { SectionField } from "@/components/section"; +import { NumberField } from "@/components/ui/number-field"; +import { Switch } from "@/components/ui/switch"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { KeyframeToggle } from "./keyframe-toggle"; + +export function PropertyParamField({ + param, + value, + onPreview, + onCommit, + keyframe, +}: { + param: ParamDefinition; + value: number | string | boolean; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; + keyframe?: { + isActive: boolean; + isDisabled: boolean; + onToggle: () => void; + }; +}) { + return ( + <SectionField + label={param.label} + beforeLabel={ + keyframe ? ( + <KeyframeToggle + isActive={keyframe.isActive} + isDisabled={keyframe.isDisabled} + title={`Toggle ${param.label.toLowerCase()} keyframe`} + onToggle={keyframe.onToggle} + /> + ) : undefined + } + > + <ParamInput + param={param} + value={value} + onPreview={onPreview} + onCommit={onCommit} + /> + </SectionField> + ); +} + +function ParamInput({ + param, + value, + onPreview, + onCommit, +}: { + param: ParamDefinition; + value: number | string | boolean; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; +}) { + if (param.type === "number") { + return ( + <NumberParamField + param={param} + value={typeof value === "number" ? value : Number(value)} + onPreview={onPreview} + onCommit={onCommit} + /> + ); + } + + if (param.type === "boolean") { + return ( + <Switch + checked={Boolean(value)} + onCheckedChange={(checked) => { + onPreview(checked); + onCommit(); + }} + /> + ); + } + + if (param.type === "select") { + return ( + <Select + value={String(value)} + onValueChange={(selected) => { + onPreview(selected); + onCommit(); + }} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {param.options.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ); + } + + if (param.type === "color") { + return ( + <ColorPicker + value={String(value).replace(/^#/, "").toUpperCase()} + onChange={(color) => onPreview(`#${color}`)} + onChangeEnd={(color) => { + onPreview(`#${color}`); + onCommit(); + }} + /> + ); + } + + return null; +} + +function NumberParamField({ + param, + value, + onPreview, + onCommit, +}: { + param: NumberParamDefinition; + value: number; + onPreview: (value: number) => void; + onCommit: () => void; +}) { + const { min, max, step, displayMultiplier = 1 } = param; + const displayValue = value * displayMultiplier; + const clampDisplayValue = (nextDisplayValue: number) => + Math.max( + min, + max !== undefined ? Math.min(max, nextDisplayValue) : nextDisplayValue, + ); + + const previewFromDisplay = (displayVal: number) => { + const clamped = clampDisplayValue( + snapToStep({ value: displayVal, step }), + ); + onPreview(clamped / displayMultiplier); + }; + + const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step)); + + const draft = usePropertyDraft({ + displayValue: formatNumberForDisplay({ + value: displayValue, + maxFractionDigits, + }), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clampDisplayValue(snapToStep({ value: parsed, step })); + }, + onPreview: previewFromDisplay, + onCommit, + }); + + const handleReset = () => { + onPreview(param.default); + onCommit(); + }; + + return ( + <NumberField + icon={param.shortLabel} + value={draft.displayValue} + dragSensitivity="slow" + isDefault={value === param.default} + onFocus={draft.onFocus} + onChange={draft.onChange} + onBlur={draft.onBlur} + onScrub={previewFromDisplay} + onScrubEnd={onCommit} + onReset={handleReset} + /> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx deleted file mode 100644 index 408d430a..00000000 --- a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects"; -import { clamp } from "@/utils/math"; -import { SectionField } from "./section"; -import { Slider } from "@/components/ui/slider"; -import { NumberField } from "@/components/ui/number-field"; -import { Switch } from "@/components/ui/switch"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { usePropertyDraft } from "./hooks/use-property-draft"; - -export function EffectParamField({ - param, - value, - onPreview, - onCommit, -}: { - param: EffectParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; -}) { - return ( - <SectionField label={param.label}> - <EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} /> - </SectionField> - ); -} - -function EffectParamInput({ - param, - value, - onPreview, - onCommit, -}: { - param: EffectParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; -}) { - if (param.type === "number") { - return ( - <NumberParamField - param={param} - value={typeof value === "number" ? value : Number(value)} - onPreview={onPreview} - onCommit={onCommit} - /> - ); - } - - if (param.type === "boolean") { - return ( - <Switch - checked={Boolean(value)} - onCheckedChange={(checked) => { - onPreview(checked); - onCommit(); - }} - /> - ); - } - - if (param.type === "select") { - return ( - <Select - value={String(value)} - onValueChange={(selected) => { - onPreview(selected); - onCommit(); - }} - > - <SelectTrigger className="w-full"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {param.options.map((option) => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - ); - } - - if (param.type === "color") { - return ( - <input - type="color" - className="h-8 w-full cursor-pointer rounded border" - value={String(value)} - onChange={(event) => onPreview(event.target.value)} - onBlur={onCommit} - /> - ); - } - - return null; -} - -function NumberParamField({ - param, - value, - onPreview, - onCommit, -}: { - param: NumberEffectParamDefinition; - value: number; - onPreview: (value: number) => void; - onCommit: () => void; -}) { - const { min, max, step } = param; - - const draft = usePropertyDraft({ - displayValue: String(value), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min, max }); - }, - onPreview, - onCommit, - }); - - return ( - <div className="flex items-center gap-3"> - <Slider - className="flex-1" - min={min} - max={max} - step={step} - value={[value]} - onValueChange={([newValue]) => onPreview(newValue)} - onValueCommit={onCommit} - /> - <NumberField - className="w-16 shrink-0" - value={draft.displayValue} - onFocus={draft.onFocus} - onChange={draft.onChange} - onBlur={draft.onBlur} - /> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx deleted file mode 100644 index 75fc30a1..00000000 --- a/apps/web/src/components/editor/panels/properties/effect-properties.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import type { EffectElement } from "@/types/timeline"; -import { getEffect } from "@/lib/effects/registry"; -import { useEditor } from "@/hooks/use-editor"; -import { - Section, - SectionContent, - SectionHeader, - SectionFields, - SectionTitle, -} from "./section"; -import { EffectParamField } from "./effect-param-field"; - -export function EffectProperties({ - element, - trackId, -}: { - element: EffectElement; - trackId: string; -}) { - const editor = useEditor(); - const definition = getEffect({ effectType: element.effectType }); - - const previewParam = - ({ key }: { key: string }) => - (value: number | string | boolean) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { params: { ...element.params, [key]: value } }, - }, - ], - }); - - return ( - <Section showTopBorder={false}> - <SectionHeader> - <SectionTitle>{definition.name}</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - {definition.params.map((param) => ( - <EffectParamField - key={param.key} - param={param} - value={element.params[param.key] ?? param.default} - onPreview={previewParam({ key: param.key })} - onCommit={() => editor.timeline.commitPreview()} - /> - ))} - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index dbd19e4c..957ee1c9 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -4,8 +4,8 @@ import { hasKeyframesForPath, upsertElementKeyframe, } from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; -import type { TimelineElement } from "@/types/timeline"; +import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; export function useKeyframedColorProperty({ trackId, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 11aa049c..0f0168f8 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -4,8 +4,9 @@ import { hasKeyframesForPath, upsertElementKeyframe, } from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation"; -import type { TimelineElement } from "@/types/timeline"; +import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; +import { snapToStep } from "@/utils/math"; import { usePropertyDraft } from "./use-property-draft"; export function useKeyframedNumberProperty({ @@ -18,6 +19,7 @@ export function useKeyframedNumberProperty({ displayValue, parse, valueAtPlayhead, + step, buildBaseUpdates, }: { trackId: string; @@ -29,9 +31,12 @@ export function useKeyframedNumberProperty({ displayValue: string; parse: (input: string) => number | null; valueAtPlayhead: number; + step?: number; buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>; }) { const editor = useEditor(); + const snapValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); const keyframeAtTime = isPlayheadWithinElementRange @@ -43,6 +48,7 @@ export function useKeyframedNumberProperty({ hasAnimatedKeyframes && isPlayheadWithinElementRange; const previewValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); if (shouldUseAnimatedChannel) { editor.timeline.previewElements({ updates: [ @@ -54,7 +60,7 @@ export function useKeyframedNumberProperty({ animations, propertyPath, time: localTime, - value, + value: nextValue, }), }, }, @@ -68,7 +74,7 @@ export function useKeyframedNumberProperty({ { trackId, elementId, - updates: buildBaseUpdates({ value }), + updates: buildBaseUpdates({ value: nextValue }), }, ], }); @@ -76,7 +82,10 @@ export function useKeyframedNumberProperty({ const propertyDraft = usePropertyDraft({ displayValue, - parse, + parse: (input) => { + const parsedValue = parse(input); + return parsedValue === null ? null : snapValue(parsedValue); + }, onPreview: (value) => previewValue({ value }), onCommit: () => editor.timeline.commitPreview(), }); @@ -107,13 +116,14 @@ export function useKeyframedNumberProperty({ elementId, propertyPath, time: localTime, - value: valueAtPlayhead, + value: snapValue(valueAtPlayhead), }, ], }); }; const commitValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); if (shouldUseAnimatedChannel) { editor.timeline.upsertKeyframes({ keyframes: [ @@ -122,7 +132,7 @@ export function useKeyframedNumberProperty({ elementId, propertyPath, time: localTime, - value, + value: nextValue, }, ], }); @@ -134,7 +144,7 @@ export function useKeyframedNumberProperty({ { trackId, elementId, - updates: buildBaseUpdates({ value }), + updates: buildBaseUpdates({ value: nextValue }), }, ], }); diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts new file mode 100644 index 00000000..335e3fb6 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -0,0 +1,148 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { + buildGraphicParamPath, + getKeyframeAtTime, + getParamDefaultInterpolation, + getParamValueKind, + hasKeyframesForPath, + upsertPathKeyframe, +} from "@/lib/animation"; +import type { + ElementAnimations, +} from "@/lib/animation/types"; +import type { ParamDefinition } from "@/lib/params"; +import type { TimelineElement } from "@/lib/timeline"; + +export interface KeyframedParamPropertyResult { + hasAnimatedKeyframes: boolean; + isKeyframedAtTime: boolean; + keyframeIdAtTime: string | null; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; + toggleKeyframe: () => void; +} + +export function useKeyframedParamProperty({ + param, + trackId, + elementId, + animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates, +}: { + param: ParamDefinition; + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: number | string | boolean; + buildBaseUpdates: ({ + value, + }: { + value: number | string | boolean; + }) => Partial<TimelineElement>; +}): KeyframedParamPropertyResult { + const editor = useEditor(); + const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ + animations, + propertyPath, + time: localTime, + }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertPathKeyframe({ + animations, + propertyPath, + time: localTime, + value, + valueKind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ + param, + }), + numericRange: + param.type === "number" + ? { min: param.min, max: param.max, step: param.step } + : undefined, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + return { + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + onPreview: previewValue, + onCommit: () => editor.timeline.commitPreview(), + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts new file mode 100644 index 00000000..188f05cb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts @@ -0,0 +1,190 @@ +import { useEditor } from "@/hooks/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/lib/animation"; +import type { + AnimationPropertyPath, + ElementAnimations, + VectorValue, +} from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; +import { snapToStep } from "@/utils/math"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedVectorProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + displayX, + displayY, + parseComponent, + step, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: VectorValue; + displayX: string; + displayY: string; + parseComponent: (input: string) => number | null; + step?: number; + buildBaseUpdates: ({ + value, + }: { + value: VectorValue; + }) => Partial<TimelineElement>; +}) { + const editor = useEditor(); + const snapComponentValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; + + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewVector = ({ value }: { value: VectorValue }) => { + const nextValue = { + x: snapComponentValue(value.x), + y: snapComponentValue(value.y), + }; + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: nextValue, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }], + }); + }; + + const x = usePropertyDraft({ + displayValue: displayX, + parse: (input) => { + const parsedValue = parseComponent(input); + return parsedValue === null ? null : snapComponentValue(parsedValue); + }, + onPreview: (xVal) => + previewVector({ value: { x: xVal, y: resolvedValue.y } }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const y = usePropertyDraft({ + displayValue: displayY, + parse: (input) => { + const parsedValue = parseComponent(input); + return parsedValue === null ? null : snapComponentValue(parsedValue); + }, + onPreview: (yVal) => + previewVector({ value: { x: resolvedValue.x, y: yVal } }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) return; + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + const commitX = ({ value }: { value: number }) => { + const vector: VectorValue = { + x: snapComponentValue(value), + y: snapComponentValue(resolvedValue.y), + }; + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: vector }, + ], + }); + return; + } + editor.timeline.updateElements({ + updates: [ + { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + ], + }); + }; + + const commitY = ({ value }: { value: number }) => { + const vector: VectorValue = { + x: snapComponentValue(resolvedValue.x), + y: snapComponentValue(value), + }; + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: vector }, + ], + }); + return; + } + editor.timeline.updateElements({ + updates: [ + { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + ], + }); + }; + + return { + x, + y, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitX, + commitY, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index cb45996d..3f3ff1f3 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -1,86 +1,95 @@ "use client"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { AudioProperties } from "./audio-properties"; -import { VideoProperties } from "./video-properties"; -import { TextProperties } from "./text-properties"; -import { EffectProperties } from "./effect-properties"; -import { ClipEffectsProperties } from "./clip-effects-properties"; -import { EmptyView } from "./empty-view"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { usePropertiesStore } from "@/stores/properties-store"; -import { isVisualElement } from "@/lib/timeline"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; - -function ElementProperties({ - track, - element, -}: { - track: TimelineTrack; - element: TimelineElement; -}) { - if (element.type === "text") { - return <TextProperties element={element} trackId={track.id} />; - } - if (element.type === "audio") { - return <AudioProperties />; - } - if ( - element.type === "video" || - element.type === "image" || - element.type === "sticker" - ) { - return <VideoProperties element={element} trackId={track.id} />; - } - if (element.type === "effect") { - return <EffectProperties element={element} trackId={track.id} />; - } - return null; -} +import { usePropertiesStore } from "./stores/properties-store"; +import { getPropertiesConfig } from "./registry"; +import { cn } from "@/utils/ui"; +import { EmptyView } from "./empty-view"; export function PropertiesPanel() { const editor = useEditor(); + useEditor((e) => e.timeline.getTracks()); + useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); - const clipEffectsTarget = usePropertiesStore( - (state) => state.clipEffectsTarget, - ); + const { activeTabPerType, setActiveTab } = usePropertiesStore(); - const clipEffectsTrack = clipEffectsTarget - ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId }) - : null; - const clipEffectsElement = clipEffectsTrack?.elements.find( - (element) => element.id === clipEffectsTarget?.elementId, - ); - const isShowingClipEffects = - clipEffectsTrack && - clipEffectsElement && - isVisualElement(clipEffectsElement); + if (selectedElements.length === 0) { + return ( + <div className="panel bg-background flex h-full flex-col items-center justify-center overflow-hidden rounded-sm border"> + <EmptyView /> + </div> + ); + } + + if (selectedElements.length > 1) { + return ( + <div className="panel bg-background flex h-full flex-col items-center justify-center overflow-hidden rounded-sm border"> + <p className="text-muted-foreground text-sm"> + {selectedElements.length} elements selected.0 + </p> + </div> + ); + } + + const mediaAssets = editor.media.getAssets(); const elementsWithTracks = editor.timeline.getElementsWithTracks({ elements: selectedElements, }); + const elementWithTrack = elementsWithTracks[0]; + + if (!elementWithTrack) return null; + + const { element, track } = elementWithTrack; + const config = getPropertiesConfig({ element, mediaAssets }); + const visibleTabs = config.tabs; + + const storedTabId = activeTabPerType[element.type]; + const isStoredTabVisible = visibleTabs.some((t) => t.id === storedTabId); + const activeTabId = isStoredTabVisible ? storedTabId : config.defaultTab; + const activeTab = + visibleTabs.find((t) => t.id === activeTabId) ?? visibleTabs[0]; + + if (!activeTab) return null; return ( - <div className="panel bg-background h-full rounded-sm border overflow-hidden"> - {isShowingClipEffects ? ( - <ClipEffectsProperties - element={clipEffectsElement} - trackId={clipEffectsTrack.id} - /> - ) : selectedElements.length > 0 ? ( - <ScrollArea className="h-full scrollbar-hidden"> - {elementsWithTracks.map(({ track, element }) => ( - <ElementProperties - key={element.id} - track={track} - element={element} - /> + <div className="panel bg-background flex h-full overflow-hidden rounded-sm border"> + <TooltipProvider delayDuration={0}> + <div className="flex shrink-0 flex-col gap-0.5 border-r p-1 scrollbar-hidden overflow-y-auto"> + {visibleTabs.map((tab) => ( + <Tooltip key={tab.id}> + <TooltipTrigger asChild> + <Button + variant={tab.id === activeTab.id ? "secondary" : "ghost"} + size="icon" + onClick={() => setActiveTab(element.type, tab.id)} + aria-label={tab.label} + className={cn( + "shrink-0", + "h-8 w-8", + tab.id !== activeTab.id && "text-muted-foreground", + )} + > + {tab.icon} + </Button> + </TooltipTrigger> + <TooltipContent side="right">{tab.label}</TooltipContent> + </Tooltip> ))} - </ScrollArea> - ) : ( - <EmptyView /> - )} + </div> + </TooltipProvider> + <ScrollArea className="flex-1 scrollbar-hidden"> + {activeTab.content({ trackId: track.id })} + </ScrollArea> </div> ); } diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx new file mode 100644 index 00000000..706faef4 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -0,0 +1,303 @@ +import type { ReactNode } from "react"; +import type { + EffectElement, + GraphicElement, + ImageElement, + MaskableElement, + RetimableElement, + StickerElement, + TextElement, + VisualElement, + VideoElement, + AudioElement, + TimelineElement, +} from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + TextFontIcon, + ArrowExpandIcon, + RainDropIcon, + MusicNote03Icon, + MagicWand05Icon, + DashboardSpeed02Icon, +} from "@hugeicons/core-free-icons"; +import { TransformTab } from "./tabs/transform-tab"; +import { BlendingTab } from "./tabs/blending-tab"; +import { AudioTab } from "./tabs/audio-tab"; +import { TextTab } from "./tabs/text-tab"; +import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab"; +import { MasksTab } from "./tabs/masks-tab"; +import { SpeedTab } from "./tabs/speed-tab"; +import { GraphicTab } from "./tabs/graphic-tab"; +import { OcShapesIcon } from "@opencut/ui/icons"; + +export type TabContentProps = { + trackId: string; +}; + +export type PropertiesTabDef = { + id: string; + label: string; + icon: ReactNode; + content: (props: TabContentProps) => ReactNode; +}; + +export type ElementPropertiesConfig = { + defaultTab: string; + tabs: PropertiesTabDef[]; +}; + +function buildTransformTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "transform", + label: "Transform", + icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, + content: ({ trackId }) => ( + <TransformTab element={element} trackId={trackId} /> + ), + }; +} + +function buildBlendingTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "blending", + label: "Blending", + icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, + content: ({ trackId }) => ( + <BlendingTab element={element} trackId={trackId} /> + ), + }; +} + +function buildAudioTab({ + element, +}: { + element: AudioElement | VideoElement; +}): PropertiesTabDef { + return { + id: "audio", + label: "Audio", + icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, + content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, + }; +} + +function buildSpeedTab({ + element, +}: { + element: RetimableElement; +}): PropertiesTabDef { + return { + id: "speed", + label: "Speed", + icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />, + content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />, + }; +} + +function buildMasksTab({ + element, +}: { + element: MaskableElement; +}): PropertiesTabDef { + return { + id: "masks", + label: "Masks", + icon: <OcShapesIcon size={16} />, + content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />, + }; +} + +function buildClipEffectsTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, + content: ({ trackId }) => ( + <ClipEffectsTab element={element} trackId={trackId} /> + ), + }; +} + +function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { + return { + id: "text", + label: "Text", + icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, + content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, + }; +} + +function buildGraphicTab({ + element, +}: { + element: GraphicElement; +}): PropertiesTabDef { + return { + id: "graphic", + label: "Graphic", + icon: <OcShapesIcon size={16} />, + content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />, + }; +} + +function buildStandaloneEffectTab({ + element, +}: { + element: EffectElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, + content: ({ trackId }) => ( + <StandaloneEffectTab element={element} trackId={trackId} /> + ), + }; +} + +function getTextConfig({ + element, +}: { + element: TextElement; +}): ElementPropertiesConfig { + return { + defaultTab: "text", + tabs: [ + buildTextTab({ element }), + buildTransformTab({ element }), + buildBlendingTab({ element }), + ], + }; +} + +function getVideoConfig({ + element, + mediaAsset, +}: { + element: VideoElement; + mediaAsset: MediaAsset | undefined; +}): ElementPropertiesConfig { + const showAudioTab = mediaAsset?.hasAudio !== false; + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + ...(showAudioTab ? [buildAudioTab({ element })] : []), + buildSpeedTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getImageConfig({ + element, +}: { + element: ImageElement; +}): ElementPropertiesConfig { + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getStickerConfig({ + element, +}: { + element: StickerElement; +}): ElementPropertiesConfig { + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getGraphicConfig({ + element, +}: { + element: GraphicElement; +}): ElementPropertiesConfig { + return { + defaultTab: "graphic", + tabs: [ + buildGraphicTab({ element }), + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getAudioConfig({ + element, +}: { + element: AudioElement; +}): ElementPropertiesConfig { + return { + defaultTab: "audio", + tabs: [buildAudioTab({ element }), buildSpeedTab({ element })], + }; +} + +function getEffectConfig({ + element, +}: { + element: EffectElement; +}): ElementPropertiesConfig { + return { + defaultTab: "effects", + tabs: [buildStandaloneEffectTab({ element })], + }; +} + +export function getPropertiesConfig({ + element, + mediaAssets, +}: { + element: TimelineElement; + mediaAssets: MediaAsset[]; +}): ElementPropertiesConfig { + switch (element.type) { + case "text": + return getTextConfig({ element }); + case "video": { + const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId); + return getVideoConfig({ element, mediaAsset }); + } + case "image": + return getImageConfig({ element }); + case "sticker": + return getStickerConfig({ element }); + case "graphic": + return getGraphicConfig({ element }); + case "audio": + return getAudioConfig({ element }); + case "effect": + return getEffectConfig({ element }); + } +} diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx deleted file mode 100644 index 8be58f39..00000000 --- a/apps/web/src/components/editor/panels/properties/sections/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./transform"; -export * from "./blending"; diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx deleted file mode 100644 index 6aaaa1fe..00000000 --- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import { NumberField } from "@/components/ui/number-field"; -import { useEditor } from "@/hooks/use-editor"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { AnimationPropertyPath } from "@/types/animation"; -import type { VisualElement } from "@/types/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "../section"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowExpandIcon, - Link05Icon, - RotateClockwiseIcon, -} from "@hugeicons/core-free-icons"; -import { useState } from "react"; -import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; -import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation"; -import { KeyframeToggle } from "../keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; - -export function parseNumericInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : parsed; -} - -export function isPropertyAtDefault({ - hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue, - staticValue, - defaultValue, -}: { - hasAnimatedKeyframes: boolean; - isPlayheadWithinElementRange: boolean; - resolvedValue: number; - staticValue: number; - defaultValue: number; -}): boolean { - if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { - return isNearlyEqual({ - leftValue: resolvedValue, - rightValue: defaultValue, - }); - } - - return staticValue === defaultValue; -} - -export function TransformSection({ - element, - trackId, - showTopBorder = true, -}: { - element: VisualElement; - trackId: string; - showTopBorder?: boolean; -}) { - const editor = useEditor(); - const [isScaleLocked, setIsScaleLocked] = useState(false); - const playheadTime = editor.playback.getCurrentTime(); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - const isPlayheadWithinElementRange = - playheadTime >= element.startTime - TIME_EPSILON_SECONDS && - playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS; - - const positionX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.position.x", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.x).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.x, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { - ...element.transform.position, - x: value, - }, - }, - }), - }); - - const positionY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.position.y", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.y).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.y, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { - ...element.transform.position, - y: value, - }, - }, - }), - }); - - const scale = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scale", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scale * 100).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return Math.max(parsed, 1) / 100; - }, - valueAtPlayhead: resolvedTransform.scale, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scale: value, - }, - }), - }); - const scaleFieldProps = { - className: "flex-1", - value: scale.displayValue, - onFocus: scale.onFocus, - onChange: scale.onChange, - onBlur: scale.onBlur, - dragSensitivity: "slow" as const, - onScrub: scale.scrubTo, - onScrubEnd: scale.commitScrub, - onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scale.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scale, - staticValue: element.transform.scale, - defaultValue: DEFAULT_TRANSFORM.scale, - }), - }; - - const rotation = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.rotate", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.rotate).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return clamp({ value: parsed, min: -360, max: 360 }); - }, - valueAtPlayhead: resolvedTransform.rotate, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - rotate: value, - }, - }), - }); - - const hasPositionKeyframe = - positionX.isKeyframedAtTime || positionY.isKeyframedAtTime; - - const togglePositionKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) { - const keyframesToRemove: Array<{ - trackId: string; - elementId: string; - propertyPath: AnimationPropertyPath; - keyframeId: string; - }> = []; - if (positionX.keyframeIdAtTime) { - keyframesToRemove.push({ - trackId, - elementId: element.id, - propertyPath: "transform.position.x" as const, - keyframeId: positionX.keyframeIdAtTime, - }); - } - if (positionY.keyframeIdAtTime) { - keyframesToRemove.push({ - trackId, - elementId: element.id, - propertyPath: "transform.position.y" as const, - keyframeId: positionY.keyframeIdAtTime, - }); - } - - editor.timeline.removeKeyframes({ - keyframes: keyframesToRemove, - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId: element.id, - propertyPath: "transform.position.x", - time: localTime, - value: resolvedTransform.position.x, - }, - { - trackId, - elementId: element.id, - propertyPath: "transform.position.y", - time: localTime, - value: resolvedTransform.position.y, - }, - ], - }); - }; - - return ( - <Section - collapsible - sectionKey={`${element.type}:transform`} - showTopBorder={showTopBorder} - > - <SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader> - <SectionContent> - <SectionFields> - <SectionField - label="Scale" - beforeLabel={ - <KeyframeToggle - isActive={scale.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle scale keyframe" - onToggle={scale.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - {isScaleLocked ? ( - <> - <NumberField icon="W" {...scaleFieldProps} /> - <NumberField icon="H" {...scaleFieldProps} /> - </> - ) : ( - <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldProps} - className="flex-1" - /> - )} - <Button - type="button" - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setIsScaleLocked((isLocked) => !isLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - </div> - </SectionField> - <SectionField - label="Position" - beforeLabel={ - <KeyframeToggle - isActive={hasPositionKeyframe} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle position keyframe" - onToggle={togglePositionKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon="X" - className="flex-1" - value={positionX.displayValue} - onFocus={positionX.onFocus} - onChange={positionX.onChange} - onBlur={positionX.onBlur} - onScrub={positionX.scrubTo} - onScrubEnd={positionX.commitScrub} - onReset={() => - positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.x, - staticValue: element.transform.position.x, - defaultValue: DEFAULT_TRANSFORM.position.x, - })} - /> - <NumberField - icon="Y" - className="flex-1" - value={positionY.displayValue} - onFocus={positionY.onFocus} - onChange={positionY.onChange} - onBlur={positionY.onBlur} - onScrub={positionY.scrubTo} - onScrubEnd={positionY.commitScrub} - onReset={() => - positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.y, - staticValue: element.transform.position.y, - defaultValue: DEFAULT_TRANSFORM.position.y, - })} - /> - </div> - </SectionField> - - <SectionField - label="Rotation" - beforeLabel={ - <KeyframeToggle - isActive={rotation.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle rotation keyframe" - onToggle={rotation.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - className="flex-none" - value={rotation.displayValue} - onFocus={rotation.onFocus} - onChange={rotation.onChange} - onBlur={rotation.onBlur} - dragSensitivity="slow" - onScrub={rotation.scrubTo} - onScrubEnd={rotation.commitScrub} - onReset={() => - rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.rotate, - staticValue: element.transform.rotate, - defaultValue: DEFAULT_TRANSFORM.rotate, - })} - /> - </div> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts new file mode 100644 index 00000000..1783e3fc --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; + +interface PropertiesState { + activeTabPerType: Record<string, string>; + setActiveTab: (elementType: string, tabId: string) => void; + isTransformScaleLocked: boolean; + setTransformScaleLocked: (locked: boolean) => void; +} + +export const usePropertiesStore = create<PropertiesState>()((set) => ({ + activeTabPerType: {}, + setActiveTab: (elementType, tabId) => + set((state) => ({ + activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId }, + })), + isTransformScaleLocked: false, + setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }), +})); diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx new file mode 100644 index 00000000..41b6ec6d --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx @@ -0,0 +1,109 @@ +import { NumberField } from "@/components/ui/number-field"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import type { AudioElement, VideoElement } from "@/lib/timeline"; +import { resolveNumberAtTime } from "@/lib/animation"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { KeyframeToggle } from "../components/keyframe-toggle"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { VolumeHighIcon } from "@hugeicons/core-free-icons"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; + +export function AudioTab({ + element, + trackId, +}: { + element: AudioElement | VideoElement; + trackId: string; +}) { + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedVolume = resolveNumberAtTime({ + baseValue: element.volume ?? DEFAULTS.element.volume, + animations: element.animations, + propertyPath: "volume", + localTime, + }); + const volume = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "volume", + localTime, + isPlayheadWithinElementRange, + displayValue: resolvedVolume.toFixed(1), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) { + return null; + } + + return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }); + }, + valueAtPlayhead: resolvedVolume, + step: 0.1, + buildBaseUpdates: ({ value }) => ({ + volume: value, + }), + }); + const isDefault = + volume.hasAnimatedKeyframes && isPlayheadWithinElementRange + ? isNearlyEqual({ + leftValue: resolvedVolume, + rightValue: DEFAULTS.element.volume, + }) + : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; + + return ( + <Section collapsible sectionKey={`${element.id}:audio`}> + <SectionHeader> + <SectionTitle>Audio</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + <SectionField + label="Volume" + beforeLabel={ + <KeyframeToggle + isActive={volume.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle volume keyframe" + onToggle={volume.toggleKeyframe} + /> + } + > + <NumberField + icon={<HugeiconsIcon icon={VolumeHighIcon} />} + value={volume.displayValue} + onFocus={volume.onFocus} + onChange={volume.onChange} + onBlur={volume.onBlur} + dragSensitivity="slow" + scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} + onScrub={volume.scrubTo} + onScrubEnd={volume.commitScrub} + onReset={() => + volume.commitValue({ + value: DEFAULTS.element.volume, + }) + } + isDefault={isDefault} + suffix="dB" + /> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx index a50e84e7..c360817c 100644 --- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx @@ -1,13 +1,16 @@ import { useEditor } from "@/hooks/use-editor"; import { clamp } from "@/utils/math"; import { NumberField } from "@/components/ui/number-field"; -import { - DEFAULT_BLEND_MODE, - DEFAULT_OPACITY, -} from "@/constants/timeline-constants"; import { OcCheckerboardIcon } from "@opencut/ui/icons"; import { Fragment, useRef } from "react"; -import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section"; +import { useMenuPreview } from "@/hooks/use-menu-preview"; +import { + Section, + SectionContent, + SectionField, + SectionHeader, + SectionTitle, +} from "@/components/section"; import { Select, SelectContent, @@ -16,16 +19,17 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { BlendMode } from "@/types/rendering"; -import type { ElementType } from "@/types/timeline"; -import type { ElementAnimations } from "@/types/animation"; +import type { BlendMode } from "@/lib/rendering"; +import type { ElementType } from "@/lib/timeline"; +import type { ElementAnimations } from "@/lib/animation/types"; import { HugeiconsIcon } from "@hugeicons/react"; import { RainDropIcon } from "@hugeicons/core-free-icons"; -import { KeyframeToggle } from "../keyframe-toggle"; +import { KeyframeToggle } from "../components/keyframe-toggle"; import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; import { useElementPlayhead } from "../hooks/use-element-playhead"; import { resolveOpacityAtTime } from "@/lib/animation"; -import { isPropertyAtDefault } from "./transform"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { isPropertyAtDefault } from "./transform-tab"; type BlendingElement = { id: string; @@ -67,7 +71,7 @@ const BLEND_MODE_GROUPS = [ ], ]; -export function BlendingSection({ +export function BlendingTab({ element, trackId, }: { @@ -75,13 +79,19 @@ export function BlendingSection({ trackId: string; }) { const editor = useEditor(); - const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE; - const didSelectRef = useRef(false); + const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); + const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; const committedBlendModeRef = useRef(blendMode); - if (!editor.timeline.isPreviewActive()) { + if (!isPreviewActive) { committedBlendModeRef.current = blendMode; } + const { + onPointerLeave, + onOpenChange: handleBlendModeOpenChange, + markCommitted, + } = useMenuPreview(); + const previewBlendMode = ({ value }: { value: BlendMode }) => editor.timeline.previewElements({ updates: [ @@ -103,14 +113,7 @@ export function BlendingSection({ ], }); } - didSelectRef.current = true; - }; - - const handleBlendModeOpenChange = (isOpen: boolean) => { - if (!isOpen) { - if (!didSelectRef.current) editor.timeline.discardPreview(); - didSelectRef.current = false; - } + markCommitted(); }; const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ @@ -137,86 +140,91 @@ export function BlendingSection({ return clamp({ value: parsed, min: 0, max: 100 }) / 100; }, valueAtPlayhead: resolvedOpacity, + step: 0.01, buildBaseUpdates: ({ value }) => ({ opacity: value }), }); return ( - <Section collapsible sectionKey={`${element.type}:blending`}> - <SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField - label="Opacity" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={opacity.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle opacity keyframe" - onToggle={opacity.toggleKeyframe} - /> - } - > - <NumberField - className="w-full" - icon={ - <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> + <Section collapsible sectionKey={`${element.id}:blending`}> + <SectionHeader> + <SectionTitle>Blending</SectionTitle> + </SectionHeader> + <SectionContent> + <div className="flex items-start gap-2"> + <SectionField + label="Opacity" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={opacity.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle opacity keyframe" + onToggle={opacity.toggleKeyframe} + /> } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })} - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOpacity, - staticValue: element.opacity, - defaultValue: DEFAULT_OPACITY, - })} - dragSensitivity="slow" - /> - </SectionField> - <SectionField label="Blend mode" className="w-1/2"> - <Select - value={committedBlendModeRef.current} - onOpenChange={handleBlendModeOpenChange} - onValueChange={commitBlendMode} > - <SelectTrigger - icon={<HugeiconsIcon icon={RainDropIcon} />} + <NumberField className="w-full" + icon={ + <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> + } + value={opacity.displayValue} + min={0} + max={100} + onFocus={opacity.onFocus} + onChange={opacity.onChange} + onBlur={opacity.onBlur} + onScrub={opacity.scrubTo} + onScrubEnd={opacity.commitScrub} + onReset={() => + opacity.commitValue({ value: DEFAULTS.element.opacity }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOpacity, + staticValue: element.opacity, + defaultValue: DEFAULTS.element.opacity, + })} + dragSensitivity="slow" + /> + </SectionField> + <SectionField label="Blend mode" className="w-1/2"> + <Select + value={committedBlendModeRef.current} + onOpenChange={handleBlendModeOpenChange} + onValueChange={commitBlendMode} > - <SelectValue placeholder="Select blend mode" /> - </SelectTrigger> - <SelectContent className="w-36"> - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> - {group.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - onPointerEnter={() => - previewBlendMode({ value: option.value as BlendMode }) - } - > - {option.label} - </SelectItem> - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - <SelectSeparator /> - ) : null} - </Fragment> - ))} - </SelectContent> - </Select> - </SectionField> - </div> - </SectionContent> + <SelectTrigger + icon={<HugeiconsIcon icon={RainDropIcon} />} + className="w-full" + > + <SelectValue placeholder="Select blend mode" /> + </SelectTrigger> + <SelectContent className="w-36" onPointerLeave={onPointerLeave}> + {BLEND_MODE_GROUPS.map((group, groupIndex) => ( + <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> + {group.map((option) => ( + <SelectItem + key={option.value} + value={option.value} + onPointerEnter={() => + previewBlendMode({ value: option.value as BlendMode }) + } + > + {option.label} + </SelectItem> + ))} + {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( + <SelectSeparator /> + ) : null} + </Fragment> + ))} + </SelectContent> + </Select> + </SectionField> + </div> + </SectionContent> </Section> ); } diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx new file mode 100644 index 00000000..1b94ee2d --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useState } from "react"; +import type { ParamValues } from "@/lib/params"; +import type { Effect } from "@/lib/effects/types"; +import type { EffectElement, VisualElement } from "@/lib/timeline"; +import { effectsRegistry } from "@/lib/effects"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementPreview } from "@/hooks/use-element-preview"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, + SectionFields, +} from "@/components/section"; +import { PropertyParamField } from "../components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Delete02Icon, + ViewIcon, + ViewOffSlashIcon, + MagicWand05Icon, +} from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; +import { Separator } from "@/components/ui/separator"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; + +export function StandaloneEffectTab({ + element, + trackId, +}: { + element: EffectElement; + trackId: string; +}) { + const { renderElement, previewUpdates, commit } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const effect: Effect = { + id: element.id, + type: element.effectType, + params: element.params, + enabled: true, + }; + + const previewParam = (key: string) => (value: number | string | boolean) => { + previewUpdates({ + params: { ...(renderElement as EffectElement).params, [key]: value }, + }); + }; + + return ( + <div className="flex flex-col h-full"> + <div className="border-b px-3.5 h-11 shrink-0 flex items-center"> + <SectionTitle>Effect</SectionTitle> + </div> + <EffectSection + effect={effect} + renderParams={(renderElement as EffectElement).params} + previewParam={previewParam} + onCommit={commit} + /> + </div> + ); +} + +export function ClipEffectsTab({ + element, + trackId, +}: { + element: VisualElement; + trackId: string; +}) { + const [dragIndex, setDragIndex] = useState<number | null>(null); + const [dropIndex, setDropIndex] = useState<number | null>(null); + const editor = useEditor(); + const { renderElement, previewUpdates, commit } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const effects: Effect[] = element.effects ?? []; + + const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => { + return ( + (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId) + ?.params ?? + effects.find((ef) => ef.id === effectId)?.params ?? + {} + ); + }; + + const buildPreviewParam = + (effectId: string) => + (key: string) => + (value: number | string | boolean) => { + const updatedEffects = ( + (renderElement as VisualElement).effects ?? [] + ).map((existing) => + existing.id !== effectId + ? existing + : { ...existing, params: { ...existing.params, [key]: value } }, + ); + previewUpdates({ effects: updatedEffects }); + }; + + const handleDragStart = ({ index }: { index: number }) => setDragIndex(index); + + const handleDragOver = ({ + event, + index, + }: { + event: React.DragEvent; + index: number; + }) => { + event.preventDefault(); + if (index !== dropIndex) setDropIndex(index); + }; + + const handleDrop = ({ toIndex }: { toIndex: number }) => { + if (dragIndex !== null && dragIndex !== toIndex) { + editor.timeline.reorderClipEffects({ + trackId, + elementId: element.id, + fromIndex: dragIndex, + toIndex, + }); + } + setDragIndex(null); + setDropIndex(null); + }; + + const handleDragEnd = () => { + setDragIndex(null); + setDropIndex(null); + }; + + return ( + <div className="flex flex-col h-full"> + <div className="border-b px-3.5 h-11 shrink-0 flex items-center"> + <SectionTitle>Effects</SectionTitle> + </div> + {effects.length === 0 ? ( + <EmptyView /> + ) : ( + <ul className="flex flex-col"> + {effects.map((effect, index) => { + const resolvedDragIndex = dragIndex ?? -1; + const isDragging = dragIndex === index; + const isDropTarget = + dropIndex === index && dragIndex !== null && dragIndex !== index; + const showTopDropIndicator = + isDropTarget && index < resolvedDragIndex; + const showBottomDropIndicator = + isDropTarget && index > resolvedDragIndex; + + return ( + <li + key={effect.id} + draggable + onDragStart={() => handleDragStart({ index })} + onDragOver={(event) => handleDragOver({ event, index })} + onDrop={() => handleDrop({ toIndex: index })} + onDragEnd={handleDragEnd} + className={cn( + "group list-none", + isDragging && "opacity-40", + showTopDropIndicator && "border-t-2 border-primary", + showBottomDropIndicator && "border-b-2 border-primary", + )} + > + <EffectSection + effect={effect} + renderParams={getRenderParams({ effectId: effect.id })} + previewParam={buildPreviewParam(effect.id)} + onCommit={commit} + onToggle={() => + editor.timeline.toggleClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }) + } + onRemove={() => + editor.timeline.removeClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }) + } + /> + </li> + ); + })} + </ul> + )} + </div> + ); +} + +function EmptyView() { + const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab); + + return ( + <div className="flex flex-col h-full items-center justify-center gap-4 text-center"> + <HugeiconsIcon + icon={MagicWand05Icon} + className="size-10 text-muted-foreground" + strokeWidth={1} + /> + <div className="flex flex-col gap-2"> + <h3 className="font-medium text-foreground">No effects</h3> + <p className="text-muted-foreground text-sm text-balance max-w-44"> + Add effects to this layer from the Assets panel. + </p> + </div> + <Button + variant="default" + size="sm" + onClick={() => setActiveTab("effects")} + > + Open effects + </Button> + </div> + ); +} + +function EffectSection({ + effect, + renderParams, + previewParam, + onCommit, + onToggle, + onRemove, +}: { + effect: Effect; + renderParams: ParamValues; + previewParam: (key: string) => (value: number | string | boolean) => void; + onCommit: () => void; + onToggle?: () => void; + onRemove?: () => void; +}) { + const definition = effectsRegistry.get(effect.type); + + return ( + <Section + sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined} + showTopBorder={false} + > + <SectionHeader + className={cn(onToggle && "cursor-move")} + trailing={ + onToggle && ( + <div className="flex items-center gap-1"> + <Button + variant={effect.enabled ? "secondary" : "ghost"} + size="icon" + aria-label={`Toggle ${definition.name}`} + onClick={onToggle} + > + <HugeiconsIcon + icon={effect.enabled ? ViewIcon : ViewOffSlashIcon} + /> + </Button> + <Button + variant="ghost" + size="icon" + aria-label={`Remove ${definition.name}`} + onClick={onRemove} + > + <HugeiconsIcon icon={Delete02Icon} /> + </Button> + </div> + ) + } + > + <SectionTitle + className={cn(onToggle && !effect.enabled && "text-muted-foreground")} + > + {definition.name} + </SectionTitle> + </SectionHeader> + <SectionContent + className={cn("p-0", onToggle && !effect.enabled && "opacity-50")} + > + <SectionFields> + {definition.params.map((param) => ( + <div key={param.key} className="flex flex-col gap-3.5"> + <div className="px-4"> + <PropertyParamField + param={param} + value={renderParams[param.key] ?? param.default} + onPreview={previewParam(param.key)} + onCommit={onCommit} + /> + </div> + <Separator /> + </div> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx new file mode 100644 index 00000000..22dae8cc --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useRef } from "react"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { + useKeyframedParamProperty, + type KeyframedParamPropertyResult, +} from "../hooks/use-keyframed-param-property"; +import { resolveGraphicParamsAtTime } from "@/lib/animation"; +import type { ParamDefinition, ParamValues } from "@/lib/params"; +import type { GraphicElement } from "@/lib/timeline"; +import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; +import { useElementPreview } from "@/hooks/use-element-preview"; +import { useEditor } from "@/hooks/use-editor"; +import { + Section, + SectionContent, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { PropertyParamField } from "../components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +registerDefaultGraphics(); + +const DEFAULT_STROKE_WIDTH = 2; + +export function GraphicTab({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicParamsAtTime({ + element: liveElement, + localTime, + }); + + const shapeParams = definition.params.filter((p) => p.group !== "stroke"); + const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); + + return ( + <div className="flex flex-col"> + <Section collapsible sectionKey={`${element.id}:graphic`}> + <SectionHeader> + <SectionTitle>{definition.name}</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + {shapeParams.map((param) => ( + <AnimatedGraphicParamField + key={param.key} + param={param} + trackId={trackId} + element={liveElement} + localTime={localTime} + isPlayheadWithinElementRange={isPlayheadWithinElementRange} + resolvedParams={resolvedParams} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + {hasStrokeParams && ( + <StrokeSection element={element} trackId={trackId} /> + )} + </div> + ); +} + +function StrokeSection({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const editor = useEditor(); + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicParamsAtTime({ + element: liveElement, + localTime, + }); + const strokeParams = definition.params.filter((p) => p.group === "stroke"); + const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); + const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; + + const toggleStroke = () => { + if (isStrokeEnabled) { + lastStrokeWidth.current = Number( + element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, + ); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { params: { ...element.params, strokeWidth: 0 } }, + }, + ], + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + params: { + ...element.params, + strokeWidth: lastStrokeWidth.current, + }, + }, + }, + ], + }); + } + }; + + return ( + <Section + collapsible + defaultOpen={isStrokeEnabled} + sectionKey={`${element.id}:stroke`} + > + <SectionHeader + trailing={ + <Button + variant="ghost" + size="icon" + onClick={(event) => { + event.stopPropagation(); + toggleStroke(); + }} + > + <HugeiconsIcon + icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon} + strokeWidth={1} + /> + </Button> + } + > + <SectionTitle>Stroke</SectionTitle> + </SectionHeader> + <SectionContent + className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")} + > + <SectionFields> + {strokeParams.map((param) => ( + <AnimatedGraphicParamField + key={param.key} + param={param} + trackId={trackId} + element={liveElement} + localTime={localTime} + isPlayheadWithinElementRange={isPlayheadWithinElementRange} + resolvedParams={resolvedParams} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} + +function AnimatedGraphicParamField({ + param, + trackId, + element, + localTime, + isPlayheadWithinElementRange, + resolvedParams, +}: { + param: ParamDefinition; + trackId: string; + element: GraphicElement; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedParams: ParamValues; +}) { + const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({ + param, + trackId, + elementId: element.id, + animations: element.animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue: resolvedParams[param.key] ?? param.default, + buildBaseUpdates: ({ value }) => ({ + params: { + ...element.params, + [param.key]: value, + }, + }), + }); + + return ( + <PropertyParamField + param={param} + value={resolvedParams[param.key] ?? param.default} + onPreview={animatedParam.onPreview} + onCommit={animatedParam.onCommit} + keyframe={{ + isActive: animatedParam.isKeyframedAtTime, + isDisabled: !isPlayheadWithinElementRange, + onToggle: animatedParam.toggleKeyframe, + }} + /> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx new file mode 100644 index 00000000..6a5d0d0b --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx @@ -0,0 +1,660 @@ +"use client"; + +import type { MaskableElement } from "@/lib/timeline"; +import type { Mask, MaskType } from "@/lib/masks/types"; +import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params"; +import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementPreview } from "@/hooks/use-element-preview"; +import { useMenuPreview } from "@/hooks/use-menu-preview"; +import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Delete02Icon, + FeatherIcon, + PlusSignIcon, + RotateClockwiseIcon, +} from "@hugeicons/core-free-icons"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { NumberField } from "@/components/ui/number-field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons"; +import { cn } from "@/utils/ui"; + +type MasksTabProps = { + element: MaskableElement; + trackId: string; +}; + +type MaskItemProps = { + trackId: string; + elementId: string; + mask: Mask; + previewParam: (key: string) => (value: number | string | boolean) => void; + onCommit: () => void; +}; + +type EmptyViewProps = { + onAddMask: () => void; +}; + +type PreviewParamHandler = ( + key: string, +) => (value: number | string | boolean) => void; + +type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; + +export function MasksTab({ element, trackId }: MasksTabProps) { + const editor = useEditor(); + const { renderElement, previewUpdates, commit } = + useElementPreview<MaskableElement>({ + trackId, + elementId: element.id, + fallback: element, + }); + const maskDefs = masksRegistry.getAll(); + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); + const masks = element.masks ?? []; + const renderMasks = renderElement.masks ?? masks; + const hasMask = masks.length > 0; + const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const elementBounds = useMemo(() => { + const clampedTime = Math.min( + Math.max(currentTime, element.startTime), + element.startTime + element.duration - TIME_EPSILON_SECONDS, + ); + + return ( + getVisibleElementsWithBounds({ + tracks, + currentTime: clampedTime, + canvasSize, + mediaAssets, + }).find( + (item) => item.trackId === trackId && item.elementId === element.id, + )?.bounds ?? null + ); + }, [ + canvasSize, + currentTime, + element.duration, + element.id, + element.startTime, + mediaAssets, + trackId, + tracks, + ]); + + const handleDropdownOpenChange = (open: boolean) => { + if (hasMask && open) return; + setIsDropdownOpen(open); + onOpenChange(open); + }; + + const previewMask = ({ maskType }: { maskType: MaskType }) => { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + masks: [ + buildDefaultMaskInstance({ + maskType, + elementSize: elementBounds + ? { + width: elementBounds.width, + height: elementBounds.height, + } + : undefined, + }), + ], + } as Partial<MaskableElement>, + }, + ], + }); + }; + + const commitMask = ({ maskType }: { maskType: MaskType }) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + masks: [ + buildDefaultMaskInstance({ + maskType, + elementSize: elementBounds + ? { + width: elementBounds.width, + height: elementBounds.height, + } + : undefined, + }), + ], + } as Partial<MaskableElement>, + }, + ], + }); + } + markCommitted(); + setIsDropdownOpen(false); + }; + + const previewMaskParam = + ({ index, key }: { index: number; key: string }) => + (value: number | string | boolean) => { + if (!renderMasks[index]) { + return; + } + + const updatedMasks = renderMasks.map((existingMask, maskIndex) => + maskIndex !== index + ? existingMask + : { + ...existingMask, + params: { + ...existingMask.params, + [key]: value, + }, + }, + ); + + previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>); + }; + + return ( + <div className="flex flex-col h-full"> + <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2"> + <SectionTitle>Masks</SectionTitle> + <DropdownMenu + open={hasMask ? false : isDropdownOpen} + onOpenChange={handleDropdownOpenChange} + > + {hasMask ? ( + <Tooltip> + <TooltipTrigger asChild> + <span className="inline-flex"> + <Button + variant="ghost" + size="icon" + disabled + aria-label="Add mask" + > + <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" /> + </Button> + </span> + </TooltipTrigger> + <TooltipContent className="max-w-56 text-balance"> + Only one mask is supported right now. If you need more, + duplicate the clip and apply a different mask to each copy. + </TooltipContent> + </Tooltip> + ) : ( + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon" aria-label="Add mask"> + <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" /> + </Button> + </DropdownMenuTrigger> + )} + <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}> + {maskDefs.map((definition) => ( + <DropdownMenuItem + key={definition.type} + onPointerEnter={() => + previewMask({ maskType: definition.type }) + } + onClick={() => commitMask({ maskType: definition.type })} + > + <HugeiconsIcon {...definition.icon} /> + {definition.name} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + + {masks.length === 0 ? ( + <EmptyView onAddMask={() => setIsDropdownOpen(true)} /> + ) : ( + masks.map((mask, index) => ( + <MaskItem + key={mask.id} + trackId={trackId} + elementId={element.id} + mask={renderMasks[index] ?? mask} + previewParam={(paramKey) => + previewMaskParam({ index, key: paramKey }) + } + onCommit={commit} + /> + )) + )} + </div> + ); +} + +function MaskItem({ + trackId, + elementId, + mask, + previewParam, + onCommit, +}: MaskItemProps) { + const editor = useEditor(); + const definition = masksRegistry.get(mask.type); + + return ( + <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}> + <SectionHeader + trailing={ + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + aria-label={`Toggle ${definition.name} mask inversion`} + onClick={() => + editor.timeline.toggleMaskInverted({ + trackId, + elementId, + maskId: mask.id, + }) + } + > + <OcMirrorIcon + className={cn(mask.params.inverted && "-scale-x-100")} + /> + </Button> + <Button + variant="ghost" + size="icon" + aria-label={`Remove ${definition.name} mask`} + onClick={() => + editor.timeline.removeMask({ + trackId, + elementId, + maskId: mask.id, + }) + } + > + <HugeiconsIcon icon={Delete02Icon} /> + </Button> + </div> + } + > + <div className="flex items-center gap-2"> + <HugeiconsIcon {...definition.icon} size={14} /> + <SectionTitle className="capitalize font-normal"> + {definition.name} + </SectionTitle> + </div> + </SectionHeader> + <SectionContent> + <MaskParamsFields + mask={mask} + definition={definition} + previewParam={previewParam} + onCommit={onCommit} + /> + </SectionContent> + </Section> + ); +} + +function MaskParamsFields({ + mask, + definition, + previewParam, + onCommit, +}: { + mask: Mask; + definition: RegisteredMaskDefinition; + previewParam: PreviewParamHandler; + onCommit: () => void; +}) { + const featherParam = getNumberParamDefinition({ + definition, + key: "feather", + }); + const strokeWidthParam = getNumberParamDefinition({ + definition, + key: "strokeWidth", + }); + const previewNumberParam = (key: string) => (value: number) => + previewParam(key)(value); + const previewStrokeColor = previewParam("strokeColor"); + const strokeAlignParam = definition.params.find( + (param): param is SelectParamDefinition => + param.key === "strokeAlign" && param.type === "select", + ); + + return ( + <SectionFields> + {definition.features.hasPosition && + "centerX" in mask.params && + "centerY" in mask.params && ( + <SectionField label="Position"> + <div className="flex items-center gap-2"> + <MaskNumberField + className="flex-1" + icon="X" + param={getNumberParamDefinition({ + definition, + key: "centerX", + })} + value={getMaskNumber({ + params: mask.params, + key: "centerX", + })} + onPreview={previewNumberParam("centerX")} + onCommit={onCommit} + /> + <MaskNumberField + className="flex-1" + icon="Y" + param={getNumberParamDefinition({ + definition, + key: "centerY", + })} + value={getMaskNumber({ + params: mask.params, + key: "centerY", + })} + onPreview={previewNumberParam("centerY")} + onCommit={onCommit} + /> + </div> + </SectionField> + )} + + {definition.features.sizeMode === "width-height" && + "width" in mask.params && + "height" in mask.params && ( + <SectionField label="Size"> + <div className="flex items-center gap-2"> + <MaskNumberField + className="flex-1" + icon="W" + param={getNumberParamDefinition({ + definition, + key: "width", + })} + value={getMaskNumber({ + params: mask.params, + key: "width", + })} + onPreview={previewNumberParam("width")} + onCommit={onCommit} + /> + <MaskNumberField + className="flex-1" + icon="H" + param={getNumberParamDefinition({ + definition, + key: "height", + })} + value={getMaskNumber({ + params: mask.params, + key: "height", + })} + onPreview={previewNumberParam("height")} + onCommit={onCommit} + /> + </div> + </SectionField> + )} + + {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( + <SectionField label="Scale"> + <MaskNumberField + icon="S" + param={getNumberParamDefinition({ + definition, + key: "scale", + })} + value={getMaskNumber({ + params: mask.params, + key: "scale", + })} + onPreview={previewNumberParam("scale")} + onCommit={onCommit} + /> + </SectionField> + )} + + {definition.features.hasRotation && "rotation" in mask.params && ( + <SectionField label="Rotation"> + <MaskNumberField + icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} + param={getNumberParamDefinition({ + definition, + key: "rotation", + })} + value={getMaskNumber({ + params: mask.params, + key: "rotation", + })} + onPreview={previewNumberParam("rotation")} + onCommit={onCommit} + /> + </SectionField> + )} + + <SectionField label="Feather"> + <MaskNumberField + icon={<HugeiconsIcon icon={FeatherIcon} />} + param={featherParam} + value={getMaskNumber({ + params: mask.params, + key: "feather", + })} + onPreview={previewNumberParam("feather")} + onCommit={onCommit} + /> + </SectionField> + + <SectionField label="Stroke"> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <MaskNumberField + className="flex-1" + icon="W" + param={strokeWidthParam} + value={getMaskNumber({ + params: mask.params, + key: "strokeWidth", + })} + onPreview={previewNumberParam("strokeWidth")} + onCommit={onCommit} + /> + <ColorPicker + className="" + value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()} + onChange={(color) => previewStrokeColor(`#${color}`)} + onChangeEnd={(color) => { + previewStrokeColor(`#${color}`); + onCommit(); + }} + /> + </div> + {strokeAlignParam ? ( + <Select + value={mask.params.strokeAlign} + onValueChange={(value) => { + previewParam("strokeAlign")(value); + onCommit(); + }} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {strokeAlignParam.options.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : null} + </div> + </SectionField> + </SectionFields> + ); +} + +function getNumberParamDefinition({ + definition, + key, +}: { + definition: RegisteredMaskDefinition; + key: string; +}): NumberParamDefinition { + const param = definition.params.find((candidate) => candidate.key === key); + + if (!param || param.type !== "number") { + throw new Error(`Missing number param definition for mask key "${key}"`); + } + + return param; +} + +function getMaskNumber({ + params, + key, +}: { + params: Mask["params"]; + key: string; +}): number { + const value = params[key]; + + if (typeof value !== "number") { + throw new Error(`Expected numeric mask param for "${key}"`); + } + + return value; +} + +function MaskNumberField({ + param, + value, + onPreview, + onCommit, + icon, + className, +}: { + param: NumberParamDefinition; + value: number; + onPreview: (value: number) => void; + onCommit: () => void; + icon?: React.ReactNode; + className?: string; +}) { + const isPercent = param.unit === "percent"; + const percentMax = param.max ?? 100; + const displayMultiplier = isPercent + ? 100 / percentMax + : (param.displayMultiplier ?? 1); + const min = isPercent ? 0 : param.min; + const max = isPercent ? 100 : param.max; + const step = isPercent ? 1 : param.step; + const displayValue = value * displayMultiplier; + const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step)); + + const clampDisplay = (nextDisplayValue: number) => + max !== undefined + ? clamp({ value: nextDisplayValue, min, max }) + : Math.max(min, nextDisplayValue); + + const previewFromDisplay = (nextDisplayValue: number) => { + onPreview( + clampDisplay(snapToStep({ value: nextDisplayValue, step })) / + displayMultiplier, + ); + }; + + const draft = usePropertyDraft({ + displayValue: formatNumberForDisplay({ + value: displayValue, + maxFractionDigits, + }), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier; + }, + onPreview, + onCommit, + }); + + return ( + <NumberField + className={className} + icon={icon} + value={draft.displayValue} + dragSensitivity="slow" + onFocus={draft.onFocus} + onChange={draft.onChange} + onBlur={draft.onBlur} + onScrub={previewFromDisplay} + onScrubEnd={onCommit} + /> + ); +} + +function EmptyView({ onAddMask }: EmptyViewProps) { + return ( + <div className="flex flex-col h-full items-center justify-center gap-4 text-center"> + <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} /> + <div className="flex flex-col gap-2"> + <h3 className="font-medium text-foreground">No masks</h3> + <p className="text-muted-foreground text-sm text-balance max-w-40"> + Add a mask to hide or reveal parts of this layer. + </p> + </div> + <Button variant="default" size="sm" onClick={onAddMask}> + Add mask + </Button> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx new file mode 100644 index 00000000..44f5dd74 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx @@ -0,0 +1,137 @@ +import { useRef } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { NumberField } from "@/components/ui/number-field"; +import { Switch } from "@/components/ui/switch"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons"; +import { buildConstantRetime } from "@/lib/retime"; +import { + DEFAULT_RETIME_RATE, + MIN_RETIME_RATE, + MAX_RETIME_RATE, + clampRetimeRate, + canMaintainPitch, +} from "@/constants/retime-constants"; +import type { AudioElement, VideoElement } from "@/lib/timeline"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { usePropertyDraft } from "../hooks/use-property-draft"; + +function rateToDisplay({ rate }: { rate: number }): string { + return rate.toFixed(2); +} + +function parseSpeedInput({ input }: { input: string }): number | null { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + const rounded = Math.round(parsed * 100) / 100; + return clampRetimeRate({ rate: rounded }); +} + +function buildRetime({ + rate, + maintainPitch, +}: { + rate: number; + maintainPitch: boolean; +}) { + if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined; + return buildConstantRetime({ rate, maintainPitch }); +} + +export function SpeedTab({ + element, + trackId, +}: { + element: AudioElement | VideoElement; + trackId: string; +}) { + const editor = useEditor(); + const rate = clampRetimeRate({ + rate: element.retime?.rate ?? DEFAULT_RETIME_RATE, + }); + const maintainPitchAvailable = canMaintainPitch({ rate }); + const maintainPitch = element.retime?.maintainPitch ?? false; + const pendingRateRef = useRef(rate); + + const applyRetime = ({ + rate: nextRate, + maintainPitch: nextMaintainPitch, + pushHistory = true, + }: { + rate: number; + maintainPitch: boolean; + pushHistory?: boolean; + }) => { + editor.timeline.updateElementRetime({ + trackId, + elementId: element.id, + retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }), + pushHistory, + }); + }; + + const speedDraft = usePropertyDraft({ + displayValue: rateToDisplay({ rate }), + parse: (input) => parseSpeedInput({ input }), + onPreview: (nextRate) => { + pendingRateRef.current = nextRate; + applyRetime({ rate: nextRate, maintainPitch, pushHistory: false }); + }, + onCommit: () => { + applyRetime({ rate: pendingRateRef.current, maintainPitch }); + }, + }); + + return ( + <Section collapsible sectionKey={`${element.id}:speed`}> + <SectionHeader> + <SectionTitle>Speed</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + <SectionField label="Speed"> + <NumberField + icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />} + value={speedDraft.displayValue} + suffix="x" + scrubRanges={[ + { from: 0.01, to: 1, pixelsPerUnit: 160 }, + { from: 1, to: 5, pixelsPerUnit: 48 }, + ]} + scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }} + onFocus={() => { + pendingRateRef.current = rate; + speedDraft.onFocus(); + }} + onChange={speedDraft.onChange} + onBlur={speedDraft.onBlur} + onScrub={speedDraft.scrubTo} + onScrubEnd={speedDraft.commitScrub} + onReset={() => + applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch }) + } + isDefault={rate === DEFAULT_RETIME_RATE} + /> + </SectionField> + <div className="flex items-center justify-between"> + <span className="text-sm">Change pitch</span> + <Switch + checked={!maintainPitch} + disabled={!maintainPitchAvailable} + onCheckedChange={(checked) => + applyRetime({ rate, maintainPitch: !checked }) + } + /> + </div> + </SectionFields> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx index ae6de420..f8e2527a 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -1,6 +1,8 @@ +"use client"; + import { Textarea } from "@/components/ui/textarea"; import { FontPicker } from "@/components/ui/font-picker"; -import type { TextElement } from "@/types/timeline"; +import type { TextElement } from "@/lib/timeline"; import { NumberField } from "@/components/ui/number-field"; import { useRef } from "react"; import { @@ -10,7 +12,7 @@ import { SectionFields, SectionHeader, SectionTitle, -} from "./section"; +} from "@/components/section"; import { ColorPicker } from "@/components/ui/color-picker"; import { Button } from "@/components/ui/button"; import { uppercase } from "@/utils/string"; @@ -20,31 +22,27 @@ import { DEFAULT_COLOR } from "@/constants/project-constants"; import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, - DEFAULT_LETTER_SPACING, - DEFAULT_LINE_HEIGHT, - DEFAULT_TEXT_BACKGROUND, - DEFAULT_TEXT_ELEMENT, MAX_FONT_SIZE, MIN_FONT_SIZE, } from "@/constants/text-constants"; -import { usePropertyDraft } from "./hooks/use-property-draft"; -import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property"; -import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "./hooks/use-element-playhead"; -import { TransformSection, BlendingSection } from "./sections"; -import { KeyframeToggle } from "./keyframe-toggle"; -import { isPropertyAtDefault } from "./sections/transform"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { KeyframeToggle } from "../components/keyframe-toggle"; +import { isPropertyAtDefault } from "./transform-tab"; import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation"; import { HugeiconsIcon } from "@hugeicons/react"; import { + MinusSignIcon, + PlusSignIcon, TextFontIcon, - ViewIcon, - ViewOffSlashIcon, } from "@hugeicons/core-free-icons"; import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons"; +import { DEFAULTS } from "@/lib/timeline/defaults"; import { cn } from "@/utils/ui"; -export function TextProperties({ +export function TextTab({ element, trackId, }: { @@ -52,10 +50,8 @@ export function TextProperties({ trackId: string; }) { return ( - <div className="flex h-full flex-col"> + <div className="flex flex-col"> <ContentSection element={element} trackId={trackId} /> - <TransformSection element={element} trackId={trackId} /> - <BlendingSection element={element} trackId={trackId} /> <TypographySection element={element} trackId={trackId} /> <SpacingSection element={element} trackId={trackId} /> <BackgroundSection element={element} trackId={trackId} /> @@ -85,7 +81,7 @@ function ContentSection({ }); return ( - <Section collapsible sectionKey="text:content" showTopBorder={false}> + <Section collapsible sectionKey={`${element.id}:content`}> <SectionHeader> <SectionTitle>Content</SectionTitle> </SectionHeader> @@ -138,7 +134,11 @@ function TypographySection({ parse: (input) => { const parsed = parseFloat(input); if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); + return clamp({ + value: Math.round(parsed), + min: MIN_FONT_SIZE, + max: MAX_FONT_SIZE, + }); }, onPreview: (value) => editor.timeline.previewElements({ @@ -150,7 +150,7 @@ function TypographySection({ }); return ( - <Section collapsible sectionKey="text:typography"> + <Section collapsible sectionKey={`${element.id}:typography`}> <SectionHeader> <SectionTitle>Typography</SectionTitle> </SectionHeader> @@ -188,12 +188,14 @@ function TypographySection({ { trackId, elementId: element.id, - updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize }, + updates: { + fontSize: DEFAULTS.text.element.fontSize, + }, }, ], }) } - isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + isDefault={element.fontSize === DEFAULTS.text.element.fontSize} icon={<HugeiconsIcon icon={TextFontIcon} />} /> </SectionField> @@ -233,7 +235,7 @@ function SpacingSection({ const letterSpacing = usePropertyDraft({ displayValue: Math.round( - element.letterSpacing ?? DEFAULT_LETTER_SPACING, + element.letterSpacing ?? DEFAULTS.text.letterSpacing, ).toString(), parse: (input) => { const parsed = parseFloat(input); @@ -249,7 +251,7 @@ function SpacingSection({ }); const lineHeight = usePropertyDraft({ - displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1), + displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1), parse: (input) => { const parsed = parseFloat(input); return Number.isNaN(parsed) @@ -266,7 +268,7 @@ function SpacingSection({ }); return ( - <Section collapsible sectionKey="text:spacing" showBottomBorder={false}> + <Section collapsible sectionKey={`${element.id}:spacing`}> <SectionHeader> <SectionTitle>Spacing</SectionTitle> </SectionHeader> @@ -286,14 +288,14 @@ function SpacingSection({ { trackId, elementId: element.id, - updates: { letterSpacing: DEFAULT_LETTER_SPACING }, + updates: { letterSpacing: DEFAULTS.text.letterSpacing }, }, ], }) } isDefault={ - (element.letterSpacing ?? DEFAULT_LETTER_SPACING) === - DEFAULT_LETTER_SPACING + (element.letterSpacing ?? DEFAULTS.text.letterSpacing) === + DEFAULTS.text.letterSpacing } icon={<OcTextWidthIcon size={14} />} /> @@ -312,14 +314,14 @@ function SpacingSection({ { trackId, elementId: element.id, - updates: { lineHeight: DEFAULT_LINE_HEIGHT }, + updates: { lineHeight: DEFAULTS.text.lineHeight }, }, ], }) } isDefault={ - (element.lineHeight ?? DEFAULT_LINE_HEIGHT) === - DEFAULT_LINE_HEIGHT + (element.lineHeight ?? DEFAULTS.text.lineHeight) === + DEFAULTS.text.lineHeight } icon={<OcTextHeightIcon size={14} />} /> @@ -366,25 +368,25 @@ function BackgroundSection({ const bg = element.background; const resolvedPaddingX = resolveNumberAtTime({ - baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, animations: element.animations, propertyPath: "background.paddingX", localTime, }); const resolvedPaddingY = resolveNumberAtTime({ - baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, animations: element.animations, propertyPath: "background.paddingY", localTime, }); const resolvedOffsetX = resolveNumberAtTime({ - baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, animations: element.animations, propertyPath: "background.offsetX", localTime, }); const resolvedOffsetY = resolveNumberAtTime({ - baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, + baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, animations: element.animations, propertyPath: "background.offsetY", localTime, @@ -409,6 +411,7 @@ function BackgroundSection({ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); }, valueAtPlayhead: resolvedPaddingX, + step: 1, buildBaseUpdates: ({ value }) => ({ background: { ...bg, paddingX: value }, }), @@ -427,6 +430,7 @@ function BackgroundSection({ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); }, valueAtPlayhead: resolvedPaddingY, + step: 1, buildBaseUpdates: ({ value }) => ({ background: { ...bg, paddingY: value }, }), @@ -445,6 +449,7 @@ function BackgroundSection({ return Number.isNaN(parsed) ? null : Math.round(parsed); }, valueAtPlayhead: resolvedOffsetX, + step: 1, buildBaseUpdates: ({ value }) => ({ background: { ...bg, offsetX: value }, }), @@ -463,6 +468,7 @@ function BackgroundSection({ return Number.isNaN(parsed) ? null : Math.round(parsed); }, valueAtPlayhead: resolvedOffsetY, + step: 1, buildBaseUpdates: ({ value }) => ({ background: { ...bg, offsetY: value }, }), @@ -479,9 +485,14 @@ function BackgroundSection({ parse: (input) => { const parsed = parseFloat(input); if (Number.isNaN(parsed)) return null; - return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }); + return clamp({ + value: Math.round(parsed), + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + }); }, valueAtPlayhead: resolvedCornerRadius, + step: 1, buildBaseUpdates: ({ value }) => ({ background: { ...bg, cornerRadius: value }, }), @@ -514,7 +525,7 @@ function BackgroundSection({ <Section collapsible defaultOpen={element.background.enabled} - sectionKey="text:background" + sectionKey={`${element.id}:background`} > <SectionHeader trailing={ @@ -527,7 +538,8 @@ function BackgroundSection({ }} > <HugeiconsIcon - icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon} + icon={element.background.enabled ? MinusSignIcon : PlusSignIcon} + strokeWidth={1} /> </Button> } @@ -590,13 +602,17 @@ function BackgroundSection({ onBlur={paddingX.onBlur} onScrub={paddingX.scrubTo} onScrubEnd={paddingX.commitScrub} - onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })} + onReset={() => + paddingX.commitValue({ + value: DEFAULTS.text.background.paddingX, + }) + } isDefault={isPropertyAtDefault({ hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue: resolvedPaddingX, - staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, - defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, + staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, + defaultValue: DEFAULTS.text.background.paddingX, })} /> </SectionField> @@ -621,13 +637,17 @@ function BackgroundSection({ onBlur={paddingY.onBlur} onScrub={paddingY.scrubTo} onScrubEnd={paddingY.commitScrub} - onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })} + onReset={() => + paddingY.commitValue({ + value: DEFAULTS.text.background.paddingY, + }) + } isDefault={isPropertyAtDefault({ hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue: resolvedPaddingY, - staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, - defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, + staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, + defaultValue: DEFAULTS.text.background.paddingY, })} /> </SectionField> @@ -653,13 +673,17 @@ function BackgroundSection({ onBlur={offsetX.onBlur} onScrub={offsetX.scrubTo} onScrubEnd={offsetX.commitScrub} - onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })} + onReset={() => + offsetX.commitValue({ + value: DEFAULTS.text.background.offsetX, + }) + } isDefault={isPropertyAtDefault({ hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue: resolvedOffsetX, - staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, - defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX, + staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, + defaultValue: DEFAULTS.text.background.offsetX, })} /> </SectionField> @@ -683,13 +707,17 @@ function BackgroundSection({ onBlur={offsetY.onBlur} onScrub={offsetY.scrubTo} onScrubEnd={offsetY.commitScrub} - onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })} + onReset={() => + offsetY.commitValue({ + value: DEFAULTS.text.background.offsetY, + }) + } isDefault={isPropertyAtDefault({ hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue: resolvedOffsetY, - staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, - defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY, + staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, + defaultValue: DEFAULTS.text.background.offsetY, })} /> </SectionField> @@ -715,7 +743,9 @@ function BackgroundSection({ onBlur={cornerRadius.onBlur} onScrub={cornerRadius.scrubTo} onScrubEnd={cornerRadius.commitScrub} - onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })} + onReset={() => + cornerRadius.commitValue({ value: CORNER_RADIUS_MIN }) + } isDefault={isPropertyAtDefault({ hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, isPlayheadWithinElementRange, diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx new file mode 100644 index 00000000..a015a9bb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx @@ -0,0 +1,424 @@ +import { NumberField } from "@/components/ui/number-field"; +import { useEditor } from "@/hooks/use-editor"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import type { VisualElement } from "@/lib/timeline"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowExpandIcon, + Link05Icon, + RotateClockwiseIcon, +} from "@hugeicons/core-free-icons"; +import { + getGroupKeyframesAtTime, + hasGroupKeyframeAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { KeyframeToggle } from "../components/keyframe-toggle"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property"; +import { usePropertiesStore } from "../stores/properties-store"; + +export function parseNumericInput({ input }: { input: string }): number | null { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : parsed; +} + +export function isPropertyAtDefault({ + hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue, + staticValue, + defaultValue, +}: { + hasAnimatedKeyframes: boolean; + isPlayheadWithinElementRange: boolean; + resolvedValue: number; + staticValue: number; + defaultValue: number; +}): boolean { + if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { + return isNearlyEqual({ + leftValue: resolvedValue, + rightValue: defaultValue, + }); + } + + return staticValue === defaultValue; +} + +export function TransformTab({ + element, + trackId, +}: { + element: VisualElement; + trackId: string; +}) { + const editor = useEditor(); + const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); + const setTransformScaleLocked = usePropertiesStore( + (s) => s.setTransformScaleLocked, + ); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const position = useKeyframedVectorProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.position", + localTime, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position, + displayX: Math.round(resolvedTransform.position.x).toString(), + displayY: Math.round(resolvedTransform.position.y).toString(), + parseComponent: (input) => parseNumericInput({ input }), + step: 1, + buildBaseUpdates: ({ value }) => ({ + transform: { ...element.transform, position: value }, + }), + }); + + const parseScale = (input: string) => { + const parsed = parseNumericInput({ input }); + if (parsed === null) return null; + return parsed / 100; + }; + + const scaleX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.scaleX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), + parse: parseScale, + valueAtPlayhead: resolvedTransform.scaleX, + step: 0.01, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + scaleX: value, + ...(isScaleLocked ? { scaleY: value } : {}), + }, + }), + }); + + const scaleY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.scaleY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), + parse: parseScale, + valueAtPlayhead: resolvedTransform.scaleY, + step: 0.01, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + scaleY: value, + ...(isScaleLocked ? { scaleX: value } : {}), + }, + }), + }); + + const scaleFieldPropsX = { + value: scaleX.displayValue, + onFocus: scaleX.onFocus, + onChange: scaleX.onChange, + onBlur: scaleX.onBlur, + dragSensitivity: "slow" as const, + onScrub: scaleX.scrubTo, + onScrubEnd: scaleX.commitScrub, + onReset: () => + scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), + isDefault: isPropertyAtDefault({ + hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.scaleX, + staticValue: element.transform.scaleX, + defaultValue: DEFAULTS.element.transform.scaleX, + }), + }; + + const scaleFieldPropsY = { + value: scaleY.displayValue, + onFocus: scaleY.onFocus, + onChange: scaleY.onChange, + onBlur: scaleY.onBlur, + dragSensitivity: "slow" as const, + onScrub: scaleY.scrubTo, + onScrubEnd: scaleY.commitScrub, + onReset: () => + scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), + isDefault: isPropertyAtDefault({ + hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.scaleY, + staticValue: element.transform.scaleY, + defaultValue: DEFAULTS.element.transform.scaleY, + }), + }; + + const rotation = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.rotate", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.rotate).toString(), + parse: (input) => { + const parsed = parseNumericInput({ input }); + if (parsed === null) return null; + return clamp({ value: parsed, min: -360, max: 360 }); + }, + valueAtPlayhead: resolvedTransform.rotate, + step: 1, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + rotate: value, + }, + }), + }); + + const hasScaleKeyframe = hasGroupKeyframeAtTime({ + animations: element.animations, + group: "transform.scale", + time: localTime, + }); + + const toggleScaleKeyframe = () => { + if (!isPlayheadWithinElementRange) return; + const existing = getGroupKeyframesAtTime({ + animations: element.animations, + group: "transform.scale", + time: localTime, + }); + if (existing.length > 0) { + editor.timeline.removeKeyframes({ + keyframes: existing.map((ref) => ({ + trackId, + elementId: element.id, + ...ref, + })), + }); + return; + } + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId: element.id, + propertyPath: "transform.scaleX", + time: localTime, + value: resolvedTransform.scaleX, + }, + { + trackId, + elementId: element.id, + propertyPath: "transform.scaleY", + time: localTime, + value: resolvedTransform.scaleY, + }, + ], + }); + }; + + const scaleLockButton = ( + <Button + type="button" + variant={isScaleLocked ? "secondary" : "ghost"} + size="icon" + aria-pressed={isScaleLocked} + onClick={() => setTransformScaleLocked(!isScaleLocked)} + > + <HugeiconsIcon icon={Link05Icon} /> + </Button> + ); + + return ( + <Section collapsible sectionKey={`${element.id}:transform`}> + <SectionHeader> + <SectionTitle>Transform</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + <div className="flex items-end gap-2"> + {isScaleLocked ? ( + <> + <SectionField + label="Scale" + className="min-w-0 flex-1" + beforeLabel={ + <KeyframeToggle + isActive={hasScaleKeyframe} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle scale keyframe" + onToggle={toggleScaleKeyframe} + /> + } + > + <NumberField + icon={<HugeiconsIcon icon={ArrowExpandIcon} />} + {...scaleFieldPropsX} + /> + </SectionField> + {scaleLockButton} + </> + ) : ( + <> + <SectionField + label="Width" + className="min-w-0 flex-1" + beforeLabel={ + <KeyframeToggle + isActive={scaleX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle width scale keyframe" + onToggle={scaleX.toggleKeyframe} + /> + } + > + <NumberField icon="W" {...scaleFieldPropsX} /> + </SectionField> + {scaleLockButton} + <SectionField + label="Height" + className="min-w-0 flex-1" + beforeLabel={ + <KeyframeToggle + isActive={scaleY.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle height scale keyframe" + onToggle={scaleY.toggleKeyframe} + /> + } + > + <NumberField icon="H" {...scaleFieldPropsY} /> + </SectionField> + </> + )} + </div> + <SectionField + label="Position" + beforeLabel={ + <KeyframeToggle + isActive={position.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle position keyframe" + onToggle={position.toggleKeyframe} + /> + } + > + <div className="flex items-center gap-2"> + <NumberField + icon="X" + className="flex-1" + value={position.x.displayValue} + onFocus={position.x.onFocus} + onChange={position.x.onChange} + onBlur={position.x.onBlur} + onScrub={position.x.scrubTo} + onScrubEnd={position.x.commitScrub} + onReset={() => + position.commitX({ + value: DEFAULTS.element.transform.position.x, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: position.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.x, + staticValue: element.transform.position.x, + defaultValue: DEFAULTS.element.transform.position.x, + })} + /> + <NumberField + icon="Y" + className="flex-1" + value={position.y.displayValue} + onFocus={position.y.onFocus} + onChange={position.y.onChange} + onBlur={position.y.onBlur} + onScrub={position.y.scrubTo} + onScrubEnd={position.y.commitScrub} + onReset={() => + position.commitY({ + value: DEFAULTS.element.transform.position.y, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: position.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.y, + staticValue: element.transform.position.y, + defaultValue: DEFAULTS.element.transform.position.y, + })} + /> + </div> + </SectionField> + + <SectionField + label="Rotation" + beforeLabel={ + <KeyframeToggle + isActive={rotation.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle rotation keyframe" + onToggle={rotation.toggleKeyframe} + /> + } + > + <div className="flex items-center gap-2"> + <NumberField + icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} + className="flex-none" + value={rotation.displayValue} + onFocus={rotation.onFocus} + onChange={rotation.onChange} + onBlur={rotation.onBlur} + dragSensitivity="slow" + onScrub={rotation.scrubTo} + onScrubEnd={rotation.commitScrub} + onReset={() => + rotation.commitValue({ + value: DEFAULTS.element.transform.rotate, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.rotate, + staticValue: element.transform.rotate, + defaultValue: DEFAULTS.element.transform.rotate, + })} + /> + </div> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx deleted file mode 100644 index 49dd8fe5..00000000 --- a/apps/web/src/components/editor/panels/properties/video-properties.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { - ImageElement, - StickerElement, - VideoElement, -} from "@/types/timeline"; -import { BlendingSection, TransformSection } from "./sections"; - -export function VideoProperties({ - element, - trackId, -}: { - element: VideoElement | ImageElement | StickerElement; - trackId: string; -}) { - return ( - <div className="flex h-full flex-col"> - <TransformSection - element={element} - trackId={trackId} - showTopBorder={false} - /> - <BlendingSection element={element} trackId={trackId} /> - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx index bbc5cb83..cbb19bd0 100644 --- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx +++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx @@ -1,176 +1,167 @@ -import { useEffect, useRef, useState } from "react"; -import WaveSurfer from "wavesurfer.js"; +"use client"; +import { useCallback, useEffect, useRef } from "react"; +import { useResizeObserver } from "@/hooks/use-resize-observer"; +import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio"; +import { findScrollParent } from "@/utils/browser"; +import { cn } from "@/utils/ui"; + +const BAR_WIDTH = 2; +const BAR_GAP = 1; +const BAR_STEP = BAR_WIDTH + BAR_GAP; interface AudioWaveformProps { audioUrl?: string; audioBuffer?: AudioBuffer; - height?: number; + color?: string; className?: string; } -function extractPeaks({ - buffer, - length = 512, -}: { - buffer: AudioBuffer; - length?: number; -}): number[][] { - const channels = buffer.numberOfChannels; - const peaks: number[][] = []; - - for (let c = 0; c < channels; c++) { - const data = buffer.getChannelData(c); - const step = Math.floor(data.length / length); - const channelPeaks: number[] = []; - - for (let i = 0; i < length; i++) { - const start = i * step; - const end = Math.min(start + step, data.length); - let max = 0; - for (let j = start; j < end; j++) { - const abs = Math.abs(data[j]); - if (abs > max) max = abs; - } - channelPeaks.push(max); - } - peaks.push(channelPeaks); - } - - return peaks; -} - export function AudioWaveform({ audioUrl, audioBuffer, - height = 32, + color = "rgba(255, 255, 255, 0.7)", className = "", }: AudioWaveformProps) { - const waveformRef = useRef<HTMLDivElement>(null); - const wavesurfer = useRef<WaveSurfer | null>(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(false); + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const bufferRef = useRef<AudioBuffer | null>(null); + const globalMaxRef = useRef<number>(1); + const scrollParentRef = useRef<HTMLElement | null>(null); + const heightRef = useRef<number>(0); - useEffect(() => { - let mounted = true; - const ws = wavesurfer.current; + const drawVisible = useCallback(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + const buffer = bufferRef.current; + const height = heightRef.current; - const initWaveSurfer = async () => { - if (!waveformRef.current || (!audioUrl && !audioBuffer)) return; + if (!container || !canvas || !buffer || height <= 0) return; - try { - if (ws) { - wavesurfer.current = null; - } + const elementWidth = container.offsetWidth; + if (elementWidth <= 0) return; - const newWaveSurfer = WaveSurfer.create({ - container: waveformRef.current, - waveColor: "rgba(255, 255, 255, 0.6)", - progressColor: "rgba(255, 255, 255, 0.9)", - cursorColor: "transparent", - barWidth: 2, - barGap: 1, - height, - normalize: true, - interact: false, - }); + const containerRect = container.getBoundingClientRect(); + const scrollParent = scrollParentRef.current; - if (mounted) { - wavesurfer.current = newWaveSurfer; - } else { - try { - newWaveSurfer.destroy(); - } catch {} - return; - } + let clipLeft: number; + let clipRight: number; - newWaveSurfer.on("ready", () => { - if (mounted) { - setIsLoading(false); - setError(false); - } - }); + if (scrollParent) { + const parentRect = scrollParent.getBoundingClientRect(); + clipLeft = Math.max(0, parentRect.left - containerRect.left); + clipRight = Math.min(elementWidth, parentRect.right - containerRect.left); + } else { + clipLeft = Math.max(0, -containerRect.left); + clipRight = Math.min( + elementWidth, + window.innerWidth - containerRect.left, + ); + } - newWaveSurfer.on("error", (err) => { - if (mounted) { - console.error("WaveSurfer error:", err); - setError(true); - setIsLoading(false); - } - }); + const visibleWidth = clipRight - clipLeft; + if (visibleWidth <= 0) return; - if (audioBuffer) { - const peaks = extractPeaks({ buffer: audioBuffer }); - newWaveSurfer.load("", peaks, audioBuffer.duration); - } else if (audioUrl) { - await newWaveSurfer.load(audioUrl); - } - } catch (err) { - if (mounted) { - console.error("Failed to initialize WaveSurfer:", err); - setError(true); - setIsLoading(false); - } - } - }; + const dpr = window.devicePixelRatio || 1; + const canvasW = Math.round(visibleWidth * dpr); + const canvasH = Math.round(height * dpr); + + if (canvasW <= 0 || canvasH <= 0) return; + + canvas.width = canvasW; + canvas.height = canvasH; + canvas.style.width = `${visibleWidth}px`; + canvas.style.height = `${height}px`; + canvas.style.left = `${clipLeft}px`; + + const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); + const startFraction = clipLeft / elementWidth; + const endFraction = clipRight / elementWidth; + const startSample = Math.floor(startFraction * buffer.length); + const endSample = Math.min( + buffer.length, + Math.ceil(endFraction * buffer.length), + ); - if (ws) { - const wsToDestroy = ws; - wavesurfer.current = null; + const peaks = extractRmsRange({ + buffer, + count: barCount, + startSample, + endSample, + globalMax: globalMaxRef.current, + }); - requestAnimationFrame(() => { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, canvasW, canvasH); + ctx.scale(dpr, dpr); + ctx.fillStyle = color; + + const maxBarHeight = height * 0.7; + + for (let i = 0; i < barCount; i++) { + const scaled = Math.log1p(peaks[i]) / Math.log1p(1); + const barH = Math.max(1, scaled * maxBarHeight); + ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH); + } + }, [color]); + + useEffect(() => { + let isCancelled = false; + + async function load() { + let buffer = audioBuffer ?? null; + + if (!buffer && audioUrl) { try { - wsToDestroy.destroy(); - } catch {} - if (mounted) { - initWaveSurfer(); + const resp = await fetch(audioUrl); + const arrayBuffer = await resp.arrayBuffer(); + const actx = new AudioContext(); + buffer = await actx.decodeAudioData(arrayBuffer); + actx.close(); + } catch { + return; } - }); - } else { - initWaveSurfer(); + } + + if (!buffer || isCancelled) return; + + bufferRef.current = buffer; + globalMaxRef.current = computeGlobalMaxRms({ buffer }); + drawVisible(); } + load(); return () => { - mounted = false; + isCancelled = true; + }; + }, [audioUrl, audioBuffer, drawVisible]); - const wsToDestroy = wavesurfer.current; + useEffect(() => { + const container = containerRef.current; + if (!container) return; - wavesurfer.current = null; + scrollParentRef.current = findScrollParent({ element: container }); + const scrollParent = scrollParentRef.current; + if (!scrollParent) return; - if (wsToDestroy) { - requestAnimationFrame(() => { - try { - wsToDestroy.destroy(); - } catch {} - }); - } - }; - }, [audioUrl, audioBuffer, height]); + scrollParent.addEventListener("scroll", drawVisible, { passive: true }); + return () => scrollParent.removeEventListener("scroll", drawVisible); + }, [drawVisible]); - if (error) { - return ( - <div - className={`flex items-center justify-center ${className}`} - style={{ height }} - > - <span className="text-foreground/60 text-xs">Audio unavailable</span> - </div> - ); - } + const onResize = useCallback( + (entry: ResizeObserverEntry) => { + heightRef.current = entry.contentRect.height; + drawVisible(); + }, + [drawVisible], + ); + + useResizeObserver({ ref: containerRef, onResize }); return ( - <div className={`relative ${className}`}> - {isLoading && ( - <div className="absolute inset-0 flex items-center justify-center"> - <span className="text-foreground/60 text-xs">Loading...</span> - </div> - )} - <div - ref={waveformRef} - className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`} - style={{ height }} - /> + <div ref={containerRef} className={cn("relative size-full", className)}> + <canvas ref={canvasRef} className="absolute bottom-0" /> </div> ); } - -export default AudioWaveform; diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index 864bb557..7000f98b 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -5,7 +5,10 @@ import type { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; -import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants"; +import { + DEFAULT_BOOKMARK_COLOR, + TIMELINE_BOOKMARK_ROW_HEIGHT, +} from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { getSnappedSeekTime } from "@/lib/time"; import { @@ -13,7 +16,7 @@ import { Delete02Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import type { Bookmark } from "@/types/timeline"; +import type { Bookmark } from "@/lib/timeline"; import { Popover, PopoverAnchor, @@ -25,7 +28,10 @@ import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; -import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline"; +import { + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/lib/timeline"; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; @@ -72,14 +78,17 @@ export function TimelineBookmarksRow({ handleRulerTrackingMouseDown, handleRulerMouseDown, }: TimelineBookmarksRowProps) { - const editor = useEditor(); - const activeScene = editor.scenes.getActiveScene(); + const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks); return ( - <div className="relative h-4 flex-1 overflow-hidden"> + <div + className="relative flex-1 overflow-hidden" + style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }} + > <button - className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0" + className="relative w-full cursor-default select-none border-0 bg-transparent p-0" style={{ + height: TIMELINE_BOOKMARK_ROW_HEIGHT, width: `${dynamicTimelineWidth}px`, }} aria-label="Timeline ruler" @@ -95,7 +104,7 @@ export function TimelineBookmarksRow({ handleRulerTrackingMouseDown(event); }} > - {activeScene.bookmarks.map((bookmark) => ( + {bookmarks.map((bookmark) => ( <TimelineBookmark key={`bookmark-${bookmark.time}`} bookmark={bookmark} @@ -174,7 +183,7 @@ function TimelineBookmark({ <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> <PopoverAnchor asChild> <button - className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`} + className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0" style={{ left: `${bookmarkLeft}px`, width: `${bookmarkWidth}px`, diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index 291a4633..7597a620 100644 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -1,5 +1,6 @@ import { getDropLineY } from "@/lib/timeline/drop-utils"; -import type { TimelineTrack, DropTarget } from "@/types/timeline"; +import type { TimelineTrack, DropTarget } from "@/lib/timeline"; +import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; interface DragLineProps { dropTarget: DropTarget | null; @@ -22,7 +23,7 @@ export function DragLine({ return ( <div className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5" - style={{ top: `${lineTop}px` }} + style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }} /> ); } diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 593976a8..f1ecd9f4 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -3,31 +3,44 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Delete02Icon, + MagicWand05Icon, + MusicNote03Icon, TaskAdd02Icon, + TextIcon, ViewIcon, ViewOffSlashIcon, VolumeHighIcon, VolumeOffIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; +import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, -} from "../../../ui/context-menu"; +} from "@/components/ui/context-menu"; import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; -import { useState, useRef, useCallback } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { ElementDragState, DropTarget } from "@/lib/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; -import { SelectionBox } from "../../selection-box"; -import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; +import { SelectionBox } from "@/lib/selection/selection-box"; +import { useBoxSelect } from "@/lib/selection/hooks/use-box-select"; import { SnapIndicator } from "./snap-indicator"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { TIMELINE_CONSTANTS, - TRACK_CONFIG, + TRACK_GAP, + TIMELINE_SCROLLBAR_SIZE_PX, } from "@/constants/timeline-constants"; import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; import { @@ -36,12 +49,11 @@ import { getTotalTracksHeight, canTracktHaveAudio, canTrackBeHidden, + isMainTrack, getTimelineZoomMin, getTimelinePaddingPx, - isMainTrack, } from "@/lib/timeline"; import { TimelineToolbar } from "./timeline-toolbar"; -import { useScrollSync } from "@/hooks/timeline/use-scroll-sync"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; @@ -49,27 +61,58 @@ import { TimelineRuler } from "./timeline-ruler"; import { TimelineBookmarksRow } from "./bookmarks"; import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; +import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom"; import { useTimelineStore } from "@/stores/timeline-store"; import { useEditor } from "@/hooks/use-editor"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/lib/actions"; +import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing"; +import { cn } from "@/utils/ui"; const TRACKS_CONTAINER_MAX_HEIGHT = 800; const FALLBACK_CONTAINER_WIDTH = 1000; +const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; +const TRACK_ICONS: Record<TimelineTrack["type"], ReactNode> = { + video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />, + text: ( + <HugeiconsIcon + icon={TextIcon} + className="text-muted-foreground size-4 shrink-0" + /> + ), + audio: ( + <HugeiconsIcon + icon={MusicNote03Icon} + className="text-muted-foreground size-4 shrink-0" + /> + ), + graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />, + effect: ( + <HugeiconsIcon + icon={MagicWand05Icon} + className="text-muted-foreground size-4 shrink-0" + /> + ), +}; export function Timeline() { - const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); - const { clearElementSelection, setElementSelection } = useElementSelection(); + const { + selectedElements, + clearElementSelection, + setElementSelection, + mergeElementsIntoSelection, + } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; - const tracks = timeline.getTracks(); + const tracks = useEditor((editor) => editor.timeline.getTracks()); const seek = (time: number) => editor.playback.seek({ time }); const timelineRef = useRef<HTMLDivElement>(null); const timelineHeaderRef = useRef<HTMLDivElement>(null); const rulerRef = useRef<HTMLDivElement>(null); + const rulerScrollRef = useRef<HTMLDivElement>(null); const tracksContainerRef = useRef<HTMLDivElement>(null); const tracksScrollRef = useRef<HTMLDivElement>(null); const trackLabelsRef = useRef<HTMLDivElement>(null); @@ -110,8 +153,109 @@ export function Timeline() { initialScrollLeft: savedViewState?.scrollLeft, initialPlayheadTime: savedViewState?.playheadTime, tracksScrollRef, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, + }); + + // Stable refs so the wheel listener never goes stale + const setZoomLevelRef = useRef(setZoomLevel); + useEffect(() => { + setZoomLevelRef.current = setZoomLevel; + }, [setZoomLevel]); + + const saveScrollPositionRef = useRef(saveScrollPosition); + useEffect(() => { + saveScrollPositionRef.current = saveScrollPosition; + }, [saveScrollPosition]); + + const minZoomLevelRef = useRef(minZoomLevel); + useEffect(() => { + minZoomLevelRef.current = minZoomLevel; + }, [minZoomLevel]); + + // Pushes tracks scroll position to the two overflow:hidden followers + // (ruler and track labels). Called from the wheel handler (before paint, + // zero lag) and from onScroll on the tracks area (covers scrollbar drag). + const syncFollowers = useCallback(() => { + const tracks = tracksScrollRef.current; + if (!tracks) return; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = tracks.scrollLeft; + } + if (trackLabelsScrollRef.current) { + trackLabelsScrollRef.current.scrollTop = tracks.scrollTop; + } + }, []); + + // Single non-passive capture listener owns all wheel input. Prevents any + // native scroll or browser zoom from firing inside the timeline. + useEffect(() => { + const container = timelineRef.current; + if (!container) return; + + let pendingZoomDelta = 0; + let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null; + + const onWheel = (e: WheelEvent) => { + const isZoom = e.ctrlKey || e.metaKey; + + if (isZoom) { + e.preventDefault(); + const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + pendingZoomDelta += normalizedDelta; + + if (zoomRafId === null) { + zoomRafId = requestAnimationFrame(() => { + const frameRawDelta = pendingZoomDelta; + const cappedDelta = + Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + setZoomLevelRef.current((prev) => prev * zoomFactor); + pendingZoomDelta = 0; + zoomRafId = null; + }); + } + return; + } + + const tracks = tracksScrollRef.current; + if (!tracks) return; + + const isHorizontal = + e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY); + + e.preventDefault(); + + if (isHorizontal) { + const raw = + Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + const clamped = + Math.sign(raw) * + Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX); + tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped); + } else { + tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY); + } + + syncFollowers(); + saveScrollPositionRef.current(); + }; + + container.addEventListener("wheel", onWheel, { + passive: false, + capture: true, }); + return () => { + container.removeEventListener("wheel", onWheel, { capture: true }); + if (zoomRafId !== null) cancelAnimationFrame(zoomRafId); + }; + }, [syncFollowers]); + + useInitialScrollBottom({ + tracksScrollRef, + trackLabelsScrollRef, + onAfterScroll: () => saveScrollPositionRef.current(), + isReady: tracks.length > 0, + }); const { dragState, @@ -124,7 +268,6 @@ export function Timeline() { timelineRef, tracksContainerRef, tracksScrollRef, - headerRef: timelineHeaderRef, snappingEnabled, onSnapPointChange: handleSnapPointChange, }); @@ -144,14 +287,13 @@ export function Timeline() { useTimelinePlayhead({ zoomLevel, rulerRef, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, tracksScrollRef, playheadRef, }); const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ containerRef: tracksContainerRef, - headerRef: timelineHeaderRef, tracksScrollRef, zoomLevel, }); @@ -161,17 +303,37 @@ export function Timeline() { handleMouseDown: handleSelectionMouseDown, isSelecting, shouldIgnoreClick, - } = useSelectionBox({ + } = useBoxSelect({ containerRef: tracksContainerRef, - headerRef: timelineHeaderRef, - onSelectionComplete: (elements) => { - setElementSelection({ elements }); + selectedIds: selectedElements, + anchorId: null, + getIsAdditiveSelection: (event) => + event.shiftKey || event.ctrlKey || event.metaKey, + resolveIntersections: ({ startPos, currentPos }) => { + if (!tracksContainerRef.current) { + return []; + } + + return resolveTimelineElementIntersections({ + container: tracksContainerRef.current, + scrollContainer: tracksScrollRef.current, + tracks, + zoomLevel, + startPos, + currentPos, + }); + }, + onSelectionChange: ({ intersectedIds, isAdditive }) => { + if (isAdditive) { + mergeElementsIntoSelection({ elements: intersectedIds }); + } else { + setElementSelection({ elements: intersectedIds }); + } }, - tracksScrollRef, - zoomLevel, }); - const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; + const containerWidth = + tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const paddingPx = getTimelinePaddingPx({ @@ -183,11 +345,16 @@ export function Timeline() { contentWidth + paddingPx, containerWidth, ); + const tracksViewportWidth = + tracksScrollRef.current?.clientWidth ?? + tracksContainerRef.current?.clientWidth ?? + containerWidth; + const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; useEdgeAutoScroll({ isActive: bookmarkDragState.isDragging, getMouseClientX: () => bookmarkLastMouseXRef.current, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, tracksScrollRef, contentWidth: dynamicTimelineWidth, }); @@ -205,7 +372,7 @@ export function Timeline() { } = useTimelineSeek({ playheadRef, trackLabelsRef, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, tracksScrollRef, zoomLevel, duration: timeline.getTotalDuration(), @@ -214,13 +381,9 @@ export function Timeline() { seek, }); - useScrollSync({ - tracksScrollRef, - trackLabelsScrollRef, - }); - const timelineHeaderHeight = - timelineHeaderRef.current?.getBoundingClientRect().height ?? 0; + (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + + TIMELINE_CONSTANTS.PADDING_TOP_PX || 0; return ( <section @@ -236,315 +399,409 @@ export function Timeline() { setZoomLevel={({ zoom }) => setZoomLevel(zoom)} /> - <div - className="relative flex flex-1 flex-col overflow-hidden" - ref={timelineRef} - > - <SnapIndicator - snapPoint={currentSnapPoint} - zoomLevel={zoomLevel} - tracks={tracks} - timelineRef={timelineRef} + <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}> + <TrackLabelsPanel trackLabelsRef={trackLabelsRef} - tracksScrollRef={tracksScrollRef} - isVisible={showSnapIndicator} + trackLabelsScrollRef={trackLabelsScrollRef} + timelineHeaderHeight={timelineHeaderHeight} + hasHorizontalScrollbar={hasHorizontalScrollbar} /> - <div className="flex flex-1 overflow-hidden"> - <div className="bg-background flex w-28 shrink-0 flex-col border-r"> - <div className="bg-background flex h-4 items-center justify-between px-3"> - <span className="opacity-0">.</span> - </div> - <div className="bg-background flex h-4 items-center justify-between px-3"> - <span className="opacity-0">.</span> + + <div + className="relative isolate flex flex-1 flex-col overflow-hidden" + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <DragLine + dropTarget={dropTarget} + tracks={timeline.getTracks()} + isVisible={isDragOver && !dropTarget?.targetElement} + headerHeight={timelineHeaderHeight} + /> + <DragLine + dropTarget={dragDropTarget} + tracks={timeline.getTracks()} + isVisible={dragState.isDragging} + headerHeight={timelineHeaderHeight} + /> + + <div ref={rulerScrollRef} className="shrink-0 overflow-hidden"> + <div + ref={timelineHeaderRef} + className="flex flex-col" + style={{ width: `${dynamicTimelineWidth}px` }} + > + <TimelineRuler + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + rulerRef={rulerRef} + tracksScrollRef={rulerScrollRef} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + <TimelineBookmarksRow + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + dragState={bookmarkDragState} + onBookmarkMouseDown={handleBookmarkMouseDown} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> </div> - {tracks.length > 0 && ( - <div - ref={trackLabelsRef} - className="bg-background flex-1 overflow-y-auto" - style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }} - > - <ScrollArea className="size-full" ref={trackLabelsScrollRef}> - <div className="flex flex-col gap-1"> - {tracks.map((track) => ( - <div - key={track.id} - className="group flex items-center px-3" - style={{ - height: `${getTrackHeight({ type: track.type })}px`, - }} - > - <div className="flex min-w-0 flex-1 items-center justify-end gap-2"> - {process.env.NODE_ENV === "development" && - isMainTrack(track) && ( - <div className="bg-red-500 size-1.5 rounded-full" /> - )} - {canTracktHaveAudio(track) && ( - <TrackToggleIcon - isOff={track.muted} - icons={{ - on: VolumeHighIcon, - off: VolumeOffIcon, - }} - onClick={() => - editor.timeline.toggleTrackMute({ - trackId: track.id, - }) - } - /> - )} - {canTrackBeHidden(track) && ( - <TrackToggleIcon - isOff={track.hidden} - icons={{ - on: ViewIcon, - off: ViewOffSlashIcon, - }} - onClick={() => - editor.timeline.toggleTrackVisibility({ - trackId: track.id, - }) - } - /> - )} - <TrackIcon track={track} /> - </div> - </div> - ))} - </div> - </ScrollArea> - </div> - )} </div> - <div - className="relative flex flex-1 flex-col overflow-hidden" - ref={tracksContainerRef} + <ScrollArea + className="flex-1" + ref={tracksScrollRef} + onScroll={() => { + syncFollowers(); + saveScrollPosition(); + }} > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - <DragLine - dropTarget={dropTarget} - tracks={timeline.getTracks()} - isVisible={isDragOver && !dropTarget?.targetElement} - headerHeight={timelineHeaderHeight} - /> - <DragLine - dropTarget={dragDropTarget} - tracks={timeline.getTracks()} - isVisible={dragState.isDragging} - headerHeight={timelineHeaderHeight} - /> - <ScrollArea - className="size-full overflow-y-hidden" - ref={tracksScrollRef} - onMouseDown={(event) => { - const isDirectTarget = event.target === event.currentTarget; - if (!isDirectTarget) return; - event.stopPropagation(); - handleTracksMouseDown(event); - handleSelectionMouseDown(event); - }} - onClick={(event) => { - const isDirectTarget = event.target === event.currentTarget; - if (!isDirectTarget) return; - event.stopPropagation(); - handleTracksClick(event); - }} - onWheel={(event) => { - if ( - event.shiftKey || - Math.abs(event.deltaX) > Math.abs(event.deltaY) - ) { - return; - } - handleWheel(event); - }} - onScroll={() => { - saveScrollPosition(); - }} + <div + className="flex min-h-full flex-col" + style={{ width: `${dynamicTimelineWidth}px` }} > + {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} <div - className="relative" + className="relative shrink-0" style={{ - width: `${dynamicTimelineWidth}px`, + height: `${ + Math.max( + TRACKS_CONTAINER_HEIGHT.min, + Math.min( + TRACKS_CONTAINER_HEIGHT.max, + getTotalTracksHeight({ tracks }), + ), + ) + TIMELINE_CONSTANTS.PADDING_TOP_PX + }px`, + }} + onMouseDown={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksClick(event); }} > - <div - ref={timelineHeaderRef} - className="bg-background sticky top-0 flex flex-col" - > - <TimelineRuler + {tracks.length > 0 && ( + <TimelineTrackRows + dragElementId={dragState.elementId} zoomLevel={zoomLevel} - dynamicTimelineWidth={dynamicTimelineWidth} - rulerRef={rulerRef} + dragState={dragState} tracksScrollRef={tracksScrollRef} - handleWheel={handleWheel} - handleTimelineContentClick={handleRulerClick} - handleRulerTrackingMouseDown={handleRulerMouseDown} - handleRulerMouseDown={handlePlayheadRulerMouseDown} - /> - <TimelineBookmarksRow - zoomLevel={zoomLevel} - dynamicTimelineWidth={dynamicTimelineWidth} - dragState={bookmarkDragState} - onBookmarkMouseDown={handleBookmarkMouseDown} - handleWheel={handleWheel} - handleTimelineContentClick={handleRulerClick} - handleRulerTrackingMouseDown={handleRulerMouseDown} - handleRulerMouseDown={handlePlayheadRulerMouseDown} + lastMouseXRef={lastMouseXRef} + onSnapPointChange={handleSnapPointChange} + onResizeStateChange={handleResizeStateChange} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + onTrackMouseDown={(event) => { + handleSelectionMouseDown(event); + handleTracksMouseDown(event); + }} + onTrackMouseUp={handleTracksClick} + shouldIgnoreClick={shouldIgnoreClick} + isDragOver={isDragOver} + dropTarget={dropTarget} /> - </div> - <TimelinePlayhead - zoomLevel={zoomLevel} - rulerRef={rulerRef} - rulerScrollRef={tracksScrollRef} - tracksScrollRef={tracksScrollRef} - timelineRef={timelineRef} - playheadRef={playheadRef} - isSnappingToPlayhead={ - showSnapIndicator && currentSnapPoint?.type === "playhead" - } - /> + )} + </div> + <TimelineGutter + onMouseDown={(event) => { + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={handleTracksClick} + /> + </div> + </ScrollArea> + + <TimelinePlayhead + zoomLevel={zoomLevel} + hasHorizontalScrollbar={hasHorizontalScrollbar} + rulerRef={rulerRef} + rulerScrollRef={rulerScrollRef} + tracksScrollRef={tracksScrollRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + </div> + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + timelineRef={timelineRef} + tracksScrollRef={tracksScrollRef} + isVisible={showSnapIndicator} + tracks={tracks} + /> + </div> + </section> + ); +} + +function TrackLabelsPanel({ + trackLabelsRef, + trackLabelsScrollRef, + timelineHeaderHeight, + hasHorizontalScrollbar, +}: { + trackLabelsRef: React.RefObject<HTMLDivElement | null>; + trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>; + timelineHeaderHeight: number; + hasHorizontalScrollbar: boolean; +}) { + const editor = useEditor(); + const tracks = useEditor((e) => e.timeline.getTracks()); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + return ( + <div className="flex w-28 shrink-0 flex-col border-r"> + <div + className="shrink-0" + style={{ height: timelineHeaderHeight || 48 }} + /> + <div ref={trackLabelsRef} className="flex-1 overflow-hidden"> + <div ref={trackLabelsScrollRef} className="size-full overflow-hidden"> + {tracks.length > 0 && ( + <div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}> + {tracks.map((track) => ( <div - className="relative" + key={track.id} + className={cn( + "group flex items-center px-3", + tracksWithSelection.has(track.id) && + TIMELINE_CONSTANTS.TRACK_SELECTED_BG, + )} style={{ - height: `${Math.max( - tracksContainerHeight.min, - Math.min( - tracksContainerHeight.max, - getTotalTracksHeight({ tracks }), - ), - )}px`, + height: `${getTrackHeight({ type: track.type })}px`, }} > - {tracks.length === 0 ? ( - <div /> - ) : ( - [...tracks] - .map((track, index) => ({ track, index })) - .sort((a, b) => { - const aHasDragged = a.track.elements.some( - (element) => element.id === dragState.elementId, - ); - const bHasDragged = b.track.elements.some( - (element) => element.id === dragState.elementId, - ); - if (aHasDragged) return 1; - if (bHasDragged) return -1; - return 0; - }) - .map(({ track, index }) => ( - <ContextMenu key={track.id}> - <ContextMenuTrigger asChild> - <div - className="absolute right-0 left-0" - style={{ - top: `${getCumulativeHeightBefore({ - tracks, - trackIndex: index, - })}px`, - height: `${getTrackHeight({ - type: track.type, - })}px`, - }} - > - <TimelineTrackContent - track={track} - zoomLevel={zoomLevel} - dragState={dragState} - rulerScrollRef={tracksScrollRef} - tracksScrollRef={tracksScrollRef} - lastMouseXRef={lastMouseXRef} - onSnapPointChange={handleSnapPointChange} - onResizeStateChange={handleResizeStateChange} - onElementMouseDown={handleElementMouseDown} - onElementClick={handleElementClick} - onTrackMouseDown={(event) => { - handleSelectionMouseDown(event); - handleTracksMouseDown(event); - }} - onTrackClick={handleTracksClick} - shouldIgnoreClick={shouldIgnoreClick} - targetElementId={ - isDragOver - ? dropTarget?.targetElement?.elementId ?? null - : null - } - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent className="w-40"> - <ContextMenuItem - icon={<HugeiconsIcon icon={TaskAdd02Icon} />} - onClick={(event) => { - event.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - </ContextMenuItem> - <ContextMenuItem - onClick={(event) => { - event.stopPropagation(); - timeline.toggleTrackMute({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={VolumeHighIcon} /> - <span> - {canTracktHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(event) => { - event.stopPropagation(); - timeline.toggleTrackVisibility({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={ViewIcon} /> - <span> - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(event) => { - event.stopPropagation(); - timeline.removeTrack({ - trackId: track.id, - }); - }} - variant="destructive" - > - <HugeiconsIcon icon={Delete02Icon} /> - Delete track - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - )) - )} + <div className="flex min-w-0 flex-1 items-center justify-end gap-2"> + {canTracktHaveAudio(track) && ( + <TrackToggleIcon + isOff={track.muted} + icons={{ on: VolumeHighIcon, off: VolumeOffIcon }} + onClick={() => + editor.timeline.toggleTrackMute({ trackId: track.id }) + } + /> + )} + {canTrackBeHidden(track) && ( + <TrackToggleIcon + isOff={track.hidden} + icons={{ on: ViewIcon, off: ViewOffSlashIcon }} + onClick={() => + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + <TrackIcon track={track} /> + </div> </div> - </div> - </ScrollArea> - </div> + ))} + </div> + )} </div> </div> - </section> + <div + className="bg-background shrink-0" + style={{ + height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0, + }} + /> + </div> + ); +} + +function TimelineTrackRows({ + dragElementId, + zoomLevel, + dragState, + tracksScrollRef, + lastMouseXRef, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + onTrackMouseUp, + shouldIgnoreClick, + isDragOver, + dropTarget, +}: { + dragElementId: string | null; + zoomLevel: number; + dragState: ElementDragState; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + lastMouseXRef: React.RefObject<number>; + onSnapPointChange: (snapPoint: SnapPoint | null) => void; + onResizeStateChange: (params: { isResizing: boolean }) => void; + onElementMouseDown: React.ComponentProps< + typeof TimelineTrackContent + >["onElementMouseDown"]; + onElementClick: React.ComponentProps< + typeof TimelineTrackContent + >["onElementClick"]; + onTrackMouseDown: (event: React.MouseEvent) => void; + onTrackMouseUp: (event: React.MouseEvent) => void; + shouldIgnoreClick: () => boolean; + isDragOver: boolean; + dropTarget: DropTarget | null; +}) { + const timeline = useEditor((e) => e.timeline); + const tracks = useEditor((e) => e.timeline.getTracks()); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + const sortedTracks = useMemo( + () => + [...tracks] + .map((track, index) => ({ track, index })) + .sort((a, b) => { + const aHasDragged = a.track.elements.some( + (el) => el.id === dragElementId, + ); + const bHasDragged = b.track.elements.some( + (el) => el.id === dragElementId, + ); + if (aHasDragged) return 1; + if (bHasDragged) return -1; + return 0; + }), + [tracks, dragElementId], ); + + return ( + <> + {sortedTracks.map(({ track, index }) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className={cn( + "absolute right-0 left-0 transition-colors", + tracksWithSelection.has(track.id) && + TIMELINE_CONSTANTS.TRACK_SELECTED_BG, + )} + style={{ + top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`, + height: `${getTrackHeight({ type: track.type })}px`, + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + dragState={dragState} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + lastMouseXRef={lastMouseXRef} + onSnapPointChange={onSnapPointChange} + onResizeStateChange={onResizeStateChange} + onElementMouseDown={onElementMouseDown} + onElementClick={onElementClick} + onTrackMouseDown={onTrackMouseDown} + onTrackMouseUp={onTrackMouseUp} + shouldIgnoreClick={shouldIgnoreClick} + targetElementId={ + isDragOver + ? (dropTarget?.targetElement?.elementId ?? null) + : null + } + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent className="w-40"> + <ContextMenuItem + icon={<HugeiconsIcon icon={TaskAdd02Icon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={VolumeHighIcon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackMute({ trackId: track.id }); + }} + > + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={ViewIcon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackVisibility({ trackId: track.id }); + }} + > + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + </ContextMenuItem> + {!isMainTrack(track) && ( + <ContextMenuItem + icon={<HugeiconsIcon icon={Delete02Icon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.removeTrack({ trackId: track.id }); + }} + variant="destructive" + > + Delete track + </ContextMenuItem> + )} + </ContextMenuContent> + </ContextMenu> + ))} + </> + ); +} + +function TimelineGutter({ + onMouseDown, + onClick, +}: { + onMouseDown: (event: React.MouseEvent) => void; + onClick: (event: React.MouseEvent) => void; +}) { + // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system + // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system + return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />; } function TrackIcon({ track }: { track: TimelineTrack }) { - return <>{TRACK_CONFIG[track.type].icon}</>; + return <>{TRACK_ICONS[track.type]}</>; } function TrackToggleIcon({ diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 927d336a..acb57203 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -6,15 +6,12 @@ import { getCenteredLineLeft, TIMELINE_INDICATOR_LINE_WIDTH_PX, } from "@/lib/timeline"; -import type { TimelineTrack } from "@/types/timeline"; - +import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; interface SnapIndicatorProps { snapPoint: SnapPoint | null; zoomLevel: number; isVisible: boolean; - tracks: TimelineTrack[]; timelineRef: React.RefObject<HTMLDivElement | null>; - trackLabelsRef?: React.RefObject<HTMLDivElement | null>; tracksScrollRef: React.RefObject<HTMLDivElement | null>; } @@ -22,17 +19,13 @@ export function SnapIndicator({ snapPoint, zoomLevel, isVisible, - tracks, timelineRef, - trackLabelsRef, tracksScrollRef, }: SnapIndicatorProps) { const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ snapPoint, zoomLevel, - tracks, timelineRef, - trackLabelsRef, tracksScrollRef, }); @@ -48,6 +41,7 @@ export function SnapIndicator({ top: topPosition, height: `${height}px`, width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, + zIndex: TIMELINE_LAYERS.snapIndicator, }} > <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 9121f6c2..13a31375 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -2,7 +2,7 @@ import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; -import AudioWaveform from "./audio-waveform"; +import { AudioWaveform } from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; import { useKeyframeDrag, @@ -12,14 +12,16 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { getElementKeyframes } from "@/lib/animation"; import { - getTrackClasses, + getElementClasses, getTrackHeight, canElementHaveAudio, canElementBeHidden, + hasElementEffects, hasMediaId, timelineTimeToPixels, timelineTimeToSnappedPixels, } from "@/lib/timeline"; +import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants"; import { ContextMenu, ContextMenuContent, @@ -30,14 +32,22 @@ import { import type { TimelineElement as TimelineElementType, TimelineTrack, - VisualElement, ElementDragState, -} from "@/types/timeline"; -import type { MediaAsset } from "@/types/assets"; + VideoElement, + ImageElement, + AudioElement, +} from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; -import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; +import { + getActionDefinition, + type TAction, + type TActionWithOptionalArgs, + invokeAction, +} from "@/lib/actions"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { resolveStickerId } from "@/lib/stickers"; +import { buildGraphicPreviewUrl } from "@/lib/graphics"; import Image from "next/image"; import { ScissorIcon, @@ -56,13 +66,16 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; import type { ComponentProps, ReactNode } from "react"; -import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; +import type { + SelectedKeyframeRef, + ElementKeyframe, +} from "@/lib/animation/types"; import { cn } from "@/utils/ui"; -import { Button } from "@/components/ui/button"; -import { usePropertiesStore } from "@/stores/properties-store"; +import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; const ELEMENT_RING_WIDTH_PX = 1.5; +const THUMBNAIL_ASPECT_RATIO = 16 / 9; interface KeyframeIndicator { time: number; @@ -195,16 +208,15 @@ export function TimelineElement({ dragState, isDropTarget = false, }: TimelineElementProps) { - const editor = useEditor(); + const mediaAssets = useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); - const { requestRevealMedia } = useAssetsPanelStore(); + const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); let mediaAsset: MediaAsset | null = null; if (hasMediaId(element)) { mediaAsset = - editor.media.getAssets().find((asset) => asset.id === element.mediaId) ?? - null; + mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); @@ -394,60 +406,46 @@ function ElementInner({ }) => void; isDropTarget?: boolean; }) { - const opacityClass = - (canElementBeHidden(element) && element.hidden) || isDropTarget - ? "opacity-50" - : ""; - const closeClipEffects = usePropertiesStore( - (state) => state.closeClipEffects, - ); - + const isReducedOpacity = + (canElementBeHidden(element) && element.hidden) || isDropTarget; return ( <div - className="relative h-full cursor-pointer" - style={{ marginInline: ELEMENT_RING_WIDTH_PX }} + className="absolute top-0 bottom-0" + style={{ + left: `${ELEMENT_RING_WIDTH_PX}px`, + right: `${ELEMENT_RING_WIDTH_PX}px`, + }} > <div - className={cn( - "absolute inset-0 overflow-hidden rounded-sm", - getTrackClasses({ type: track.type }), - opacityClass, - )} + className="absolute inset-0 rounded-sm" style={ isSelected ? { - boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`, + boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`, } : undefined } > - <button - type="button" - className="absolute inset-0 size-full cursor-pointer flex flex-col" - onClick={(event) => { - closeClipEffects(); - onElementClick(event, element); - }} - onMouseDown={(event) => onElementMouseDown(event, element)} + <div + className={cn( + "absolute inset-0 overflow-hidden rounded-sm", + getElementClasses({ type: track.type }), + isReducedOpacity && "opacity-50", + )} > - <div className="flex flex-1 min-h-0 items-center overflow-hidden"> - <ElementContent - element={element} - track={track} - isSelected={isSelected} - /> - </div> - </button> - </div> - - {element.type !== "audio" && element.type !== "effect" && ( - <div className="sticky left-1 mt-1 ml-1 w-fit"> - <EffectsButton - element={element as VisualElement} - trackId={track.id} - /> + <button + type="button" + tabIndex={-1} + className="absolute inset-0 size-full flex flex-col" + onClick={(event) => onElementClick(event, element)} + onMouseDown={(event) => onElementMouseDown(event, element)} + > + <div className="flex flex-1 min-h-0 items-center overflow-hidden"> + <ElementContent element={element} track={track} /> + </div> + </button> </div> - )} + </div> {isSelected && ( <> @@ -535,8 +533,8 @@ function KeyframeIndicators({ const isIndicatorSelected = indicator.keyframes.some((keyframe) => isKeyframeSelected({ keyframe }), ); - const isBeingDragged = indicator.keyframes.some((kf) => - dragState.draggingKeyframeIds.has(kf.keyframeId), + const isBeingDragged = indicator.keyframes.some((keyframe) => + dragState.draggingKeyframeIds.has(keyframe.keyframeId), ); const visualOffsetPx = getVisualOffsetPx({ indicatorTime: indicator.time, @@ -581,212 +579,244 @@ function KeyframeIndicators({ interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; - isSelected: boolean; } -interface ElementContentRendererProps extends ElementContentProps { - mediaAssets: MediaAsset[]; +function TextElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "text" }>; +}) { + return ( + <div className="flex size-full items-center justify-start pl-2"> + <span className="truncate text-xs text-white">{element.content}</span> + </div> + ); +} + +function EffectElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "effect" }>; +}) { + return ( + <div className="flex size-full items-center justify-start gap-1 pl-2"> + <HugeiconsIcon + icon={MagicWand05Icon} + className="size-4 shrink-0 text-white" + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); } -type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode; +function StickerElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "sticker" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={resolveStickerId({ + stickerId: element.stickerId, + options: { width: 20, height: 20 }, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} -export function renderTiledMedia({ +function GraphicElementContent({ element, - imageUrl, - track, }: { - element: VisualElement; - imageUrl: string | undefined; - track: ElementContentProps["track"]; -}): ReactNode { - if (!imageUrl) { + element: Extract<TimelineElementType, { type: "graphic" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={buildGraphicPreviewUrl({ + definitionId: element.definitionId, + params: element.params, + size: 20, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} + +function AudioElementContent({ element }: { element: AudioElement }) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + const mediaAsset = + element.sourceType === "upload" + ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) + : null; + + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; + const audioUrl = + element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; + const mediaLabel = mediaAsset?.name ?? element.name; + + if (audioBuffer || audioUrl) { return ( - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> + <div className="relative size-full"> + <AudioWaveform + audioBuffer={audioBuffer} + audioUrl={audioUrl} + color={ELEMENT_TYPE_CONFIG.audio.waveformColor} + /> + <MediaElementHeader name={mediaLabel} hasFade={false} /> + </div> ); } - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - return ( - <div - className="absolute inset-0" - style={{ - backgroundImage: `url(${imageUrl})`, - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - /> + <span className="text-foreground/80 truncate text-xs">{element.name}</span> ); } function EffectsButton({ element, - trackId, - className, + track, }: { - element: VisualElement; - trackId: string; - className?: string; + element: VideoElement | ImageElement; + track: TimelineTrack; }) { - const openClipEffects = usePropertiesStore((state) => state.openClipEffects); - const { selectElement } = useElementSelection(); - - if (!element.effects?.length) { - return null; - } + const editor = useEditor(); + const setActiveTab = usePropertiesStore((s) => s.setActiveTab); const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); - selectElement({ elementId: element.id, trackId }); - openClipEffects({ elementId: element.id, trackId }); + editor.selection.setSelectedElements({ + elements: [{ trackId: track.id, elementId: element.id }], + }); + setActiveTab(element.type, "effects"); }; return ( - <Button - variant="text" - size="icon" - className={cn("rounded-sm !size-5 bg-black/50 text-white", className)} - onClick={handleClick} + <button + type="button" + className="flex shrink-0 justify-center text-white cursor-pointer" onMouseDown={(event) => event.stopPropagation()} + onClick={handleClick} > - <HugeiconsIcon icon={MagicWand05Icon} /> - </Button> + <HugeiconsIcon icon={MagicWand05Icon} size={12} /> + </button> ); } -const ELEMENT_CONTENT_RENDERERS: Record< - TimelineElementType["type"], - ElementContentRenderer -> = { - text: ({ element }) => { - const textElement = element as Extract< - TimelineElementType, - { type: "text" } - >; - return ( - <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white"> - {textElement.content} - </span> - </div> - ); - }, - effect: ({ element }) => ( - <div className="flex size-full items-center justify-start gap-1 pl-2"> - <HugeiconsIcon - icon={MagicWand05Icon} - className="size-4 shrink-0 text-white" - /> - <span className="truncate text-xs text-white ml-1">{element.name}</span> - </div> - ), - sticker: ({ element }) => { - const stickerElement = element as Extract< - TimelineElementType, - { type: "sticker" } - >; - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={resolveStickerId({ - stickerId: stickerElement.stickerId, - options: { width: 20, height: 20 }, - })} - alt={stickerElement.name} - className="size-5 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white"> - {stickerElement.name} - </span> - </div> - ); - }, - audio: ({ element, mediaAssets }) => { - const audioElement = element as Extract< - TimelineElementType, - { type: "audio" } - >; - const audioBuffer = - audioElement.sourceType === "library" ? audioElement.buffer : undefined; - const audioUrl = - audioElement.sourceType === "library" - ? audioElement.sourceUrl - : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url; +function TiledMediaContent({ + element, + track, +}: { + element: VideoElement | ImageElement; + track: TimelineTrack; +}) { + const mediaAssets = useEditor((e) => e.media.getAssets()); - if (audioBuffer || audioUrl) { - return ( - <div className="flex size-full items-center gap-2"> - <div className="min-w-0 flex-1"> - <AudioWaveform - audioBuffer={audioBuffer} - audioUrl={audioUrl} - height={24} - className="w-full" - /> - </div> - </div> - ); - } + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + const imageUrl = + element.type === "video" + ? mediaAsset?.thumbnailUrl + : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); + if (!imageUrl) { return ( <span className="text-foreground/80 truncate text-xs"> - {audioElement.name} + {element.name} </span> ); - }, - video: ({ element, track, mediaAssets }) => { - const videoElement = element as Extract< - TimelineElementType, - { type: "video" } - >; - const mediaAsset = mediaAssets.find( - (asset) => asset.id === videoElement.mediaId, - ); - return renderTiledMedia({ - element: videoElement, - imageUrl: mediaAsset?.thumbnailUrl, - track, - }); - }, - image: ({ element, track, mediaAssets }) => { - const imageElement = element as Extract< - TimelineElementType, - { type: "image" } - >; - const mediaAsset = mediaAssets.find( - (asset) => asset.id === imageElement.mediaId, - ); - return renderTiledMedia({ - element: imageElement, - imageUrl: mediaAsset?.url, - track, - }); - }, -}; + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; -function ElementContent({ element, track, isSelected }: ElementContentProps) { - const editor = useEditor(); - const renderer = ELEMENT_CONTENT_RENDERERS[element.type]; return ( <> - {renderer({ - element, - track, - isSelected, - mediaAssets: editor.media.getAssets(), - })} + <div + className="absolute inset-0" + style={{ + backgroundColor: "var(--muted)", + backgroundImage: `url(${imageUrl})`, + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + /> + <MediaElementHeader + name={mediaAsset?.name} + leading={ + hasElementEffects({ element }) ? ( + <EffectsButton element={element} track={track} /> + ) : null + } + hasFade={true} + /> </> ); } +function MediaElementHeader({ + name, + leading, + hasFade, +}: { + name?: string | null; + leading?: ReactNode; + hasFade?: boolean; +}) { + if (!name && !leading) { + return null; + } + + return ( + <div + className={cn( + "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1", + hasFade && "from-black/30 to-transparent", + )} + > + {leading && <div className="pl-1">{leading}</div>} + {name && ( + <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75"> + {name} + </span> + )} + </div> + ); +} + +function ElementContent({ element, track }: ElementContentProps) { + switch (element.type) { + case "text": + return <TextElementContent element={element} />; + case "effect": + return <EffectElementContent element={element} />; + case "sticker": + return <StickerElementContent element={element} />; + case "graphic": + return <GraphicElementContent element={element} />; + case "audio": + return <AudioElementContent element={element} />; + case "video": + case "image": + return <TiledMediaContent element={element} track={track} />; + } +} + function CopyMenuItem() { return ( <ActionMenuItem @@ -886,7 +916,7 @@ function ActionMenuItem({ children, ...props }: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { - action: TAction; + action: TActionWithOptionalArgs; children: ReactNode; }) { return ( diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index e290259e..1abe65bd 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -8,9 +8,14 @@ import { } from "@/lib/timeline"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useEditor } from "@/hooks/use-editor"; +import { + TIMELINE_LAYERS, + TIMELINE_SCROLLBAR_SIZE_PX, +} from "@/constants/timeline-constants"; interface TimelinePlayheadProps { zoomLevel: number; + hasHorizontalScrollbar: boolean; rulerRef: React.RefObject<HTMLDivElement | null>; rulerScrollRef: React.RefObject<HTMLDivElement | null>; tracksScrollRef: React.RefObject<HTMLDivElement | null>; @@ -21,6 +26,7 @@ interface TimelinePlayheadProps { export function TimelinePlayhead({ zoomLevel, + hasHorizontalScrollbar, rulerRef, rulerScrollRef, tracksScrollRef, @@ -33,7 +39,7 @@ export function TimelinePlayhead({ const internalPlayheadRef = useRef<HTMLDivElement>(null); const playheadRef = externalPlayheadRef || internalPlayheadRef; - const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + const { handlePlayheadMouseDown } = useTimelinePlayhead({ zoomLevel, rulerRef, rulerScrollRef, @@ -42,16 +48,23 @@ export function TimelinePlayhead({ }); const timelineContainerHeight = - tracksScrollRef.current?.clientHeight ?? timelineRef.current?.clientHeight ?? + tracksScrollRef.current?.clientHeight ?? 400; - const totalHeight = Math.max(0, timelineContainerHeight - 4); + const totalHeight = Math.max( + 0, + timelineContainerHeight - + (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0), + ); + const currentTime = editor.playback.getCurrentTime(); const centerPosition = timelineTimeToSnappedPixels({ - time: playheadPosition, + time: currentTime, zoomLevel, }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); + const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; + const leftPosition = + getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; const handlePlayheadKeyDown = ( event: React.KeyboardEvent<HTMLDivElement>, @@ -61,10 +74,8 @@ export function TimelinePlayhead({ event.preventDefault(); const step = 1 / Math.max(1, editor.project.getActive().settings.fps); const direction = event.key === "ArrowRight" ? 1 : -1; - const nextTime = Math.max( - 0, - Math.min(duration, playheadPosition + direction * step), - ); + const now = editor.playback.getCurrentTime(); + const nextTime = Math.max(0, Math.min(duration, now + direction * step)); editor.playback.seek({ time: nextTime }); }; @@ -76,23 +87,24 @@ export function TimelinePlayhead({ aria-label="Timeline playhead" aria-valuemin={0} aria-valuemax={duration} - aria-valuenow={playheadPosition} + aria-valuenow={currentTime} tabIndex={0} - className="pointer-events-none absolute z-5" + className="pointer-events-none absolute" style={{ left: `${leftPosition}px`, top: 0, height: `${totalHeight}px`, width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, + zIndex: TIMELINE_LAYERS.playhead, }} onKeyDown={handlePlayheadKeyDown} > - <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" /> + <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" /> <button type="button" aria-label="Drag playhead" - className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} + className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`} onMouseDown={handlePlayheadMouseDown} /> </div> diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx index b0261aa2..09562f23 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx @@ -1,5 +1,8 @@ import { type JSX, useLayoutEffect, useRef } from "react"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + TIMELINE_CONSTANTS, + TIMELINE_RULER_HEIGHT, +} from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; @@ -27,13 +30,12 @@ export function TimelineRuler({ handleRulerTrackingMouseDown, handleRulerMouseDown, }: TimelineRulerProps) { - const editor = useEditor(); - const duration = editor.timeline.getTotalDuration(); + const duration = useEditor((e) => e.timeline.getTotalDuration()); const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const visibleDuration = dynamicTimelineWidth / pixelsPerSecond; const effectiveDuration = Math.max(duration, visibleDuration); - const project = editor.project.getActive(); - const fps = project?.settings.fps ?? DEFAULT_FPS; + const fps = + useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS; const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ zoomLevel, fps, @@ -104,17 +106,25 @@ export function TimelineRuler({ aria-valuemin={0} aria-valuemax={effectiveDuration} aria-valuenow={0} - className="relative h-4 flex-1 overflow-x-visible" + className="relative flex-1 overflow-x-visible" + style={{ height: TIMELINE_RULER_HEIGHT }} onWheel={handleWheel} - onClick={handleTimelineContentClick} + onClick={(event) => { + // Ruler seek already happens on mousedown via playhead scrubbing. + // Forwarding the follow-up click re-enters the selection-clearing path. + if (event.target === event.currentTarget) { + handleTimelineContentClick(event); + } + }} onMouseDown={handleRulerTrackingMouseDown} onKeyDown={() => {}} > <div role="none" ref={rulerRef} - className="relative h-4 cursor-default select-none" + className="relative cursor-default select-none" style={{ + height: TIMELINE_RULER_HEIGHT, width: `${dynamicTimelineWidth}px`, }} onMouseDown={handleRulerMouseDown} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 22cd52cf..29fd513b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx @@ -22,7 +22,7 @@ export function TimelineTick({ const label = formatRulerLabel({ timeInSeconds: time, fps }); return ( <span - className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none" + className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none" style={{ left: `${leftPosition}px` }} > {label} @@ -32,7 +32,7 @@ export function TimelineTick({ return ( <div - className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l" + className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l" style={{ left: `${leftPosition}px` }} /> ); diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 533ea63d..e785d147 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -16,8 +16,8 @@ import { import { Slider } from "@/components/ui/slider"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; -import { ScenesView } from "../../scenes-view"; -import { type TAction, invokeAction } from "@/lib/actions"; +import { ScenesView } from "@/components/editor/scenes-view"; +import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions"; import { cn } from "@/utils/ui"; import { useTimelineStore } from "@/stores/timeline-store"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -76,15 +76,15 @@ export function TimelineToolbar({ } function ToolbarLeftSection() { - const editor = useEditor(); - const currentTime = editor.playback.getCurrentTime(); - const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + const isCurrentlyBookmarked = useEditor((e) => + e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), + ); const handleAction = ({ action, event, }: { - action: TAction; + action: TActionWithOptionalArgs; event: React.MouseEvent; }) => { event.stopPropagation(); @@ -149,8 +149,8 @@ function ToolbarLeftSection() { <Tooltip> <ToolbarButton icon={<HugeiconsIcon icon={Bookmark02Icon} />} - isActive={isCurrentlyBookmarked} - tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} + isActive={isCurrentlyBookmarked} + tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} onClick={({ event }) => handleAction({ action: "toggle-bookmark", event }) } diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 81cda7a6..6a9e4dfe 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -2,12 +2,12 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { TimelineElement } from "./timeline-element"; -import type { TimelineTrack } from "@/types/timeline"; -import type { TimelineElement as TimelineElementType } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; +import type { TimelineElement as TimelineElementType } from "@/lib/timeline"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import type { ElementDragState } from "@/types/timeline"; +import type { ElementDragState } from "@/lib/timeline"; import { useEditor } from "@/hooks/use-editor"; interface TimelineTrackContentProps { @@ -30,7 +30,7 @@ interface TimelineTrackContentProps { track: TimelineTrack; }) => void; onTrackMouseDown?: (event: React.MouseEvent) => void; - onTrackClick?: (event: React.MouseEvent) => void; + onTrackMouseUp?: (event: React.MouseEvent) => void; shouldIgnoreClick?: () => boolean; targetElementId?: string | null; } @@ -47,14 +47,12 @@ export function TimelineTrackContent({ onElementMouseDown, onElementClick, onTrackMouseDown, - onTrackClick, + onTrackMouseUp, shouldIgnoreClick, targetElementId = null, }: TimelineTrackContentProps) { - const editor = useEditor(); const { isElementSelected } = useElementSelection(); - - const duration = editor.timeline.getTotalDuration(); + const duration = useEditor((e) => e.timeline.getTotalDuration()); useEdgeAutoScroll({ isActive: dragState.isDragging, @@ -65,21 +63,37 @@ export function TimelineTrackContent({ }); return ( - <button - className="size-full" - onClick={(event) => { - if (shouldIgnoreClick?.()) return; - onTrackClick?.(event); - }} - onMouseDown={(event) => { - event.preventDefault(); - onTrackMouseDown?.(event); - }} - type="button" - > - <div className="relative h-full min-w-full"> + <div className="relative size-full"> + <button + type="button" + className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0" + aria-label={`Select ${track.name} track`} + onMouseUp={(event) => { + if (shouldIgnoreClick?.()) return; + onTrackMouseUp?.(event); + }} + onMouseDown={(event) => { + event.preventDefault(); + onTrackMouseDown?.(event); + }} + /> + {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} + <div + className="relative h-full min-w-full" + style={{ zIndex: TIMELINE_LAYERS.trackContent }} + onMouseUp={(event) => { + if (event.target !== event.currentTarget) return; + if (shouldIgnoreClick?.()) return; + onTrackMouseUp?.(event); + }} + onMouseDown={(event) => { + if (event.target !== event.currentTarget) return; + event.preventDefault(); + onTrackMouseDown?.(event); + }} + > {track.elements.length === 0 ? ( - <div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> + <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> ) : ( track.elements.map((element) => { const isSelected = isElementSelected({ @@ -109,6 +123,6 @@ export function TimelineTrackContent({ }) )} </div> - </button> + </div> ); } diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx deleted file mode 100644 index 0b757e36..00000000 --- a/apps/web/src/components/editor/selection-box.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useMemo } from "react"; - -interface SelectionBoxProps { - startPos: { x: number; y: number } | null; - currentPos: { x: number; y: number } | null; - containerRef: React.RefObject<HTMLElement | null>; - isActive: boolean; -} - -export function SelectionBox({ - startPos, - currentPos, - containerRef, - isActive, -}: SelectionBoxProps) { - const selectionBoxStyle = useMemo(() => { - if (!isActive || !startPos || !currentPos || !containerRef.current) { - return null; - } - - const containerRect = containerRef.current.getBoundingClientRect(); - const startX = startPos.x - containerRect.left; - const startY = startPos.y - containerRect.top; - const currentX = currentPos.x - containerRect.left; - const currentY = currentPos.y - containerRect.top; - - const left = Math.min(startX, currentX); - const top = Math.min(startY, currentY); - const width = Math.abs(currentX - startX); - const height = Math.abs(currentY - startY); - - return { - left: `${left}px`, - top: `${top}px`, - width: `${width}px`, - height: `${height}px`, - }; - }, [containerRef, currentPos, isActive, startPos]); - - if (!selectionBoxStyle) return null; - - return ( - <div - style={selectionBoxStyle} - className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border" - /> - ); -} diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index 746cfd16..35e5dfba 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -3,13 +3,12 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Loader2 } from "lucide-react"; +import { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; -import { - useKeybindingsListener, - useKeybindingDisabler, -} from "@/hooks/use-keybindings"; +import { useKeybindingsListener } from "@/hooks/use-keybindings"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; import { useEditorActions } from "@/hooks/actions/use-editor-actions"; -import { prefetchFontAtlas } from "@/lib/fonts/google-fonts"; +import { loadFontAtlas } from "@/lib/fonts/google-fonts"; interface EditorProviderProps { projectId: string; @@ -17,23 +16,19 @@ interface EditorProviderProps { } export function EditorProvider({ projectId, children }: EditorProviderProps) { - const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActiveOrNull()); const router = useRouter(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<string | null>(null); - const { disableKeybindings, enableKeybindings } = useKeybindingDisabler(); - const activeProject = editor.project.getActiveOrNull(); + const { setLoadingProject } = useKeybindingsStore(); useEffect(() => { - if (isLoading) { - disableKeybindings(); - } else { - enableKeybindings(); - } - }, [isLoading, disableKeybindings, enableKeybindings]); + setLoadingProject(isLoading); + }, [isLoading, setLoadingProject]); useEffect(() => { let cancelled = false; + const editor = EditorCore.getInstance(); const loadProject = async () => { try { @@ -43,7 +38,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) { if (cancelled) return; setIsLoading(false); - prefetchFontAtlas(); + loadFontAtlas(); } catch (err) { if (cancelled) return; @@ -76,7 +71,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) { return () => { cancelled = true; }; - }, [projectId, editor, router]); + }, [projectId, router]); if (error) { return ( diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/section.tsx index 5989108b..e3fc96df 100644 --- a/apps/web/src/components/editor/panels/properties/section.tsx +++ b/apps/web/src/components/section.tsx @@ -36,7 +36,7 @@ export function Section({ defaultOpen = true, sectionKey, className, - showTopBorder = true, + showTopBorder = false, showBottomBorder = true, }: SectionProps) { const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; @@ -44,11 +44,18 @@ export function Section({ useEffect(() => { if (!sectionKey) return; - if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) { - console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`); + if ( + process.env.NODE_ENV !== "production" && + mountedSectionKeys.has(sectionKey) + ) { + console.error( + `[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`, + ); } mountedSectionKeys.add(sectionKey); - return () => { mountedSectionKeys.delete(sectionKey); }; + return () => { + mountedSectionKeys.delete(sectionKey); + }; }, [sectionKey]); const toggle = () => { @@ -62,8 +69,8 @@ export function Section({ <div className={cn( "flex flex-col", - showTopBorder && "border-t", - showBottomBorder && "last:border-b", + showTopBorder && "border-t first:border-t-0", + showBottomBorder && "border-b", className, )} > @@ -96,63 +103,58 @@ export function SectionHeader({ const isInteractive = isCollapsible || !!onClick; const handleClick = isCollapsible ? ctx?.toggle : onClick; - const chevronIcon = isCollapsible ? ( + const chevronIcon = ( <HugeiconsIcon icon={ArrowDownIcon} className={cn( "size-4 shrink-0 transition-transform duration-200 ease-out", - isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground", + isOpen + ? "rotate-0 text-foreground" + : "-rotate-90 text-muted-foreground", )} /> - ) : null; + ); + + const trailingArea = + trailing || isCollapsible ? ( + <div className="flex items-center"> + {trailing} + {isCollapsible && ( + <Button + variant="ghost" + size="icon" + aria-label={isOpen ? "Collapse section" : "Expand section"} + onClick={handleClick} + > + {chevronIcon} + </Button> + )} + </div> + ) : null; - const headerContent = ( + const innerContent = isInteractive ? ( + <button + type="button" + className="min-w-0 flex-1 flex items-center gap-2 h-full cursor-pointer text-left" + onClick={handleClick} + > + {leading} + <div className="min-w-0 flex-1 flex items-center">{children}</div> + </button> + ) : ( <> {leading} - <div className="min-w-0 flex-1">{children}</div> - {(trailing || chevronIcon) && ( - <div className="flex items-center"> - {trailing} - {chevronIcon && ( - <Button - variant="ghost" - size="icon" - aria-label={isOpen ? "Collapse section" : "Expand section"} - onClick={(event) => { - event.stopPropagation(); - handleClick?.(); - }} - > - {chevronIcon} - </Button> - )} - </div> - )} - {actions} + <div className="min-w-0 flex-1 flex items-center">{children}</div> </> ); - if (!isInteractive) { - return ( - <div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}> - {headerContent} - </div> - ); - } - return ( - // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here <div - role="button" - tabIndex={0} - className={cn( - "flex h-11 w-full cursor-pointer items-center gap-2 px-3.5", - className, - )} - onClick={handleClick} - onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }} + className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)} > - {headerContent} + {innerContent} + {trailingArea} + {actions} </div> ); } @@ -167,17 +169,20 @@ export function SectionTitle({ onClick?: () => void; }) { const ctx = useSectionContext(); + const isCollapsible = ctx?.collapsible ?? false; const isOpen = ctx?.isOpen ?? true; + const titleClass = cn( + "text-sm font-medium", + isCollapsible && isOpen ? "text-foreground" : "text-muted-foreground", + className, + ); + if (onClick) { return ( <button type="button" - className={cn( - "cursor-pointer text-sm font-medium", - isOpen ? "text-foreground" : "text-muted-foreground", - className, - )} + className={cn("cursor-pointer", titleClass)} onClick={onClick} > {children} @@ -185,17 +190,7 @@ export function SectionTitle({ ); } - return ( - <span - className={cn( - "text-sm font-medium", - isOpen ? "text-foreground" : "text-muted-foreground", - className, - )} - > - {children} - </span> - ); + return <span className={titleClass}>{children}</span>; } export function SectionFields({ @@ -243,6 +238,8 @@ export function SectionContent({ const isCollapsible = ctx?.collapsible ?? false; const isOpen = ctx?.isOpen ?? true; + const content = <div className={cn("p-4 pt-0", className)}>{children}</div>; + if (isCollapsible) { return ( <div @@ -251,12 +248,10 @@ export function SectionContent({ isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]", )} > - <div className="overflow-hidden"> - <div className={cn("p-4 pt-0", className)}>{children}</div> - </div> + <div className="overflow-hidden">{content}</div> </div> ); } - return <div className={cn("p-4 pt-0", className)}>{children}</div>; + return content; } diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index aa52e228..3dff9417 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -23,7 +23,7 @@ const buttonVariants = cva( }, size: { default: "h-9 px-4 py-2", - sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm", + sm: "h-7 p-1 px-2.5 text-sm rounded-sm", lg: "h-10 p-5 px-6", icon: "size-7 rounded-sm", text: "p-0", diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index fbf9bbd7..6d911a75 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -270,6 +270,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( backgroundColor: "#fff", }; + return ( <Popover> <div @@ -285,10 +286,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative" type="button" > - <span - className="absolute inset-0" - style={checkerboardStyle} - /> + <span + className="absolute inset-0 dark:invert" + style={checkerboardStyle} + /> <span className="absolute inset-0" style={{ backgroundColor: `#${value}` }} @@ -298,7 +299,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( <div className="flex flex-1 items-center"> <Input className={cn( - "!border-0 bg-transparent p-0 !ring-0 !ring-offset-0", + "border-0! bg-transparent p-0 ring-0! ring-offset-0!", colorFormat === "hex" && "uppercase", )} size="sm" @@ -387,10 +388,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( <button ref={opacityRef} className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0" - style={checkerboardStyle} type="button" onMouseDown={handleOpacityMouseDown} > + <div className="absolute inset-0 dark:invert" style={checkerboardStyle} /> <div className="absolute inset-0 rounded-lg" style={{ diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index d8791bdf..ec02de6b 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -10,8 +10,20 @@ import { ArrowRightIcon, CircleIcon, } from "@hugeicons/core-free-icons"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const ContextMenu = ContextMenuPrimitive.Root; +function ContextMenu({ + onOpenChange, + ...props +}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "context-menu", + onOpenChange, + }); + return ( + <ContextMenuPrimitive.Root onOpenChange={handleOpenChange} {...props} /> + ); +} const ContextMenuTrigger = ContextMenuPrimitive.Trigger; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index d124a2e9..f5045f95 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -4,8 +4,22 @@ import * as React from "react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { X } from "lucide-react"; import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Dialog = DialogPrimitive.Root; +function Dialog({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "dialog", + open, + onOpenChange, + }); + return ( + <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} /> + ); +} const DialogTrigger = DialogPrimitive.Trigger; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 2d6541b8..91913e25 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -4,10 +4,27 @@ import * as React from "react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import { Check, ChevronRight, Circle } from "lucide-react"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const DropdownMenu = DropdownMenuPrimitive.Root; +function DropdownMenu({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "dropdown-menu", + open, + onOpenChange, + }); + return ( + <DropdownMenuPrimitive.Root + open={open} + onOpenChange={handleOpenChange} + {...props} + /> + ); +} const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; @@ -118,7 +135,7 @@ const DropdownMenuItem = React.forwardRef< ref, ) => { const iconSlot = ( - <span className="hidden size-4 shrink-0 items-center justify-center group-has-[[data-has-icon]]/menu:flex"> + <span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex"> {icon} </span> ); diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 09ba0a9a..77d8ab7f 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,13 +1,6 @@ "use client"; -import { - useState, - useMemo, - useRef, - useEffect, - useCallback, - type CSSProperties, -} from "react"; +import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties } from "react"; import { List, type RowComponentProps } from "react-window"; import { Popover, @@ -16,28 +9,24 @@ import { } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { - getCachedFontAtlas, - loadFullFont, - prefetchFontAtlas, - clearFontAtlasCache, -} from "@/lib/fonts/google-fonts"; -import type { FontAtlas, FontAtlasEntry } from "@/types/fonts"; +import { loadFullFont } from "@/lib/fonts/google-fonts"; +import { SYSTEM_FONTS } from "@/constants/font-constants"; +import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types"; +import { useFontAtlas } from "@/hooks/use-font-atlas"; import { cn } from "@/utils/ui"; -import { ChevronDown, Search, Upload } from "lucide-react"; +import { ChevronDown, Search } from "lucide-react"; import { HugeiconsIcon } from "@hugeicons/react"; import { TextIcon } from "@hugeicons/core-free-icons"; const FONT_TABS = [ { key: "all", label: "All fonts" }, - { key: "favorites", label: "Favorites" }, { key: "my-fonts", label: "My fonts" }, + { key: "favorites", label: "Favorites" }, ] as const; type FontTab = (typeof FONT_TABS)[number]["key"]; const ROW_HEIGHT = 40; -const SPRITE_ROW_HEIGHT = 40; const PREVIEW_SCALE = 0.8; const LIST_WIDTH = 288; const MAX_LIST_HEIGHT = 288; @@ -57,18 +46,8 @@ export function FontPicker({ const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [activeTab, setActiveTab] = useState<FontTab>("all"); - const [atlas, setAtlas] = useState<FontAtlas | null>(() => - getCachedFontAtlas(), - ); - const [status, setStatus] = useState<"idle" | "loading" | "error">(() => - getCachedFontAtlas() ? "idle" : "loading", - ); const searchInputRef = useRef<HTMLInputElement>(null); - - const fontNames = useMemo(() => { - if (!atlas) return []; - return Object.keys(atlas.fonts).sort(); - }, [atlas]); + const { atlas, status, fontNames, retry: handleRetry } = useFontAtlas({ open }); const filteredFonts = useMemo(() => { if (!search) return fontNames; @@ -83,32 +62,19 @@ export function FontPicker({ const handleSelect = useCallback( async ({ family }: { family: string }) => { - try { - await loadFullFont({ family }); - onValueChange?.(family); - } catch { - onValueChange?.(family); + if (!SYSTEM_FONTS.has(family)) { + try { + await loadFullFont({ family }); + } catch { + // ignore load failure, font will fall back to system default + } } + onValueChange?.(family); setOpen(false); }, [onValueChange], ); - // Load atlas on first open if cache is empty (fallback when prefetch hasn't completed) - useEffect(() => { - if (!open || atlas) return; - - setStatus("loading"); - prefetchFontAtlas().then((data) => { - if (data) { - setAtlas(data); - setStatus("idle"); - } else { - setStatus("error"); - } - }); - }, [open, atlas]); - useEffect(() => { if (!open) { setSearch(""); @@ -116,18 +82,8 @@ export function FontPicker({ } }, [open]); - const handleRetry = useCallback(() => { - clearFontAtlasCache(); - setStatus("loading"); - prefetchFontAtlas().then((data) => { - if (data) { - setAtlas(data); - setStatus("idle"); - } else { - setStatus("error"); - } - }); - }, []); + const activeTabLabel = + FONT_TABS.find((t) => t.key === activeTab)?.label.toLowerCase() ?? ""; return ( <Popover open={open} onOpenChange={setOpen}> @@ -164,11 +120,11 @@ export function FontPicker({ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" /> <Input ref={searchInputRef} - placeholder="Search fonts..." + placeholder={`Search ${activeTabLabel}...`} value={search} onChange={(event) => setSearch(event.target.value)} size="xs" - className="w-full pl-5 bg-transparent !border-none !shadow-none" + className="w-full pl-5 bg-transparent border-none! shadow-none!" /> </div> <div className="flex border-b px-3"> @@ -225,20 +181,6 @@ export function FontPicker({ style={{ height: listHeight, width: LIST_WIDTH }} /> )} - <div className="border-t p-1"> - <Button - variant="ghost" - size="sm" - className="w-full justify-start text-muted-foreground h-8 font-normal" - onClick={() => { - // TODO: Implement local font loading - console.log("Load local fonts clicked"); - }} - > - <Upload className="!size-3.5" /> - Load local fonts - </Button> - </div> </PopoverContent> </Popover> ); @@ -250,7 +192,7 @@ function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) { className="shrink-0" style={{ width: entry.w, - height: SPRITE_ROW_HEIGHT, + height: ROW_HEIGHT, backgroundColor: "currentColor", WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`, WebkitMaskPosition: `-${entry.x}px -${entry.y}px`, @@ -283,6 +225,7 @@ function FontRow({ const fontName = filteredFonts[index]; const entry = atlas.fonts[fontName]; const isSelected = fontName === selectedFont; + const isSystemFont = SYSTEM_FONTS.has(fontName); return ( <button @@ -302,7 +245,13 @@ function FontRow({ aria-label={fontName} > <div className="min-w-0 overflow-hidden"> - <FontSpritePreview entry={entry} /> + {isSystemFont ? ( + <span className="text-xl text-foreground/85" style={{ fontFamily: fontName }}> + {fontName} + </span> + ) : ( + <FontSpritePreview entry={entry} /> + )} </div> </button> ); diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx deleted file mode 100644 index 1c22fd2e..00000000 --- a/apps/web/src/components/ui/input-with-back.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { ArrowLeft, Search } from "lucide-react"; -import { motion } from "motion/react"; -import { useState, useEffect } from "react"; - -interface InputWithBackProps { - isExpanded: boolean; - setIsExpanded: (isExpanded: boolean) => void; - placeholder?: string; - value?: string; - onChange?: (value: string) => void; - disableAnimation?: boolean; -} - -export function InputWithBack({ - isExpanded, - setIsExpanded, - placeholder = "Search anything", - value, - onChange, - disableAnimation = false, -}: InputWithBackProps) { - const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null); - const [buttonOffset, setButtonOffset] = useState(-60); - - const smoothTransition = { - duration: disableAnimation ? 0 : 0.35, - ease: [0.25, 0.1, 0.25, 1] as const, - }; - - useEffect(() => { - if (containerRef) { - const rect = containerRef.getBoundingClientRect(); - setButtonOffset(-rect.left - 48); - } - }, [containerRef]); - - return ( - <div ref={setContainerRef} className="relative w-full"> - <motion.div - className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75" - initial={{ - x: isExpanded ? 0 : buttonOffset, - opacity: isExpanded ? 1 : 0.5, - }} - animate={{ - x: isExpanded ? 0 : buttonOffset, - opacity: isExpanded ? 1 : 0.5, - }} - transition={smoothTransition} - onClick={() => setIsExpanded(!isExpanded)} - > - <Button variant="outline" className="bg-accent !size-9 rounded-full"> - <ArrowLeft /> - </Button> - </motion.div> - <div - className="relative flex-1" - style={{ marginLeft: "0px", paddingLeft: "0px" }} - > - <motion.div - className="relative" - initial={{ - marginLeft: isExpanded ? 50 : 0, - }} - animate={{ - marginLeft: isExpanded ? 50 : 0, - }} - transition={smoothTransition} - > - <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> - <Input - placeholder={placeholder} - className="bg-accent w-full pl-9" - value={value} - onChange={(e) => onChange?.(e.target.value)} - /> - </motion.div> - </div> - </div> - ); -} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 762cc675..1dce2f24 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -8,18 +8,19 @@ import { forwardRef, type ComponentProps } from "react"; import { useState } from "react"; const inputVariants = cva( - "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { - default: "selection:bg-primary selection:text-primary-foreground", + default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20", + outline: "bg-background", destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10", }, size: { default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", xs: "h-7 px-3 text-xs file:h-6 file:text-xs", - sm: "h-8 px-3 text-sm file:h-6 file:text-xs", + sm: "h-7 px-3 text-sm file:h-6 file:text-xs", lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm", }, }, diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx index 48297dc0..18d71591 100644 --- a/apps/web/src/components/ui/number-field.tsx +++ b/apps/web/src/components/ui/number-field.tsx @@ -1,12 +1,15 @@ "use client"; import { cn } from "@/utils/ui"; -import { useRef, useState, type ComponentProps } from "react"; +import { clamp } from "@/utils/math"; +import { useRef, useState, useLayoutEffect, type ComponentProps } from "react"; import { useFocusLock } from "@/hooks/use-focus-lock"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons"; +const SUFFIX_GAP_PX = 6; + const DRAG_SENSITIVITIES = { default: 1, slow: 0.5, @@ -14,10 +17,94 @@ const DRAG_SENSITIVITIES = { type DragSensitivity = "default" | "slow"; +type ScrubRange = { + from: number; + to: number; + pixelsPerUnit: number; +}; + +type ScrubClamp = { + min?: number; + max?: number; +}; + +function clampScrubValue({ + value, + min, + max, +}: { + value: number; + min?: number; + max?: number; +}): number { + if (min != null && max != null) return clamp({ value, min, max }); + if (min != null) return Math.max(min, value); + if (max != null) return Math.min(max, value); + return value; +} + +function getActiveRange({ + value, + direction, + ranges, +}: { + value: number; + direction: number; + ranges: readonly ScrubRange[]; +}): ScrubRange | undefined { + return ranges.find((range) => + direction > 0 + ? value >= range.from && value < range.to + : value > range.from && value <= range.to, + ); +} + +function scrubAcrossRanges({ + startValue, + pixelDelta, + ranges, + min, + max, +}: { + startValue: number; + pixelDelta: number; + ranges: readonly ScrubRange[]; + min?: number; + max?: number; +}): number { + let currentValue = clampScrubValue({ value: startValue, min, max }); + let remainingPixels = pixelDelta; + + while (remainingPixels !== 0) { + const direction = Math.sign(remainingPixels); + + const range = getActiveRange({ value: currentValue, direction, ranges }); + if (!range) break; + + const boundary = direction > 0 ? range.to : range.from; + const pixelsToBoundary = + Math.abs(boundary - currentValue) * range.pixelsPerUnit; + + if (Math.abs(remainingPixels) <= pixelsToBoundary) { + currentValue += remainingPixels / range.pixelsPerUnit; + break; + } + + currentValue = boundary; + remainingPixels -= direction * pixelsToBoundary; + } + + return clampScrubValue({ value: currentValue, min, max }); +} + interface NumberFieldProps extends Omit<ComponentProps<"input">, "size" | "type"> { icon?: React.ReactNode; + suffix?: string; + suffixClassName?: string; dragSensitivity?: DragSensitivity; + scrubRanges?: readonly ScrubRange[]; + scrubClamp?: ScrubClamp; onScrub?: (value: number) => void; onScrubEnd?: () => void; allowExpressions?: boolean; @@ -28,8 +115,12 @@ interface NumberFieldProps function NumberField({ className, icon, + suffix, + suffixClassName, disabled, dragSensitivity = "default", + scrubRanges, + scrubClamp, onScrub, onScrubEnd, value, @@ -45,9 +136,18 @@ function NumberField({ }: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) { const iconRef = useRef<HTMLSpanElement>(null); const inputRef = useRef<HTMLInputElement>(null); + const ghostRef = useRef<HTMLSpanElement>(null); const startValueRef = useRef(0); const cumulativeDeltaRef = useRef(0); const [isInputFocused, setIsInputFocused] = useState(false); + const [suffixLeft, setSuffixLeft] = useState(0); + + useLayoutEffect(() => { + if (!suffix || !ghostRef.current || !inputRef.current) return; + const paddingLeft = + parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0; + setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth); + }, [value, suffix]); const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({ isActive: isInputFocused, @@ -71,12 +171,16 @@ function NumberField({ return; } cumulativeDeltaRef.current += moveEvent.movementX; - const sensitivity = - typeof dragSensitivity === "number" - ? dragSensitivity - : DRAG_SENSITIVITIES[dragSensitivity]; - const newValue = - startValueRef.current + cumulativeDeltaRef.current * sensitivity; + const newValue = scrubRanges + ? scrubAcrossRanges({ + startValue: startValueRef.current, + pixelDelta: cumulativeDeltaRef.current, + ranges: scrubRanges, + min: scrubClamp?.min, + max: scrubClamp?.max, + }) + : startValueRef.current + + cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity]; onScrub(newValue); }; @@ -93,11 +197,48 @@ function NumberField({ const canScrub = Boolean(icon && onScrub); + const inputNode = ( + <input + type={allowExpressions ? "text" : "number"} + inputMode={allowExpressions ? "decimal" : undefined} + ref={inputRef} + disabled={disabled} + value={value} + className="text-sm leading-none bg-transparent outline-none min-w-0 flex-1 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + onMouseDown={(event) => { + const inputElement = event.currentTarget; + const shouldPreventNativeCaretPlacement = + event.button === 0 && document.activeElement !== inputElement; + if (shouldPreventNativeCaretPlacement) { + event.preventDefault(); + inputElement.focus(); + inputElement.select(); + } + onMouseDown?.(event); + }} + onFocus={(event) => { + setIsInputFocused(true); + event.currentTarget.select(); + onFocus?.(event); + }} + onKeyDown={(event) => { + const shouldBlurInput = event.key === "Enter" || event.key === "Escape"; + if (shouldBlurInput) event.currentTarget.blur(); + onKeyDown?.(event); + }} + onBlur={(event) => { + setIsInputFocused(false); + onBlur?.(event); + }} + {...props} + /> + ); + return ( <div ref={wrapperRef} className={cn( - "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", + "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none cursor-text disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", disabled && "pointer-events-none cursor-not-allowed opacity-50", className, )} @@ -106,53 +247,45 @@ function NumberField({ <span ref={iconRef} className={cn( - "text-muted-foreground [&_svg]:!size-3.5 shrink-0 select-none pl-2.5 text-sm leading-none", + "text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none", canScrub && "cursor-ew-resize", )} + onMouseDown={canScrub ? (event) => event.preventDefault() : undefined} onPointerDown={canScrub ? handleIconPointerDown : undefined} > {icon} </span> )} - <input - type={allowExpressions ? "text" : "number"} - inputMode={allowExpressions ? "decimal" : undefined} - ref={inputRef} - disabled={disabled} - value={value} + <span className={cn( - "min-w-0 flex-1 text-sm leading-none bg-transparent outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + "relative flex flex-1 min-w-0 items-center", icon ? "px-1.5" : "pl-2.5", onReset ? "pr-0" : "pr-2.5", )} - onMouseDown={(event) => { - const inputElement = event.currentTarget; - const shouldPreventNativeCaretPlacement = - event.button === 0 && document.activeElement !== inputElement; - if (shouldPreventNativeCaretPlacement) { - event.preventDefault(); - inputElement.focus(); - inputElement.select(); - } - onMouseDown?.(event); - }} - onFocus={(event) => { - setIsInputFocused(true); - event.currentTarget.select(); - onFocus?.(event); - }} - onKeyDown={(event) => { - const shouldBlurInput = - event.key === "Enter" || event.key === "Escape"; - if (shouldBlurInput) event.currentTarget.blur(); - onKeyDown?.(event); - }} - onBlur={(event) => { - setIsInputFocused(false); - onBlur?.(event); - }} - {...props} - /> + > + {inputNode} + {suffix && ( + <> + {/* Ghost mirrors value text to measure width for suffix positioning */} + <span + ref={ghostRef} + className="invisible absolute text-sm leading-none whitespace-pre pointer-events-none" + aria-hidden="true" + > + {value} + </span> + <span + className={cn( + "absolute top-1/2 -translate-y-1/2 select-none pointer-events-none text-sm leading-none", + suffixClassName, + )} + style={{ left: suffixLeft + SUFFIX_GAP_PX }} + > + {suffix} + </span> + </> + )} + </span> {onReset && !isDefault && ( <div className="shrink-0 pr-2 flex items-center"> <Button @@ -161,7 +294,7 @@ function NumberField({ aria-label="Reset to default" onClick={onReset} > - <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="!size-3.5" /> + <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="size-3.5!" /> </Button> </div> )} diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 8fd511a3..cf480c8c 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -3,8 +3,26 @@ import * as React from "react"; import { Popover as PopoverPrimitive } from "radix-ui"; import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Popover = PopoverPrimitive.Root; +function Popover({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "popover", + open, + onOpenChange, + }); + return ( + <PopoverPrimitive.Root + open={open} + onOpenChange={handleOpenChange} + {...props} + /> + ); +} const PopoverTrigger = PopoverPrimitive.Trigger; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 1b57ea4d..b7092b87 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -7,8 +7,22 @@ import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Select = SelectPrimitive.Root; +function Select({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "select", + open, + onOpenChange, + }); + return ( + <SelectPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} /> + ); +} const SelectGroup = SelectPrimitive.Group; diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 3155787d..d56cd0ac 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -4,10 +4,23 @@ import * as React from "react"; import { Dialog as SheetPrimitive } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"; import { X } from "lucide-react"; - import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Sheet = SheetPrimitive.Root; +function Sheet({ + open, + onOpenChange, + ...props +}: React.ComponentProps<typeof SheetPrimitive.Root>) { + const handleOpenChange = useOverlayOpenChange({ + source: "sheet", + open, + onOpenChange, + }); + return ( + <SheetPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} /> + ); +} const SheetTrigger = SheetPrimitive.Trigger; diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 4bae2fad..f0c8197a 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -12,7 +12,7 @@ const Toaster = ({ ...props }: ToasterProps) => { <Sonner theme={theme as ToasterProps["theme"]} className="toaster group" - position="top-center" + position="bottom-right" offset={20} toastOptions={{ classNames: { diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index d2844b63..c5cc02d7 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -5,51 +5,81 @@ import { Tabs as TabsPrimitive } from "radix-ui"; import { cn } from "@/utils/ui"; -const Tabs = TabsPrimitive.Root; +type TabsVariant = "default" | "underline"; + +const TabsVariantContext = React.createContext<TabsVariant>("default"); + +const Tabs = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & { + variant?: TabsVariant; + } +>(({ variant = "default", ...props }, ref) => ( + <TabsVariantContext.Provider value={variant}> + <TabsPrimitive.Root ref={ref} {...props} /> + </TabsVariantContext.Provider> +)); +Tabs.displayName = TabsPrimitive.Root.displayName; const TabsList = React.forwardRef< React.ElementRef<typeof TabsPrimitive.List>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> ->(({ className, ...props }, ref) => ( - <TabsPrimitive.List - ref={ref} - className={cn( - "text-muted-foreground inline-flex h-auto items-center justify-center gap-2 rounded-lg bg-transparent p-0", - className, - )} - {...props} - /> -)); +>(({ className, ...props }, ref) => { + const variant = React.useContext(TabsVariantContext); + return ( + <TabsPrimitive.List + ref={ref} + className={cn( + "text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0", + variant === "default" && "rounded-lg", + variant === "underline" && "border-b border-border w-full gap-0 px-2", + className, + )} + {...props} + /> + ); +}); TabsList.displayName = TabsPrimitive.List.displayName; const TabsTrigger = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> ->(({ className, ...props }, ref) => ( - <TabsPrimitive.Trigger - ref={ref} - className={cn( - "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50", - className, - )} - {...props} - /> -)); +>(({ className, ...props }, ref) => { + const variant = React.useContext(TabsVariantContext); + return ( + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent", + variant === "default" && + "data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5", + variant === "underline" && + "text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px", + className, + )} + {...props} + /> + ); +}); TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; const TabsContent = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> ->(({ className, ...props }, ref) => ( - <TabsPrimitive.Content - ref={ref} - className={cn( - "ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden", - className, - )} - {...props} - /> -)); +>(({ className, ...props }, ref) => { + const variant = React.useContext(TabsVariantContext); + return ( + <TabsPrimitive.Content + ref={ref} + className={cn( + "ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden", + variant === "underline" && "px-4", + className, + )} + {...props} + /> + ); +}); TabsContent.displayName = TabsPrimitive.Content.displayName; export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 067042e6..2e7b53d4 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -11,7 +11,7 @@ const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; const tooltipVariants = cva( - "z-50 overflow-visible rounded-sm text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 overflow-visible rounded-sm text-sm shadow-md", { variants: { variant: { @@ -22,7 +22,7 @@ const tooltipVariants = cva( important: "bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]", promotions: - "bg-red-100/90 text-red-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]", + "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]", personal: "bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]", updates: diff --git a/apps/web/src/components/ui/use-overlay-open-change.ts b/apps/web/src/components/ui/use-overlay-open-change.ts new file mode 100644 index 00000000..165efc79 --- /dev/null +++ b/apps/web/src/components/ui/use-overlay-open-change.ts @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useId, useRef } from "react"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; + +export function useOverlayOpenChange({ + source, + open, + onOpenChange, +}: { + source: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const { openOverlay, closeOverlay } = useKeybindingsStore(); + const isTrackedRef = useRef(false); + const isControlled = typeof open === "boolean"; + const overlayId = useId(); + + useEffect(() => { + if (!isControlled) return; + + if (open && !isTrackedRef.current) { + openOverlay(overlayId, source); + isTrackedRef.current = true; + return; + } + + if (!open && isTrackedRef.current) { + closeOverlay(overlayId, source); + isTrackedRef.current = false; + } + }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]); + + useEffect(() => { + return () => { + if (!isTrackedRef.current) return; + // #region agent log + fetch( + "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "3997d9", + }, + body: JSON.stringify({ + sessionId: "3997d9", + runId: "post-fix", + hypothesisId: "H2", + location: "use-overlay-open-change.ts:cleanup", + message: "Overlay closed during unmount cleanup", + data: { source, overlayId }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion + closeOverlay(overlayId, source); + isTrackedRef.current = false; + }; + }, [closeOverlay, overlayId, source]); + + return useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + if (nextOpen && !isTrackedRef.current) { + openOverlay(overlayId, source); + isTrackedRef.current = true; + } else if (!nextOpen && isTrackedRef.current) { + closeOverlay(overlayId, source); + isTrackedRef.current = false; + } + } + + onOpenChange?.(nextOpen); + }, + [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source], + ); +} |
