@kywk/open-slide-core 2.3.2 → 2.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{config-CDrYgaEj.d.ts → config-DJ8ZOe7C.d.ts} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +1 -1
- package/dist/{types-DFK02XVv.d.ts → types-tdaXSyah.d.ts} +1 -0
- package/dist/vite/index.d.ts +1 -1
- package/dist/{zh-tw-B1pkKQsc.js → zh-tw-DXcqQTrA.js} +5 -0
- package/package.json +2 -2
- package/src/app/components/player.tsx +105 -23
- package/src/app/components/present/curtain.tsx +52 -0
- package/src/app/components/present/last-slide-hint.tsx +36 -0
- package/src/app/components/slide-canvas.tsx +1 -1
- package/src/app/components/thumbnail-rail.tsx +1 -1
- package/src/app/routes/home.tsx +13 -5
- package/src/app/routes/slide.tsx +70 -13
- package/src/locale/en.ts +1 -0
- package/src/locale/es.ts +1 -0
- package/src/locale/ja.ts +1 -0
- package/src/locale/types.ts +1 -0
- package/src/locale/zh-cn.ts +1 -0
- package/src/locale/zh-tw.ts +1 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as Plural, t as Locale } from "./types-
|
|
2
|
-
import { n as OpenSlideConfig, t as OpenSlideAgentConfig } from "./config-
|
|
1
|
+
import { n as Plural, t as Locale } from "./types-tdaXSyah.js";
|
|
2
|
+
import { n as OpenSlideConfig, t as OpenSlideAgentConfig } from "./config-DJ8ZOe7C.js";
|
|
3
3
|
import { CSSProperties, ComponentType, HTMLAttributes, PropsWithChildren, ReactNode } from "react";
|
|
4
4
|
//#region src/app/components/image-placeholder.d.ts
|
|
5
5
|
type ImagePlaceholderProps = {
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as en, i as es, n as zhCN, r as ja, t as zhTW } from "./zh-tw-
|
|
1
|
+
import { a as en, i as es, n as zhCN, r as ja, t as zhTW } from "./zh-tw-DXcqQTrA.js";
|
|
2
2
|
import { n as defaultDesign, r as designToCssVars, t as cssVarsToString } from "./design-CoZ0cWDN.js";
|
|
3
3
|
import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { toast } from "sonner";
|
package/dist/locale/index.d.ts
CHANGED
package/dist/locale/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as en, i as es, n as zhCN, r as ja, t as zhTW } from "../zh-tw-
|
|
1
|
+
import { a as en, i as es, n as zhCN, r as ja, t as zhTW } from "../zh-tw-DXcqQTrA.js";
|
|
2
2
|
//#region src/locale/format.ts
|
|
3
3
|
function format(template, vars) {
|
|
4
4
|
return template.replace(/\{(\w+)\}/g, (m, key) => {
|
package/dist/vite/index.d.ts
CHANGED
|
@@ -168,6 +168,7 @@ const en = {
|
|
|
168
168
|
helpAria: "Keyboard shortcuts (?)",
|
|
169
169
|
exitAria: "Exit (Esc)",
|
|
170
170
|
elapsedTime: "Elapsed time",
|
|
171
|
+
lastSlideHint: "Last slide · Press Esc to exit",
|
|
171
172
|
helpEyebrow: "Present mode",
|
|
172
173
|
helpTitle: "Keyboard shortcuts",
|
|
173
174
|
shortcutNext: "Next slide",
|
|
@@ -796,6 +797,7 @@ const es = {
|
|
|
796
797
|
helpAria: "Atajos de teclado (?)",
|
|
797
798
|
exitAria: "Salir (Esc)",
|
|
798
799
|
elapsedTime: "Tiempo transcurrido",
|
|
800
|
+
lastSlideHint: "Última diapositiva · Pulsa Esc para salir",
|
|
799
801
|
helpEyebrow: "Modo presentación",
|
|
800
802
|
helpTitle: "Atajos de teclado",
|
|
801
803
|
shortcutNext: "Diapositiva siguiente",
|
|
@@ -1424,6 +1426,7 @@ const ja = {
|
|
|
1424
1426
|
helpAria: "キーボードショートカット (?)",
|
|
1425
1427
|
exitAria: "終了 (Esc)",
|
|
1426
1428
|
elapsedTime: "経過時間",
|
|
1429
|
+
lastSlideHint: "最後のスライドです · Esc で終了",
|
|
1427
1430
|
helpEyebrow: "発表モード",
|
|
1428
1431
|
helpTitle: "キーボードショートカット",
|
|
1429
1432
|
shortcutNext: "次のスライド",
|
|
@@ -2052,6 +2055,7 @@ const zhCN = {
|
|
|
2052
2055
|
helpAria: "键盘快捷键 (?)",
|
|
2053
2056
|
exitAria: "退出 (Esc)",
|
|
2054
2057
|
elapsedTime: "已用时",
|
|
2058
|
+
lastSlideHint: "已是最后一页 · 按 Esc 退出放映",
|
|
2055
2059
|
helpEyebrow: "演示模式",
|
|
2056
2060
|
helpTitle: "键盘快捷键",
|
|
2057
2061
|
shortcutNext: "下一张",
|
|
@@ -2680,6 +2684,7 @@ const zhTW = {
|
|
|
2680
2684
|
helpAria: "鍵盤快速鍵 (?)",
|
|
2681
2685
|
exitAria: "離開 (Esc)",
|
|
2682
2686
|
elapsedTime: "已耗時",
|
|
2687
|
+
lastSlideHint: "已是最後一頁 · 按 Esc 結束播放",
|
|
2683
2688
|
helpEyebrow: "簡報模式",
|
|
2684
2689
|
helpTitle: "鍵盤快速鍵",
|
|
2685
2690
|
shortcutNext: "下一張",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kywk/open-slide-core",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.3",
|
|
4
4
|
"description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
},
|
|
107
107
|
"devDependencies": {
|
|
108
108
|
"@playwright/test": "~1.63.0",
|
|
109
|
-
"@types/node": "^26.6.
|
|
109
|
+
"@types/node": "^26.6.6",
|
|
110
110
|
"@types/react": "^19.2.18",
|
|
111
111
|
"@types/react-dom": "^19.2.5",
|
|
112
112
|
"tsdown": "^0.22.14",
|
|
@@ -12,9 +12,16 @@ import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
|
|
|
12
12
|
import { OverviewGrid } from './overview-grid';
|
|
13
13
|
import { PresentBlackoutOverlay } from './present/blackout-overlay';
|
|
14
14
|
import { PresentControlBar } from './present/control-bar';
|
|
15
|
+
import {
|
|
16
|
+
afterPaint,
|
|
17
|
+
CURTAIN_FADE_MS,
|
|
18
|
+
PresentCurtain,
|
|
19
|
+
whenFullscreenSettled,
|
|
20
|
+
} from './present/curtain';
|
|
15
21
|
import { PresentHelpOverlay } from './present/help-overlay';
|
|
16
22
|
import { PresentJumpInput } from './present/jump-input';
|
|
17
23
|
import { PresentLaserPointer } from './present/laser-pointer';
|
|
24
|
+
import { PresentLastSlideHint } from './present/last-slide-hint';
|
|
18
25
|
import { PresentProgressBar } from './present/progress-bar';
|
|
19
26
|
import { useIdle } from './present/use-idle';
|
|
20
27
|
import { usePointerNearBottom } from './present/use-pointer-near-bottom';
|
|
@@ -48,6 +55,8 @@ type Props = {
|
|
|
48
55
|
* without entering fullscreen. Defaults to true for back-compat.
|
|
49
56
|
*/
|
|
50
57
|
fullscreen?: boolean;
|
|
58
|
+
/** Fade in from black on mount and fade to black before calling `onExit`. */
|
|
59
|
+
curtain?: boolean;
|
|
51
60
|
};
|
|
52
61
|
|
|
53
62
|
export function Player({
|
|
@@ -62,9 +71,13 @@ export function Player({
|
|
|
62
71
|
slideId,
|
|
63
72
|
onSwitchSlide,
|
|
64
73
|
fullscreen = true,
|
|
74
|
+
curtain = false,
|
|
65
75
|
}: Props) {
|
|
66
76
|
const isMobile = useIsMobile();
|
|
67
77
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
78
|
+
const fade = curtain && !prefersReducedMotion;
|
|
79
|
+
const [curtainOpaque, setCurtainOpaque] = useState(fade);
|
|
80
|
+
const exitingRef = useRef(false);
|
|
68
81
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
69
82
|
// Mirrored as state so descendants portaling *into* the player subtree
|
|
70
83
|
// (tooltips, popovers — the body is outside the fullscreen tree) re-render
|
|
@@ -93,6 +106,28 @@ export function Player({
|
|
|
93
106
|
const canPrev = index > 0;
|
|
94
107
|
const canNext = index < pages.length - 1;
|
|
95
108
|
|
|
109
|
+
// Stepping past either end nudges the slide like a rubber band so the
|
|
110
|
+
// press visibly registered; past the last slide a hint says how to leave.
|
|
111
|
+
const nudgeRef = useRef<HTMLDivElement | null>(null);
|
|
112
|
+
const [lastSlideHintAt, setLastSlideHintAt] = useState(0);
|
|
113
|
+
const bumpEdge = useCallback(
|
|
114
|
+
(direction: 'start' | 'end') => {
|
|
115
|
+
if (!controls) return;
|
|
116
|
+
if (direction === 'end') setLastSlideHintAt(Date.now());
|
|
117
|
+
if (prefersReducedMotion) return;
|
|
118
|
+
const offset = direction === 'end' ? -14 : 14;
|
|
119
|
+
nudgeRef.current?.animate(
|
|
120
|
+
[
|
|
121
|
+
{ transform: 'translateX(0)' },
|
|
122
|
+
{ transform: `translateX(${offset}px)`, offset: 0.3 },
|
|
123
|
+
{ transform: 'translateX(0)' },
|
|
124
|
+
],
|
|
125
|
+
{ duration: 360, easing: 'cubic-bezier(0.2, 0.8, 0.3, 1)' },
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
[controls, prefersReducedMotion],
|
|
129
|
+
);
|
|
130
|
+
|
|
96
131
|
const stepControllerRef = useRef<StepController | null>(null);
|
|
97
132
|
const [entryDirection, setEntryDirection] = useState<EntryDirection>('jump');
|
|
98
133
|
const [stepAggregate, setStepAggregate] = useState<StepAggregate>({
|
|
@@ -116,14 +151,34 @@ export function Player({
|
|
|
116
151
|
[index, onIndexChange],
|
|
117
152
|
);
|
|
118
153
|
|
|
154
|
+
const exitTimerRef = useRef<number | null>(null);
|
|
155
|
+
useEffect(
|
|
156
|
+
() => () => {
|
|
157
|
+
if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current);
|
|
158
|
+
},
|
|
159
|
+
[],
|
|
160
|
+
);
|
|
161
|
+
const requestExit = useCallback(() => {
|
|
162
|
+
if (!fade) {
|
|
163
|
+
onExit();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (exitingRef.current) return;
|
|
167
|
+
exitingRef.current = true;
|
|
168
|
+
setCurtainOpaque(true);
|
|
169
|
+
exitTimerRef.current = window.setTimeout(onExit, CURTAIN_FADE_MS);
|
|
170
|
+
}, [fade, onExit]);
|
|
171
|
+
|
|
119
172
|
const goPrev = useCallback(() => {
|
|
120
173
|
if (stepControllerRef.current?.retreat()) return;
|
|
121
174
|
if (index > 0) handleIndexChange(index - 1);
|
|
122
|
-
|
|
175
|
+
else bumpEdge('start');
|
|
176
|
+
}, [index, handleIndexChange, bumpEdge]);
|
|
123
177
|
const goNext = useCallback(() => {
|
|
124
178
|
if (stepControllerRef.current?.advance()) return;
|
|
125
179
|
if (index < pages.length - 1) handleIndexChange(index + 1);
|
|
126
|
-
|
|
180
|
+
else bumpEdge('end');
|
|
181
|
+
}, [index, pages.length, handleIndexChange, bumpEdge]);
|
|
127
182
|
|
|
128
183
|
const overlayActive = controls && (overviewOpen || helpOpen);
|
|
129
184
|
const overlayActiveRef = useRef(overlayActive);
|
|
@@ -178,8 +233,8 @@ export function Player({
|
|
|
178
233
|
useClickPageNavigation({
|
|
179
234
|
ref: rootRef,
|
|
180
235
|
enabled: !overlayActive,
|
|
181
|
-
canPrev,
|
|
182
|
-
canNext,
|
|
236
|
+
canPrev: canPrev || controls,
|
|
237
|
+
canNext: canNext || controls,
|
|
183
238
|
onPrev: goPrev,
|
|
184
239
|
onNext: goNext,
|
|
185
240
|
onViewportClick: controls && isMobile ? handleMobileViewportClick : undefined,
|
|
@@ -216,11 +271,33 @@ export function Player({
|
|
|
216
271
|
useEffect(() => {
|
|
217
272
|
if (!allowExit) return;
|
|
218
273
|
const onFsChange = () => {
|
|
219
|
-
if (!document.fullscreenElement && !windowedRef.current)
|
|
274
|
+
if (!document.fullscreenElement && !windowedRef.current) requestExit();
|
|
220
275
|
};
|
|
221
276
|
document.addEventListener('fullscreenchange', onFsChange);
|
|
222
277
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
223
|
-
}, [
|
|
278
|
+
}, [requestExit, allowExit]);
|
|
279
|
+
|
|
280
|
+
// Wait out the browser's own fullscreen animation before revealing the
|
|
281
|
+
// first slide, otherwise the fade plays while the screen is still resizing.
|
|
282
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: runs once on mount
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (!fade) return;
|
|
285
|
+
let cancelPaint = () => {};
|
|
286
|
+
const reveal = () => {
|
|
287
|
+
cancelPaint = afterPaint(() => {
|
|
288
|
+
if (!exitingRef.current) setCurtainOpaque(false);
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
if (!fullscreen || document.fullscreenElement) {
|
|
292
|
+
reveal();
|
|
293
|
+
return () => cancelPaint();
|
|
294
|
+
}
|
|
295
|
+
const cancelWait = whenFullscreenSettled(reveal);
|
|
296
|
+
return () => {
|
|
297
|
+
cancelWait();
|
|
298
|
+
cancelPaint();
|
|
299
|
+
};
|
|
300
|
+
}, []);
|
|
224
301
|
|
|
225
302
|
const toggleFullscreen = useCallback(() => {
|
|
226
303
|
setWindowed((w) => !w);
|
|
@@ -296,7 +373,7 @@ export function Player({
|
|
|
296
373
|
setBlackout(null);
|
|
297
374
|
return;
|
|
298
375
|
}
|
|
299
|
-
if (allowExit)
|
|
376
|
+
if (allowExit) requestExit();
|
|
300
377
|
return;
|
|
301
378
|
}
|
|
302
379
|
|
|
@@ -364,7 +441,7 @@ export function Player({
|
|
|
364
441
|
helpOpen,
|
|
365
442
|
blackout,
|
|
366
443
|
allowExit,
|
|
367
|
-
|
|
444
|
+
requestExit,
|
|
368
445
|
goNext,
|
|
369
446
|
goPrev,
|
|
370
447
|
handleIndexChange,
|
|
@@ -403,26 +480,29 @@ export function Player({
|
|
|
403
480
|
controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
|
|
404
481
|
)}
|
|
405
482
|
>
|
|
406
|
-
<
|
|
407
|
-
|
|
483
|
+
<div ref={nudgeRef} className="h-full w-full">
|
|
484
|
+
<SlideCanvas flat design={design}>
|
|
485
|
+
{/* Keyed per deck so a presenter-driven deck switch cuts instead of
|
|
408
486
|
animating a transition between two unrelated decks. */}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
487
|
+
<SlideTransitionLayer
|
|
488
|
+
key={slideId}
|
|
489
|
+
pages={pages}
|
|
490
|
+
index={index}
|
|
491
|
+
total={pages.length}
|
|
492
|
+
moduleTransition={transition}
|
|
493
|
+
disabled={prefersReducedMotion}
|
|
494
|
+
stepControllerRef={stepControllerRef}
|
|
495
|
+
entryDirection={entryDirection}
|
|
496
|
+
onStepAggregateChange={handleAggregateChange}
|
|
497
|
+
/>
|
|
498
|
+
</SlideCanvas>
|
|
499
|
+
</div>
|
|
421
500
|
|
|
422
501
|
{controls && (
|
|
423
502
|
<div data-osd-chrome style={{ display: 'contents' }}>
|
|
424
503
|
<PresentProgressBar index={index} total={pages.length} visible={chromeVisible} />
|
|
425
504
|
<PresentBlackoutOverlay mode={blackout} />
|
|
505
|
+
<PresentLastSlideHint shownAt={lastSlideHintAt} />
|
|
426
506
|
<PresentJumpInput pageCount={pages.length} onJump={handleIndexChange} />
|
|
427
507
|
<PresentLaserPointer enabled={laser} />
|
|
428
508
|
<PresentControlBar
|
|
@@ -444,7 +524,7 @@ export function Player({
|
|
|
444
524
|
onPresenter={() => slideId && openPresenterWindow(slideId)}
|
|
445
525
|
onToggleFullscreen={toggleFullscreen}
|
|
446
526
|
onHelp={() => setHelpOpen(true)}
|
|
447
|
-
onExit={
|
|
527
|
+
onExit={requestExit}
|
|
448
528
|
/>
|
|
449
529
|
<OverviewGrid
|
|
450
530
|
pages={pages}
|
|
@@ -460,6 +540,8 @@ export function Player({
|
|
|
460
540
|
<PresentHelpOverlay open={helpOpen} onOpenChange={setHelpOpen} container={rootEl} />
|
|
461
541
|
</div>
|
|
462
542
|
)}
|
|
543
|
+
|
|
544
|
+
{fade && <PresentCurtain opaque={curtainOpaque} className="absolute z-[100]" />}
|
|
463
545
|
</div>
|
|
464
546
|
);
|
|
465
547
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
|
|
3
|
+
export const CURTAIN_FADE_MS = 400;
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
opaque: boolean;
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function PresentCurtain({ opaque, className }: Props) {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
aria-hidden
|
|
14
|
+
className={cn(
|
|
15
|
+
'pointer-events-none inset-0 bg-black transition-opacity ease-in-out',
|
|
16
|
+
opaque ? 'opacity-100' : 'opacity-0',
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
style={{ transitionDuration: `${CURTAIN_FADE_MS}ms` }}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Two frames so the opaque state is painted before the fade starts;
|
|
25
|
+
// a single rAF can land in the same style recalc and skip the transition.
|
|
26
|
+
export function afterPaint(cb: () => void): () => void {
|
|
27
|
+
let inner = 0;
|
|
28
|
+
const outer = requestAnimationFrame(() => {
|
|
29
|
+
inner = requestAnimationFrame(cb);
|
|
30
|
+
});
|
|
31
|
+
return () => {
|
|
32
|
+
cancelAnimationFrame(outer);
|
|
33
|
+
cancelAnimationFrame(inner);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function whenFullscreenSettled(cb: () => void, maxWaitMs = 800): () => void {
|
|
38
|
+
let fired = false;
|
|
39
|
+
const timer = window.setTimeout(done, maxWaitMs);
|
|
40
|
+
document.addEventListener('fullscreenchange', done);
|
|
41
|
+
function done() {
|
|
42
|
+
if (fired) return;
|
|
43
|
+
fired = true;
|
|
44
|
+
cleanup();
|
|
45
|
+
cb();
|
|
46
|
+
}
|
|
47
|
+
function cleanup() {
|
|
48
|
+
window.clearTimeout(timer);
|
|
49
|
+
document.removeEventListener('fullscreenchange', done);
|
|
50
|
+
}
|
|
51
|
+
return cleanup;
|
|
52
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { usePanelMount } from '@/components/panel/panel-shell';
|
|
3
|
+
import { useLocale } from '@/lib/use-locale';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
const VISIBLE_MS = 1800;
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
shownAt: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function PresentLastSlideHint({ shownAt }: Props) {
|
|
13
|
+
const t = useLocale();
|
|
14
|
+
const [open, setOpen] = useState(false);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!shownAt) return;
|
|
17
|
+
setOpen(true);
|
|
18
|
+
const id = window.setTimeout(() => setOpen(false), VISIBLE_MS);
|
|
19
|
+
return () => window.clearTimeout(id);
|
|
20
|
+
}, [shownAt]);
|
|
21
|
+
const { mounted, animVisible } = usePanelMount(open);
|
|
22
|
+
if (!mounted) return null;
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
role="status"
|
|
26
|
+
className={cn(
|
|
27
|
+
'pointer-events-none absolute bottom-10 left-1/2 z-30 -translate-x-1/2 rounded-full bg-black/70 px-3.5 py-1.5 text-[12px] text-white/85 shadow-lg backdrop-blur-md motion-safe:transition-[opacity,translate]',
|
|
28
|
+
animVisible
|
|
29
|
+
? 'opacity-100 motion-safe:duration-200'
|
|
30
|
+
: 'translate-y-1 opacity-0 motion-safe:duration-200',
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{t.present.lastSlideHint}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -51,7 +51,7 @@ export function SlideCanvas({
|
|
|
51
51
|
<div ref={containerRef} className={cn('relative h-full w-full', flat && 'overflow-hidden')}>
|
|
52
52
|
<div
|
|
53
53
|
className={cn(
|
|
54
|
-
'overflow-hidden bg-white text-black',
|
|
54
|
+
'overflow-hidden bg-white text-start text-black',
|
|
55
55
|
!flat && 'rounded-[6px] shadow-floating',
|
|
56
56
|
)}
|
|
57
57
|
style={
|
|
@@ -568,7 +568,7 @@ function HorizontalVirtualThumbList({
|
|
|
568
568
|
aria-label={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
|
|
569
569
|
aria-current={active ? 'page' : undefined}
|
|
570
570
|
className={cn(
|
|
571
|
-
'group/thumb relative flex shrink-0 flex-col items-center gap-1.5 rounded-[6px] outline-none motion-safe:transition-[scale] motion-safe:duration-100 active:scale-[0.985]',
|
|
571
|
+
'group/thumb relative flex shrink-0 flex-col items-center gap-1.5 rounded-[6px] text-left outline-none motion-safe:transition-[scale] motion-safe:duration-100 active:scale-[0.985]',
|
|
572
572
|
'focus-visible:ring-1 focus-visible:ring-brand focus-visible:ring-offset-1 focus-visible:ring-offset-sidebar',
|
|
573
573
|
)}
|
|
574
574
|
>
|
package/src/app/routes/home.tsx
CHANGED
|
@@ -323,10 +323,12 @@ function SortControl({ value, onChange }: { value: SortKey; onChange: (next: Sor
|
|
|
323
323
|
);
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
// Offered even before an agent is connected: the new deck opens the agent
|
|
327
|
+
// panel, which walks first-time users through installing one.
|
|
326
328
|
function useCreateDeckWithAgent() {
|
|
327
|
-
const { provider } = useAgentStatus();
|
|
329
|
+
const { enabled, provider } = useAgentStatus();
|
|
328
330
|
const start = useCreateDeck();
|
|
329
|
-
if (!
|
|
331
|
+
if (!enabled) return null;
|
|
330
332
|
return { provider, start };
|
|
331
333
|
}
|
|
332
334
|
|
|
@@ -339,7 +341,11 @@ function NewDeckButton() {
|
|
|
339
341
|
size="sm"
|
|
340
342
|
className="h-8 shrink-0"
|
|
341
343
|
onClick={agent.start}
|
|
342
|
-
title={
|
|
344
|
+
title={
|
|
345
|
+
agent.provider
|
|
346
|
+
? format(t.agent.createDeckWith, { provider: agent.provider.name })
|
|
347
|
+
: t.agent.createDeck
|
|
348
|
+
}
|
|
343
349
|
>
|
|
344
350
|
<Plus />
|
|
345
351
|
{t.agent.createDeck}
|
|
@@ -404,8 +410,10 @@ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: st
|
|
|
404
410
|
</p>
|
|
405
411
|
{agent ? (
|
|
406
412
|
<Button size="sm" className="mt-4" onClick={agent.start}>
|
|
407
|
-
<ProviderLogo provider={agent.provider.id} />
|
|
408
|
-
{
|
|
413
|
+
{agent.provider ? <ProviderLogo provider={agent.provider.id} /> : <Plus />}
|
|
414
|
+
{agent.provider
|
|
415
|
+
? format(t.agent.createDeckWith, { provider: agent.provider.name })
|
|
416
|
+
: t.agent.createDeck}
|
|
409
417
|
</Button>
|
|
410
418
|
) : (
|
|
411
419
|
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
package/src/app/routes/slide.tsx
CHANGED
|
@@ -87,6 +87,12 @@ import { PdfProgressToast, PptxProgressToast } from '../components/export-progre
|
|
|
87
87
|
import { NotesDrawer } from '../components/notes-drawer';
|
|
88
88
|
import { OverviewGrid } from '../components/overview-grid';
|
|
89
89
|
import { openPresenterWindow, Player } from '../components/player';
|
|
90
|
+
import {
|
|
91
|
+
afterPaint,
|
|
92
|
+
CURTAIN_FADE_MS,
|
|
93
|
+
PresentCurtain,
|
|
94
|
+
whenFullscreenSettled,
|
|
95
|
+
} from '../components/present/curtain';
|
|
90
96
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
91
97
|
import { isDeckWarmed, markDeckWarmed, SlidePreloadLayer } from '../components/slide-preload-layer';
|
|
92
98
|
import { SlideTransitionLayer } from '../components/slide-transition-layer';
|
|
@@ -166,6 +172,55 @@ export function Slide() {
|
|
|
166
172
|
const narrowToolbar = useMediaQuery('(max-width: 639.98px)');
|
|
167
173
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
168
174
|
|
|
175
|
+
// 'entering' darkens the editor before the Player mounts; 'leaving' keeps
|
|
176
|
+
// the editor dark after the Player unmounts, then fades it back in.
|
|
177
|
+
const [curtain, setCurtain] = useState<'entering' | 'leaving' | null>(null);
|
|
178
|
+
const curtainTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
179
|
+
useEffect(
|
|
180
|
+
() => () => {
|
|
181
|
+
if (curtainTimerRef.current) clearTimeout(curtainTimerRef.current);
|
|
182
|
+
},
|
|
183
|
+
[],
|
|
184
|
+
);
|
|
185
|
+
const present = useCallback(
|
|
186
|
+
(mode: 'window' | 'fullscreen') => {
|
|
187
|
+
if (prefersReducedMotion) {
|
|
188
|
+
setPlayMode(mode);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (curtainTimerRef.current) return;
|
|
192
|
+
setCurtain('entering');
|
|
193
|
+
// Fullscreen is requested when the Player mounts; the delay stays well
|
|
194
|
+
// inside the browser's transient user-activation window.
|
|
195
|
+
curtainTimerRef.current = setTimeout(() => {
|
|
196
|
+
curtainTimerRef.current = null;
|
|
197
|
+
setCurtain(null);
|
|
198
|
+
setPlayMode(mode);
|
|
199
|
+
}, CURTAIN_FADE_MS);
|
|
200
|
+
},
|
|
201
|
+
[prefersReducedMotion],
|
|
202
|
+
);
|
|
203
|
+
const exitPresent = useCallback(() => {
|
|
204
|
+
setPlayMode(null);
|
|
205
|
+
if (!prefersReducedMotion) setCurtain('leaving');
|
|
206
|
+
}, [prefersReducedMotion]);
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (curtain !== 'leaving' || playMode) return;
|
|
209
|
+
let cancelPaint = () => {};
|
|
210
|
+
const reveal = () => {
|
|
211
|
+
cancelPaint = afterPaint(() => setCurtain(null));
|
|
212
|
+
};
|
|
213
|
+
if (!document.fullscreenElement) {
|
|
214
|
+
reveal();
|
|
215
|
+
return () => cancelPaint();
|
|
216
|
+
}
|
|
217
|
+
const cancelWait = whenFullscreenSettled(reveal);
|
|
218
|
+
return () => {
|
|
219
|
+
cancelWait();
|
|
220
|
+
cancelPaint();
|
|
221
|
+
};
|
|
222
|
+
}, [curtain, playMode]);
|
|
223
|
+
|
|
169
224
|
const modulePages = useMemo(() => slide?.default ?? [], [slide]);
|
|
170
225
|
const [pages, setPages] = useState<typeof modulePages>(modulePages);
|
|
171
226
|
useEffect(() => {
|
|
@@ -335,7 +390,7 @@ export function Slide() {
|
|
|
335
390
|
// When showSlideUi is false the read-only <Player> is rendered and owns
|
|
336
391
|
// keyboard navigation (including step-aware advance/retreat). Attaching this
|
|
337
392
|
// page-nav handler too would race it and skip <Steps> reveals, so bail out.
|
|
338
|
-
if (playMode || !showSlideUi) return;
|
|
393
|
+
if (playMode || curtain || !showSlideUi) return;
|
|
339
394
|
const onKey = (e: KeyboardEvent) => {
|
|
340
395
|
if (
|
|
341
396
|
isTypingTarget(e.target) ||
|
|
@@ -368,12 +423,12 @@ export function Slide() {
|
|
|
368
423
|
return;
|
|
369
424
|
}
|
|
370
425
|
if (e.key === 'f' || e.key === 'F') {
|
|
371
|
-
|
|
426
|
+
present('fullscreen');
|
|
372
427
|
} else if (e.key === 'Enter') {
|
|
373
|
-
|
|
428
|
+
present('window');
|
|
374
429
|
} else if (e.key === 'p' || e.key === 'P') {
|
|
375
430
|
if (slideId) openPresenterWindow(slideId);
|
|
376
|
-
|
|
431
|
+
present('window');
|
|
377
432
|
} else if (import.meta.env.DEV && (e.key === 'd' || e.key === 'D')) {
|
|
378
433
|
setDesignOpen((v) => !v);
|
|
379
434
|
} else if (import.meta.env.DEV && (e.key === 'a' || e.key === 'A')) {
|
|
@@ -382,7 +437,7 @@ export function Slide() {
|
|
|
382
437
|
};
|
|
383
438
|
window.addEventListener('keydown', onKey);
|
|
384
439
|
return () => window.removeEventListener('keydown', onKey);
|
|
385
|
-
}, [index, goTo, playMode, slideId, overviewOpen, toggleAgent]);
|
|
440
|
+
}, [index, goTo, playMode, curtain, slideId, overviewOpen, toggleAgent, present]);
|
|
386
441
|
|
|
387
442
|
if (error) {
|
|
388
443
|
return (
|
|
@@ -423,8 +478,9 @@ export function Slide() {
|
|
|
423
478
|
transition={presented.slide.transition}
|
|
424
479
|
index={presented.index}
|
|
425
480
|
onIndexChange={presentReady ? goTo : noop}
|
|
426
|
-
onExit={
|
|
481
|
+
onExit={exitPresent}
|
|
427
482
|
controls
|
|
483
|
+
curtain={!prefersReducedMotion}
|
|
428
484
|
slideId={presented.slideId}
|
|
429
485
|
onSwitchSlide={switchPresentedSlide}
|
|
430
486
|
fullscreen={playMode === 'fullscreen'}
|
|
@@ -801,7 +857,7 @@ export function Slide() {
|
|
|
801
857
|
size="sm"
|
|
802
858
|
variant="brand"
|
|
803
859
|
aria-label={t.slide.present}
|
|
804
|
-
onClick={() =>
|
|
860
|
+
onClick={() => present(isMobile ? 'window' : 'fullscreen')}
|
|
805
861
|
className="rounded-r-none px-2.5 lg:px-3"
|
|
806
862
|
>
|
|
807
863
|
<Play className="size-3.5 fill-current" />
|
|
@@ -821,12 +877,12 @@ export function Slide() {
|
|
|
821
877
|
</DropdownMenuTrigger>
|
|
822
878
|
<DropdownMenuContent align="end" className="min-w-[200px]">
|
|
823
879
|
<DropdownMenuGroup>
|
|
824
|
-
<DropdownMenuItem onClick={() =>
|
|
880
|
+
<DropdownMenuItem onClick={() => present('window')}>
|
|
825
881
|
<Play />
|
|
826
882
|
{t.slide.presentInWindow}
|
|
827
883
|
<DropdownMenuShortcut>↵</DropdownMenuShortcut>
|
|
828
884
|
</DropdownMenuItem>
|
|
829
|
-
<DropdownMenuItem onClick={() =>
|
|
885
|
+
<DropdownMenuItem onClick={() => present('fullscreen')}>
|
|
830
886
|
<Maximize />
|
|
831
887
|
{t.slide.presentFullscreen}
|
|
832
888
|
<DropdownMenuShortcut>F</DropdownMenuShortcut>
|
|
@@ -834,7 +890,7 @@ export function Slide() {
|
|
|
834
890
|
<DropdownMenuItem
|
|
835
891
|
onClick={() => {
|
|
836
892
|
if (slideId) openPresenterWindow(slideId);
|
|
837
|
-
|
|
893
|
+
present('window');
|
|
838
894
|
}}
|
|
839
895
|
>
|
|
840
896
|
<MonitorSpeaker />
|
|
@@ -957,11 +1013,11 @@ export function Slide() {
|
|
|
957
1013
|
currentIndex={index}
|
|
958
1014
|
exporting={exporting}
|
|
959
1015
|
handlers={{
|
|
960
|
-
onPresentWindow: () =>
|
|
961
|
-
onPresentFullscreen: () =>
|
|
1016
|
+
onPresentWindow: () => present('window'),
|
|
1017
|
+
onPresentFullscreen: () => present('fullscreen'),
|
|
962
1018
|
onPresenterView: () => {
|
|
963
1019
|
if (slideId) openPresenterWindow(slideId);
|
|
964
|
-
|
|
1020
|
+
present('window');
|
|
965
1021
|
},
|
|
966
1022
|
onCopyLink: copyLink,
|
|
967
1023
|
onOverview: () => setOverviewOpen(true),
|
|
@@ -976,6 +1032,7 @@ export function Slide() {
|
|
|
976
1032
|
/>
|
|
977
1033
|
)}
|
|
978
1034
|
</div>
|
|
1035
|
+
<PresentCurtain opaque={curtain !== null} className="fixed z-[1000]" />
|
|
979
1036
|
</InspectorProvider>
|
|
980
1037
|
</HistoryProvider>
|
|
981
1038
|
);
|
package/src/locale/en.ts
CHANGED
|
@@ -178,6 +178,7 @@ export const en: Locale = {
|
|
|
178
178
|
helpAria: 'Keyboard shortcuts (?)',
|
|
179
179
|
exitAria: 'Exit (Esc)',
|
|
180
180
|
elapsedTime: 'Elapsed time',
|
|
181
|
+
lastSlideHint: 'Last slide · Press Esc to exit',
|
|
181
182
|
helpEyebrow: 'Present mode',
|
|
182
183
|
helpTitle: 'Keyboard shortcuts',
|
|
183
184
|
shortcutNext: 'Next slide',
|
package/src/locale/es.ts
CHANGED
|
@@ -180,6 +180,7 @@ export const es: Locale = {
|
|
|
180
180
|
helpAria: 'Atajos de teclado (?)',
|
|
181
181
|
exitAria: 'Salir (Esc)',
|
|
182
182
|
elapsedTime: 'Tiempo transcurrido',
|
|
183
|
+
lastSlideHint: 'Última diapositiva · Pulsa Esc para salir',
|
|
183
184
|
helpEyebrow: 'Modo presentación',
|
|
184
185
|
helpTitle: 'Atajos de teclado',
|
|
185
186
|
shortcutNext: 'Diapositiva siguiente',
|
package/src/locale/ja.ts
CHANGED
package/src/locale/types.ts
CHANGED
package/src/locale/zh-cn.ts
CHANGED
package/src/locale/zh-tw.ts
CHANGED