@open-slide/core 1.3.0 → 1.4.0

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.
Files changed (38) hide show
  1. package/dist/{build-_276DMmJ.js → build-1Rqivz0d.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-BAwKWNtW.js → config-XZJnC_fu.js} +533 -59
  4. package/dist/{config-D9cZ1A0X.d.ts → config-s0YUbmUe.d.ts} +2 -1
  5. package/dist/{dev-BoqeVXVq.js → dev-0W8gYiSa.js} +1 -1
  6. package/dist/{en-CDKzoZvf.js → en-7GU-DHbJ.js} +13 -3
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +40 -10
  11. package/dist/{preview-BLPxspc9.js → preview-DT9hJvzM.js} +1 -1
  12. package/dist/{types-JYG1cmwC.d.ts → types-QCpkHkiS.d.ts} +11 -1
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/slide-authoring/SKILL.md +10 -2
  17. package/src/app/app.tsx +2 -0
  18. package/src/app/components/asset-view.tsx +36 -9
  19. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  20. package/src/app/components/inspector/inspector-panel.tsx +251 -24
  21. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  22. package/src/app/components/player.tsx +25 -5
  23. package/src/app/components/present/control-bar.tsx +12 -0
  24. package/src/app/components/sidebar/folder-item.tsx +14 -3
  25. package/src/app/components/sidebar/sidebar.tsx +10 -0
  26. package/src/app/lib/export-pdf.ts +6 -0
  27. package/src/app/lib/inspector/use-editor.ts +9 -1
  28. package/src/app/lib/slides.ts +7 -0
  29. package/src/app/lib/use-slide-module.ts +48 -0
  30. package/src/app/routes/assets.tsx +9 -0
  31. package/src/app/routes/home-shell.tsx +23 -2
  32. package/src/app/routes/presenter.tsx +2 -20
  33. package/src/app/routes/slide.tsx +73 -40
  34. package/src/locale/en.ts +14 -4
  35. package/src/locale/ja.ts +14 -4
  36. package/src/locale/types.ts +11 -1
  37. package/src/locale/zh-cn.ts +14 -5
  38. package/src/locale/zh-tw.ts +14 -5
@@ -32,6 +32,12 @@ type Props = {
32
32
  allowExit?: boolean;
33
33
  controls?: boolean;
34
34
  slideId?: string;
35
+ /**
36
+ * When true, the Player enters the browser Fullscreen API on mount.
37
+ * When false, it renders as a window-sized overlay (viewport-filling)
38
+ * without entering fullscreen. Defaults to true for back-compat.
39
+ */
40
+ fullscreen?: boolean;
35
41
  };
36
42
 
37
43
  export function Player({
@@ -43,8 +49,9 @@ export function Player({
43
49
  allowExit = true,
44
50
  controls = false,
45
51
  slideId,
52
+ fullscreen = true,
46
53
  }: Props) {
47
- const rootRef = useRef<HTMLDivElement>(null);
54
+ const rootRef = useRef<HTMLDivElement | null>(null);
48
55
  // Mirrored as state so descendants portaling *into* the player subtree
49
56
  // (tooltips, popovers — the body is outside the fullscreen tree) re-render
50
57
  // once the node mounts.
@@ -60,6 +67,12 @@ export function Player({
60
67
  const [laser, setLaser] = useState(false);
61
68
  const [keyboardDriven, setKeyboardDriven] = useState(false);
62
69
  const [startedAt] = useState(() => Date.now());
70
+ const [windowed, setWindowed] = useState(!fullscreen);
71
+ // Mirror windowed into a ref so the fullscreenchange listener can read the
72
+ // latest value without re-binding — exits from window mode must not call
73
+ // onExit, but exits initiated by the browser (Esc in fullscreen) must.
74
+ const windowedRef = useRef(windowed);
75
+ windowedRef.current = windowed;
63
76
 
64
77
  const canPrev = index > 0;
65
78
  const canNext = index < pages.length - 1;
@@ -90,6 +103,7 @@ export function Player({
90
103
  });
91
104
 
92
105
  useEffect(() => {
106
+ if (windowed) return;
93
107
  const el = rootRef.current;
94
108
  if (!el) return;
95
109
  if (document.fullscreenElement !== el) {
@@ -98,17 +112,21 @@ export function Player({
98
112
  return () => {
99
113
  if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
100
114
  };
101
- }, []);
115
+ }, [windowed]);
102
116
 
103
117
  useEffect(() => {
104
118
  if (!allowExit) return;
105
119
  const onFsChange = () => {
106
- if (!document.fullscreenElement) onExit();
120
+ if (!document.fullscreenElement && !windowedRef.current) onExit();
107
121
  };
108
122
  document.addEventListener('fullscreenchange', onFsChange);
109
123
  return () => document.removeEventListener('fullscreenchange', onFsChange);
110
124
  }, [onExit, allowExit]);
111
125
 
126
+ const toggleFullscreen = useCallback(() => {
127
+ setWindowed((w) => !w);
128
+ }, []);
129
+
112
130
  // Player is the source of truth: it re-publishes state on every change
113
131
  // and answers `request-state` pings so newly opened presenter windows
114
132
  // hydrate immediately.
@@ -271,7 +289,7 @@ export function Player({
271
289
  <div
272
290
  ref={setRoot}
273
291
  className={cn(
274
- 'relative flex h-dvh w-screen items-center justify-center bg-black',
292
+ 'fixed inset-0 flex items-center justify-center overflow-hidden bg-black',
275
293
  controls && 'select-none',
276
294
  controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
277
295
  )}
@@ -310,12 +328,14 @@ export function Player({
310
328
  blackout={blackout}
311
329
  laser={laser}
312
330
  allowExit={allowExit}
331
+ windowed={windowed}
313
332
  onPrev={goPrev}
314
333
  onNext={goNext}
315
334
  onOverview={() => setOverviewOpen(true)}
316
335
  onBlackout={(mode) => setBlackout((c) => (c === mode ? null : mode))}
317
336
  onLaser={() => setLaser((v) => !v)}
318
337
  onPresenter={() => slideId && openPresenterWindow(slideId)}
338
+ onToggleFullscreen={toggleFullscreen}
319
339
  onHelp={() => setHelpOpen(true)}
320
340
  onExit={onExit}
321
341
  />
@@ -334,7 +354,7 @@ export function Player({
334
354
  );
335
355
  }
336
356
 
337
- function openPresenterWindow(slideId: string) {
357
+ export function openPresenterWindow(slideId: string) {
338
358
  if (typeof window === 'undefined') return;
339
359
  const url = `/s/${encodeURIComponent(slideId)}/presenter`;
340
360
  window.open(url, `open-slide-presenter-${slideId}`, 'popup,width=1280,height=800');
@@ -5,6 +5,8 @@ import {
5
5
  Grid2x2,
6
6
  Keyboard,
7
7
  LogOut,
8
+ Maximize,
9
+ Minimize,
8
10
  MonitorSpeaker,
9
11
  Square,
10
12
  Sun,
@@ -24,12 +26,14 @@ type Props = {
24
26
  blackout: 'black' | 'white' | null;
25
27
  laser: boolean;
26
28
  allowExit: boolean;
29
+ windowed: boolean;
27
30
  onPrev: () => void;
28
31
  onNext: () => void;
29
32
  onOverview: () => void;
30
33
  onBlackout: (mode: 'black' | 'white') => void;
31
34
  onLaser: () => void;
32
35
  onPresenter: () => void;
36
+ onToggleFullscreen: () => void;
33
37
  onHelp: () => void;
34
38
  onExit: () => void;
35
39
  /**
@@ -48,12 +52,14 @@ export function PresentControlBar({
48
52
  blackout,
49
53
  laser,
50
54
  allowExit,
55
+ windowed,
51
56
  onPrev,
52
57
  onNext,
53
58
  onOverview,
54
59
  onBlackout,
55
60
  onLaser,
56
61
  onPresenter,
62
+ onToggleFullscreen,
57
63
  onHelp,
58
64
  onExit,
59
65
  tooltipContainer,
@@ -123,6 +129,12 @@ export function PresentControlBar({
123
129
  <BarButton label={t.present.presenterAria} onClick={onPresenter}>
124
130
  <MonitorSpeaker className="size-4" />
125
131
  </BarButton>
132
+ <BarButton
133
+ label={windowed ? t.present.enterFullscreenAria : t.present.exitFullscreenAria}
134
+ onClick={onToggleFullscreen}
135
+ >
136
+ {windowed ? <Maximize className="size-4" /> : <Minimize className="size-4" />}
137
+ </BarButton>
126
138
  <BarButton label={t.present.helpAria} onClick={onHelp}>
127
139
  <Keyboard className="size-4" />
128
140
  </BarButton>
@@ -70,6 +70,9 @@ type Row =
70
70
  }
71
71
  | {
72
72
  kind: 'themes';
73
+ }
74
+ | {
75
+ kind: 'assets';
73
76
  };
74
77
 
75
78
  export function FolderItem({
@@ -92,7 +95,7 @@ export function FolderItem({
92
95
  const slideDragActive = useSlideDragActive();
93
96
  const t = useLocale();
94
97
 
95
- const acceptsSlideDrop = row.kind !== 'themes';
98
+ const acceptsSlideDrop = row.kind !== 'themes' && row.kind !== 'assets';
96
99
  const isSlideDrag = (e: React.DragEvent) =>
97
100
  acceptsSlideDrop && e.dataTransfer.types.includes(SLIDE_DND_MIME);
98
101
  const handleDragEnter = (e: React.DragEvent) => {
@@ -125,9 +128,17 @@ export function FolderItem({
125
128
  ? { type: 'emoji', value: '📝' }
126
129
  : row.kind === 'themes'
127
130
  ? { type: 'emoji', value: '🎨' }
128
- : row.folder.icon;
131
+ : row.kind === 'assets'
132
+ ? { type: 'emoji', value: '🗂️' }
133
+ : row.folder.icon;
129
134
  const label =
130
- row.kind === 'draft' ? t.home.draft : row.kind === 'themes' ? t.home.themes : row.folder.name;
135
+ row.kind === 'draft'
136
+ ? t.home.draft
137
+ : row.kind === 'themes'
138
+ ? t.home.themes
139
+ : row.kind === 'assets'
140
+ ? t.home.assets
141
+ : row.folder.name;
131
142
 
132
143
  const commitRename = () => {
133
144
  if (row.kind !== 'folder') return;
@@ -10,11 +10,13 @@ import { IconPicker, PRESET_COLORS } from './icon-picker';
10
10
 
11
11
  export const DRAFT_ID = 'draft';
12
12
  export const THEMES_ID = '__themes__';
13
+ export const ASSETS_ID = '__assets__';
13
14
 
14
15
  export function Sidebar({
15
16
  folders,
16
17
  countFor,
17
18
  themesCount,
19
+ assetsCount,
18
20
  selectedId,
19
21
  onSelect,
20
22
  onCreate,
@@ -27,6 +29,7 @@ export function Sidebar({
27
29
  folders: Folder[];
28
30
  countFor: (folderId: string | null) => number;
29
31
  themesCount: number;
32
+ assetsCount: number;
30
33
  selectedId: string;
31
34
  onSelect: (id: string) => void;
32
35
  onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
@@ -121,6 +124,13 @@ export function Sidebar({
121
124
  onSelect={() => onSelect(THEMES_ID)}
122
125
  onDropSlide={() => {}}
123
126
  />
127
+ <FolderItem
128
+ row={{ kind: 'assets' }}
129
+ count={assetsCount}
130
+ selected={selectedId === ASSETS_ID}
131
+ onSelect={() => onSelect(ASSETS_ID)}
132
+ onDropSlide={() => {}}
133
+ />
124
134
  </div>
125
135
 
126
136
  <div className="mt-5 flex items-center gap-2 px-4 pb-1.5">
@@ -65,6 +65,12 @@ const PRINT_STYLES = `
65
65
  }
66
66
  `;
67
67
 
68
+ export function isSafari(): boolean {
69
+ if (typeof navigator === 'undefined') return false;
70
+ const ua = navigator.userAgent;
71
+ return /Safari/.test(ua) && !/Chrome|Chromium|Edg|OPR|Firefox/.test(ua);
72
+ }
73
+
68
74
  export type PdfExportProgress = {
69
75
  phase: 'processing' | 'printing' | 'done';
70
76
  /** Number of pages whose intro animations have finished (0..total). */
@@ -1,8 +1,16 @@
1
1
  import { useCallback } from 'react';
2
2
 
3
3
  export type EditOp =
4
- | { kind: 'set-style'; key: string; value: string | null }
4
+ | { kind: 'set-style'; key: string; value: string | null; prevText?: string }
5
5
  | { kind: 'set-text'; value: string; prevText?: string }
6
+ | {
7
+ kind: 'set-text-range-style';
8
+ start: number;
9
+ end: number;
10
+ key: string;
11
+ value: string | null;
12
+ prevText?: string;
13
+ }
6
14
  | { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string }
7
15
  | { kind: 'replace-placeholder-with-image'; assetPath: string };
8
16
 
@@ -15,3 +15,10 @@ export function slidesByTheme(themeId: string): string[] {
15
15
  export async function loadSlide(id: string): Promise<SlideModule> {
16
16
  return load(id);
17
17
  }
18
+
19
+ export function slideChangeIncludes(data: unknown, slideId: string): boolean {
20
+ if (!data || typeof data !== 'object') return false;
21
+ const payload = data as { slideId?: unknown; slideIds?: unknown };
22
+ if (payload.slideId === slideId) return true;
23
+ return Array.isArray(payload.slideIds) && payload.slideIds.includes(slideId);
24
+ }
@@ -0,0 +1,48 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { SlideModule } from './sdk';
3
+ import { loadSlide, slideChangeIncludes } from './slides';
4
+
5
+ export function useSlideModule(slideId: string) {
6
+ const [slide, setSlide] = useState<SlideModule | null>(null);
7
+ const [error, setError] = useState<string | null>(null);
8
+ const loadSeqRef = useRef(0);
9
+
10
+ const reload = useCallback(
11
+ (reset: boolean) => {
12
+ const seq = ++loadSeqRef.current;
13
+ if (reset) setSlide(null);
14
+ setError(null);
15
+ loadSlide(slideId)
16
+ .then((mod) => {
17
+ if (seq === loadSeqRef.current) setSlide(mod);
18
+ })
19
+ .catch((e) => {
20
+ if (seq === loadSeqRef.current) setError(String(e?.message ?? e));
21
+ });
22
+ },
23
+ [slideId],
24
+ );
25
+
26
+ useEffect(() => {
27
+ reload(true);
28
+ }, [reload]);
29
+
30
+ useEffect(() => {
31
+ if (!import.meta.hot) return;
32
+ let cancelled = false;
33
+ const handler = (data: unknown) => {
34
+ if (slideChangeIncludes(data, slideId)) {
35
+ queueMicrotask(() => {
36
+ if (!cancelled) reload(false);
37
+ });
38
+ }
39
+ };
40
+ import.meta.hot.on('open-slide:slide-changed', handler);
41
+ return () => {
42
+ cancelled = true;
43
+ import.meta.hot?.off('open-slide:slide-changed', handler);
44
+ };
45
+ }, [slideId, reload]);
46
+
47
+ return { slide, error, reload };
48
+ }
@@ -0,0 +1,9 @@
1
+ import { AssetView } from '../components/asset-view';
2
+
3
+ export function AssetsPage() {
4
+ return (
5
+ <div className="flex min-h-0 flex-1 flex-col">
6
+ <AssetView slideId={null} />
7
+ </div>
8
+ );
9
+ }
@@ -1,10 +1,12 @@
1
1
  import { useCallback, useMemo, useState } from 'react';
2
2
  import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
3
3
  import { toast } from 'sonner';
4
+ import { useAssets } from '@/lib/assets';
4
5
  import { useFolders } from '@/lib/folders';
5
6
  import { format, useLocale } from '@/lib/use-locale';
7
+ import { cn } from '@/lib/utils';
6
8
  import { MobileFolderPill } from '../components/sidebar/mobile-pill';
7
- import { DRAFT_ID, Sidebar, THEMES_ID } from '../components/sidebar/sidebar';
9
+ import { ASSETS_ID, DRAFT_ID, Sidebar, THEMES_ID } from '../components/sidebar/sidebar';
8
10
  import type { FoldersManifest } from '../lib/sdk';
9
11
  import { slideIds } from '../lib/slides';
10
12
  import { themes as themeRegistry } from '../lib/themes';
@@ -25,6 +27,7 @@ export type HomeOutletContext = {
25
27
 
26
28
  function pathToSelectedId(pathname: string, search: URLSearchParams): string {
27
29
  if (pathname === '/themes' || pathname.startsWith('/themes/')) return THEMES_ID;
30
+ if (pathname === '/assets') return ASSETS_ID;
28
31
  return search.get('f') ?? DRAFT_ID;
29
32
  }
30
33
 
@@ -48,12 +51,16 @@ export function HomeShell() {
48
51
  const selectFolder = useCallback(
49
52
  (id: string) => {
50
53
  if (id === THEMES_ID) navigate('/themes', { replace: true });
54
+ else if (id === ASSETS_ID) navigate('/assets', { replace: true });
51
55
  else if (id === DRAFT_ID) navigate('/', { replace: true });
52
56
  else navigate(`/?f=${encodeURIComponent(id)}`, { replace: true });
53
57
  },
54
58
  [navigate],
55
59
  );
56
60
 
61
+ const { assets: globalAssets } = useAssets('@global');
62
+ const isAssetsRoute = location.pathname === '/assets';
63
+
57
64
  const { draftSlides, slidesByFolder } = useMemo(() => {
58
65
  const byFolder: Record<string, string[]> = {};
59
66
  const draft: string[] = [];
@@ -111,6 +118,7 @@ export function HomeShell() {
111
118
  folders={manifest.folders}
112
119
  countFor={countFor}
113
120
  themesCount={themeRegistry.length}
121
+ assetsCount={globalAssets.length}
114
122
  selectedId={selectedId}
115
123
  onSelect={selectFolder}
116
124
  onCreate={(name, icon) => create(name, icon)}
@@ -151,6 +159,13 @@ export function HomeShell() {
151
159
  active={selectedId === THEMES_ID}
152
160
  onClick={() => selectFolder(THEMES_ID)}
153
161
  />
162
+ <MobileFolderPill
163
+ icon={{ type: 'emoji', value: '🗂️' }}
164
+ label={t.home.assets}
165
+ count={globalAssets.length}
166
+ active={selectedId === ASSETS_ID}
167
+ onClick={() => selectFolder(ASSETS_ID)}
168
+ />
154
169
  {manifest.folders.map((f) => (
155
170
  <MobileFolderPill
156
171
  key={f.id}
@@ -164,7 +179,13 @@ export function HomeShell() {
164
179
  </div>
165
180
  </div>
166
181
 
167
- <div className="mx-auto w-full max-w-[1180px] px-5 py-8 md:px-10 md:py-12">
182
+ <div
183
+ className={cn(
184
+ isAssetsRoute
185
+ ? 'flex min-h-0 flex-1 flex-col'
186
+ : 'mx-auto w-full max-w-[1180px] px-5 py-8 md:px-10 md:py-12',
187
+ )}
188
+ >
168
189
  <Outlet context={ctx} />
169
190
  </div>
170
191
  </div>
@@ -9,14 +9,12 @@ import {
9
9
  usePresenterChannel,
10
10
  } from '../components/present/use-presenter-channel';
11
11
  import { SlideCanvas } from '../components/slide-canvas';
12
- import type { SlideModule } from '../lib/sdk';
13
12
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
14
- import { loadSlide } from '../lib/slides';
13
+ import { useSlideModule } from '../lib/use-slide-module';
15
14
 
16
15
  export function Presenter() {
17
16
  const { slideId = '' } = useParams();
18
- const [slide, setSlide] = useState<SlideModule | null>(null);
19
- const [error, setError] = useState<string | null>(null);
17
+ const { slide, error } = useSlideModule(slideId);
20
18
 
21
19
  // Presenter view is a passive mirror of the projection window. It only
22
20
  // tracks the index it last heard about; navigation buttons send commands
@@ -29,22 +27,6 @@ export function Presenter() {
29
27
  const requestedRef = useRef(false);
30
28
  const t = useLocale();
31
29
 
32
- useEffect(() => {
33
- let cancelled = false;
34
- setSlide(null);
35
- setError(null);
36
- loadSlide(slideId)
37
- .then((mod) => {
38
- if (!cancelled) setSlide(mod);
39
- })
40
- .catch((e) => {
41
- if (!cancelled) setError(String(e?.message ?? e));
42
- });
43
- return () => {
44
- cancelled = true;
45
- };
46
- }, [slideId]);
47
-
48
30
  const channel = usePresenterChannel(slideId, (msg) => {
49
31
  if (msg.type === 'state') {
50
32
  setState(msg.state);
@@ -1,5 +1,16 @@
1
1
  import config from 'virtual:open-slide/config';
2
- import { ChevronLeft, Download, FileCode2, FileText, Loader2, Pencil, Play } from 'lucide-react';
2
+ import {
3
+ ChevronDown,
4
+ ChevronLeft,
5
+ Download,
6
+ FileCode2,
7
+ FileText,
8
+ Loader2,
9
+ Maximize,
10
+ MonitorSpeaker,
11
+ Pencil,
12
+ Play,
13
+ } from 'lucide-react';
3
14
  import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
15
  import { Link, useParams, useSearchParams } from 'react-router-dom';
5
16
  import { toast } from 'sonner';
@@ -33,45 +44,28 @@ import { cn } from '@/lib/utils';
33
44
  import { ClickNavZones } from '../components/click-nav-zones';
34
45
  import { NotesDrawer } from '../components/notes-drawer';
35
46
  import { PdfProgressToast } from '../components/pdf-progress-toast';
36
- import { Player } from '../components/player';
47
+ import { openPresenterWindow, Player } from '../components/player';
37
48
  import { SlideCanvas } from '../components/slide-canvas';
38
49
  import { type ThumbnailActions, ThumbnailRail } from '../components/thumbnail-rail';
39
50
  import { exportSlideAsHtml } from '../lib/export-html';
40
- import { exportSlideAsPdf } from '../lib/export-pdf';
51
+ import { exportSlideAsPdf, isSafari } from '../lib/export-pdf';
41
52
  import { remapNotesSessionCacheAfterReorder } from '../lib/inspector/use-notes';
42
53
  import type { SlideModule } from '../lib/sdk';
43
- import { loadSlide } from '../lib/slides';
54
+ import { useSlideModule } from '../lib/use-slide-module';
44
55
 
45
56
  const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
46
57
 
47
58
  export function Slide() {
48
59
  const { slideId = '' } = useParams();
49
60
  const [searchParams, setSearchParams] = useSearchParams();
50
- const [slide, setSlide] = useState<SlideModule | null>(null);
51
- const [error, setError] = useState<string | null>(null);
52
- const [playing, setPlaying] = useState(false);
61
+ const { slide, error } = useSlideModule(slideId);
62
+ const [playMode, setPlayMode] = useState<'window' | 'fullscreen' | null>(null);
53
63
  const [exporting, setExporting] = useState(false);
54
64
  const [designOpen, setDesignOpen] = useState(false);
55
65
  const { renameSlide } = useFolders();
56
66
  const slideViewportRef = useRef<HTMLElement>(null);
57
67
  const t = useLocale();
58
68
 
59
- useEffect(() => {
60
- let cancelled = false;
61
- setSlide(null);
62
- setError(null);
63
- loadSlide(slideId)
64
- .then((mod) => {
65
- if (!cancelled) setSlide(mod);
66
- })
67
- .catch((e) => {
68
- if (!cancelled) setError(String(e?.message ?? e));
69
- });
70
- return () => {
71
- cancelled = true;
72
- };
73
- }, [slideId]);
74
-
75
69
  const modulePages = useMemo(() => slide?.default ?? [], [slide]);
76
70
  const [pages, setPages] = useState<typeof modulePages>(modulePages);
77
71
  useEffect(() => {
@@ -221,7 +215,7 @@ export function Slide() {
221
215
  );
222
216
 
223
217
  useEffect(() => {
224
- if (playing) return;
218
+ if (playMode) return;
225
219
  const onKey = (e: KeyboardEvent) => {
226
220
  if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
227
221
  if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown') {
@@ -231,12 +225,12 @@ export function Slide() {
231
225
  e.preventDefault();
232
226
  goTo(index - 1);
233
227
  } else if (e.key === 'f' || e.key === 'F') {
234
- setPlaying(true);
228
+ setPlayMode('fullscreen');
235
229
  }
236
230
  };
237
231
  window.addEventListener('keydown', onKey);
238
232
  return () => window.removeEventListener('keydown', onKey);
239
- }, [index, goTo, playing]);
233
+ }, [index, goTo, playMode]);
240
234
 
241
235
  if (error) {
242
236
  return (
@@ -315,16 +309,17 @@ export function Slide() {
315
309
  );
316
310
  }
317
311
 
318
- if (playing) {
312
+ if (playMode) {
319
313
  return (
320
314
  <Player
321
315
  pages={pages}
322
316
  design={slide.design}
323
317
  index={index}
324
318
  onIndexChange={goTo}
325
- onExit={() => setPlaying(false)}
319
+ onExit={() => setPlayMode(null)}
326
320
  controls
327
321
  slideId={slideId}
322
+ fullscreen={playMode === 'fullscreen'}
328
323
  />
329
324
  );
330
325
  }
@@ -417,6 +412,10 @@ export function Slide() {
417
412
  disabled={exporting}
418
413
  onSelect={async () => {
419
414
  if (!slide || exporting) return;
415
+ if (isSafari()) {
416
+ toast.error(t.slide.pdfExportSafariUnsupported, { duration: 5000 });
417
+ return;
418
+ }
420
419
  setExporting(true);
421
420
  const toastId = `pdf-export-${slideId}`;
422
421
  toast.custom(
@@ -460,18 +459,52 @@ export function Slide() {
460
459
  {view === 'slides' && <InspectToggleButton />}
461
460
  <span aria-hidden className="mx-0.5 hidden h-5 w-px bg-hairline md:block" />
462
461
  {view === 'slides' && (
463
- <Button
464
- size="sm"
465
- variant="brand"
466
- onClick={() => setPlaying(true)}
467
- className="px-2.5 md:px-3"
468
- >
469
- <Play className="size-3.5 fill-current" />
470
- <span className="hidden md:inline">{t.slide.present}</span>
471
- <kbd className="ml-1 hidden rounded-[3px] bg-brand-foreground/15 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
472
- F
473
- </kbd>
474
- </Button>
462
+ <div className="inline-flex items-stretch">
463
+ <Button
464
+ size="sm"
465
+ variant="brand"
466
+ onClick={() => setPlayMode('fullscreen')}
467
+ className="rounded-r-none px-2.5 md:px-3"
468
+ >
469
+ <Play className="size-3.5 fill-current" />
470
+ <span className="hidden md:inline">{t.slide.present}</span>
471
+ <kbd className="ml-1 hidden rounded-[3px] bg-brand-foreground/15 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
472
+ F
473
+ </kbd>
474
+ </Button>
475
+ <DropdownMenu>
476
+ <DropdownMenuTrigger
477
+ type="button"
478
+ aria-label={t.slide.presentMenuAria}
479
+ title={t.slide.presentMenuAria}
480
+ className={cn(
481
+ buttonVariants({ variant: 'brand', size: 'sm' }),
482
+ 'rounded-l-none px-1.5 shadow-[inset_1px_0_0_oklch(0_0_0/0.12),inset_0_1px_0_oklch(1_0_0/0.18),0_1px_0_oklch(0_0_0/0.16)]',
483
+ )}
484
+ >
485
+ <ChevronDown className="size-3.5" />
486
+ </DropdownMenuTrigger>
487
+ <DropdownMenuContent align="end" className="min-w-[200px]">
488
+ <DropdownMenuItem onSelect={() => setPlayMode('window')}>
489
+ <Play />
490
+ {t.slide.presentInWindow}
491
+ </DropdownMenuItem>
492
+ <DropdownMenuItem onSelect={() => setPlayMode('fullscreen')}>
493
+ <Maximize />
494
+ {t.slide.presentFullscreen}
495
+ </DropdownMenuItem>
496
+ <DropdownMenuItem
497
+ onSelect={() => {
498
+ if (slideId) openPresenterWindow(slideId);
499
+ setPlayMode('window');
500
+ }}
501
+ >
502
+ <MonitorSpeaker />
503
+ {t.slide.presentPresenter}
504
+ </DropdownMenuItem>
505
+ </DropdownMenuContent>
506
+ </DropdownMenu>
507
+ </div>
475
508
  )}
476
509
  </div>
477
510
  </header>