From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- .../editor/panels/assets/drag-overlay.tsx | 61 ++ .../editor/panels/assets/draggable-item.tsx | 244 ++++++++ .../src/components/editor/panels/assets/index.tsx | 52 ++ .../src/components/editor/panels/assets/tabbar.tsx | 116 ++++ .../editor/panels/assets/views/captions.tsx | 152 +++++ .../editor/panels/assets/views/media.tsx | 675 +++++++++++++++++++++ .../editor/panels/assets/views/settings.tsx | 394 ++++++++++++ .../editor/panels/assets/views/sounds.tsx | 574 ++++++++++++++++++ .../editor/panels/assets/views/stickers.tsx | 615 +++++++++++++++++++ .../components/editor/panels/assets/views/text.tsx | 46 ++ .../components/editor/panels/panel-base-view.tsx | 85 +++ .../src/components/editor/panels/preview/index.tsx | 124 ++++ .../editor/panels/properties/audio-properties.tsx | 5 + .../components/editor/panels/properties/index.tsx | 68 +++ .../editor/panels/properties/property-item.tsx | 91 +++ .../editor/panels/properties/text-properties.tsx | 410 +++++++++++++ .../editor/panels/properties/video-properties.tsx | 9 + 17 files changed, 3721 insertions(+) create mode 100644 apps/web/src/components/editor/panels/assets/drag-overlay.tsx create mode 100644 apps/web/src/components/editor/panels/assets/draggable-item.tsx create mode 100644 apps/web/src/components/editor/panels/assets/index.tsx create mode 100644 apps/web/src/components/editor/panels/assets/tabbar.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/captions.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/media.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/settings.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/sounds.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/stickers.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/text.tsx create mode 100644 apps/web/src/components/editor/panels/panel-base-view.tsx create mode 100644 apps/web/src/components/editor/panels/preview/index.tsx create mode 100644 apps/web/src/components/editor/panels/properties/audio-properties.tsx create mode 100644 apps/web/src/components/editor/panels/properties/index.tsx create mode 100644 apps/web/src/components/editor/panels/properties/property-item.tsx create mode 100644 apps/web/src/components/editor/panels/properties/text-properties.tsx create mode 100644 apps/web/src/components/editor/panels/properties/video-properties.tsx (limited to 'apps/web/src/components/editor/panels') diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx new file mode 100644 index 00000000..c6b0861c --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx @@ -0,0 +1,61 @@ +import { HugeiconsIcon } from "@hugeicons/react"; +import { UploadIcon } from "@hugeicons/core-free-icons"; + +interface MediaDragOverlayProps { + isVisible: boolean; + isProcessing?: boolean; + progress?: number; + onClick?: () => void; +} + +export function MediaDragOverlay({ + isVisible, + isProcessing = false, + progress = 0, + onClick, +}: MediaDragOverlayProps) { + if (!isVisible) return null; + + const handleClick = ({ + event, + }: { + event: React.MouseEvent; + }) => { + if (isProcessing || !onClick) return; + event.preventDefault(); + event.stopPropagation(); + onClick(); + }; + + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx new file mode 100644 index 00000000..10cb1f8c --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEditor } from "@/hooks/use-editor"; +import { clearDragData, setDragData } from "@/lib/drag-data"; +import type { TimelineDragData } from "@/types/drag"; +import { cn } from "@/utils/ui"; + +export interface DraggableItemProps { + name: string; + preview: ReactNode; + dragData: TimelineDragData; + onDragStart?: ({ e }: { e: React.DragEvent }) => void; + onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; + aspectRatio?: number; + className?: string; + containerClassName?: string; + shouldShowPlusOnDrag?: boolean; + shouldShowLabel?: boolean; + isRounded?: boolean; + variant?: "card" | "compact"; + isDraggable?: boolean; + isHighlighted?: boolean; +} + +export function DraggableItem({ + name, + preview, + dragData, + onDragStart, + onAddToTimeline, + aspectRatio = 16 / 9, + className = "", + containerClassName, + shouldShowPlusOnDrag = true, + shouldShowLabel = true, + isRounded = true, + variant = "card", + isDraggable = true, + isHighlighted = false, +}: DraggableItemProps) { + const [isDragging, setIsDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const dragRef = useRef(null); + const editor = useEditor(); + const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`; + + const handleAddToTimeline = () => { + onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); + }; + + const emptyImg = new window.Image(); + emptyImg.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; + + useEffect(() => { + if (!isDragging) return; + + const handleDragOver = (e: DragEvent) => { + setDragPosition({ x: e.clientX, y: e.clientY }); + }; + + document.addEventListener("dragover", handleDragOver); + + return () => { + document.removeEventListener("dragover", handleDragOver); + }; + }, [isDragging]); + + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.setDragImage(emptyImg, 0, 0); + + setDragData({ dataTransfer: e.dataTransfer, dragData }); + e.dataTransfer.effectAllowed = "copy"; + + setDragPosition({ x: e.clientX, y: e.clientY }); + setIsDragging(true); + + onDragStart?.({ e }); + }; + + const handleDragEnd = () => { + setIsDragging(false); + clearDragData(); + }; + + return ( + <> + {variant === "card" ? ( +
+
+ + {preview} + {!isDragging && ( + + )} + + {shouldShowLabel && ( + + {name} + + + )} +
+
+ ) : ( +
+ +
+ )} + + {isDraggable && + isDragging && + typeof document !== "undefined" && + createPortal( +
+
+ +
+ {preview} +
+ {shouldShowPlusOnDrag && ( + + )} +
+
+
, + document.body, + )} + + ); +} + +function PlusButton({ + className, + onClick, + tooltipText, +}: { + className?: string; + onClick?: () => void; + tooltipText?: string; +}) { + const button = ( + + ); + + if (tooltipText) { + return ( + + {button} + +

{tooltipText}

+
+
+ ); + } + + return button; +} diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx new file mode 100644 index 00000000..9161cb1d --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Separator } from "@/components/ui/separator"; +import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; +import { TabBar } from "./tabbar"; +import { Captions } from "./views/captions"; +import { MediaView } from "./views/media"; +import { SettingsView } from "./views/settings"; +import { SoundsView } from "./views/sounds"; +import { StickersView } from "./views/stickers"; +import { TextView } from "./views/text"; + +export function AssetsPanel() { + const { activeTab } = useAssetsPanelStore(); + + const viewMap: Record = { + media: , + sounds: , + text: , + stickers: , + effects: ( +
+ Effects view coming soon... +
+ ), + transitions: ( +
+ Transitions view coming soon... +
+ ), + captions: , + filters: ( +
+ Filters view coming soon... +
+ ), + adjustment: ( +
+ Adjustment view coming soon... +
+ ), + settings: , + }; + + return ( +
+ + +
{viewMap[activeTab]}
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx new file mode 100644 index 00000000..2c950fdc --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/utils/ui"; +import { + TAB_KEYS, + tabs, + useAssetsPanelStore, +} from "@/stores/assets-panel-store"; + +export function TabBar() { + const { activeTab, setActiveTab } = useAssetsPanelStore(); + const scrollRef = useRef(null); + const [showTopFade, setShowTopFade] = useState(false); + const [showBottomFade, setShowBottomFade] = useState(false); + + const checkScrollPosition = useCallback(() => { + const element = scrollRef.current; + if (!element) return; + + const { scrollTop, scrollHeight, clientHeight } = element; + setShowTopFade(scrollTop > 0); + setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1); + }, []); + + useEffect(() => { + const element = scrollRef.current; + if (!element) return; + + checkScrollPosition(); + element.addEventListener("scroll", checkScrollPosition); + + const resizeObserver = new ResizeObserver(checkScrollPosition); + resizeObserver.observe(element); + + return () => { + element.removeEventListener("scroll", checkScrollPosition); + resizeObserver.disconnect(); + }; + }, [checkScrollPosition]); + + return ( +
+
+ {TAB_KEYS.map((tabKey) => { + const tab = tabs[tabKey]; + return ( + + + + + +
+ {tab.label} +
+
+
+ ); + })} +
+ + + +
+ ); +} + +function FadeOverlay({ + direction, + show, +}: { + direction: "top" | "bottom"; + show: boolean; +}) { + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx new file mode 100644 index 00000000..306bad2a --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -0,0 +1,152 @@ +import { Button } from "@/components/ui/button"; +import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useState, useRef } from "react"; +import { extractTimelineAudio } from "@/lib/media/mediabunny"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants"; +import type { + TranscriptionLanguage, + TranscriptionProgress, +} from "@/types/transcription"; +import { transcriptionService } from "@/services/transcription"; +import { decodeAudioToFloat32 } from "@/lib/media/audio"; +import { buildCaptionChunks } from "@/lib/transcription/caption"; +import { Spinner } from "@/components/ui/spinner"; + +export function Captions() { + const [selectedLanguage, setSelectedLanguage] = + useState("auto"); + const [isProcessing, setIsProcessing] = useState(false); + const [processingStep, setProcessingStep] = useState(""); + const [error, setError] = useState(null); + const containerRef = useRef(null); + const editor = useEditor(); + + const handleProgress = (progress: TranscriptionProgress) => { + if (progress.status === "loading-model") { + setProcessingStep(`Loading model ${Math.round(progress.progress)}%`); + } else if (progress.status === "transcribing") { + setProcessingStep("Transcribing..."); + } + }; + + const handleGenerateTranscript = async () => { + try { + setIsProcessing(true); + setError(null); + setProcessingStep("Extracting audio..."); + + const audioBlob = await extractTimelineAudio({ + tracks: editor.timeline.getTracks(), + mediaAssets: editor.media.getAssets(), + totalDuration: editor.timeline.getTotalDuration(), + }); + + setProcessingStep("Preparing audio..."); + const { samples } = await decodeAudioToFloat32({ audioBlob }); + + const result = await transcriptionService.transcribe({ + audioData: samples, + language: selectedLanguage === "auto" ? undefined : selectedLanguage, + onProgress: handleProgress, + }); + + setProcessingStep("Generating captions..."); + const captionChunks = buildCaptionChunks({ segments: result.segments }); + + const captionTrackId = editor.timeline.addTrack({ + type: "text", + index: 0, + }); + + for (let i = 0; i < captionChunks.length; i++) { + const caption = captionChunks[i]; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: captionTrackId }, + element: { + ...DEFAULT_TEXT_ELEMENT, + name: `Caption ${i + 1}`, + content: caption.text, + duration: caption.duration, + startTime: caption.startTime, + fontSize: 65, + fontWeight: "bold", + }, + }); + } + } catch (error) { + console.error("Transcription failed:", error); + setError( + error instanceof Error ? error.message : "An unexpected error occurred", + ); + } finally { + setIsProcessing(false); + setProcessingStep(""); + } + }; + + const handleLanguageChange = ({ value }: { value: string }) => { + if (value === "auto") { + setSelectedLanguage("auto"); + return; + } + + const matchedLanguage = TRANSCRIPTION_LANGUAGES.find( + (language) => language.code === value, + ); + if (!matchedLanguage) return; + setSelectedLanguage(matchedLanguage.code); + }; + + return ( + + + + + +
+ {error && ( +
+

{error}

+
+ )} + + +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx new file mode 100644 index 00000000..76ae0064 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/media.tsx @@ -0,0 +1,675 @@ +"use client"; + +import Image from "next/image"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { useFileUpload } from "@/hooks/use-file-upload"; +import { useRevealItem } from "@/hooks/use-reveal-item"; +import { processMediaAssets } from "@/lib/media/processing"; +import { + buildImageElement, + buildUploadAudioElement, + buildVideoElement, +} from "@/lib/timeline/element-utils"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import type { MediaAsset } from "@/types/assets"; +import type { CreateTimelineElement } from "@/types/timeline"; +import { cn } from "@/utils/ui"; +import { + CloudUploadIcon, + GridViewIcon, + LeftToRightListDashIcon, + SortingOneNineIcon, + Image02Icon, + MusicNote03Icon, + Video01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; + +export function MediaView() { + const editor = useEditor(); + const mediaFiles = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + + const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } = + useAssetsPanelStore(); + const { highlightedId, registerElement } = useRevealItem( + highlightMediaId, + clearHighlight, + ); + + 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; + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + const processedAssets = await processMediaAssets({ + files, + onProgress: (progress: { progress: number }) => + setProgress(progress.progress), + }); + for (const asset of processedAssets) { + await editor.media.addMediaAsset({ + projectId: activeProject.metadata.id, + asset, + }); + } + } catch (error) { + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; + + const { isDragOver, dragProps, openFilePicker, fileInputProps } = + useFileUpload({ + accept: "image/*,video/*,audio/*", + multiple: true, + onFilesSelected: (files) => processFiles({ files }), + }); + + const handleRemove = async ({ + event, + id, + }: { + event: React.MouseEvent; + id: string; + }) => { + event.stopPropagation(); + + if (!activeProject) { + toast.error("No active project"); + return; + } + + await editor.media.removeMediaAsset({ + projectId: activeProject.metadata.id, + id, + }); + }; + + const addElementAtTime = ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }): boolean => { + const element = createElementFromMedia({ asset, startTime }); + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + return true; + }; + + const filteredMediaItems = useMemo(() => { + const filtered = mediaFiles.filter((item) => !item.ephemeral); + + filtered.sort((a, b) => { + let valueA: string | number; + let valueB: string | number; + + switch (sortBy) { + case "name": + valueA = a.name.toLowerCase(); + valueB = b.name.toLowerCase(); + break; + case "type": + valueA = a.type; + valueB = b.type; + break; + case "duration": + valueA = a.duration || 0; + valueB = b.duration || 0; + break; + case "size": + valueA = a.file.size; + valueB = b.file.size; + break; + default: + return 0; + } + + if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; + if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + + return filtered; + }, [mediaFiles, sortBy, sortOrder]); + + const previewComponents = useMemo(() => { + const previews = new Map(); + + filteredMediaItems.forEach((item) => { + previews.set(item.id, ); + previews.set( + `compact-${item.id}`, + , + ); + }); + + return previews; + }, [filteredMediaItems]); + + const renderPreview = (item: MediaAsset) => previewComponents.get(item.id); + const renderCompactPreview = (item: MediaAsset) => + previewComponents.get(`compact-${item.id}`); + + return ( + <> + + +
+
+ Assets +
+ + + + + + +

+ {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} +

+
+ + + + + + + + + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + + + +

+ Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) +

+
+
+
+
+ +
+
+ +
+
+ {isDragOver || filteredMediaItems.length === 0 ? ( + + ) : mediaViewMode === "grid" ? ( + + ) : ( + + )} +
+
+
+ + ); +} + +function MediaItemWithContextMenu({ + item, + children, + onRemove, +}: { + item: MediaAsset; + children: React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; +}) { + return ( + + {children} + + Export clips + onRemove({ event, id: item.id })} + > + Delete + + + + ); +} + +function GridView({ + 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 ( +
+ {items.map((item) => ( +
registerElement(item.id, el)}> + + + onAddToTimeline({ asset: item, startTime: currentTime }) + } + isRounded={false} + variant="card" + isHighlighted={highlightedId === item.id} + /> + +
+ ))} +
+ ); +} + +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 ( +
+ {items.map((item) => ( +
registerElement(item.id, el)}> + + + onAddToTimeline({ asset: item, startTime: currentTime }) + } + variant="compact" + isHighlighted={highlightedId === item.id} + /> + +
+ ))} +
+ ); +} + +const 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; + + return ( +
+ {formatDuration({ duration })} +
+ ); +} + +function MediaDurationLabel({ duration }: { duration?: number }) { + if (!duration) return null; + + return ( + {formatDuration({ duration })} + ); +} + +function MediaTypePlaceholder({ + icon, + label, + duration, + variant, +}: { + icon: IconSvgElement; + label: string; + duration?: number; + variant: "muted" | "bordered"; +}) { + const iconClassName = cn("size-6", variant === "bordered" && "mb-1"); + + return ( +
+ + {label} + +
+ ); +} + +function MediaPreview({ + item, + variant = "grid", +}: { + item: MediaAsset; + variant?: "grid" | "compact"; +}) { + const shouldShowDurationBadge = variant === "grid"; + + if (item.type === "image") { + return ( +
+ {item.name} +
+ ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( +
+ {item.name} + {shouldShowDurationBadge ? ( + + ) : null} +
+ ); + } + + return ( + + ); + } + + if (item.type === "audio") { + return ( + + ); + } + + return ( + + ); +} + +function SortMenuItem({ + label, + sortKey, + currentSortBy, + currentSortOrder, + onSort, +}: { + label: string; + sortKey: "name" | "type" | "duration" | "size"; + currentSortBy: string; + currentSortOrder: "asc" | "desc"; + onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void; +}) { + const isActive = currentSortBy === sortKey; + const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : ""; + + return ( + onSort({ key: sortKey })}> + {label} {arrow} + + ); +} + +function createElementFromMedia({ + asset, + startTime, +}: { + asset: MediaAsset; + startTime: number; +}): CreateTimelineElement { + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + + switch (asset.type) { + case "video": + return buildVideoElement({ + mediaId: asset.id, + name: asset.name, + duration, + startTime, + }); + case "image": + return buildImageElement({ + mediaId: asset.id, + name: asset.name, + duration, + startTime, + }); + case "audio": + return buildUploadAudioElement({ + mediaId: asset.id, + name: asset.name, + duration, + startTime, + }); + default: + throw new Error(`Unsupported media type: ${asset.type}`); + } +} diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx new file mode 100644 index 00000000..b8c54510 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -0,0 +1,394 @@ +"use client"; + +import Image from "next/image"; +import { memo, useCallback, useMemo } from "react"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + BLUR_INTENSITY_PRESETS, + DEFAULT_BLUR_INTENSITY, + DEFAULT_COLOR, + FPS_PRESETS, +} from "@/constants/project-constants"; +import { patternCraftGradients } from "@/data/colors/pattern-craft"; +import { colors } from "@/data/colors/solid"; +import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { useEditor } from "@/hooks/use-editor"; +import { useEditorStore } from "@/stores/editor-store"; +import type { TProject } from "@/types/project"; +import { dimensionToAspectRatio } from "@/utils/geometry"; +import { cn } from "@/utils/ui"; +import { + PropertyGroup, + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "@/components/editor/panels/properties/property-item"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { DropperIcon } from "@hugeicons/core-free-icons"; + +export function SettingsView() { + return ; +} + +function ProjectSettingsTabs() { + return ( + + +
+ ), + }, + { + value: "background", + label: "Background", + content: ( +
+
+ +
+ {/*
+ + +
*/} + + {/* another ui */} + {/*
+ +
*/} +
+ ), + }, + ]} + className="flex h-full flex-col justify-between p-0" + /> + ); +} + +function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) { + const { canvasSize } = activeProject.settings; + + return { + width: canvasSize.width, + height: canvasSize.height, + }; +} + +function ProjectInfoView() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const { canvasPresets } = useEditorStore(); + + const findPresetIndexByAspectRatio = ({ + presets, + targetAspectRatio, + }: { + presets: Array<{ width: number; height: number }>; + targetAspectRatio: string; + }) => { + for (let index = 0; index < presets.length; index++) { + const preset = presets[index]; + const presetAspectRatio = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + if (presetAspectRatio === targetAspectRatio) { + return index; + } + } + return -1; + }; + + const currentCanvasSize = getCurrentCanvasSize({ activeProject }); + const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); + const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; + const presetIndex = findPresetIndexByAspectRatio({ + presets: canvasPresets, + targetAspectRatio: currentAspectRatio, + }); + const originalPresetValue = "original"; + const selectedPresetValue = + presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; + + const handleAspectRatioChange = ({ value }: { value: string }) => { + if (value === originalPresetValue) { + const canvasSize = originalCanvasSize ?? currentCanvasSize; + editor.project.updateSettings({ + settings: { canvasSize }, + }); + return; + } + const index = parseInt(value, 10); + const preset = canvasPresets[index]; + if (preset) { + editor.project.updateSettings({ settings: { canvasSize: preset } }); + } + }; + + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + editor.project.updateSettings({ settings: { fps } }); + }; + + return ( +
+ + Name + {activeProject.metadata.name} + + + + Aspect ratio + + + + + + + Frame rate + + + + +
+ ); +} + +const BlurPreview = memo( + ({ + blur, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + isSelected: boolean; + onSelect: () => void; + }) => ( + + ), +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + handleColorSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + handleColorSelect: ({ bg }: { bg: string }) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg, index) => ( + + + + toggleCommercialFilter()} + > + Show only commercially licensed + +
+ {showCommercialOnly + ? "Only showing sounds licensed for commercial use" + : "Showing all sounds regardless of license"} +
+
+ + + +
+ +
+ {isLoading && !searchQuery && ( +
+ Loading sounds... +
+ )} + {isSearching && searchQuery && ( +
Searching...
+ )} + {displayedSounds.map((sound) => ( + + ))} + {!isLoading && !isSearching && displayedSounds.length === 0 && ( +
+ {searchQuery ? "No sounds found" : "No sounds available"} +
+ )} + {isLoadingMore && ( +
+ Loading more sounds... +
+ )} +
+
+
+ + ); +} + +function SavedSoundsView() { + const { + savedSounds, + isLoadingSavedSounds, + savedSoundsError, + loadSavedSounds, + clearSavedSounds, + } = useSoundsStore(); + + const [playingId, setPlayingId] = useState(null); + const [audioElement, setAudioElement] = useState( + null, + ); + + const [showClearDialog, setShowClearDialog] = useState(false); + + useEffect(() => { + loadSavedSounds(); + }, [loadSavedSounds]); + + const playSound = ({ sound }: { sound: SoundEffect }) => { + if (playingId === sound.id) { + audioElement?.pause(); + setPlayingId(null); + return; + } + + audioElement?.pause(); + + if (sound.previewUrl) { + const audio = new Audio(sound.previewUrl); + audio.addEventListener("ended", () => { + setPlayingId(null); + }); + audio.addEventListener("error", () => { + setPlayingId(null); + }); + audio.play().catch((error) => { + console.error("Failed to play sound preview:", error); + setPlayingId(null); + }); + + setAudioElement(audio); + setPlayingId(sound.id); + } + }; + + const convertToSoundEffect = ({ + savedSound, + }: { + savedSound: SavedSound; + }): SoundEffect => ({ + id: savedSound.id, + name: savedSound.name, + description: "", + url: "", + previewUrl: savedSound.previewUrl, + downloadUrl: savedSound.downloadUrl, + duration: savedSound.duration, + filesize: 0, + type: "audio", + channels: 0, + bitrate: 0, + bitdepth: 0, + samplerate: 0, + username: savedSound.username, + tags: savedSound.tags, + license: savedSound.license, + created: savedSound.savedAt, + downloads: 0, + rating: 0, + ratingCount: 0, + }); + + if (isLoadingSavedSounds) { + return ( +
+
+ Loading saved sounds... +
+
+ ); + } + + if (savedSoundsError) { + return ( +
+
+ Error: {savedSoundsError} +
+
+ ); + } + + if (savedSounds.length === 0) { + return ( +
+ +
+

No saved sounds

+

+ Click the heart icon on any sound to save it here +

+
+
+ ); + } + + return ( +
+
+

+ {savedSounds.length} saved{" "} + {savedSounds.length === 1 ? "sound" : "sounds"} +

+ + + + + + + Clear all saved sounds? + + This will permanently remove all {savedSounds.length} saved + sounds from your collection. This action cannot be undone. + + + + + + + + +
+ +
+ +
+ {savedSounds.map((sound) => ( + + ))} +
+
+
+
+ ); +} + +function SongsView() { + return
Songs
; +} + +interface AudioItemProps { + sound: SoundEffect; + isPlaying: boolean; + onPlay: ({ sound }: { sound: SoundEffect }) => void; +} + +function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) { + const { addSoundToTimeline, isSoundSaved, toggleSavedSound } = + useSoundsStore(); + const isSaved = isSoundSaved({ soundId: sound.id }); + + const handleClick = () => { + onPlay({ sound }); + }; + + const handleSaveClick = ({ + stopPropagation, + }: React.MouseEvent) => { + stopPropagation(); + toggleSavedSound({ soundEffect: sound }); + }; + + const handleAddToTimeline = async ({ + stopPropagation, + }: React.MouseEvent) => { + stopPropagation(); + await addSoundToTimeline({ sound }); + }; + + return ( +
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx new file mode 100644 index 00000000..6b48e775 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -0,0 +1,615 @@ +"use client"; + +import Image from "next/image"; +import type { CSSProperties } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { Button } from "@/components/ui/button"; +import { InputWithBack } from "@/components/ui/input-with-back"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { STICKER_CATEGORIES } from "@/constants/stickers-constants"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; +import { + buildIconSvgUrl, + getIconSvgUrl, + ICONIFY_HOSTS, + POPULAR_COLLECTIONS, +} from "@/lib/iconify-api"; +import { useStickersStore } from "@/stores/stickers-store"; +import type { StickerCategory } from "@/types/stickers"; +import { cn } from "@/utils/ui"; +import { + ArrowRightIcon, + HappyIcon, + ClockIcon, + LayoutGridIcon, + MultiplicationSignIcon, + SparklesIcon, + HashtagIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Spinner } from "@/components/ui/spinner"; + +function isStickerCategory(value: string): value is StickerCategory { + return STICKER_CATEGORIES.includes(value as StickerCategory); +} + +export function StickersView() { + const { selectedCategory, setSelectedCategory } = useStickersStore(); + + return ( + { + if (isStickerCategory(v)) { + setSelectedCategory({ category: v }); + } + }} + tabs={[ + { + value: "all", + label: "All", + icon: , + content: , + }, + { + value: "general", + label: "Icons", + icon: , + content: , + }, + { + value: "brands", + label: "Brands", + icon: , + content: , + }, + { + value: "emoji", + label: "Emoji", + icon: , + content: , + }, + ]} + className="flex h-full flex-col overflow-hidden p-0" + /> + ); +} + +function StickerGrid({ + icons, + onAdd, + addingSticker, + capSize = false, +}: { + icons: string[]; + onAdd: (iconName: string) => void; + addingSticker: string | null; + capSize?: boolean; +}) { + const gridStyle: CSSProperties & { + "--sticker-min": string; + "--sticker-max"?: string; + } = { + gridTemplateColumns: capSize + ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" + : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", + "--sticker-min": "96px", + ...(capSize ? { "--sticker-max": "160px" } : {}), + }; + + return ( +
+ {icons.map((iconName) => ( + + ))} +
+ ); +} + +function CollectionGrid({ + collections, + onSelectCollection, +}: { + collections: Array<{ + prefix: string; + name: string; + total: number; + category?: string; + }>; + onSelectCollection: ({ prefix }: { prefix: string }) => void; +}) { + return ( +
+ {collections.map((collection) => ( + onSelectCollection({ prefix: collection.prefix })} + /> + ))} +
+ ); +} + +function EmptyView({ message }: { message: string }) { + return ( +
+ +
+

No stickers found

+

{message}

+
+
+ ); +} + +function StickersContentView({ category }: { category: StickerCategory }) { + const { + searchQuery, + selectedCollection, + viewMode, + collections, + currentCollection, + searchResults, + recentStickers, + isLoadingCollections, + isLoadingCollection, + isSearching, + setSearchQuery, + setSelectedCollection, + loadCollections, + searchStickers, + addStickerToTimeline, + clearRecentStickers, + setSelectedCategory, + addingSticker, + } = useStickersStore(); + + const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); + const [collectionsToShow, setCollectionsToShow] = useState(20); + const [showCollectionItems, setShowCollectionItems] = useState(false); + + const filteredCollections = useMemo(() => { + if (category === "all") { + return Object.entries(collections).map(([prefix, collection]) => ({ + prefix, + name: collection.name, + total: collection.total, + category: collection.category, + })); + } + + const collectionList = + POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; + if (!collectionList) return []; + + return collectionList + .map((c) => { + const collection = collections[c.prefix]; + return collection + ? { + prefix: c.prefix, + name: c.name, + total: collection.total, + } + : null; + }) + .filter(Boolean) as Array<{ + prefix: string; + name: string; + total: number; + }>; + }, [collections, category]); + + const { scrollAreaRef, handleScroll } = useInfiniteScroll({ + onLoadMore: () => setCollectionsToShow((prev) => prev + 20), + hasMore: filteredCollections.length > collectionsToShow, + isLoading: isLoadingCollections, + enabled: viewMode === "browse" && !selectedCollection && category === "all", + }); + + useEffect(() => { + if (Object.keys(collections).length === 0) { + loadCollections(); + } + }, [collections, loadCollections]); + + useEffect(() => { + const timer = setTimeout(() => { + if (localSearchQuery !== searchQuery) { + setSearchQuery({ query: localSearchQuery }); + if (localSearchQuery.trim()) { + searchStickers({ query: localSearchQuery }); + } + } + }, 500); + + return () => clearTimeout(timer); + }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]); + + const handleAddSticker = async (iconName: string) => { + try { + await addStickerToTimeline({ iconName }); + } catch (error) { + console.error("Failed to add sticker:", error); + toast.error("Failed to add sticker to timeline"); + } + }; + + const iconsToDisplay = useMemo(() => { + if (viewMode === "search" && searchResults) { + return searchResults.icons; + } + + if (viewMode === "collection" && currentCollection) { + const icons: string[] = []; + + if (currentCollection.uncategorized) { + icons.push( + ...currentCollection.uncategorized.map( + (name) => `${currentCollection.prefix}:${name}`, + ), + ); + } + + if (currentCollection.categories) { + Object.values(currentCollection.categories).forEach((categoryIcons) => { + icons.push( + ...categoryIcons.map( + (name) => `${currentCollection.prefix}:${name}`, + ), + ); + }); + } + + return icons.slice(0, 100); + } + + return []; + }, [viewMode, searchResults, currentCollection]); + + const isInCollection = viewMode === "collection" && !!selectedCollection; + + useEffect(() => { + if (isInCollection) { + setShowCollectionItems(false); + const timer = setTimeout(() => setShowCollectionItems(true), 350); + return () => clearTimeout(timer); + } else { + setShowCollectionItems(false); + } + }, [isInCollection]); + + return ( +
+
+ { + if (!expanded && isInCollection) { + setSelectedCollection({ collection: null }); + } + }} + placeholder={ + category === "all" + ? "Search all stickers" + : category === "general" + ? "Search icons" + : category === "brands" + ? "Search brands" + : "Search Emojis" + } + value={localSearchQuery} + onChange={setLocalSearchQuery} + disableAnimation={true} + /> +
+ +
+ +
+ {recentStickers.length > 0 && viewMode === "browse" && ( +
+
+ + Recent + + + + + + +

Clear recent stickers

+
+
+
+
+ +
+ )} + + {viewMode === "collection" && selectedCollection && ( +
+ {isLoadingCollection ? ( +
+ +
+ ) : showCollectionItems ? ( + + ) : ( +
+ +
+ )} +
+ )} + + {viewMode === "search" && ( +
+ {isSearching ? ( +
+ +
+ ) : searchResults?.icons.length ? ( + <> +
+ + {searchResults.total} results + +
+ + + ) : searchQuery ? ( +
+ + {category !== "all" && ( + + )} +
+ ) : null} +
+ )} + + {viewMode === "browse" && !selectedCollection && ( +
+ {isLoadingCollections ? ( +
+ +
+ ) : ( + <> + {category !== "all" && ( +
+ + setSelectedCollection({ collection: prefix }) + } + /> +
+ )} + + {category === "all" && filteredCollections.length > 0 && ( +
+ + setSelectedCollection({ collection: prefix }) + } + /> +
+ )} + + )} +
+ )} +
+
+
+
+ ); +} + +interface CollectionItemProps { + title: string; + subtitle: string; + onClick: () => void; +} + +function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { + return ( + + ); +} + +interface StickerItemProps { + iconName: string; + onAdd: (iconName: string) => void; + isAdding?: boolean; + capSize?: boolean; +} + +function StickerItem({ + iconName, + onAdd, + isAdding, + capSize = false, +}: StickerItemProps) { + const [imageError, setImageError] = useState(false); + const [hostIndex, setHostIndex] = useState(0); + + useEffect(() => { + if (!iconName) { + return; + } + setImageError(false); + setHostIndex(0); + }, [iconName]); + + const displayName = iconName.split(":")[1] || iconName; + const collectionPrefix = iconName.split(":")[0]; + + const preview = imageError ? ( +
+ + {displayName} + +
+ ) : ( +
+ {displayName} { + const next = hostIndex + 1; + if (next < ICONIFY_HOSTS.length) { + setHostIndex(next); + } else { + setImageError(true); + } + }} + loading="lazy" + unoptimized + /> +
+ ); + + return ( + + +
+ onAdd(iconName)} + aspectRatio={1} + shouldShowLabel={false} + isRounded={true} + variant="card" + className="" + containerClassName="w-full" + /> + {isAdding && ( +
+ +
+ )} +
+
+ +
+

{displayName}

+

{collectionPrefix}

+
+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx new file mode 100644 index 00000000..6ccf5d98 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -0,0 +1,46 @@ +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { buildTextElement } from "@/lib/timeline/element-utils"; + +export function TextView() { + const editor = useEditor(); + + const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => { + const activeScene = editor.scenes.getActiveScene(); + if (!activeScene) return; + + const element = buildTextElement({ + raw: DEFAULT_TEXT_ELEMENT, + startTime: currentTime, + }); + + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + + + Default text + + } + dragData={{ + id: "temp-text-id", + type: DEFAULT_TEXT_ELEMENT.type, + name: DEFAULT_TEXT_ELEMENT.name, + content: DEFAULT_TEXT_ELEMENT.content, + }} + aspectRatio={1} + onAddToTimeline={handleAddToTimeline} + shouldShowLabel={false} + /> + + ); +} diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx new file mode 100644 index 00000000..b16d1db6 --- /dev/null +++ b/apps/web/src/components/editor/panels/panel-base-view.tsx @@ -0,0 +1,85 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/utils/ui"; + +interface PanelBaseViewProps { + children?: React.ReactNode; + defaultTab?: string; + value?: string; + onValueChange?: (value: string) => void; + tabs?: { + value: string; + label: string; + icon?: React.ReactNode; + content: React.ReactNode; + }[]; + className?: string; + ref?: React.RefObject; +} + +function ViewContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + +
{children}
+
+ ); +} + +export function PanelBaseView({ + children, + defaultTab, + value, + onValueChange, + tabs, + className = "", + ref, +}: PanelBaseViewProps) { + return ( +
+ {!tabs || tabs.length === 0 ? ( + {children} + ) : ( + +
+
+ + {tabs.map((tab) => ( + + {tab.icon ? ( + + {tab.icon} + + ) : null} + {tab.label} + + ))} + +
+ +
+ {tabs.map((tab) => ( + + {tab.content} + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx new file mode 100644 index 00000000..5ed9db00 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import useDeepCompareEffect from "use-deep-compare-effect"; +import { useEditor } from "@/hooks/use-editor"; +import { useRafLoop } from "@/hooks/use-raf-loop"; +import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; +import type { RootNode } from "@/services/renderer/nodes/root-node"; +import { buildScene } from "@/services/renderer/scene-builder"; +import { getLastFrameTime } from "@/lib/time"; + +function usePreviewSize() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + + return { + width: activeProject?.settings.canvasSize.width, + height: activeProject?.settings.canvasSize.height, + }; +} + +function RenderTreeController() { + const editor = useEditor(); + const tracks = editor.timeline.getTracks(); + const mediaAssets = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + + const { width, height } = usePreviewSize(); + + useDeepCompareEffect(() => { + if (!activeProject) return; + + const duration = editor.timeline.getTotalDuration(); + const renderTree = buildScene({ + tracks, + mediaAssets, + duration, + canvasSize: { width, height }, + background: activeProject.settings.background, + }); + + editor.renderer.setRenderTree({ renderTree }); + }, [tracks, mediaAssets, activeProject?.settings.background, width, height]); + + return null; +} + +export function PreviewPanel() { + return ( +
+
+ + +
+
+ ); +} + +function PreviewCanvas() { + const ref = useRef(null); + const lastFrameRef = useRef(-1); + const lastSceneRef = useRef(null); + const renderingRef = useRef(false); + const { width, height } = usePreviewSize(); + const editor = useEditor(); + const activeProject = editor.project.getActive(); + + const renderer = useMemo(() => { + return new CanvasRenderer({ + width, + height, + fps: activeProject.settings.fps, + }); + }, [width, height, activeProject.settings.fps]); + + const renderTree = editor.renderer.getRenderTree(); + + const render = useCallback(() => { + if (ref.current && renderTree && !renderingRef.current) { + const time = editor.playback.getCurrentTime(); + const lastFrameTime = getLastFrameTime({ + duration: renderTree.duration, + fps: renderer.fps, + }); + const renderTime = Math.min(time, lastFrameTime); + const frame = Math.floor(renderTime * renderer.fps); + + if ( + frame !== lastFrameRef.current || + renderTree !== lastSceneRef.current + ) { + renderingRef.current = true; + lastSceneRef.current = renderTree; + lastFrameRef.current = frame; + renderer + .renderToCanvas({ + node: renderTree, + time: renderTime, + targetCanvas: ref.current, + }) + .then(() => { + renderingRef.current = false; + }); + } + } + }, [renderer, renderTree, editor.playback]); + + useRafLoop(render); + + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx new file mode 100644 index 00000000..0b83a4b6 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx @@ -0,0 +1,5 @@ +import type { AudioElement } from "@/types/timeline"; + +export function AudioProperties({ _element }: { _element: AudioElement }) { + return
Audio properties
; +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx new file mode 100644 index 00000000..3a552b93 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { AudioProperties } from "./audio-properties"; +import { VideoProperties } from "./video-properties"; +import { TextProperties } from "./text-properties"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Settings05Icon } from "@hugeicons/core-free-icons"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; + +export function PropertiesPanel() { + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + + const elementsWithTracks = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + }); + + return ( + <> + {selectedElements.length > 0 ? ( + + {elementsWithTracks.map(({ track, element }) => { + if (element.type === "text") { + return ( +
+ +
+ ); + } + if (element.type === "audio") { + return ; + } + if (element.type === "video" || element.type === "image") { + return ( +
+ +
+ ); + } + return null; + })} +
+ ) : ( + + )} + + ); +} + +function EmptyView() { + return ( +
+ +
+

It's empty here

+

+ Click an element on the timeline to edit its properties +

+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx new file mode 100644 index 00000000..6d2fe444 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/property-item.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowDownIcon } from "@hugeicons/core-free-icons"; + +interface PropertyItemProps { + direction?: "row" | "column"; + children: React.ReactNode; + className?: string; +} + +export function PropertyItem({ + direction = "row", + children, + className, +}: PropertyItemProps) { + return ( +
+ {children} +
+ ); +} + +export function PropertyItemLabel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +export function PropertyItemValue({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} + +interface PropertyGroupProps { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; + className?: string; + titleClassName?: string; +} + +export function PropertyGroup({ + title, + children, + defaultExpanded = true, + className, + titleClassName, +}: PropertyGroupProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + + + {isExpanded && {children}} + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx new file mode 100644 index 00000000..f8c9bf87 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -0,0 +1,410 @@ +import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import type { FontFamily } from "@/constants/font-constants"; +import type { TextElement } from "@/types/timeline"; +import { Slider } from "@/components/ui/slider"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useState, useRef } from "react"; +import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; +import { + TEXT_PROPERTIES_TABS, + isTextPropertiesTab, + useTextPropertiesStore, +} from "@/stores/text-properties-store"; +import { + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "./property-item"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { cn } from "@/utils/ui"; +import { capitalizeFirstLetter, uppercase } from "@/utils/string"; +import { clamp } from "@/utils/math"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { LayoutGridIcon } from "@hugeicons/core-free-icons"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_COLOR } from "@/constants/project-constants"; + +export function TextProperties({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const { activeTab, setActiveTab } = useTextPropertiesStore(); + const containerRef = useRef(null); + const [fontSizeInput, setFontSizeInput] = useState( + element.fontSize.toString(), + ); + const [opacityInput, setOpacityInput] = useState( + Math.round(element.opacity * 100).toString(), + ); + + const lastSelectedColor = useRef(DEFAULT_COLOR); + + const handleFontSizeChange = ({ value }: { value: string }) => { + setFontSizeInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + } + }; + + const handleFontSizeBlur = () => { + const parsed = parseInt(fontSizeInput, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + setFontSizeInput(fontSize.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + }; + + const handleOpacityChange = ({ value }: { value: string }) => { + setOpacityInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + } + }; + + const handleOpacityBlur = () => { + const parsed = parseInt(opacityInput, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + setOpacityInput(opacityPercent.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + }; + + const handleColorChange = ({ color }: { color: string }) => { + if (color !== "transparent") { + lastSelectedColor.current = color; + } + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }); + }; + + const handleTransparentToggle = ({ + isTransparent, + }: { + isTransparent: boolean; + }) => { + const newColor = isTransparent ? "transparent" : lastSelectedColor.current; + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: newColor }, + }); + }; + + return ( + { + if (isTextPropertiesTab(v)) setActiveTab(v); + }} + ref={containerRef} + tabs={TEXT_PROPERTIES_TABS.map((t) => ({ + value: t.value, + label: t.label, + content: + t.value === "transform" ? ( +
+ ) : ( +
+