diff options
Diffstat (limited to 'apps/web/src/components/editor')
20 files changed, 1503 insertions, 970 deletions
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index bc44e650..ddf8308d 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef } from "react"; +import { useState } from "react"; import { TransitionTopIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { @@ -14,35 +14,47 @@ 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 } 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, - type ExportResult, } from "@/types/export"; import { Section, SectionContent, SectionHeader, + SectionTitle, } from "@/components/editor/panels/properties/section"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; +function isExportFormat(value: string): value is ExportFormat { + return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); +} + +function isExportQuality(value: string): value is ExportQuality { + return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); +} + export function ExportButton() { const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); const editor = useEditor(); - const handleExport = () => { - setIsExportPopoverOpen(true); - }; + const hasProject = !!editor.project.getActiveOrNull(); - const hasProject = !!editor.project.getActive(); + const handlePopoverOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + editor.project.cancelExport(); + editor.project.clearExportState(); + } + setIsExportPopoverOpen(open); + }; return ( - <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}> + <Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}> <PopoverTrigger asChild> <button type="button" @@ -50,12 +62,12 @@ export function ExportButton() { "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white", hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50", )} - onClick={hasProject ? handleExport : undefined} + onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined} disabled={!hasProject} onKeyDown={(event) => { if (hasProject && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); - handleExport(); + setIsExportPopoverOpen(true); } }} > @@ -80,71 +92,49 @@ function ExportPopover({ }) { const editor = useEditor(); const activeProject = editor.project.getActive(); + const { isExporting, progress, result: exportResult } = + editor.project.getExportState(); const [format, setFormat] = useState<ExportFormat>( DEFAULT_EXPORT_OPTIONS.format, ); const [quality, setQuality] = useState<ExportQuality>( DEFAULT_EXPORT_OPTIONS.quality, ); - const [includeAudio, setIncludeAudio] = useState<boolean>( - DEFAULT_EXPORT_OPTIONS.includeAudio || true, + const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>( + DEFAULT_EXPORT_OPTIONS.includeAudio ?? true, ); - const [isExporting, setIsExporting] = useState(false); - const [progress, setProgress] = useState(0); - const [exportResult, setExportResult] = useState<ExportResult | null>(null); - const cancelRequestedRef = useRef(false); const handleExport = async () => { if (!activeProject) return; - cancelRequestedRef.current = false; - setIsExporting(true); - setProgress(0); - setExportResult(null); - const result = await editor.project.export({ options: { - format, - quality, - fps: activeProject.settings.fps, - includeAudio, - onProgress: ({ progress }) => setProgress(progress), - onCancel: () => cancelRequestedRef.current, + format, + quality, + fps: activeProject.settings.fps, + includeAudio: shouldIncludeAudio, }, }); - setIsExporting(false); - if (result.cancelled) { - setExportResult(null); - setProgress(0); + editor.project.clearExportState(); return; } - setExportResult(result); - if (result.success && result.buffer) { - const mimeType = getExportMimeType({ format }); - const extension = getExportFileExtension({ format }); - const blob = new Blob([result.buffer], { type: mimeType }); - const url = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.href = url; - a.download = `${activeProject.metadata.name}${extension}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + downloadBuffer({ + buffer: result.buffer, + filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`, + mimeType: getExportMimeType({ format }), + }); + editor.project.clearExportState(); onOpenChange(false); - setExportResult(null); - setProgress(0); } }; const handleCancel = () => { - cancelRequestedRef.current = true; + editor.project.cancelExport(); }; return ( @@ -166,8 +156,10 @@ function ExportPopover({ {!isExporting && ( <> <div className="flex flex-col"> - <Section collapsible defaultOpen={false} hasBorderTop={false}> - <SectionHeader title="Format" /> + <Section collapsible defaultOpen={false} showTopBorder={false}> + <SectionHeader> + <SectionTitle>Format</SectionTitle> + </SectionHeader> <SectionContent> <RadioGroup value={format} @@ -194,7 +186,9 @@ function ExportPopover({ </Section> <Section collapsible defaultOpen={false}> - <SectionHeader title="Quality" /> + <SectionHeader> + <SectionTitle>Quality</SectionTitle> + </SectionHeader> <SectionContent> <RadioGroup value={quality} @@ -219,7 +213,7 @@ function ExportPopover({ <div className="flex items-center space-x-2"> <RadioGroupItem value="very_high" id="very_high" /> <Label htmlFor="very_high"> - Very High - Largest file size + Very high - Largest file size </Label> </div> </RadioGroup> @@ -227,15 +221,17 @@ function ExportPopover({ </Section> <Section collapsible defaultOpen={false}> - <SectionHeader title="Audio" /> + <SectionHeader> + <SectionTitle>Audio</SectionTitle> + </SectionHeader> <SectionContent> <div className="flex items-center space-x-2"> <Checkbox id="include-audio" - checked={includeAudio} - onCheckedChange={(checked) => - setIncludeAudio(!!checked) - } + checked={shouldIncludeAudio} + onCheckedChange={(checked) => + setShouldIncludeAudio(!!checked) + } /> <Label htmlFor="include-audio"> Include audio in export @@ -256,15 +252,15 @@ function ExportPopover({ {isExporting && ( <div className="space-y-4 p-3"> - <div className="flex flex-col"> - <div className="flex items-center justify-between text-center"> - <p className="text-muted-foreground mb-2 text-sm"> - {Math.round(progress * 100)}% - </p> - <p className="text-muted-foreground mb-2 text-sm">100%</p> - </div> - <Progress value={progress * 100} className="w-full" /> + <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> <Button variant="outline" @@ -282,14 +278,6 @@ function ExportPopover({ ); } -function isExportFormat(value: string): value is ExportFormat { - return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); -} - -function isExportQuality(value: string): value is ExportQuality { - return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); -} - function ExportError({ error, onRetry, @@ -306,7 +294,7 @@ function ExportError({ }; return ( - <div className="space-y-4"> + <div className="space-y-4 p-3"> <div className="flex flex-col gap-1.5"> <p className="text-destructive text-sm font-medium">Export failed</p> <p className="text-muted-foreground text-xs">{error}</p> 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 4c6922ff..8814dd4d 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -31,7 +31,12 @@ import { useFileUpload } from "@/hooks/use-file-upload"; import { useRevealItem } from "@/hooks/use-reveal-item"; import { processMediaAssets } from "@/lib/media/processing"; import { buildElementFromMedia } from "@/lib/timeline/element-utils"; -import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import { + type MediaSortKey, + type MediaSortOrder, + type MediaViewMode, + useAssetsPanelStore, +} from "@/stores/assets-panel-store"; import type { MediaAsset } from "@/types/assets"; import { cn } from "@/utils/ui"; import { @@ -50,8 +55,15 @@ export function MediaView() { const mediaFiles = editor.media.getAssets(); const activeProject = editor.project.getActive(); - const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } = - useAssetsPanelStore(); + const { + mediaViewMode, + setMediaViewMode, + highlightMediaId, + clearHighlight, + mediaSortBy, + mediaSortOrder, + setMediaSort, + } = useAssetsPanelStore(); const { highlightedId, registerElement } = useRevealItem( highlightMediaId, clearHighlight, @@ -59,10 +71,6 @@ export function MediaView() { const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); - const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( - "name", - ); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const processFiles = async ({ files }: { files: FileList }) => { if (!files || files.length === 0) return; @@ -121,27 +129,12 @@ export function MediaView() { }); }; - const addElementAtTime = ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }): boolean => { - const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - const element = buildElementFromMedia({ - mediaId: asset.id, - mediaType: asset.type, - name: asset.name, - duration, - startTime, - }); - editor.timeline.insertElement({ - element, - placement: { mode: "auto" }, - }); - return true; + const handleSort = ({ key }: { key: SortKey }) => { + if (mediaSortBy === key) { + setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc"); + } else { + setMediaSort(key, "asc"); + } }; const filteredMediaItems = useMemo(() => { @@ -151,7 +144,7 @@ export function MediaView() { let valueA: string | number; let valueB: string | number; - switch (sortBy) { + switch (mediaSortBy) { case "name": valueA = a.name.toLowerCase(); valueB = b.name.toLowerCase(); @@ -172,154 +165,13 @@ export function MediaView() { return 0; } - if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; - if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; + if (valueA < valueB) return mediaSortOrder === "asc" ? -1 : 1; + if (valueA > valueB) return mediaSortOrder === "asc" ? 1 : -1; return 0; }); return filtered; - }, [mediaFiles, sortBy, sortOrder]); - - const previewComponents = useMemo(() => { - const previews = new Map<string, React.ReactNode>(); - - filteredMediaItems.forEach((item) => { - previews.set(item.id, <MediaPreview item={item} />); - previews.set( - `compact-${item.id}`, - <MediaPreview item={item} variant="compact" />, - ); - }); - - return previews; - }, [filteredMediaItems]); - - const renderPreview = (item: MediaAsset) => previewComponents.get(item.id); - const renderCompactPreview = (item: MediaAsset) => - previewComponents.get(`compact-${item.id}`); - - const mediaActions = ( - <div> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - size="icon" - variant="ghost" - onClick={() => - setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid") - } - disabled={isProcessing} - className="items-center justify-center" - > - {mediaViewMode === "grid" ? ( - <HugeiconsIcon icon={LeftToRightListDashIcon} /> - ) : ( - <HugeiconsIcon icon={GridViewIcon} /> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - <p> - {mediaViewMode === "grid" - ? "Switch to list view" - : "Switch to grid view"} - </p> - </TooltipContent> - <Tooltip> - <DropdownMenu> - <TooltipTrigger asChild> - <DropdownMenuTrigger asChild> - <Button - size="icon" - variant="ghost" - disabled={isProcessing} - className="items-center justify-center" - > - <HugeiconsIcon icon={SortingOneNineIcon} /> - </Button> - </DropdownMenuTrigger> - </TooltipTrigger> - <DropdownMenuContent align="end"> - <SortMenuItem - label="Name" - sortKey="name" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="Type" - sortKey="type" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="Duration" - sortKey="duration" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="File size" - sortKey="size" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - </DropdownMenuContent> - </DropdownMenu> - <TooltipContent> - <p> - Sort by {sortBy} ( - {sortOrder === "asc" ? "ascending" : "descending"}) - </p> - </TooltipContent> - </Tooltip> - </Tooltip> - </TooltipProvider> - <Button - variant="outline" - onClick={openFilePicker} - disabled={isProcessing} - size="sm" - className="items-center justify-center gap-1.5 ml-1.5" - > - <HugeiconsIcon icon={CloudUploadIcon} /> - Import - </Button> - </div> - ); + }, [mediaFiles, mediaSortBy, mediaSortOrder]); return ( <> @@ -327,8 +179,18 @@ export function MediaView() { <PanelView title="Assets" - actions={mediaActions} - className={isDragOver ? "bg-accent/30" : ""} + actions={ + <MediaActions + mediaViewMode={mediaViewMode} + setMediaViewMode={setMediaViewMode} + isProcessing={isProcessing} + sortBy={mediaSortBy} + sortOrder={mediaSortOrder} + onSort={handleSort} + onImport={openFilePicker} + /> + } + className={cn(isDragOver && "bg-accent/30")} {...dragProps} > {isDragOver || filteredMediaItems.length === 0 ? ( @@ -338,21 +200,11 @@ export function MediaView() { progress={progress} onClick={openFilePicker} /> - ) : mediaViewMode === "grid" ? ( - <GridView - items={filteredMediaItems} - renderPreview={renderPreview} - onRemove={handleRemove} - onAddToTimeline={addElementAtTime} - highlightedId={highlightedId} - registerElement={registerElement} - /> ) : ( - <ListView + <MediaItemList items={filteredMediaItems} - renderPreview={renderCompactPreview} + mode={mediaViewMode} onRemove={handleRemove} - onAddToTimeline={addElementAtTime} highlightedId={highlightedId} registerElement={registerElement} /> @@ -362,6 +214,67 @@ export function MediaView() { ); } +function MediaAssetDraggable({ + item, + preview, + isHighlighted, + variant, + isRounded, +}: { + item: MediaAsset; + preview: React.ReactNode; + isHighlighted: boolean; + variant: "card" | "compact"; + isRounded?: boolean; +}) { + const editor = useEditor(); + + const addElementAtTime = ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => { + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const element = buildElementFromMedia({ + mediaId: asset.id, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + }); + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + <DraggableItem + name={item.name} + preview={preview} + dragData={{ + id: item.id, + type: "media", + mediaType: item.type, + name: item.name, + ...(item.type !== "audio" && { + targetElementTypes: ["video", "image"] as const, + }), + }} + shouldShowPlusOnDrag={false} + onAddToTimeline={({ currentTime }) => + addElementAtTime({ asset: item, startTime: currentTime }) + } + variant={variant} + isRounded={isRounded} + isHighlighted={isHighlighted} + /> + ); +} + function MediaItemWithContextMenu({ item, children, @@ -387,107 +300,41 @@ function MediaItemWithContextMenu({ ); } -function GridView({ +function MediaItemList({ items, - renderPreview, + mode, onRemove, - onAddToTimeline, highlightedId, registerElement, }: { items: MediaAsset[]; - renderPreview: (item: MediaAsset) => React.ReactNode; + mode: MediaViewMode; onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - onAddToTimeline: ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }) => boolean; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const isGrid = mode === "grid"; + return ( <div - className="grid gap-2" - style={{ - gridTemplateColumns: "repeat(auto-fill, 160px)", - }} + className={cn(isGrid ? "grid gap-2" : "flex flex-col gap-1")} + style={ + isGrid ? { gridTemplateColumns: "repeat(auto-fill, 160px)" } : undefined + } > {items.map((item) => ( - <div key={item.id} ref={(el) => registerElement(item.id, el)}> + <div key={item.id} ref={(element) => registerElement(item.id, element)}> <MediaItemWithContextMenu item={item} onRemove={onRemove}> - <DraggableItem - name={item.name} - preview={renderPreview(item)} - dragData={{ - id: item.id, - type: "media", - mediaType: item.type, - name: item.name, - ...(item.type !== "audio" && { - targetElementTypes: ["video", "image"] as const, - }), - }} - shouldShowPlusOnDrag={false} - onAddToTimeline={({ currentTime }) => - onAddToTimeline({ asset: item, startTime: currentTime }) - } - isRounded={false} - variant="card" - isHighlighted={highlightedId === item.id} - /> - </MediaItemWithContextMenu> - </div> - ))} - </div> - ); -} - -function ListView({ - items, - renderPreview, - onRemove, - onAddToTimeline, - highlightedId, - registerElement, -}: { - items: MediaAsset[]; - renderPreview: (item: MediaAsset) => React.ReactNode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - onAddToTimeline: ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }) => boolean; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; -}) { - return ( - <div className="space-y-1"> - {items.map((item) => ( - <div key={item.id} ref={(el) => registerElement(item.id, el)}> - <MediaItemWithContextMenu item={item} onRemove={onRemove}> - <DraggableItem - name={item.name} - preview={renderPreview(item)} - dragData={{ - id: item.id, - type: "media", - mediaType: item.type, - name: item.name, - ...(item.type !== "audio" && { - targetElementTypes: ["video", "image"] as const, - }), - }} - shouldShowPlusOnDrag={false} - onAddToTimeline={({ currentTime }) => - onAddToTimeline({ asset: item, startTime: currentTime }) + <MediaAssetDraggable + item={item} + preview={ + <MediaPreview + item={item} + variant={isGrid ? "grid" : "compact"} + /> } - variant="compact" + variant={isGrid ? "card" : "compact"} + isRounded={isGrid ? false : undefined} isHighlighted={highlightedId === item.id} /> </MediaItemWithContextMenu> @@ -497,11 +344,11 @@ function ListView({ ); } -const formatDuration = ({ duration }: { duration: number }) => { +export function formatDuration({ duration }: { duration: number }) { const min = Math.floor(duration / 60); const sec = Math.floor(duration % 60); return `${min}:${sec.toString().padStart(2, "0")}`; -}; +} function MediaDurationBadge({ duration }: { duration?: number }) { if (!duration) return null; @@ -619,6 +466,119 @@ function MediaPreview({ ); } +function MediaActions({ + mediaViewMode, + setMediaViewMode, + isProcessing, + sortBy, + sortOrder, + onSort, + onImport, +}: { + mediaViewMode: MediaViewMode; + setMediaViewMode: (mode: MediaViewMode) => void; + isProcessing: boolean; + sortBy: MediaSortKey; + sortOrder: MediaSortOrder; + onSort: ({ key }: { key: MediaSortKey }) => void; + onImport: () => void; +}) { + return ( + <div className="flex gap-1.5"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + size="icon" + variant="ghost" + onClick={() => + setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid") + } + disabled={isProcessing} + className="items-center justify-center" + > + {mediaViewMode === "grid" ? ( + <HugeiconsIcon icon={LeftToRightListDashIcon} /> + ) : ( + <HugeiconsIcon icon={GridViewIcon} /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} + </p> + </TooltipContent> + </Tooltip> + <Tooltip> + <DropdownMenu> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + size="icon" + variant="ghost" + disabled={isProcessing} + className="items-center justify-center" + > + <HugeiconsIcon icon={SortingOneNineIcon} /> + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <DropdownMenuContent align="end"> + <SortMenuItem + label="Name" + sortKey="name" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={onSort} + /> + <SortMenuItem + label="Type" + sortKey="type" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={onSort} + /> + <SortMenuItem + label="Duration" + sortKey="duration" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={onSort} + /> + <SortMenuItem + label="File size" + sortKey="size" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={onSort} + /> + </DropdownMenuContent> + </DropdownMenu> + <TooltipContent> + <p> + Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + <Button + variant="outline" + onClick={onImport} + disabled={isProcessing} + size="sm" + className="items-center justify-center gap-1.5" + > + <HugeiconsIcon icon={CloudUploadIcon} /> + Import + </Button> + </div> + ); +} + function SortMenuItem({ label, sortKey, @@ -627,10 +587,10 @@ function SortMenuItem({ onSort, }: { label: string; - sortKey: "name" | "type" | "duration" | "size"; - currentSortBy: string; - currentSortOrder: "asc" | "desc"; - onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void; + sortKey: MediaSortKey; + currentSortBy: MediaSortKey; + currentSortOrder: MediaSortOrder; + onSort: ({ key }: { key: MediaSortKey }) => void; }) { const isActive = currentSortBy === sortKey; const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : ""; diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx index 7e10ac9d..d46e9d1f 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -16,6 +16,7 @@ import { Section, SectionContent, SectionHeader, + SectionTitle, } from "@/components/editor/panels/properties/section"; import { Label } from "@/components/ui/label"; import { @@ -23,25 +24,46 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { useState } from "react"; -export function SettingsView() { - const [open, setOpen] = useState(false); +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 hasBorderTop={false}> + <Section showTopBorder={false}> <SectionContent> <ProjectInfoContent /> </SectionContent> </Section> - <Popover open={open} onOpenChange={setOpen}> + <Popover> <Section className="cursor-pointer"> <PopoverTrigger asChild> <div> - <SectionHeader title="Background"> - <div className="size-4 rounded-sm bg-red-500" /> + <SectionHeader + trailing={<div className="size-4 rounded-sm bg-red-500" />} + > + <SectionTitle>Background</SectionTitle> </SectionHeader> </div> </PopoverTrigger> @@ -60,26 +82,6 @@ function ProjectInfoContent() { 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; @@ -87,12 +89,11 @@ function ProjectInfoContent() { presets: canvasPresets, targetAspectRatio: currentAspectRatio, }); - const originalPresetValue = "original"; const selectedPresetValue = - presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; + presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE; const handleAspectRatioChange = ({ value }: { value: string }) => { - if (value === originalPresetValue) { + if (value === ORIGINAL_PRESET_VALUE) { const canvasSize = originalCanvasSize ?? currentCanvasSize; editor.project.updateSettings({ settings: { canvasSize }, @@ -106,7 +107,7 @@ function ProjectInfoContent() { } }; - const handleFpsChange = (value: string) => { + const handleFpsChange = ({ value }: { value: string }) => { const fps = parseFloat(value); editor.project.updateSettings({ settings: { fps } }); }; @@ -129,7 +130,7 @@ function ProjectInfoContent() { <SelectValue placeholder="Select an aspect ratio" /> </SelectTrigger> <SelectContent> - <SelectItem value={originalPresetValue}>Original</SelectItem> + <SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem> {canvasPresets.map((preset, index) => { const label = dimensionToAspectRatio({ width: preset.width, @@ -148,7 +149,7 @@ function ProjectInfoContent() { <Label>Frame rate</Label> <Select value={activeProject.settings.fps.toString()} - onValueChange={handleFpsChange} + onValueChange={(value) => handleFpsChange({ value })} > <SelectTrigger className="w-fit"> <SelectValue placeholder="Select a frame rate" /> 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 fdd4055d..26649b26 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 @@ -56,7 +56,7 @@ export function TextEditOverlay({ }, [editor.timeline, trackId, elementId]); const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { + ({ event }: { event: React.KeyboardEvent }) => { const { key } = event; if (key === "Escape") { event.preventDefault(); @@ -98,10 +98,13 @@ export function TextEditOverlay({ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; const letterSpacing = element.letterSpacing ?? 0; - const backgroundColor = - element.background.color === "transparent" - ? "transparent" - : element.background.color; + const shouldShowBackground = + element.background.enabled && + element.background.color && + element.background.color !== "transparent"; + const backgroundColor = shouldShowBackground + ? element.background.color + : "transparent"; return ( <div @@ -134,13 +137,12 @@ export function TextEditOverlay({ backgroundColor, minHeight: displayFontSize * lineHeight, textDecoration: element.textDecoration ?? "none", - padding: - backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING, + padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0, minWidth: 1, }} onInput={handleInput} onBlur={onCommit} - onKeyDown={handleKeyDown} + onKeyDown={(event) => handleKeyDown({ event })} > {element.content || ""} </div> 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 976c3019..2fcf8096 100644 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -23,7 +23,7 @@ const CORNERS: Corner[] = [ "bottom-right", ]; -function getCornerPosition({ +export function getCornerPosition({ bounds, corner, }: { @@ -47,7 +47,7 @@ function getCornerPosition({ }; } -function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { +export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { x: number; y: number; } { @@ -61,7 +61,7 @@ function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { }; } -function getOverlayContext({ +export function getOverlayContext({ canvasRef, containerRef, }: { @@ -196,7 +196,7 @@ function BoundingBoxOutline({ height: outlineHeight, transform: `rotate(${rotation}deg)`, transformOrigin: "center center", - border: "1px solid white", + border: "1px dashed white", boxSizing: "border-box", opacity: 0.75, }} diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx index 0b83a4b6..2ad46497 100644 --- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx @@ -1,5 +1,3 @@ -import type { AudioElement } from "@/types/timeline"; - -export function AudioProperties({ _element }: { _element: AudioElement }) { +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 new file mode 100644 index 00000000..1caf73eb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { 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 ?? []; + + 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={ + <> + <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> + </> + } + > + <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/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx new file mode 100644 index 00000000..408d430a --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx @@ -0,0 +1,152 @@ +"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 index bf137291..75fc30a1 100644 --- a/apps/web/src/components/editor/panels/properties/effect-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx @@ -1,78 +1,16 @@ "use client"; import type { EffectElement } from "@/types/timeline"; -import type { EffectParamDefinition } from "@/types/effects"; import { getEffect } from "@/lib/effects/registry"; import { useEditor } from "@/hooks/use-editor"; -import { clamp } from "@/utils/math"; -import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section"; -import { Slider } from "@/components/ui/slider"; -import { NumberField } from "@/components/ui/number-field"; -import { usePropertyDraft } from "./hooks/use-property-draft"; - -function EffectParamField({ - param, - element, - trackId, -}: { - param: EffectParamDefinition; - element: EffectElement; - trackId: string; -}) { - const editor = useEditor(); - - const currentValue = Number(element.params[param.key] ?? param.default); - const min = param.min ?? 0; - const max = param.max ?? 100; - const step = param.step ?? 1; - - const updateParam = (value: number) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { params: { ...element.params, [param.key]: value } }, - }, - ], - }); - - const commitParam = () => editor.timeline.commitPreview(); - - const draft = usePropertyDraft({ - displayValue: String(currentValue), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min, max }); - }, - onPreview: updateParam, - onCommit: commitParam, - }); - - return ( - <SectionField label={param.label}> - <div className="flex items-center gap-3"> - <Slider - className="flex-1" - min={min} - max={max} - step={step} - value={[currentValue]} - onValueChange={([value]) => updateParam(value)} - onValueCommit={commitParam} - /> - <NumberField - className="w-16 shrink-0" - value={draft.displayValue} - onFocus={draft.onFocus} - onChange={draft.onChange} - onBlur={draft.onBlur} - /> - </div> - </SectionField> - ); -} +import { + Section, + SectionContent, + SectionHeader, + SectionFields, + SectionTitle, +} from "./section"; +import { EffectParamField } from "./effect-param-field"; export function EffectProperties({ element, @@ -81,19 +19,36 @@ export function EffectProperties({ 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 hasBorderTop={false}> - <SectionHeader title={definition.name} /> + <Section showTopBorder={false}> + <SectionHeader> + <SectionTitle>{definition.name}</SectionTitle> + </SectionHeader> <SectionContent> <SectionFields> {definition.params.map((param) => ( <EffectParamField key={param.key} param={param} - element={element} - trackId={trackId} + value={element.params[param.key] ?? param.default} + onPreview={previewParam({ key: param.key })} + onCommit={() => editor.timeline.commitPreview()} /> ))} </SectionFields> diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 75dd81a9..cb45996d 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -5,9 +5,12 @@ 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 { 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({ @@ -21,7 +24,7 @@ function ElementProperties({ return <TextProperties element={element} trackId={track.id} />; } if (element.type === "audio") { - return <AudioProperties _element={element} />; + return <AudioProperties />; } if ( element.type === "video" || @@ -39,16 +42,33 @@ function ElementProperties({ export function PropertiesPanel() { const editor = useEditor(); const { selectedElements } = useElementSelection(); + const clipEffectsTarget = usePropertiesStore( + (state) => state.clipEffectsTarget, + ); + + 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); const elementsWithTracks = editor.timeline.getElementsWithTracks({ elements: selectedElements, }); - const hasSelection = selectedElements.length > 0; - return ( <div className="panel bg-background h-full rounded-sm border overflow-hidden"> - {hasSelection ? ( + {isShowingClipEffects ? ( + <ClipEffectsProperties + element={clipEffectsElement} + trackId={clipEffectsTrack.id} + /> + ) : selectedElements.length > 0 ? ( <ScrollArea className="h-full scrollbar-hidden"> {elementsWithTracks.map(({ track, element }) => ( <ElementProperties diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx index 3edd17b5..4428b59c 100644 --- a/apps/web/src/components/editor/panels/properties/section.tsx +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -1,10 +1,12 @@ -import { createContext, useContext, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; import { ArrowDownIcon } from "@hugeicons/core-free-icons"; import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; const sectionExpandedCache = new Map<string, boolean>(); +const mountedSectionKeys = new Set<string>(); interface SectionContext { isOpen: boolean; @@ -24,8 +26,8 @@ interface SectionProps { defaultOpen?: boolean; sectionKey?: string; className?: string; - hasBorderTop?: boolean; - hasBorderBottom?: boolean; + showTopBorder?: boolean; + showBottomBorder?: boolean; } export function Section({ @@ -34,12 +36,21 @@ export function Section({ defaultOpen = true, sectionKey, className, - hasBorderTop = true, - hasBorderBottom = true, + showTopBorder = true, + showBottomBorder = true, }: SectionProps) { const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; const [isOpen, setIsOpen] = useState(cached ?? defaultOpen); + useEffect(() => { + if (!sectionKey) return; + if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) { + console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`); + } + mountedSectionKeys.add(sectionKey); + return () => { mountedSectionKeys.delete(sectionKey); }; + }, [sectionKey]); + const toggle = () => { const next = !isOpen; setIsOpen(next); @@ -51,8 +62,8 @@ export function Section({ <div className={cn( "flex flex-col", - hasBorderTop && "border-t", - hasBorderBottom && "last:border-b", + showTopBorder && "border-t", + showBottomBorder && "last:border-b", className, )} > @@ -63,15 +74,19 @@ export function Section({ } interface SectionHeaderProps { - title: string; children?: React.ReactNode; + trailing?: React.ReactNode; + leading?: React.ReactNode; + actions?: React.ReactNode; onClick?: () => void; className?: string; } export function SectionHeader({ - title, children, + trailing, + leading, + actions, onClick, className, }: SectionHeaderProps) { @@ -79,57 +94,108 @@ export function SectionHeader({ const isCollapsible = ctx?.collapsible ?? false; const isOpen = ctx?.isOpen ?? true; const isInteractive = isCollapsible || !!onClick; - const handleClick = isCollapsible ? ctx?.toggle : onClick; - const content = ( + const chevronIcon = isCollapsible ? ( + <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", + )} + /> + ) : null; + + const headerContent = ( <> - <span - className={cn( - "text-sm font-medium", - isOpen ? "text-foreground" : "text-muted-foreground", - )} - > - {title} - </span> - <div className="flex items-center gap-1"> - {children} - {isCollapsible && ( - <HugeiconsIcon - icon={ArrowDownIcon} - className={cn( - "size-3 shrink-0 transition-transform duration-200 ease-out", - isOpen - ? "rotate-0 text-foreground" - : "-rotate-90 text-muted-foreground", - )} - /> - )} - </div> + {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} </> ); - const baseClassName = cn( - "flex w-full items-center justify-between h-11 px-3.5", - className, + 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?.(); }} + > + {headerContent} + </div> ); +} - if (isInteractive) { +export function SectionTitle({ + children, + className, + onClick, +}: { + children: React.ReactNode; + className?: string; + onClick?: () => void; +}) { + const ctx = useSectionContext(); + const isOpen = ctx?.isOpen ?? true; + + if (onClick) { return ( <button type="button" - className={cn(baseClassName, "cursor-pointer text-left")} - onClick={(event) => { - handleClick?.(); - event.currentTarget.blur(); - }} + className={cn( + "cursor-pointer text-sm font-medium", + isOpen ? "text-foreground" : "text-muted-foreground", + className, + )} + onClick={onClick} > - {content} + {children} </button> ); } - return <div className={baseClassName}>{content}</div>; + return ( + <span + className={cn( + "text-sm font-medium", + isOpen ? "text-foreground" : "text-muted-foreground", + className, + )} + > + {children} + </span> + ); } export function SectionFields({ diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx index 4343c692..66fe820f 100644 --- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx @@ -8,7 +8,7 @@ import { } from "@/constants/timeline-constants"; import { OcCheckerboardIcon } from "@opencut/ui/icons"; import { Fragment, useRef } from "react"; -import { Section, SectionContent, SectionField, SectionHeader } from "../section"; +import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section"; import { Select, SelectContent, @@ -74,7 +74,7 @@ export function BlendingSection({ committedBlendModeRef.current = blendMode; } - const previewBlendMode = (value: BlendMode) => + const previewBlendMode = ({ value }: { value: BlendMode }) => editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, updates: { blendMode: value } }, @@ -123,7 +123,7 @@ export function BlendingSection({ return ( <Section collapsible sectionKey={`${element.type}:blending`}> - <SectionHeader title="Blending" /> + <SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader> <SectionContent> <div className="flex items-start gap-2"> <SectionField label="Opacity" className="w-1/2"> @@ -175,7 +175,7 @@ export function BlendingSection({ key={option.value} value={option.value} onPointerEnter={() => - previewBlendMode(option.value as BlendMode) + previewBlendMode({ value: option.value as BlendMode }) } > {option.label} diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx index 3fdb47dc..6aaaa1fe 100644 --- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -9,6 +9,7 @@ import { SectionField, SectionFields, SectionHeader, + SectionTitle, } from "../section"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -24,12 +25,12 @@ import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation"; import { KeyframeToggle } from "../keyframe-toggle"; import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -function parseNumericInput({ input }: { input: string }): number | null { +export function parseNumericInput({ input }: { input: string }): number | null { const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : parsed; } -function isPropertyAtDefault({ +export function isPropertyAtDefault({ hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue, @@ -55,9 +56,11 @@ function isPropertyAtDefault({ export function TransformSection({ element, trackId, + showTopBorder = true, }: { element: VisualElement; trackId: string; + showTopBorder?: boolean; }) { const editor = useEditor(); const [isScaleLocked, setIsScaleLocked] = useState(false); @@ -239,8 +242,12 @@ export function TransformSection({ }; return ( - <Section collapsible sectionKey={`${element.type}:transform`}> - <SectionHeader title="Transform" /> + <Section + collapsible + sectionKey={`${element.type}:transform`} + showTopBorder={showTopBorder} + > + <SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader> <SectionContent> <SectionFields> <SectionField @@ -261,11 +268,11 @@ export function TransformSection({ <NumberField icon="H" {...scaleFieldProps} /> </> ) : ( - <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldProps} - className="flex-1" - /> + <NumberField + icon={<HugeiconsIcon icon={ArrowExpandIcon} />} + {...scaleFieldProps} + className="flex-1" + /> )} <Button type="button" diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index 1a17c26a..73f3f1a3 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -3,13 +3,23 @@ import { FontPicker } from "@/components/ui/font-picker"; import type { TextElement } from "@/types/timeline"; import { NumberField } from "@/components/ui/number-field"; import { useRef } from "react"; -import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "./section"; import { ColorPicker } from "@/components/ui/color-picker"; +import { Button } from "@/components/ui/button"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_BACKGROUND, @@ -20,30 +30,13 @@ import { import { usePropertyDraft } from "./hooks/use-property-draft"; import { TransformSection, BlendingSection } from "./sections"; import { HugeiconsIcon } from "@hugeicons/react"; -import { TextFontIcon } from "@hugeicons/core-free-icons"; +import { + TextFontIcon, + ViewIcon, + ViewOffSlashIcon, +} from "@hugeicons/core-free-icons"; import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons"; - -function createOffsetConverter({ - defaultValue, - scale = 1, - min, -}: { - defaultValue: number; - scale?: number; - min?: number; -}) { - return { - toDisplay: (value: number) => Math.round((value - defaultValue) * scale), - fromDisplay: (display: number) => { - const stored = defaultValue + display / scale; - return min !== undefined ? Math.max(min, stored) : stored; - }, - }; -} - -const lineHeightConverter = createOffsetConverter({ defaultValue: DEFAULT_LINE_HEIGHT, scale: 10 }); -const paddingXConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, min: 0 }); -const paddingYConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, min: 0 }); +import { cn } from "@/utils/ui"; export function TextProperties({ element, @@ -86,8 +79,8 @@ function ContentSection({ }); return ( - <Section collapsible sectionKey="text:content" hasBorderTop={false}> - <SectionHeader title="Content" /> + <Section collapsible sectionKey="text:content" showTopBorder={false}> + <SectionHeader><SectionTitle>Content</SectionTitle></SectionHeader> <SectionContent> <Textarea placeholder="Name" @@ -129,71 +122,71 @@ function TypographySection({ return ( <Section collapsible sectionKey="text:typography"> - <SectionHeader title="Typography" /> - <SectionContent> - <SectionFields> - <SectionField label="Font"> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontFamily: value }, - }, - ], - }) - } - /> - </SectionField> - <SectionField label="Size"> - <NumberField - value={fontSize.displayValue} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={fontSize.onFocus} - onChange={fontSize.onChange} - onBlur={fontSize.onBlur} - onScrub={fontSize.scrubTo} - onScrubEnd={fontSize.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} - icon={<HugeiconsIcon icon={TextFontIcon} />} - /> - </SectionField> - <SectionField label="Color"> - <ColorPicker - value={uppercase({ - string: (element.color || "FFFFFF").replace("#", ""), - })} - onChange={(color) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - }) - } - onChangeEnd={() => editor.timeline.commitPreview()} - /> - </SectionField> - </SectionFields> - </SectionContent> + <SectionHeader><SectionTitle>Typography</SectionTitle></SectionHeader> + <SectionContent> + <SectionFields> + <SectionField label="Font"> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value) => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }, + ], + }) + } + /> + </SectionField> + <SectionField label="Size"> + <NumberField + value={fontSize.displayValue} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={fontSize.onFocus} + onChange={fontSize.onChange} + onBlur={fontSize.onBlur} + onScrub={fontSize.scrubTo} + onScrubEnd={fontSize.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize }, + }, + ], + }) + } + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + icon={<HugeiconsIcon icon={TextFontIcon} />} + /> + </SectionField> + <SectionField label="Color"> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + }) + } + onChangeEnd={() => editor.timeline.commitPreview()} + /> + </SectionField> + </SectionFields> + </SectionContent> </Section> ); } @@ -208,72 +201,98 @@ function SpacingSection({ const editor = useEditor(); const letterSpacing = usePropertyDraft({ - displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(), + displayValue: Math.round( + element.letterSpacing ?? DEFAULT_LETTER_SPACING, + ).toString(), parse: (input) => { const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : Math.round(parsed); }, onPreview: (value) => editor.timeline.previewElements({ - updates: [{ trackId, elementId: element.id, updates: { letterSpacing: value } }], + updates: [ + { trackId, elementId: element.id, updates: { letterSpacing: value } }, + ], }), onCommit: () => editor.timeline.commitPreview(), }); const lineHeight = usePropertyDraft({ - displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(), + displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1), parse: (input) => { const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed)); + return Number.isNaN(parsed) + ? null + : Math.max(0.1, Math.round(parsed * 10) / 10); }, onPreview: (value) => editor.timeline.previewElements({ - updates: [{ trackId, elementId: element.id, updates: { lineHeight: value } }], + updates: [ + { trackId, elementId: element.id, updates: { lineHeight: value } }, + ], }), onCommit: () => editor.timeline.commitPreview(), }); return ( - <Section collapsible sectionKey="text:spacing" hasBorderBottom={false}> - <SectionHeader title="Spacing" /> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField label="Letter spacing" className="w-1/2"> - <NumberField - value={letterSpacing.displayValue} - onFocus={letterSpacing.onFocus} - onChange={letterSpacing.onChange} - onBlur={letterSpacing.onBlur} - onScrub={letterSpacing.scrubTo} - onScrubEnd={letterSpacing.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }], - }) - } - isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING} - icon={<OcTextWidthIcon size={14} />} - /> - </SectionField> - <SectionField label="Line height" className="w-1/2"> - <NumberField - value={lineHeight.displayValue} - onFocus={lineHeight.onFocus} - onChange={lineHeight.onChange} - onBlur={lineHeight.onBlur} - onScrub={lineHeight.scrubTo} - onScrubEnd={lineHeight.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }], - }) - } - isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT} - icon={<OcTextHeightIcon size={14} />} - /> - </SectionField> - </div> - </SectionContent> + <Section collapsible sectionKey="text:spacing" showBottomBorder={false}> + <SectionHeader><SectionTitle>Spacing</SectionTitle></SectionHeader> + <SectionContent> + <div className="flex items-start gap-2"> + <SectionField label="Letter spacing" className="w-1/2"> + <NumberField + value={letterSpacing.displayValue} + onFocus={letterSpacing.onFocus} + onChange={letterSpacing.onChange} + onBlur={letterSpacing.onBlur} + onScrub={letterSpacing.scrubTo} + onScrubEnd={letterSpacing.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { letterSpacing: DEFAULT_LETTER_SPACING }, + }, + ], + }) + } + isDefault={ + (element.letterSpacing ?? DEFAULT_LETTER_SPACING) === + DEFAULT_LETTER_SPACING + } + icon={<OcTextWidthIcon size={14} />} + /> + </SectionField> + <SectionField label="Line height" className="w-1/2"> + <NumberField + value={lineHeight.displayValue} + onFocus={lineHeight.onFocus} + onChange={lineHeight.onChange} + onBlur={lineHeight.onBlur} + onScrub={lineHeight.scrubTo} + onScrubEnd={lineHeight.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { lineHeight: DEFAULT_LINE_HEIGHT }, + }, + ], + }) + } + isDefault={ + (element.lineHeight ?? DEFAULT_LINE_HEIGHT) === + DEFAULT_LINE_HEIGHT + } + icon={<OcTextHeightIcon size={14} />} + /> + </SectionField> + </div> + </SectionContent> </Section> ); } @@ -289,10 +308,21 @@ function BackgroundSection({ const lastSelectedColor = useRef(DEFAULT_COLOR); const cornerRadius = usePropertyDraft({ - displayValue: Math.round(element.background.cornerRadius ?? 0).toString(), + displayValue: Math.round( + clamp({ + value: element.background.cornerRadius ?? 0, + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + }), + ).toString(), parse: (input) => { const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); + if (Number.isNaN(parsed)) return null; + return clamp({ + value: Math.round(parsed), + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + }); }, onPreview: (value) => editor.timeline.previewElements({ @@ -310,10 +340,12 @@ function BackgroundSection({ }); const paddingX = usePropertyDraft({ - displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(), + displayValue: Math.round( + element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + ).toString(), parse: (input) => { const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed)); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); }, onPreview: (value) => editor.timeline.previewElements({ @@ -329,10 +361,12 @@ function BackgroundSection({ }); const paddingY = usePropertyDraft({ - displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(), + displayValue: Math.round( + element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + ).toString(), parse: (input) => { const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed)); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); }, onPreview: (value) => editor.timeline.previewElements({ @@ -385,14 +419,63 @@ function BackgroundSection({ onCommit: () => editor.timeline.commitPreview(), }); + const toggleBackgroundEnabled = () => { + const enabled = !element.background.enabled; + const color = + enabled && element.background.color === "transparent" + ? lastSelectedColor.current + : element.background.color; + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { + ...element.background, + enabled, + color, + }, + }, + }, + ], + }); + }; + return ( - <Section collapsible sectionKey="text:background"> - <SectionHeader title="Background" /> - <SectionContent> + <Section + collapsible + defaultOpen={element.background.enabled} + sectionKey="text:background" + > + <SectionHeader + trailing={ + <Button + variant="ghost" + size="icon" + onClick={(event) => { + event.stopPropagation(); + toggleBackgroundEnabled(); + }} + > + <HugeiconsIcon + icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon} + /> + </Button> + } + > + <SectionTitle>Background</SectionTitle> + </SectionHeader> + <SectionContent + className={cn( + !element.background.enabled && "pointer-events-none opacity-50", + )} + > <SectionFields> <SectionField label="Color"> <ColorPicker value={ + !element.background.enabled || element.background.color === "transparent" ? lastSelectedColor.current.replace("#", "") : element.background.color.replace("#", "") @@ -415,19 +498,14 @@ function BackgroundSection({ }); }} onChangeEnd={() => editor.timeline.commitPreview()} - className={ - element.background.color === "transparent" - ? "pointer-events-none opacity-50" - : "" - } /> </SectionField> <div className="flex items-start gap-2"> - <SectionField label="Width" className="w-1/2"> - <NumberField - icon="W" - value={paddingX.displayValue} - min={0} + <SectionField label="Width" className="w-1/2"> + <NumberField + icon="W" + value={paddingX.displayValue} + min={0} onFocus={paddingX.onFocus} onChange={paddingX.onChange} onBlur={paddingX.onBlur} @@ -450,16 +528,17 @@ function BackgroundSection({ }) } isDefault={ - (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) === + (element.background.paddingX ?? + DEFAULT_TEXT_BACKGROUND.paddingX) === DEFAULT_TEXT_BACKGROUND.paddingX } /> </SectionField> - <SectionField label="Height" className="w-1/2"> - <NumberField - icon="H" - value={paddingY.displayValue} - min={0} + <SectionField label="Height" className="w-1/2"> + <NumberField + icon="H" + value={paddingY.displayValue} + min={0} onFocus={paddingY.onFocus} onChange={paddingY.onChange} onBlur={paddingY.onBlur} @@ -482,17 +561,18 @@ function BackgroundSection({ }) } isDefault={ - (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) === + (element.background.paddingY ?? + DEFAULT_TEXT_BACKGROUND.paddingY) === DEFAULT_TEXT_BACKGROUND.paddingY } /> </SectionField> </div> <div className="flex items-start gap-2"> - <SectionField label="X-offset" className="w-1/2"> - <NumberField - icon="X" - value={offsetX.displayValue} + <SectionField label="X-offset" className="w-1/2"> + <NumberField + icon="X" + value={offsetX.displayValue} onFocus={offsetX.onFocus} onChange={offsetX.onChange} onBlur={offsetX.onBlur} @@ -505,19 +585,19 @@ function BackgroundSection({ trackId, elementId: element.id, updates: { - background: { ...element.background, offsetX: 0 }, - }, + background: { ...element.background, offsetX: DEFAULT_TEXT_BACKGROUND.offsetX }, }, - ], - }) - } - isDefault={(element.background.offsetX ?? 0) === 0} + }, + ], + }) + } + isDefault={(element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX) === DEFAULT_TEXT_BACKGROUND.offsetX} /> </SectionField> - <SectionField label="Y-offset" className="w-1/2"> - <NumberField - icon="Y" - value={offsetY.displayValue} + <SectionField label="Y-offset" className="w-1/2"> + <NumberField + icon="Y" + value={offsetY.displayValue} onFocus={offsetY.onFocus} onChange={offsetY.onChange} onBlur={offsetY.onBlur} @@ -530,21 +610,22 @@ function BackgroundSection({ trackId, elementId: element.id, updates: { - background: { ...element.background, offsetY: 0 }, - }, + background: { ...element.background, offsetY: DEFAULT_TEXT_BACKGROUND.offsetY }, }, - ], - }) - } - isDefault={(element.background.offsetY ?? 0) === 0} + }, + ], + }) + } + isDefault={(element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY) === DEFAULT_TEXT_BACKGROUND.offsetY} /> </SectionField> </div> - <SectionField label="Corner Radius"> - <NumberField - icon="R" - value={cornerRadius.displayValue} - min={0} + <SectionField label="Corner radius"> + <NumberField + icon="R" + value={cornerRadius.displayValue} + min={CORNER_RADIUS_MIN} + max={CORNER_RADIUS_MAX} onFocus={cornerRadius.onFocus} onChange={cornerRadius.onChange} onBlur={cornerRadius.onBlur} @@ -557,16 +638,18 @@ function BackgroundSection({ trackId, elementId: element.id, updates: { - background: { - ...element.background, - cornerRadius: 0, - }, + background: { + ...element.background, + cornerRadius: CORNER_RADIUS_MIN, + }, }, }, ], }) } - isDefault={(element.background.cornerRadius ?? 0) === 0} + isDefault={ + (element.background.cornerRadius ?? 0) === CORNER_RADIUS_MIN + } /> </SectionField> </SectionFields> diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx index 3588cbb7..49dd8fe5 100644 --- a/apps/web/src/components/editor/panels/properties/video-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx @@ -1,4 +1,8 @@ -import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline"; +import type { + ImageElement, + StickerElement, + VideoElement, +} from "@/types/timeline"; import { BlendingSection, TransformSection } from "./sections"; export function VideoProperties({ @@ -10,7 +14,11 @@ export function VideoProperties({ }) { return ( <div className="flex h-full flex-col"> - <TransformSection element={element} trackId={trackId} /> + <TransformSection + element={element} + trackId={trackId} + showTopBorder={false} + /> <BlendingSection element={element} trackId={trackId} /> </div> ); 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 f31c6e28..291a4633 100644 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -21,7 +21,7 @@ export function DragLine({ return ( <div - className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5" + className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5" style={{ top: `${lineTop}px` }} /> ); diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index d89d270d..593976a8 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -55,16 +55,18 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/lib/actions"; +const TRACKS_CONTAINER_MAX_HEIGHT = 800; +const FALLBACK_CONTAINER_WIDTH = 1000; + export function Timeline() { - const tracksContainerHeight = { min: 0, max: 800 }; - const { snappingEnabled } = useTimelineStore(); + const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); const { clearElementSelection, setElementSelection } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; const tracks = timeline.getTracks(); const seek = (time: number) => editor.playback.seek({ time }); - // refs const timelineRef = useRef<HTMLDivElement>(null); const timelineHeaderRef = useRef<HTMLDivElement>(null); const rulerRef = useRef<HTMLDivElement>(null); @@ -74,7 +76,6 @@ export function Timeline() { const playheadRef = useRef<HTMLDivElement>(null); const trackLabelsScrollRef = useRef<HTMLDivElement>(null); - // state const [isResizing, setIsResizing] = useState(false); const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( null, @@ -151,6 +152,7 @@ export function Timeline() { const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ containerRef: tracksContainerRef, headerRef: timelineHeaderRef, + tracksScrollRef, zoomLevel, }); @@ -169,7 +171,7 @@ export function Timeline() { zoomLevel, }); - const containerWidth = tracksContainerRef.current?.clientWidth || 1000; + const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const paddingPx = getTimelinePaddingPx({ @@ -422,7 +424,20 @@ export function Timeline() { {tracks.length === 0 ? ( <div /> ) : ( - tracks.map((track, index) => ( + [...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 @@ -465,50 +480,50 @@ export function Timeline() { <ContextMenuContent className="w-40"> <ContextMenuItem icon={<HugeiconsIcon icon={TaskAdd02Icon} />} - onClick={(e) => { - e.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - </ContextMenuItem> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - timeline.toggleTrackMute({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={VolumeHighIcon} /> - <span> - {canTracktHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - timeline.toggleTrackVisibility({ - trackId: track.id, - }); - }} - > - <HugeiconsIcon icon={ViewIcon} /> - <span> - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - </span> - </ContextMenuItem> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - timeline.removeTrack({ - trackId: track.id, - }); - }} + 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} /> 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 38cf953b..f96979d4 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -4,6 +4,10 @@ import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import AudioWaveform from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; +import { + useKeyframeDrag, + type KeyframeDragState, +} from "@/hooks/timeline/element/use-keyframe-drag"; import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { getElementKeyframes } from "@/lib/animation"; @@ -26,6 +30,7 @@ import { import type { TimelineElement as TimelineElementType, TimelineTrack, + VisualElement, ElementDragState, } from "@/types/timeline"; import type { MediaAsset } from "@/types/assets"; @@ -53,8 +58,11 @@ import { uppercase } from "@/utils/string"; import type { ComponentProps, ReactNode } from "react"; import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; import { cn } from "@/utils/ui"; +import { Button } from "@/components/ui/button"; +import { usePropertiesStore } from "@/stores/properties-store"; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; +const ELEMENT_RING_WIDTH_PX = 1.5; interface KeyframeIndicator { time: number; @@ -62,7 +70,7 @@ interface KeyframeIndicator { keyframes: SelectedKeyframeRef[]; } -function buildKeyframeIndicator({ +export function buildKeyframeIndicator({ keyframe, trackId, elementId, @@ -98,7 +106,7 @@ function buildKeyframeIndicator({ }; } -function getKeyframeIndicators({ +export function getKeyframeIndicators({ keyframes, trackId, elementId, @@ -145,8 +153,8 @@ function getKeyframeIndicators({ return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); } -function getDisplayShortcut(action: TAction) { - const { defaultShortcuts } = getActionDefinition(action); +export function getDisplayShortcut({ action }: { action: TAction }) { + const { defaultShortcuts } = getActionDefinition({ action }); if (!defaultShortcuts?.length) { return ""; } @@ -164,10 +172,13 @@ interface TimelineElementProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onResizeStateChange?: (params: { isResizing: boolean }) => void; onElementMouseDown: ( - e: React.MouseEvent, + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: ( + event: React.MouseEvent, element: TimelineElementType, ) => void; - onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; isDropTarget?: boolean; } @@ -188,12 +199,12 @@ export function TimelineElement({ const { selectedElements } = useElementSelection(); const { requestRevealMedia } = useAssetsPanelStore(); - const mediaAssets = editor.media.getAssets(); let mediaAsset: MediaAsset | null = null; if (hasMediaId(element)) { mediaAsset = - mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + editor.media.getAssets().find((asset) => asset.id === element.mediaId) ?? + null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); @@ -242,6 +253,13 @@ export function TimelineElement({ elementWidth, }) : []; + + const { + keyframeDragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + } = useKeyframeDrag({ zoomLevel, element }); const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -255,7 +273,7 @@ export function TimelineElement({ <ContextMenu> <ContextMenuTrigger asChild> <div - className={`absolute top-0 h-full select-none`} + className="absolute top-0 h-full select-none" style={{ left: `${elementLeft}px`, width: `${elementWidth}px`, @@ -269,15 +287,24 @@ export function TimelineElement({ element={element} track={track} isSelected={isSelected} - hasAudio={hasAudio} - isMuted={isMuted} - mediaAssets={mediaAssets} onElementClick={onElementClick} onElementMouseDown={onElementMouseDown} handleResizeStart={handleResizeStart} isDropTarget={isDropTarget} /> - {isSelected && <KeyframeIndicators indicators={keyframeIndicators} />} + {isSelected && ( + <div className="pointer-events-none absolute inset-0 overflow-hidden"> + <KeyframeIndicators + indicators={keyframeIndicators} + dragState={keyframeDragState} + displayedStartTime={displayedStartTime} + elementLeft={elementLeft} + onKeyframeMouseDown={handleKeyframeMouseDown} + onKeyframeClick={handleKeyframeClick} + getVisualOffsetPx={getVisualOffsetPx} + /> + </div> + )} </div> </ContextMenuTrigger> <ContextMenuContent className="w-64"> @@ -344,9 +371,6 @@ function ElementInner({ element, track, isSelected, - hasAudio, - isMuted, - mediaAssets, onElementClick, onElementMouseDown, handleResizeStart, @@ -355,12 +379,12 @@ function ElementInner({ element: TimelineElementType; track: TimelineTrack; isSelected: boolean; - hasAudio: boolean; - isMuted: boolean; - mediaAssets: MediaAsset[]; - onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + onElementClick: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; onElementMouseDown: ( - e: React.MouseEvent, + event: React.MouseEvent, element: TimelineElementType, ) => void; handleResizeStart: (params: { @@ -374,47 +398,56 @@ function ElementInner({ (canElementBeHidden(element) && element.hidden) || isDropTarget ? "opacity-50" : ""; + const closeClipEffects = usePropertiesStore( + (state) => state.closeClipEffects, + ); return ( <div - className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( - { - type: track.type, - }, - )} ${opacityClass}`} + className="relative h-full cursor-pointer" + style={{ marginInline: ELEMENT_RING_WIDTH_PX }} > - <button - type="button" - className="absolute inset-0 size-full cursor-pointer" - onClick={(e) => onElementClick(e, element)} - onMouseDown={(e) => onElementMouseDown(e, element)} + <div + className={cn( + "absolute inset-0 overflow-hidden rounded-sm", + getTrackClasses({ type: track.type }), + opacityClass, + )} + style={ + isSelected + ? { + boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`, + } + : undefined + } > - <div className="absolute inset-0 flex h-full items-center"> - <ElementContent - element={element} - track={track} - isSelected={isSelected} - mediaAssets={mediaAssets} + <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="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 z-10 mt-1 ml-1 w-fit"> + <EffectsButton + element={element as VisualElement} + trackId={track.id} /> </div> - {(hasAudio - ? isMuted - : canElementBeHidden(element) && element.hidden) && ( - <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black"> - {hasAudio ? ( - <HugeiconsIcon - icon={VolumeHighIcon} - className="size-6 text-white" - /> - ) : ( - <HugeiconsIcon - icon={VolumeOffIcon} - className="size-6 text-white" - /> - )} - </div> - )} - </button> + )} {isSelected && ( <> @@ -451,68 +484,82 @@ function ResizeHandle({ return ( <button type="button" - className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} + className={cn( + "absolute top-0 bottom-0 w-2", + isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize", + )} onMouseDown={(event) => handleResizeStart({ event, elementId, side })} + onClick={(event) => event.stopPropagation()} aria-label={`${isLeft ? "Left" : "Right"} resize handle`} - > - <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> - </button> + ></button> ); } function KeyframeIndicators({ indicators, + dragState, + displayedStartTime, + elementLeft, + onKeyframeMouseDown, + onKeyframeClick, + getVisualOffsetPx, }: { indicators: KeyframeIndicator[]; + dragState: KeyframeDragState; + displayedStartTime: number; + elementLeft: number; + onKeyframeMouseDown: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => void; + onKeyframeClick: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + }) => void; + getVisualOffsetPx: (params: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }) => number; }) { - const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } = - useKeyframeSelection(); + const { isKeyframeSelected } = useKeyframeSelection(); const orderedKeyframes = indicators.flatMap( (indicator) => indicator.keyframes, ); - const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => { - event.preventDefault(); - event.stopPropagation(); - }; - - const handleKeyframeClick = ({ - event, - keyframes, - }: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => { - event.stopPropagation(); - if (event.shiftKey) { - selectKeyframeRange({ - orderedKeyframes, - targetKeyframes: keyframes, - isAdditive: event.metaKey || event.ctrlKey, - }); - return; - } - - toggleKeyframeSelection({ - keyframes, - isMultiKey: event.metaKey || event.ctrlKey, - }); - }; - return indicators.map((indicator) => { const isIndicatorSelected = indicator.keyframes.some((keyframe) => isKeyframeSelected({ keyframe }), ); + const isBeingDragged = indicator.keyframes.some((kf) => + dragState.draggingKeyframeIds.has(kf.keyframeId), + ); + const visualOffsetPx = getVisualOffsetPx({ + indicatorTime: indicator.time, + indicatorOffsetPx: indicator.offsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }); return ( <button key={indicator.time} type="button" - className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer" - style={{ left: indicator.offsetPx }} - onMouseDown={(event) => handleKeyframeMouseDown({ event })} + className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab" + style={{ left: visualOffsetPx }} + onMouseDown={(event) => + onKeyframeMouseDown({ event, keyframes: indicator.keyframes }) + } onClick={(event) => - handleKeyframeClick({ event, keyframes: indicator.keyframes }) + onKeyframeClick({ + event, + keyframes: indicator.keyframes, + orderedKeyframes, + }) } aria-label="Select keyframe" > @@ -533,10 +580,82 @@ interface ElementContentProps { element: TimelineElementType; track: TimelineTrack; isSelected: boolean; +} + +interface ElementContentRendererProps extends ElementContentProps { mediaAssets: MediaAsset[]; } -type ElementContentRenderer = (props: ElementContentProps) => ReactNode; +type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode; + +export function renderTiledMedia({ + element, + imageUrl, + track, +}: { + element: VisualElement; + imageUrl: string | undefined; + track: ElementContentProps["track"]; +}): ReactNode { + if (!imageUrl) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + 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", + }} + /> + ); +} + +function EffectsButton({ + element, + trackId, + className, +}: { + element: VisualElement; + trackId: string; + className?: string; +}) { + const openClipEffects = usePropertiesStore((state) => state.openClipEffects); + const { selectElement } = useElementSelection(); + + if (!element.effects?.length) { + return null; + } + + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + selectElement({ elementId: element.id, trackId }); + openClipEffects({ elementId: element.id, trackId }); + }; + + return ( + <Button + variant="text" + size="icon" + className={cn("rounded-sm !size-5 bg-background/50", className)} + onClick={handleClick} + onMouseDown={(event) => event.stopPropagation()} + > + <HugeiconsIcon icon={MagicWand05Icon} /> + </Button> + ); +} const ELEMENT_CONTENT_RENDERERS: Record< TimelineElementType["type"], @@ -548,7 +667,7 @@ const ELEMENT_CONTENT_RENDERERS: Record< { type: "text" } >; return ( - <div className="flex size-full items-center justify-start pl-3"> + <div className="flex size-full items-center justify-start pl-2"> <span className="truncate text-xs text-white"> {textElement.content} </span> @@ -557,8 +676,11 @@ const ELEMENT_CONTENT_RENDERERS: Record< }, 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">{element.name}</span> + <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 }) => { @@ -618,7 +740,7 @@ const ELEMENT_CONTENT_RENDERERS: Record< </span> ); }, - video: ({ element, track, isSelected, mediaAssets }) => { + video: ({ element, track, mediaAssets }) => { const videoElement = element as Extract< TimelineElementType, { type: "video" } @@ -626,48 +748,13 @@ const ELEMENT_CONTENT_RENDERERS: Record< const mediaAsset = mediaAssets.find( (asset) => asset.id === videoElement.mediaId, ); - - if (!mediaAsset) { - return ( - <span className="text-foreground/80 truncate text-xs"> - {videoElement.name} - </span> - ); - } - - if (mediaAsset.thumbnailUrl) { - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - - return ( - <div className="flex size-full items-center justify-center"> - <div - className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`} - > - <div - className="absolute right-0 left-0" - style={{ - backgroundImage: `url(${mediaAsset.thumbnailUrl})`, - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - top: isSelected ? "0.25rem" : "0rem", - bottom: isSelected ? "0.25rem" : "0rem", - }} - /> - </div> - </div> - ); - } - - return ( - <span className="text-foreground/80 truncate text-xs"> - {videoElement.name} - </span> - ); + return renderTiledMedia({ + element: videoElement, + imageUrl: mediaAsset?.thumbnailUrl, + track, + }); }, - image: ({ element, track, isSelected, mediaAssets }) => { + image: ({ element, track, mediaAssets }) => { const imageElement = element as Extract< TimelineElementType, { type: "image" } @@ -675,44 +762,27 @@ const ELEMENT_CONTENT_RENDERERS: Record< const mediaAsset = mediaAssets.find( (asset) => asset.id === imageElement.mediaId, ); - - if (!mediaAsset?.url) { - return ( - <span className="text-foreground/80 truncate text-xs"> - {imageElement.name} - </span> - ); - } - - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - - return ( - <div className="flex size-full items-center justify-center"> - <div - className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`} - > - <div - className="absolute right-0 left-0" - style={{ - backgroundImage: `url(${mediaAsset.url})`, - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - top: isSelected ? "0.25rem" : "0rem", - bottom: isSelected ? "0.25rem" : "0rem", - }} - /> - </div> - </div> - ); + return renderTiledMedia({ + element: imageElement, + imageUrl: mediaAsset?.url, + track, + }); }, }; -function ElementContent(props: ElementContentProps) { - const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type]; - return <>{renderer(props)}</>; +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(), + })} + </> + ); } function CopyMenuItem() { @@ -740,9 +810,9 @@ function MuteMenuItem({ return <HugeiconsIcon icon={VolumeMute02Icon} />; } return isMuted ? ( - <HugeiconsIcon icon={VolumeHighIcon} /> - ) : ( <HugeiconsIcon icon={VolumeOffIcon} /> + ) : ( + <HugeiconsIcon icon={VolumeHighIcon} /> ); }; @@ -823,7 +893,7 @@ function ActionMenuItem({ event.stopPropagation(); invokeAction(action); }} - textRight={getDisplayShortcut(action)} + textRight={getDisplayShortcut({ action })} {...props} > {children} 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 03ce88eb..533ea63d 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -78,7 +78,7 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); const currentTime = editor.playback.getCurrentTime(); - const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime }); const handleAction = ({ action, @@ -116,7 +116,7 @@ function ToolbarLeftSection() { <ToolbarButton icon={<SplitSquareHorizontal />} - tooltip="Coming soon" /* separate audio */ + tooltip="Separate audio (coming soon)" disabled={true} onClick={({ event: _event }) => {}} /> @@ -131,7 +131,7 @@ function ToolbarLeftSection() { <ToolbarButton icon={<HugeiconsIcon icon={SnowIcon} />} - tooltip="Coming soon" /* freeze frame */ + tooltip="Freeze frame (coming soon)" disabled={true} onClick={({ event: _event }) => {}} /> @@ -149,8 +149,8 @@ function ToolbarLeftSection() { <Tooltip> <ToolbarButton icon={<HugeiconsIcon icon={Bookmark02Icon} />} - isActive={currentBookmarked} - tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} + isActive={isCurrentlyBookmarked} + tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} onClick={({ event }) => handleAction({ action: "toggle-bookmark", event }) } @@ -191,12 +191,10 @@ function ToolbarRightSection({ onZoomChange: (zoom: number) => void; onZoom: (options: { direction: "in" | "out" }) => void; }) { - const { - snappingEnabled, - rippleEditingEnabled, - toggleSnapping, - toggleRippleEditing, - } = useTimelineStore(); + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); + const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); + const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); return ( <div className="flex items-center gap-1"> 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 fbc7cb3f..81cda7a6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -52,7 +52,7 @@ export function TimelineTrackContent({ targetElementId = null, }: TimelineTrackContentProps) { const editor = useEditor(); - const { isElementSelected, clearElementSelection } = useElementSelection(); + const { isElementSelected } = useElementSelection(); const duration = editor.timeline.getTotalDuration(); @@ -69,7 +69,6 @@ export function TimelineTrackContent({ className="size-full" onClick={(event) => { if (shouldIgnoreClick?.()) return; - clearElementSelection(); onTrackClick?.(event); }} onMouseDown={(event) => { |
