@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.
- package/dist/{build-_276DMmJ.js → build-1Rqivz0d.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-BAwKWNtW.js → config-XZJnC_fu.js} +533 -59
- package/dist/{config-D9cZ1A0X.d.ts → config-s0YUbmUe.d.ts} +2 -1
- package/dist/{dev-BoqeVXVq.js → dev-0W8gYiSa.js} +1 -1
- package/dist/{en-CDKzoZvf.js → en-7GU-DHbJ.js} +13 -3
- 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 +40 -10
- package/dist/{preview-BLPxspc9.js → preview-DT9hJvzM.js} +1 -1
- package/dist/{types-JYG1cmwC.d.ts → types-QCpkHkiS.d.ts} +11 -1
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +10 -2
- package/src/app/app.tsx +2 -0
- package/src/app/components/asset-view.tsx +36 -9
- package/src/app/components/inspector/inspect-overlay.tsx +49 -3
- package/src/app/components/inspector/inspector-panel.tsx +251 -24
- package/src/app/components/inspector/inspector-provider.tsx +390 -49
- package/src/app/components/player.tsx +25 -5
- package/src/app/components/present/control-bar.tsx +12 -0
- package/src/app/components/sidebar/folder-item.tsx +14 -3
- package/src/app/components/sidebar/sidebar.tsx +10 -0
- package/src/app/lib/export-pdf.ts +6 -0
- package/src/app/lib/inspector/use-editor.ts +9 -1
- package/src/app/lib/slides.ts +7 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +23 -2
- package/src/app/routes/presenter.tsx +2 -20
- package/src/app/routes/slide.tsx +73 -40
- package/src/locale/en.ts +14 -4
- package/src/locale/ja.ts +14 -4
- package/src/locale/types.ts +11 -1
- package/src/locale/zh-cn.ts +14 -5
- 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
|
-
'
|
|
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.
|
|
131
|
+
: row.kind === 'assets'
|
|
132
|
+
? { type: 'emoji', value: '🗂️' }
|
|
133
|
+
: row.folder.icon;
|
|
129
134
|
const label =
|
|
130
|
-
row.kind === 'draft'
|
|
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
|
|
package/src/app/lib/slides.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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 {
|
|
13
|
+
import { useSlideModule } from '../lib/use-slide-module';
|
|
15
14
|
|
|
16
15
|
export function Presenter() {
|
|
17
16
|
const { slideId = '' } = useParams();
|
|
18
|
-
const
|
|
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);
|
package/src/app/routes/slide.tsx
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import config from 'virtual:open-slide/config';
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
51
|
-
const [
|
|
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 (
|
|
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
|
-
|
|
228
|
+
setPlayMode('fullscreen');
|
|
235
229
|
}
|
|
236
230
|
};
|
|
237
231
|
window.addEventListener('keydown', onKey);
|
|
238
232
|
return () => window.removeEventListener('keydown', onKey);
|
|
239
|
-
}, [index, goTo,
|
|
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 (
|
|
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={() =>
|
|
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
|
-
<
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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>
|