"use client"; import { useTimelineStore } from "@/stores/timeline-store"; import { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; import { VideoPlayer } from "@/components/ui/video-player"; import { AudioPlayer } from "@/components/ui/audio-player"; import { Button } from "@/components/ui/button"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; import { FONT_CLASS_MAP } from "@/lib/font-config"; import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store"; import { TextElementDragState } from "@/types/editor"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { LayoutGuideOverlay } from "./layout-guide-overlay"; import { Label } from "../ui/label"; import { SocialsIcon } from "../icons"; import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store"; interface ActiveElement { element: TimelineElement; track: TimelineTrack; mediaItem: MediaItem | null; } export function PreviewPanel() { const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); const { mediaItems } = useMediaStore(); const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); const { activeProject } = useProjectStore(); const previewRef = useRef(null); const containerRef = useRef(null); const [previewDimensions, setPreviewDimensions] = useState({ width: 0, height: 0, }); const [isExpanded, setIsExpanded] = useState(false); const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE; const [dragState, setDragState] = useState({ isDragging: false, elementId: null, trackId: null, startX: 0, startY: 0, initialElementX: 0, initialElementY: 0, currentX: 0, currentY: 0, elementWidth: 0, elementHeight: 0, }); useEffect(() => { const updatePreviewSize = () => { if (!containerRef.current) return; let availableWidth, availableHeight; if (isExpanded) { const controlsHeight = 80; const marginSpace = 24; availableWidth = window.innerWidth - marginSpace; availableHeight = window.innerHeight - controlsHeight - marginSpace; } else { const container = containerRef.current.getBoundingClientRect(); const computedStyle = getComputedStyle(containerRef.current); const paddingTop = parseFloat(computedStyle.paddingTop); const paddingBottom = parseFloat(computedStyle.paddingBottom); const paddingLeft = parseFloat(computedStyle.paddingLeft); const paddingRight = parseFloat(computedStyle.paddingRight); const gap = parseFloat(computedStyle.gap) || 16; const toolbar = containerRef.current.querySelector("[data-toolbar]"); const toolbarHeight = toolbar ? toolbar.getBoundingClientRect().height : 0; availableWidth = container.width - paddingLeft - paddingRight; availableHeight = container.height - paddingTop - paddingBottom - toolbarHeight - (toolbarHeight > 0 ? gap : 0); } const targetRatio = canvasSize.width / canvasSize.height; const containerRatio = availableWidth / availableHeight; let width, height; if (containerRatio > targetRatio) { height = availableHeight * (isExpanded ? 0.95 : 1); width = height * targetRatio; } else { width = availableWidth * (isExpanded ? 0.95 : 1); height = width / targetRatio; } setPreviewDimensions({ width, height }); }; updatePreviewSize(); const resizeObserver = new ResizeObserver(updatePreviewSize); if (containerRef.current) { resizeObserver.observe(containerRef.current); } if (isExpanded) { window.addEventListener("resize", updatePreviewSize); } return () => { resizeObserver.disconnect(); if (isExpanded) { window.removeEventListener("resize", updatePreviewSize); } }; }, [canvasSize.width, canvasSize.height, isExpanded]); useEffect(() => { const handleEscapeKey = (event: KeyboardEvent) => { if (event.key === "Escape" && isExpanded) { setIsExpanded(false); } }; if (isExpanded) { document.addEventListener("keydown", handleEscapeKey); document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; } return () => { document.removeEventListener("keydown", handleEscapeKey); document.body.style.overflow = ""; }; }, [isExpanded]); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!dragState.isDragging) return; const deltaX = e.clientX - dragState.startX; const deltaY = e.clientY - dragState.startY; const scaleRatio = previewDimensions.width / canvasSize.width; const newX = dragState.initialElementX + deltaX / scaleRatio; const newY = dragState.initialElementY + deltaY / scaleRatio; const halfWidth = dragState.elementWidth / scaleRatio / 2; const halfHeight = dragState.elementHeight / scaleRatio / 2; const constrainedX = Math.max( -canvasSize.width / 2 + halfWidth, Math.min(canvasSize.width / 2 - halfWidth, newX) ); const constrainedY = Math.max( -canvasSize.height / 2 + halfHeight, Math.min(canvasSize.height / 2 - halfHeight, newY) ); setDragState((prev) => ({ ...prev, currentX: constrainedX, currentY: constrainedY, })); }; const handleMouseUp = () => { if (dragState.isDragging && dragState.trackId && dragState.elementId) { updateTextElement(dragState.trackId, dragState.elementId, { x: dragState.currentX, y: dragState.currentY, }); } setDragState((prev) => ({ ...prev, isDragging: false })); }; if (dragState.isDragging) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); document.body.style.cursor = "grabbing"; document.body.style.userSelect = "none"; } return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; }; }, [dragState, previewDimensions, canvasSize, updateTextElement]); const handleTextMouseDown = ( e: React.MouseEvent, element: any, trackId: string ) => { e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setDragState({ isDragging: true, elementId: element.id, trackId, startX: e.clientX, startY: e.clientY, initialElementX: element.x, initialElementY: element.y, currentX: element.x, currentY: element.y, elementWidth: rect.width, elementHeight: rect.height, }); }; const toggleExpanded = useCallback(() => { setIsExpanded((prev) => !prev); }, []); const hasAnyElements = tracks.some((track) => track.elements.length > 0); const getActiveElements = (): ActiveElement[] => { const activeElements: ActiveElement[] = []; // Iterate tracks from bottom to top so topmost track renders last (on top) ;[...tracks].reverse().forEach((track) => { track.elements.forEach((element) => { if (element.hidden) return; const elementStart = element.startTime; const elementEnd = element.startTime + (element.duration - element.trimStart - element.trimEnd); if (currentTime >= elementStart && currentTime < elementEnd) { let mediaItem = null; if (element.type === "media") { mediaItem = element.mediaId === "test" ? null : mediaItems.find((item) => item.id === element.mediaId) || null; } activeElements.push({ element, track, mediaItem }); } }); }); return activeElements; }; const activeElements = getActiveElements(); // Get media elements for blur background (video/image only) const getBlurBackgroundElements = (): ActiveElement[] => { return activeElements.filter( ({ element, mediaItem }) => element.type === "media" && mediaItem && (mediaItem.type === "video" || mediaItem.type === "image") && element.mediaId !== "test" // Exclude test elements ); }; const blurBackgroundElements = getBlurBackgroundElements(); // Render blur background layer const renderBlurBackground = () => { if ( !activeProject?.backgroundType || activeProject.backgroundType !== "blur" || blurBackgroundElements.length === 0 ) { return null; } // Use the first media element for background (could be enhanced to use primary/focused element) const backgroundElement = blurBackgroundElements[0]; const { element, mediaItem } = backgroundElement; if (!mediaItem) return null; const blurIntensity = activeProject.blurIntensity || 8; if (mediaItem.type === "video") { return (
); } if (mediaItem.type === "image") { return (
{mediaItem.name}
); } return null; }; // Render an element const renderElement = (elementData: ActiveElement, index: number) => { const { element, mediaItem } = elementData; // Text elements if (element.type === "text") { const fontClassName = FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; const scaleRatio = previewDimensions.width / canvasSize.width; return (
handleTextMouseDown(e, element, elementData.track.id) } style={{ left: `${ 50 + ((dragState.isDragging && dragState.elementId === element.id ? dragState.currentX : element.x) / canvasSize.width) * 100 }%`, top: `${ 50 + ((dragState.isDragging && dragState.elementId === element.id ? dragState.currentY : element.y) / canvasSize.height) * 100 }%`, transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, opacity: element.opacity, zIndex: 100 + index, // Text elements on top }} >
{element.content}
); } // Media elements if (element.type === "media") { // Test elements if (!mediaItem || element.mediaId === "test") { return (
🎬

{element.name}

); } // Video elements if (mediaItem.type === "video") { return (
); } // Image elements if (mediaItem.type === "image") { return (
{mediaItem.name}
); } // Audio elements (no visual representation) if (mediaItem.type === "audio") { return (
); } } return null; }; return ( <>
{hasAnyElements ? (
{renderBlurBackground()} {activeElements.length === 0 ? (
No elements at current time
) : ( activeElements.map((elementData, index) => renderElement(elementData, index) ) )}
) : null}
{isExpanded && ( )} ); } function FullscreenToolbar({ hasAnyElements, onToggleExpanded, currentTime, setCurrentTime, toggle, getTotalDuration, }: { hasAnyElements: boolean; onToggleExpanded: () => void; currentTime: number; setCurrentTime: (time: number) => void; toggle: () => void; getTotalDuration: () => number; }) { const { isPlaying, seek } = usePlaybackStore(); const { activeProject } = useProjectStore(); const [isDragging, setIsDragging] = useState(false); const totalDuration = getTotalDuration(); const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0; const handleTimelineClick = (e: React.MouseEvent) => { if (!hasAnyElements) return; const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left; const percentage = Math.max(0, Math.min(1, clickX / rect.width)); const newTime = percentage * totalDuration; setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); }; const handleTimelineDrag = (e: React.MouseEvent) => { if (!hasAnyElements) return; e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setIsDragging(true); const handleMouseMove = (moveEvent: MouseEvent) => { moveEvent.preventDefault(); const dragX = moveEvent.clientX - rect.left; const percentage = Math.max(0, Math.min(1, dragX / rect.width)); const newTime = percentage * totalDuration; setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); }; const handleMouseUp = () => { setIsDragging(false); document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); document.body.style.userSelect = ""; }; document.body.style.userSelect = "none"; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); handleMouseMove(e.nativeEvent); }; const skipBackward = () => { const newTime = Math.max(0, currentTime - 1); setCurrentTime(newTime); }; const skipForward = () => { const newTime = Math.min(totalDuration, currentTime + 1); setCurrentTime(newTime); }; return (
/ {formatTimeCode( totalDuration, "HH:MM:SS:FF", activeProject?.fps || 30 )}
); } function FullscreenPreview({ previewDimensions, activeProject, renderBlurBackground, activeElements, renderElement, blurBackgroundElements, hasAnyElements, toggleExpanded, currentTime, setCurrentTime, toggle, getTotalDuration, }: { previewDimensions: { width: number; height: number }; activeProject: any; renderBlurBackground: () => React.ReactNode; activeElements: ActiveElement[]; renderElement: (elementData: ActiveElement, index: number) => React.ReactNode; blurBackgroundElements: ActiveElement[]; hasAnyElements: boolean; toggleExpanded: () => void; currentTime: number; setCurrentTime: (time: number) => void; toggle: () => void; getTotalDuration: () => number; }) { return (
{renderBlurBackground()} {activeElements.length === 0 ? (
No elements at current time
) : ( activeElements.map((elementData, index) => renderElement(elementData, index) ) )}
); } function PreviewToolbar({ hasAnyElements, onToggleExpanded, isExpanded, currentTime, setCurrentTime, toggle, getTotalDuration, }: { hasAnyElements: boolean; onToggleExpanded: () => void; isExpanded: boolean; currentTime: number; setCurrentTime: (time: number) => void; toggle: () => void; getTotalDuration: () => number; }) { const { isPlaying } = usePlaybackStore(); const { layoutGuide, toggleLayoutGuide } = useEditorStore(); if (isExpanded) { return ( ); } return (

Layout guide

Show platform-specific layout guides to help align your content with interface elements like profile pictures, usernames, and interaction buttons.

toggleLayoutGuide(layoutGuide.platform || "tiktok") } />
{Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => (
toggleLayoutGuide(platform as PlatformLayout) } />
))}
); }