From d364bffc1f86f906e773bb51281a1221187e70f6 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 21 Jul 2025 21:00:21 +0200 Subject: [PATCH] Merge pull request [#386](https://github.com/mazeincoding/AppCut/issues/386) --- .../src/components/editor/preview-panel.tsx | 491 ++++++++++++++---- 1 file changed, 404 insertions(+), 87 deletions(-) diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 2ecee391..2cba1374 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -16,8 +16,8 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { Play, Pause, Expand } from "lucide-react"; -import { useState, useRef, useEffect } from "react"; +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 { FONT_CLASS_MAP } from "@/lib/font-config"; @@ -31,9 +31,9 @@ interface ActiveElement { } export function PreviewPanel() { - const { tracks } = useTimelineStore(); + const { tracks, getTotalDuration } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const { currentTime } = usePlaybackStore(); + const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore(); const { canvasSize } = useEditorStore(); const previewRef = useRef(null); const containerRef = useRef(null); @@ -41,52 +41,51 @@ export function PreviewPanel() { width: 0, height: 0, }); + const [isExpanded, setIsExpanded] = useState(false); const { activeProject } = useProjectStore(); - // Calculate optimal preview size that fits in container while maintaining aspect ratio useEffect(() => { const updatePreviewSize = () => { if (!containerRef.current) return; - const container = containerRef.current.getBoundingClientRect(); - const computedStyle = getComputedStyle(containerRef.current); + let availableWidth, availableHeight; - // Get padding values - const paddingTop = parseFloat(computedStyle.paddingTop); - const paddingBottom = parseFloat(computedStyle.paddingBottom); - const paddingLeft = parseFloat(computedStyle.paddingLeft); - const paddingRight = parseFloat(computedStyle.paddingRight); + 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; - // Get gap value (gap-4 = 1rem = 16px) - const gap = parseFloat(computedStyle.gap) || 16; - - // Get toolbar height if it exists - const toolbar = containerRef.current.querySelector("[data-toolbar]"); - const toolbarHeight = toolbar - ? toolbar.getBoundingClientRect().height - : 0; - - // Calculate available space after accounting for padding, gap, and toolbar - const availableWidth = container.width - paddingLeft - paddingRight; - const availableHeight = - container.height - - paddingTop - - paddingBottom - - toolbarHeight - - (toolbarHeight > 0 ? gap : 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) { - // Container is wider - constrain by height - height = availableHeight; + height = availableHeight * (isExpanded ? 0.95 : 1); width = height * targetRatio; } else { - // Container is taller - constrain by width - width = availableWidth; + width = availableWidth * (isExpanded ? 0.95 : 1); height = width / targetRatio; } @@ -94,16 +93,47 @@ export function PreviewPanel() { }; updatePreviewSize(); - const resizeObserver = new ResizeObserver(updatePreviewSize); if (containerRef.current) { resizeObserver.observe(containerRef.current); } + if (isExpanded) { + window.addEventListener("resize", updatePreviewSize); + } - return () => resizeObserver.disconnect(); - }, [canvasSize.width, canvasSize.height]); + return () => { + resizeObserver.disconnect(); + if (isExpanded) { + window.removeEventListener("resize", updatePreviewSize); + } + }; + }, [canvasSize.width, canvasSize.height, isExpanded]); - // Get active elements at current time + 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]); + + const toggleExpanded = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + const hasAnyElements = tracks.some((track) => track.elements.length > 0); const getActiveElements = (): ActiveElement[] => { const activeElements: ActiveElement[] = []; @@ -116,16 +146,13 @@ export function PreviewPanel() { if (currentTime >= elementStart && currentTime < elementEnd) { let mediaItem = null; - - // Only get media item for media elements if (element.type === "media") { mediaItem = element.mediaId === "test" - ? null // Test elements don't have a real media item + ? null : mediaItems.find((item) => item.id === element.mediaId) || null; } - activeElements.push({ element, track, mediaItem }); } }); @@ -136,9 +163,6 @@ export function PreviewPanel() { const activeElements = getActiveElements(); - // Check if there are any elements in the timeline at all - const hasAnyElements = tracks.some((track) => track.elements.length > 0); - // Get media elements for blur background (video/image only) const getBlurBackgroundElements = (): ActiveElement[] => { return activeElements.filter( @@ -338,58 +362,334 @@ export function PreviewPanel() { }; return ( -
-
-
- {hasAnyElements ? ( -
- {renderBlurBackground()} - {activeElements.length === 0 ? ( -
- No elements at current time -
- ) : ( - activeElements.map((elementData, index) => - renderElement(elementData, index) - ) - )} - {/* Show message when blur is selected but no media available */} - {activeProject?.backgroundType === "blur" && - blurBackgroundElements.length === 0 && - activeElements.length > 0 && ( -
- Add a video or image to use blur background + <> +
+
+
+ {hasAnyElements ? ( +
+ {renderBlurBackground()} + {activeElements.length === 0 ? ( +
+ No elements at current time
+ ) : ( + activeElements.map((elementData, index) => + renderElement(elementData, index) + ) )} -
- ) : null} + {activeProject?.backgroundType === "blur" && + blurBackgroundElements.length === 0 && + activeElements.length > 0 && ( +
+ Add a video or image to use blur background +
+ )} +
+ ) : 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 } = 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(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} + + / + + {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) + ) + )} + {activeProject?.backgroundType === "blur" && + blurBackgroundElements.length === 0 && + activeElements.length > 0 && ( +
+ Add a video or image to use blur background +
+ )} +
+
+
+
); } -function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { - const { isPlaying, toggle, currentTime } = usePlaybackStore(); +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 { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); - const { getTotalDuration } = useTimelineStore(); const { activeProject } = useProjectStore(); const { currentPreset, @@ -408,6 +708,21 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { setCanvasSizeToOriginal(aspectRatio); }; + if (isExpanded) { + return ( + + ); + } + return (