Merge pull request #474 from presenton/fix/present-mode

refactor: Revamp Present mode
This commit is contained in:
Shiva Raj Badu 2026-03-29 16:35:48 +05:45 committed by GitHub
commit 9fc867e23b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 281 additions and 201 deletions

View file

@ -6,40 +6,54 @@ import { V1ContentRender } from '../../(presentation-generator)/components/V1Con
const BASE_WIDTH = 1280;
const BASE_HEIGHT = 720;
const SlideScale = ({ slide, theme }: { slide: any, theme?: any }) => {
const SlideScale = ({
slide,
theme,
isEditMode = true,
/** Fill viewport; scale may exceed 1 so slides appear larger in present mode */
presentMode = false,
}: { slide: any; theme?: any; isEditMode?: boolean; presentMode?: boolean }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState<number>(0);
const [box, setBox] = useState({ w: 0, h: 0 });
const scale = useMemo(() => {
// Slight padding to avoid overflow due to borders/scrollbars
const safeWidth = Math.max(0, containerWidth + 20);
if (presentMode) {
const { w, h } = box;
if (w < 1 || h < 1) return 1;
const sx = (w / BASE_WIDTH) * 0.995;
const sy = (h / BASE_HEIGHT) * 0.995;
return Math.min(sx, sy);
}
const safeWidth = Math.max(0, box.w + 20);
if (!safeWidth) return 1;
return Math.min((safeWidth / BASE_WIDTH) * 0.98, 1);
}, [containerWidth]);
}, [presentMode, box.w, box.h]);
useEffect(() => {
if (!containerRef.current) return;
const el = containerRef.current;
const ro = new ResizeObserver(() => {
// Use clientWidth so we match the actual available column width
setContainerWidth(el.clientWidth);
setBox({ w: el.clientWidth, h: el.clientHeight });
});
ro.observe(el);
// Initial measure
setContainerWidth(el.clientWidth);
setBox({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
}, []);
return (<div
ref={containerRef}
className="relative w-full shadow-md"
className={`relative w-full ${presentMode ? "flex h-full min-h-0 items-center justify-center shadow-none" : "shadow-md"}`}
>
<div
className="relative mx-auto max-w-[1280px] "
style={{ height: `${BASE_HEIGHT * scale}px`, overflow: "hidden" }}
className={presentMode ? "relative mx-auto shrink-0" : "relative mx-auto max-w-[1280px]"}
style={{
width: presentMode ? `${BASE_WIDTH * scale}px` : undefined,
height: `${BASE_HEIGHT * scale}px`,
overflow: "hidden",
}}
>
<div
className="absolute top-0 left-0"
@ -67,7 +81,7 @@ const SlideScale = ({ slide, theme }: { slide: any, theme?: any }) => {
aria-hidden="true"
/> */}
<V1ContentRender slide={slide} isEditMode={true} theme={theme} />
<V1ContentRender slide={slide} isEditMode={isEditMode} theme={theme} />
</div>

View file

@ -1,5 +1,6 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ChevronLeft,
ChevronRight,
@ -8,297 +9,360 @@ import {
Maximize2,
StickyNote,
EyeOff,
Keyboard,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slide } from "../../types/slide";
import { V1ContentRender } from "../../components/V1ContentRender";
import SlideScale from "../../components/PresentationRender";
import type { Theme } from "../../services/api/types";
interface PresentationModeProps {
slides: Slide[];
currentSlide: number;
theme?: Theme | null;
isFullscreen: boolean;
onFullscreenToggle: () => void;
onExit: () => void;
onSlideChange: (slideNumber: number) => void;
}
const PresentationMode: React.FC<PresentationModeProps> = ({
const CHROME_HIDE_MS = 800;
const PresentationMode: React.FC<PresentationModeProps> = ({
slides,
currentSlide,
theme,
isFullscreen,
onFullscreenToggle,
onExit,
onSlideChange,
}) => {
if (slides === undefined || slides === null || slides.length === 0) {
return null;
}
const rootRef = useRef<HTMLDivElement>(null);
const hideChromeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showSpeakerNotes, setShowSpeakerNotes] = useState(true);
const [chromeVisible, setChromeVisible] = useState(true);
const currentSpeakerNote = useMemo(
() => slides[currentSlide]?.speaker_note?.trim() || "",
[slides, currentSlide]
);
const activeSlide = slides[currentSlide];
const recomputeScale = useCallback(() => {
if (typeof window === "undefined") return;
const padding = isFullscreen ? 0 : 64; // match p-8 when not fullscreen
const fullscreenMargin = isFullscreen ? 16 : 0; // small safety margin to prevent clipping
const availableWidth = Math.max(window.innerWidth - padding - fullscreenMargin, 0);
const availableHeight = Math.max(window.innerHeight - padding - fullscreenMargin, 0);
const baseW = 1280;
const baseH = 720;
const s = Math.min(availableWidth / baseW, availableHeight / baseH);
const bumpChromeVisibility = useCallback(() => {
setChromeVisible(true);
if (hideChromeTimerRef.current) clearTimeout(hideChromeTimerRef.current);
hideChromeTimerRef.current = setTimeout(() => {
if (isFullscreen) setChromeVisible(false);
}, CHROME_HIDE_MS);
}, [isFullscreen]);
useEffect(() => {
recomputeScale();
window.addEventListener("resize", recomputeScale);
return () => window.removeEventListener("resize", recomputeScale);
}, [recomputeScale]);
rootRef.current?.focus({ preventScroll: true });
}, []);
useEffect(() => {
if (!isFullscreen) {
setChromeVisible(true);
if (hideChromeTimerRef.current) {
clearTimeout(hideChromeTimerRef.current);
hideChromeTimerRef.current = null;
}
return;
}
bumpChromeVisibility();
return () => {
if (hideChromeTimerRef.current) clearTimeout(hideChromeTimerRef.current);
};
}, [isFullscreen, bumpChromeVisibility]);
// Modify the handleKeyPress to prevent default behavior
const handleKeyPress = useCallback(
const handlePointerActivity = useCallback(() => {
bumpChromeVisibility();
}, [bumpChromeVisibility]);
const goNext = useCallback(() => {
if (currentSlide < slides.length - 1) onSlideChange(currentSlide + 1);
}, [currentSlide, slides.length, onSlideChange]);
const goPrev = useCallback(() => {
if (currentSlide > 0) onSlideChange(currentSlide - 1);
}, [currentSlide, onSlideChange]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
event.preventDefault(); // Prevent default scroll behavior
const navKeys = [
"ArrowRight",
"ArrowLeft",
"ArrowUp",
"ArrowDown",
" ",
"Home",
"End",
"PageDown",
"PageUp",
];
if (navKeys.includes(event.key)) {
event.preventDefault();
}
if (event.repeat) {
if (event.key === " " || event.key === "ArrowRight" || event.key === "ArrowLeft") {
return;
}
}
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
case " ": // Space key
if (currentSlide < slides.length - 1) {
onSlideChange(currentSlide + 1);
}
case " ":
case "PageDown":
goNext();
break;
case "ArrowLeft":
case "ArrowUp":
if (currentSlide > 0) {
onSlideChange(currentSlide - 1);
case "PageUp":
goPrev();
break;
case "Home":
if (currentSlide !== 0) onSlideChange(0);
break;
case "End":
if (slides.length > 0 && currentSlide !== slides.length - 1) {
onSlideChange(slides.length - 1);
}
break;
case "Escape":
// If fullscreen is active, only exit fullscreen on first ESC. Second ESC exits present mode.
if (document.fullscreenElement) {
try { document.exitFullscreen(); } catch (_) { }
try {
document.exitFullscreen();
} catch {
/* ignore */
}
return;
}
onExit();
break;
case "f":
case "F":
onFullscreenToggle();
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
onFullscreenToggle();
}
break;
case "n":
case "N":
setShowSpeakerNotes((prev) => !prev);
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
setShowSpeakerNotes((prev) => !prev);
}
break;
default:
break;
}
},
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, isFullscreen]
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, goNext, goPrev]
);
// Add both keydown and keyup listeners
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Prevent default behavior for arrow keys and space
if (
["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", " "].includes(e.key)
) {
e.preventDefault();
}
handleKeyPress(e);
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyPress]);
// Add click handlers for the slide area
const handleSlideClick = (e: React.MouseEvent) => {
// Don't trigger navigation if clicking on controls
if ((e.target as HTMLElement).closest(".presentation-controls")) {
return;
}
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const handleSlideAreaClick = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest(".presentation-controls")) return;
const clickX = e.clientX;
const windowWidth = window.innerWidth;
if (clickX < windowWidth / 3) {
if (currentSlide > 0) {
onSlideChange(currentSlide - 1);
}
} else if (clickX > (windowWidth * 2) / 3) {
if (currentSlide < slides.length - 1) {
onSlideChange(currentSlide + 1);
}
}
const w = window.innerWidth;
if (clickX < w / 5) goPrev();
else if (clickX > (w * 4) / 5) goNext();
};
// Handle Escape key separately
useEffect(() => {
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
onFullscreenToggle(); // Just toggle fullscreen, don't exit presentation
}
};
const progress = slides.length > 0 ? ((currentSlide + 1) / slides.length) * 100 : 0;
document.addEventListener("keydown", handleEscKey);
return () => document.removeEventListener("keydown", handleEscKey);
}, [isFullscreen, onFullscreenToggle]);
if (slides === undefined || slides === null || slides.length === 0) {
return null;
}
return (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--page-background-color,#c8c7c9)" }}
ref={rootRef}
role="application"
aria-label="Presentation"
className="fixed inset-0 z-[100] flex flex-col outline-none select-none"
style={{ backgroundColor: "var(--page-background-color, #c8c7c9)" }}
tabIndex={0}
onClick={handleSlideClick}
onMouseMove={handlePointerActivity}
onClick={handleSlideAreaClick}
>
{/* Controls - Only show when not in fullscreen */}
{!isFullscreen && (
<>
<div className="presentation-controls absolute top-4 right-4 flex items-center gap-2 z-50">
<Button
variant="ghost"
style={{ color: "var(--text-body-color,#000000)" }}
size="icon"
onClick={(e) => {
e.stopPropagation();
onFullscreenToggle();
}}
className="text-white hover:bg-white/20"
>
{isFullscreen ? (
<Minimize2 className="h-5 w-5" />
) : (
<Maximize2 className="h-5 w-5" />
)}
</Button>
<Button
variant="ghost"
style={{ color: "var(--text-body-color,#000000)" }}
size="icon"
onClick={(e) => {
e.stopPropagation();
onExit();
}}
className="text-white hover:bg-white/20"
>
<X className="h-5 w-5" />
</Button>
</div>
<span className="sr-only">
Slide {currentSlide + 1} of {slides.length}
</span>
<div className="presentation-controls absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-4 z-50">
<Button
variant="ghost"
style={{ color: "var(--text-body-color,#000000)" }}
size="icon"
onClick={(e) => {
e.stopPropagation();
onSlideChange(currentSlide - 1);
}}
disabled={currentSlide === 0}
className="text-white hover:bg-white/20"
>
<ChevronLeft className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
</Button>
<span className="text-white"
style={{ color: "var(--text-body-color,#000000)" }}
>
{currentSlide + 1} / {slides.length}
</span>
<Button
variant="ghost"
style={{ color: "var(--text-body-color,#000000)" }}
size="icon"
onClick={(e) => {
e.stopPropagation();
onSlideChange(currentSlide + 1);
}}
disabled={currentSlide === slides.length - 1}
className="text-white hover:bg-white/20"
>
<ChevronRight className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
</Button>
</div>
</>
)}
{/* Centered 16:9 stage for consistent alignment in normal + fullscreen modes */}
<div className={`flex-1 min-h-0 flex items-center justify-center ${isFullscreen ? "px-6 py-8 md:px-10 md:py-12" : "p-8"}`}>
<div
className="relative rounded-sm font-inter"
style={{
aspectRatio: "16 / 9",
width: isFullscreen
? "min(90vw, calc(88vh * 16 / 9))"
: "min(calc(100vw - 4rem), calc((100vh - 4rem) * 16 / 9))",
maxHeight: isFullscreen ? "88vh" : "calc(100vh - 4rem)",
}}
>
{slides.length > 0 && slides.map((slide, index) => (
<div
key={slide.id}
className={index === currentSlide ? "h-full w-full" : "hidden h-full w-full"}
>
<V1ContentRender slide={slide} isEditMode={true} />
</div>
))}
{/* Top bar — fullscreen: auto-hide */}
<div
className={`presentation-controls absolute left-0 right-0 top-0 z-50 flex justify-end gap-2 px-3 py-3 transition-opacity duration-300 md:px-4 ${isFullscreen && !chromeVisible ? "pointer-events-none opacity-0" : "opacity-100"
}`}
>
<div className="flex items-center gap-1 rounded-full bg-white/95 px-1 py-1 backdrop-blur-sm">
<Button
type="button"
variant="ghost"
size="icon"
title="Fullscreen (F)"
onClick={(e) => {
e.stopPropagation();
onFullscreenToggle();
}}
className="h-9 w-9 text-gray-800 hover:bg-gray-100"
>
{isFullscreen ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
title="Exit presentation (Esc)"
onClick={(e) => {
e.stopPropagation();
onExit();
}}
className="h-9 w-9 text-gray-800 hover:bg-gray-100"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
{currentSpeakerNote && (
<div className="presentation-controls absolute bottom-4 right-4 z-50">
{/* Slide stage — large viewport; SlideScale uses width+height so slides scale up */}
<div
className={`flex min-h-0 flex-1 items-stretch justify-stretch ${isFullscreen ? "px-2 pb-9 pt-12 sm:px-3" : "px-3 pb-24 pt-14 sm:px-4 md:pb-28 md:pt-16"
}`}
>
<div
className={`min-h-0 w-full flex-1 overflow-hidden rounded-sm `}
>
{activeSlide ? (
<SlideScale
key={activeSlide.id ?? `slide-${currentSlide}`}
slide={activeSlide}
theme={theme ?? undefined}
isEditMode={false}
presentMode
/>
) : null}
</div>
</div>
{/* Progress */}
<div
className={`absolute bottom-0 left-0 right-0 z-40 h-1 bg-gray-200 ${isFullscreen && !chromeVisible ? "opacity-70" : "opacity-100"
}`}
aria-hidden
>
<div
className="h-full bg-[#5141e5] transition-[width] duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{/* Bottom controls */}
<div
className={`presentation-controls absolute bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-full border border-gray-200/90 bg-white/95 px-2 py-2 shadow-md backdrop-blur-sm transition-all duration-300 md:gap-4 md:px-3 ${isFullscreen && !chromeVisible
? "pointer-events-none translate-y-4 opacity-0"
: "translate-y-0 opacity-100"
}`}
>
<Button
type="button"
variant="ghost"
size="icon"
title="Previous slide"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
disabled={currentSlide === 0}
className="h-10 w-10 text-gray-800 hover:bg-gray-100 disabled:opacity-35"
>
<ChevronLeft className="h-6 w-6" />
</Button>
<div
className="min-w-22 text-center text-sm font-medium tabular-nums text-gray-800"
role="status"
aria-live="polite"
aria-atomic="true"
>
{currentSlide + 1} / {slides.length}
</div>
<Button
type="button"
variant="ghost"
size="icon"
title="Next slide"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
disabled={currentSlide === slides.length - 1}
className="h-10 w-10 text-gray-800 hover:bg-gray-100 disabled:opacity-35"
>
<ChevronRight className="h-6 w-6" />
</Button>
<div className="mx-1 hidden h-6 w-px bg-gray-200 sm:block" />
<div
className="hidden max-w-[200px] items-center gap-1.5 text-[11px] leading-tight text-gray-500 sm:flex"
title="Keyboard shortcuts"
>
<Keyboard className="h-3.5 w-3.5 shrink-0" />
<span>
space · Home/End · F fullscreen · N notes · Esc exit
</span>
</div>
</div>
{currentSpeakerNote ? (
<div
className={`presentation-controls absolute bottom-16 right-3 z-50 max-w-[min(380px,46vw)] md:bottom-20 md:right-6 ${isFullscreen && !chromeVisible ? "opacity-90" : ""
}`}
>
{showSpeakerNotes ? (
<div className="w-[360px] max-w-[50vw] rounded-xl border border-black/10 bg-white/95 shadow-xl backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-black/10 px-3 py-2">
<div className="rounded-xl border border-gray-200/90 bg-white/95 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-gray-100 px-3 py-2">
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
<StickyNote className="h-4 w-4" />
<StickyNote className="h-4 w-4 text-amber-600" />
Speaker notes
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowSpeakerNotes(false);
}}
className="h-8 px-2 text-gray-600 hover:bg-black/5 hover:text-gray-800"
className="h-8 px-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<EyeOff className="mr-1 h-4 w-4" />
Hide
</Button>
</div>
<div className="max-h-[28vh] overflow-auto whitespace-pre-wrap px-3 py-2 text-sm text-gray-700">
<div className="max-h-[min(28vh,220px)] overflow-auto whitespace-pre-wrap px-3 py-2.5 text-sm leading-relaxed text-gray-700">
{currentSpeakerNote}
</div>
</div>
) : (
<Button
type="button"
variant="secondary"
onClick={(e) => {
e.stopPropagation();
setShowSpeakerNotes(true);
}}
className="h-9 rounded-full border border-black/10 bg-white/95 px-3 text-gray-800 shadow-md hover:bg-white"
className="h-9 rounded-full border border-gray-200 bg-white/95 px-3 text-gray-800 shadow-md backdrop-blur-sm hover:bg-gray-50"
>
<StickyNote className="mr-2 h-4 w-4" />
<StickyNote className="mr-2 h-4 w-4 text-amber-600" />
Show notes
</Button>
)}
</div>
)}
) : null}
</div>
);
};

View file

@ -61,6 +61,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
const {
isPresentMode,
stream,
currentSlide: presentSlideFromUrl,
handleSlideClick,
toggleFullscreen,
handlePresentExit,
@ -93,7 +94,8 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
return (
<PresentationMode
slides={presentationData?.slides!}
currentSlide={selectedSlide}
currentSlide={presentSlideFromUrl}
theme={presentationData?.theme ?? undefined}
isFullscreen={isFullscreen}
onFullscreenToggle={toggleFullscreen}
onExit={handlePresentExit}

File diff suppressed because one or more lines are too long