返回 presentation-ai
PresentModePhoneOverlay.tsx
根目录 / src / components / presentation / present-mode / PresentModePhoneOverlay.tsx
1 "use client";
2
3 import { useRef, type PointerEvent as ReactPointerEvent } from "react";
4 import { createPortal } from "react-dom";
5
6 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
7 import { cn } from "@/lib/utils";
8 import { usePresentationState } from "@/states/presentation-state";
9
10 interface PresentModePhoneOverlayProps {
11 isPhoneViewport: boolean;
12 slideIds: string[];
13 }
14
15 interface PointerGesture {
16 pointerId: number;
17 startX: number;
18 startY: number;
19 }
20
21 const TAP_DISTANCE_THRESHOLD = 12;
22 const SWIPE_DISTANCE_THRESHOLD = 56;
23
24 function isPortraitRatio(value?: string): boolean {
25 if (!value) return false;
26
27 const parts = value.split(":").map(Number);
28 if (parts.length !== 2) {
29 return false;
30 }
31
32 const width = parts[0] ?? Number.NaN;
33 const height = parts[1] ?? Number.NaN;
34 if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0) {
35 return false;
36 }
37
38 return height > width;
39 }
40
41 function isPhoneFormattedSlide(slide?: PlateSlide): boolean {
42 if (!slide) return false;
43
44 if (slide.aspectRatio?.type === "tall") {
45 return true;
46 }
47
48 if (slide.aspectRatio?.type === "ratio") {
49 return isPortraitRatio(slide.aspectRatio.value);
50 }
51
52 return false;
53 }
54
55 export function PresentModePhoneOverlay({
56 isPhoneViewport,
57 slideIds,
58 }: PresentModePhoneOverlayProps) {
59 const isPresenting = usePresentationState((s) => s.isPresenting);
60 const currentSlide = usePresentationState((s) =>
61 s.slides.find((slide) => slide.id === s.currentSlideId),
62 );
63 const nextSlide = usePresentationState((s) => s.nextSlide);
64 const previousSlide = usePresentationState((s) => s.previousSlide);
65 const setShouldShowExitHeader = usePresentationState(
66 (s) => s.setShouldShowExitHeader,
67 );
68 const gestureRef = useRef<PointerGesture | null>(null);
69 const shouldShowForViewport =
70 isPhoneViewport || isPhoneFormattedSlide(currentSlide);
71
72 if (!isPresenting || !shouldShowForViewport || slideIds.length <= 1) {
73 return null;
74 }
75
76 if (typeof document === "undefined") {
77 return null;
78 }
79
80 const navigateFromTap = (
81 event: ReactPointerEvent<HTMLDivElement>,
82 deltaX: number,
83 deltaY: number,
84 ) => {
85 if (
86 Math.abs(deltaX) > TAP_DISTANCE_THRESHOLD ||
87 Math.abs(deltaY) > TAP_DISTANCE_THRESHOLD
88 ) {
89 return;
90 }
91
92 const bounds = event.currentTarget.getBoundingClientRect();
93 const tapX = event.clientX - bounds.left;
94 if (tapX >= bounds.width / 2) {
95 nextSlide();
96 return;
97 }
98
99 previousSlide();
100 };
101
102 const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
103 if (!event.isPrimary) return;
104
105 setShouldShowExitHeader(false);
106
107 event.currentTarget.setPointerCapture(event.pointerId);
108 gestureRef.current = {
109 pointerId: event.pointerId,
110 startX: event.clientX,
111 startY: event.clientY,
112 };
113 };
114
115 const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
116 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
117 event.currentTarget.releasePointerCapture(event.pointerId);
118 }
119 gestureRef.current = null;
120 };
121
122 const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
123 const gesture = gestureRef.current;
124
125 if (!gesture || gesture.pointerId !== event.pointerId) {
126 return;
127 }
128
129 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
130 event.currentTarget.releasePointerCapture(event.pointerId);
131 }
132
133 const deltaX = event.clientX - gesture.startX;
134 const deltaY = event.clientY - gesture.startY;
135 gestureRef.current = null;
136
137 if (
138 Math.abs(deltaX) >= SWIPE_DISTANCE_THRESHOLD &&
139 Math.abs(deltaX) > Math.abs(deltaY)
140 ) {
141 if (deltaX > 0) {
142 nextSlide();
143 return;
144 }
145
146 previousSlide();
147 return;
148 }
149
150 navigateFromTap(event, deltaX, deltaY);
151 };
152
153 return createPortal(
154 <>
155 {/* Top area to toggle header */}
156 <button
157 type="button"
158 className="pointer-events-auto fixed inset-x-0 top-0 z-999 h-20"
159 onClick={() => setShouldShowExitHeader(true)}
160 aria-label="Show presentation exit controls"
161 />
162 {/* Navigator area */}
163 <div className="pointer-events-none fixed inset-x-0 bottom-0 top-20 z-1000">
164 <div
165 className={cn(
166 "pointer-events-auto absolute inset-0 z-2147483646 select-none",
167 "touch-pan-y",
168 )}
169 onPointerDown={handlePointerDown}
170 onPointerUp={handlePointerUp}
171 onPointerCancel={handlePointerCancel}
172 aria-label="Phone presentation navigation overlay"
173 role="application"
174 >
175 <div className="absolute inset-y-0 left-0 w-1/2" />
176 <div className="absolute inset-y-0 right-0 w-1/2" />
177 </div>
178 </div>
179 </>,
180 document.body,
181 );
182 }
183
183 lines Plain Text