summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/preview-panel.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/preview-panel.tsx')
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx514
1 files changed, 256 insertions, 258 deletions
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index af603e97..2cba1374 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -44,40 +44,30 @@ export function PreviewPanel() {
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;
- let container;
let availableWidth, availableHeight;
if (isExpanded) {
- // Use full viewport when expanded, accounting for controls and margin
- const controlsHeight = 80; // Approximate height of controls area
- const marginSpace = 24; // m-3 = 12px on each side = 24px total
+ const controlsHeight = 80;
+ const marginSpace = 24;
availableWidth = window.innerWidth - marginSpace;
availableHeight = window.innerHeight - controlsHeight - marginSpace;
} else {
- container = containerRef.current.getBoundingClientRect();
+ const container = containerRef.current.getBoundingClientRect();
const computedStyle = getComputedStyle(containerRef.current);
-
- // Get padding values
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
const paddingLeft = parseFloat(computedStyle.paddingLeft);
const paddingRight = parseFloat(computedStyle.paddingRight);
-
- // 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
availableWidth = container.width - paddingLeft - paddingRight;
availableHeight =
container.height -
@@ -89,16 +79,13 @@ export function PreviewPanel() {
const targetRatio = canvasSize.width / canvasSize.height;
const containerRatio = availableWidth / availableHeight;
-
let width, height;
if (containerRatio > targetRatio) {
- // Container is wider - constrain by height
- height = availableHeight * (isExpanded ? 0.95 : 1); // Use more space when expanded
+ height = availableHeight * (isExpanded ? 0.95 : 1);
width = height * targetRatio;
} else {
- // Container is taller - constrain by width
- width = availableWidth * (isExpanded ? 0.95 : 1); // Use more space when expanded
+ width = availableWidth * (isExpanded ? 0.95 : 1);
height = width / targetRatio;
}
@@ -106,13 +93,10 @@ export function PreviewPanel() {
};
updatePreviewSize();
-
const resizeObserver = new ResizeObserver(updatePreviewSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
-
- // Listen for window resize when expanded
if (isExpanded) {
window.addEventListener("resize", updatePreviewSize);
}
@@ -125,7 +109,6 @@ export function PreviewPanel() {
};
}, [canvasSize.width, canvasSize.height, isExpanded]);
- // Handle escape key to exit expanded mode
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && isExpanded) {
@@ -135,7 +118,6 @@ export function PreviewPanel() {
if (isExpanded) {
document.addEventListener("keydown", handleEscapeKey);
- // Prevent body scroll when expanded
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
@@ -151,73 +133,7 @@ export function PreviewPanel() {
setIsExpanded((prev) => !prev);
}, []);
- // Check if there are any elements in the timeline at all
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
-
- // Handle keyboard shortcuts for timeline navigation
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- // Only handle shortcuts when preview panel is focused or no input is focused
- const activeElement = document.activeElement;
- const isInputFocused =
- activeElement?.tagName === "INPUT" ||
- activeElement?.tagName === "TEXTAREA" ||
- activeElement?.contentEditable === "true";
-
- if (isInputFocused) return;
-
- switch (event.key) {
- case " ":
- event.preventDefault();
- if (hasAnyElements) {
- toggle();
- }
- break;
- case "ArrowLeft":
- event.preventDefault();
- if (hasAnyElements) {
- const frameTime = 1 / (activeProject?.fps || 30);
- const totalDuration = getTotalDuration();
- const newTime = Math.max(
- 0,
- currentTime - (event.shiftKey ? frameTime : 1),
- );
- setCurrentTime(newTime);
- }
- break;
- case "ArrowRight":
- event.preventDefault();
- if (hasAnyElements) {
- const frameTime = 1 / (activeProject?.fps || 30);
- const totalDuration = getTotalDuration();
- const newTime = Math.min(
- totalDuration,
- currentTime + (event.shiftKey ? frameTime : 1),
- );
- setCurrentTime(newTime);
- }
- break;
- case "Home":
- event.preventDefault();
- if (hasAnyElements) {
- setCurrentTime(0);
- }
- break;
- case "End":
- event.preventDefault();
- if (hasAnyElements) {
- const totalDuration = getTotalDuration();
- setCurrentTime(totalDuration);
- }
- break;
- }
- };
-
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }, [hasAnyElements, toggle, currentTime, setCurrentTime, activeProject?.fps]);
-
- // Get active elements at current time
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
@@ -230,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 });
}
});
@@ -257,7 +170,7 @@ export function PreviewPanel() {
element.type === "media" &&
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image") &&
- element.mediaId !== "test", // Exclude test elements
+ element.mediaId !== "test" // Exclude test elements
);
};
@@ -476,10 +389,9 @@ export function PreviewPanel() {
</div>
) : (
activeElements.map((elementData, index) =>
- renderElement(elementData, index),
+ renderElement(elementData, index)
)
)}
- {/* Show message when blur is selected but no media available */}
{activeProject?.backgroundType === "blur" &&
blurBackgroundElements.length === 0 &&
activeElements.length > 0 && (
@@ -504,62 +416,29 @@ export function PreviewPanel() {
</div>
</div>
- {/* Expanded/Fullscreen Mode */}
{isExpanded && (
- <div className="fixed inset-0 z-[9999] flex flex-col">
- <div className="flex-1 flex items-center justify-center bg-background">
- <div
- className="relative overflow-hidden border border-border m-3"
- style={{
- width: previewDimensions.width,
- height: previewDimensions.height,
- backgroundColor:
- activeProject?.backgroundType === "blur"
- ? "#1a1a1a"
- : activeProject?.backgroundColor || "#1a1a1a",
- }}
- >
- {renderBlurBackground()}
- {activeElements.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center text-white/60">
- No elements at current time
- </div>
- ) : (
- 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 && (
- <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
- Add a video or image to use blur background
- </div>
- )}
- </div>
- </div>
- <div className="p-4 bg-black">
- <PreviewToolbar
- hasAnyElements={hasAnyElements}
- onToggleExpanded={toggleExpanded}
- isExpanded={isExpanded}
- currentTime={currentTime}
- setCurrentTime={setCurrentTime}
- toggle={toggle}
- getTotalDuration={getTotalDuration}
- />
- </div>
- </div>
+ <FullscreenPreview
+ previewDimensions={previewDimensions}
+ activeProject={activeProject}
+ renderBlurBackground={renderBlurBackground}
+ activeElements={activeElements}
+ renderElement={renderElement}
+ blurBackgroundElements={blurBackgroundElements}
+ hasAnyElements={hasAnyElements}
+ toggleExpanded={toggleExpanded}
+ currentTime={currentTime}
+ setCurrentTime={setCurrentTime}
+ toggle={toggle}
+ getTotalDuration={getTotalDuration}
+ />
)}
</>
);
}
-function PreviewToolbar({
+function FullscreenToolbar({
hasAnyElements,
onToggleExpanded,
- isExpanded,
currentTime,
setCurrentTime,
toggle,
@@ -567,38 +446,20 @@ function PreviewToolbar({
}: {
hasAnyElements: boolean;
onToggleExpanded: () => void;
- isExpanded: boolean;
currentTime: number;
setCurrentTime: (time: number) => void;
toggle: () => void;
getTotalDuration: () => number;
}) {
const { isPlaying } = usePlaybackStore();
- const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
const { activeProject } = useProjectStore();
- const {
- currentPreset,
- isOriginal,
- getOriginalAspectRatio,
- getDisplayName,
- canvasPresets,
- } = useAspectRatio();
-
- const handlePresetSelect = (preset: { width: number; height: number }) => {
- setCanvasSize({ width: preset.width, height: preset.height });
- };
-
- const handleOriginalSelect = () => {
- const aspectRatio = getOriginalAspectRatio();
- setCanvasSizeToOriginal(aspectRatio);
- };
+ const [isDragging, setIsDragging] = useState(false);
const totalDuration = getTotalDuration();
const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
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));
@@ -608,16 +469,12 @@ function PreviewToolbar({
const handleTimelineDrag = (e: React.MouseEvent<HTMLDivElement>) => {
if (!hasAnyElements) return;
-
e.preventDefault();
e.stopPropagation();
-
const rect = e.currentTarget.getBoundingClientRect();
- let isDragging = true;
+ setIsDragging(true);
const handleMouseMove = (moveEvent: MouseEvent) => {
- if (!isDragging) return;
-
moveEvent.preventDefault();
const dragX = moveEvent.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, dragX / rect.width));
@@ -625,48 +482,35 @@ function PreviewToolbar({
setCurrentTime(Math.max(0, Math.min(newTime, totalDuration)));
};
- const handleMouseUp = (moveEvent: MouseEvent) => {
- isDragging = false;
+ const handleMouseUp = () => {
+ setIsDragging(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
};
- // Prevent text selection during drag
document.body.style.userSelect = "none";
-
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
-
- // Also handle the initial click position
handleMouseMove(e.nativeEvent);
};
const skipBackward = () => {
- const newTime = Math.max(0, currentTime - 1); // Skip 1 second back
+ const newTime = Math.max(0, currentTime - 1);
setCurrentTime(newTime);
};
const skipForward = () => {
- const newTime = Math.min(totalDuration, currentTime + 1); // Skip 1 second forward
+ const newTime = Math.min(totalDuration, currentTime + 1);
setCurrentTime(newTime);
};
return (
<div
data-toolbar
- className={cn(
- "flex items-center gap-2 p-1 pt-2 w-full",
- isExpanded && "text-white",
- )}
+ className="flex items-center gap-2 p-1 pt-2 w-full text-white"
>
- {/* Time Display */}
- <div
- className={cn(
- "flex items-center gap-1 text-[0.70rem] tabular-nums",
- isExpanded ? "text-white/90" : "text-muted-foreground",
- )}
- >
+ <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90">
<span className="text-primary">
{formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)}
</span>
@@ -675,22 +519,18 @@ function PreviewToolbar({
{formatTimeCode(
totalDuration,
"HH:MM:SS:FF",
- activeProject?.fps || 30,
+ activeProject?.fps || 30
)}
</span>
</div>
- {/* Transport Controls */}
<div className="flex items-center gap-1">
<Button
variant="text"
size="icon"
onClick={skipBackward}
disabled={!hasAnyElements}
- className={cn(
- "h-auto p-0",
- isExpanded && "text-white hover:text-white/80",
- )}
+ className="h-auto p-0 text-white hover:text-white/80"
title="Skip backward 1s"
>
<SkipBack className="h-3 w-3" />
@@ -700,10 +540,7 @@ function PreviewToolbar({
size="icon"
onClick={toggle}
disabled={!hasAnyElements}
- className={cn(
- "h-auto p-0",
- isExpanded && "text-white hover:text-white/80",
- )}
+ className="h-auto p-0 text-white hover:text-white/80"
>
{isPlaying ? (
<Pause className="h-3 w-3" />
@@ -716,23 +553,18 @@ function PreviewToolbar({
size="icon"
onClick={skipForward}
disabled={!hasAnyElements}
- className={cn(
- "h-auto p-0",
- isExpanded && "text-white hover:text-white/80",
- )}
+ className="h-auto p-0 text-white hover:text-white/80"
title="Skip forward 1s"
>
<SkipForward className="h-3 w-3" />
</Button>
</div>
- {/* Timeline Scrubber */}
<div className="flex-1 flex items-center gap-2">
<div
className={cn(
- "relative h-1 rounded-full cursor-pointer flex-1",
- isExpanded ? "bg-white/20" : "bg-muted",
- !hasAnyElements && "opacity-50 cursor-not-allowed",
+ "relative h-1 rounded-full cursor-pointer flex-1 bg-white/20",
+ !hasAnyElements && "opacity-50 cursor-not-allowed"
)}
onClick={hasAnyElements ? handleTimelineClick : undefined}
onMouseDown={hasAnyElements ? handleTimelineDrag : undefined}
@@ -740,73 +572,239 @@ function PreviewToolbar({
>
<div
className={cn(
- "absolute top-0 left-0 h-full rounded-full transition-all duration-100",
- isExpanded ? "bg-white" : "bg-primary",
+ "absolute top-0 left-0 h-full rounded-full bg-white",
+ !isDragging && "duration-100"
)}
style={{ width: `${progress}%` }}
/>
<div
- className={cn(
- "absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-sm",
- isExpanded
- ? "bg-white border border-black/20"
- : "bg-primary border border-background",
- )}
+ className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-sm bg-white border border-black/20"
style={{ left: `${progress}%` }}
/>
</div>
</div>
- {/* Aspect Ratio and Fullscreen Controls */}
- <div className="flex items-center gap-3">
- {!isExpanded && (
- <>
- <BackgroundSettings />
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- size="sm"
- className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light"
- disabled={!hasAnyElements}
- >
- {getDisplayName()}
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={handleOriginalSelect}
- className={cn("text-xs", isOriginal && "font-semibold")}
- >
- Original
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- {canvasPresets.map((preset) => (
- <DropdownMenuItem
- key={preset.name}
- onClick={() => handlePresetSelect(preset)}
- className={cn(
- "text-xs",
- currentPreset?.name === preset.name && "font-semibold",
- )}
- >
- {preset.name}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </>
+ <Button
+ variant="text"
+ size="icon"
+ className="!size-4 text-white/80 hover:text-white"
+ onClick={onToggleExpanded}
+ title="Exit fullscreen (Esc)"
+ >
+ <Expand className="!size-4" />
+ </Button>
+ </div>
+ );
+}
+
+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 (
+ <div className="fixed inset-0 z-[9999] flex flex-col">
+ <div className="flex-1 flex items-center justify-center bg-background">
+ <div
+ className="relative overflow-hidden border border-border m-3"
+ style={{
+ width: previewDimensions.width,
+ height: previewDimensions.height,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? "#1a1a1a"
+ : activeProject?.backgroundColor || "#1a1a1a",
+ }}
+ >
+ {renderBlurBackground()}
+ {activeElements.length === 0 ? (
+ <div className="absolute inset-0 flex items-center justify-center text-white/60">
+ No elements at current time
+ </div>
+ ) : (
+ activeElements.map((elementData, index) =>
+ renderElement(elementData, index)
+ )
+ )}
+ {activeProject?.backgroundType === "blur" &&
+ blurBackgroundElements.length === 0 &&
+ activeElements.length > 0 && (
+ <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
+ Add a video or image to use blur background
+ </div>
+ )}
+ </div>
+ </div>
+ <div className="p-4 bg-black">
+ <FullscreenToolbar
+ hasAnyElements={hasAnyElements}
+ onToggleExpanded={toggleExpanded}
+ currentTime={currentTime}
+ setCurrentTime={setCurrentTime}
+ toggle={toggle}
+ getTotalDuration={getTotalDuration}
+ />
+ </div>
+ </div>
+ );
+}
+
+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 { activeProject } = useProjectStore();
+ const {
+ currentPreset,
+ isOriginal,
+ getOriginalAspectRatio,
+ getDisplayName,
+ canvasPresets,
+ } = useAspectRatio();
+
+ const handlePresetSelect = (preset: { width: number; height: number }) => {
+ setCanvasSize({ width: preset.width, height: preset.height });
+ };
+
+ const handleOriginalSelect = () => {
+ const aspectRatio = getOriginalAspectRatio();
+ setCanvasSizeToOriginal(aspectRatio);
+ };
+
+ if (isExpanded) {
+ return (
+ <FullscreenToolbar
+ {...{
+ hasAnyElements,
+ onToggleExpanded,
+ currentTime,
+ setCurrentTime,
+ toggle,
+ getTotalDuration,
+ }}
+ />
+ );
+ }
+
+ return (
+ <div
+ data-toolbar
+ className="flex items-end justify-between gap-2 p-1 pt-2 w-full"
+ >
+ <div>
+ <p
+ className={cn(
+ "text-[0.75rem] text-muted-foreground flex items-center gap-1",
+ !hasAnyElements && "opacity-50"
+ )}
+ >
+ <span className="text-primary tabular-nums">
+ {formatTimeCode(
+ currentTime,
+ "HH:MM:SS:FF",
+ activeProject?.fps || 30
+ )}
+ </span>
+ <span className="opacity-50">/</span>
+ <span className="tabular-nums">
+ {formatTimeCode(
+ getTotalDuration(),
+ "HH:MM:SS:FF",
+ activeProject?.fps || 30
+ )}
+ </span>
+ </p>
+ </div>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ disabled={!hasAnyElements}
+ className="h-auto p-0"
+ >
+ {isPlaying ? (
+ <Pause className="h-3 w-3" />
+ ) : (
+ <Play className="h-3 w-3" />
)}
+ </Button>
+ <div className="flex items-center gap-3">
+ <BackgroundSettings />
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ size="sm"
+ className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light"
+ disabled={!hasAnyElements}
+ >
+ {getDisplayName()}
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={handleOriginalSelect}
+ className={cn("text-xs", isOriginal && "font-semibold")}
+ >
+ Original
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ {canvasPresets.map((preset) => (
+ <DropdownMenuItem
+ key={preset.name}
+ onClick={() => handlePresetSelect(preset)}
+ className={cn(
+ "text-xs",
+ currentPreset?.name === preset.name && "font-semibold"
+ )}
+ >
+ {preset.name}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
<Button
variant="text"
size="icon"
- className={cn(
- "!size-4",
- isExpanded
- ? "text-white/80 hover:text-white"
- : "text-muted-foreground",
- )}
+ className="!size-4 text-muted-foreground"
onClick={onToggleExpanded}
- title={isExpanded ? "Exit fullscreen (Esc)" : "Enter fullscreen"}
+ title="Enter fullscreen"
>
<Expand className="!size-4" />
</Button>