@slidev-react/client 0.2.5
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/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +44 -0
- package/src/addons/AddonProvider.tsx +25 -0
- package/src/addons/g2/G2Chart.tsx +370 -0
- package/src/addons/g2/chartPresets.ts +43 -0
- package/src/addons/g2/chartThemeTokens.ts +124 -0
- package/src/addons/g2/index.ts +36 -0
- package/src/addons/g2/style.css +31 -0
- package/src/addons/insight/Insight.tsx +10 -0
- package/src/addons/insight/InsightAddonProvider.tsx +20 -0
- package/src/addons/insight/SpotlightLayout.tsx +11 -0
- package/src/addons/insight/index.ts +17 -0
- package/src/addons/insight/style.css +34 -0
- package/src/addons/mermaid/MermaidDiagram.tsx +379 -0
- package/src/addons/mermaid/index.ts +10 -0
- package/src/addons/registry.test.ts +28 -0
- package/src/addons/registry.ts +61 -0
- package/src/addons/types.ts +6 -0
- package/src/app/App.tsx +125 -0
- package/src/app/README.md +18 -0
- package/src/app/providers/SlidesNavigationProvider.tsx +82 -0
- package/src/app/usePresentationBootstrap.ts +85 -0
- package/src/features/presentation/PresentationStatus.tsx +514 -0
- package/src/features/presentation/PrintSlidesView.tsx +350 -0
- package/src/features/presentation/browser.ts +5 -0
- package/src/features/presentation/draw/DrawOverlay.tsx +170 -0
- package/src/features/presentation/draw/DrawProvider.tsx +394 -0
- package/src/features/presentation/draw/persistence.test.ts +80 -0
- package/src/features/presentation/draw/persistence.ts +54 -0
- package/src/features/presentation/exportArtifacts.test.ts +48 -0
- package/src/features/presentation/exportArtifacts.ts +6 -0
- package/src/features/presentation/location.test.ts +73 -0
- package/src/features/presentation/location.ts +113 -0
- package/src/features/presentation/navigation/KeyboardController.tsx +73 -0
- package/src/features/presentation/navigation/PresentationNavbar.tsx +162 -0
- package/src/features/presentation/navigation/ShortcutsHelpOverlay.test.tsx +24 -0
- package/src/features/presentation/navigation/ShortcutsHelpOverlay.tsx +111 -0
- package/src/features/presentation/navigation/keyboardShortcuts.test.ts +74 -0
- package/src/features/presentation/navigation/keyboardShortcuts.ts +221 -0
- package/src/features/presentation/navigation/useSlidesNavigation.ts +15 -0
- package/src/features/presentation/overview/NotesOverview.tsx +200 -0
- package/src/features/presentation/overview/QuickOverview.tsx +126 -0
- package/src/features/presentation/path.ts +137 -0
- package/src/features/presentation/presenter/FlowTimelinePreview.test.tsx +54 -0
- package/src/features/presentation/presenter/FlowTimelinePreview.tsx +274 -0
- package/src/features/presentation/presenter/PresenterModeView.tsx +93 -0
- package/src/features/presentation/presenter/PresenterShell.tsx +286 -0
- package/src/features/presentation/presenter/PresenterSidePreview.tsx +68 -0
- package/src/features/presentation/presenter/PresenterTopProgress.tsx +28 -0
- package/src/features/presentation/presenter/SpeakerNotesPanel.tsx +51 -0
- package/src/features/presentation/presenter/StandaloneModeView.tsx +36 -0
- package/src/features/presentation/presenter/persistence.test.ts +26 -0
- package/src/features/presentation/presenter/persistence.ts +31 -0
- package/src/features/presentation/presenter/presentationSyncBridge.test.ts +87 -0
- package/src/features/presentation/presenter/presentationSyncBridge.ts +82 -0
- package/src/features/presentation/presenter/stage.ts +15 -0
- package/src/features/presentation/presenter/types.ts +30 -0
- package/src/features/presentation/presenter/useFullscreen.ts +58 -0
- package/src/features/presentation/presenter/useIdleCursor.ts +37 -0
- package/src/features/presentation/presenter/usePresentationFlowRuntime.ts +238 -0
- package/src/features/presentation/presenter/usePresenterChromeRuntime.ts +358 -0
- package/src/features/presentation/presenter/usePresenterSessionState.ts +226 -0
- package/src/features/presentation/presenter/useWakeLock.ts +110 -0
- package/src/features/presentation/recordingFilename.test.ts +46 -0
- package/src/features/presentation/recordingFilename.ts +56 -0
- package/src/features/presentation/reveal/Reveal.tsx +119 -0
- package/src/features/presentation/reveal/RevealContext.tsx +29 -0
- package/src/features/presentation/reveal/useRevealStep.ts +35 -0
- package/src/features/presentation/session.test.ts +122 -0
- package/src/features/presentation/session.ts +124 -0
- package/src/features/presentation/stage/SlidePreviewSurface.tsx +92 -0
- package/src/features/presentation/stage/SlideStage.tsx +159 -0
- package/src/features/presentation/stage/slideSurface.ts +71 -0
- package/src/features/presentation/stage/slideViewport.tsx +47 -0
- package/src/features/presentation/sync/adapters/broadcastChannelTransport.ts +40 -0
- package/src/features/presentation/sync/adapters/websocketTransport.ts +128 -0
- package/src/features/presentation/sync/model/presence.test.ts +42 -0
- package/src/features/presentation/sync/model/presence.ts +33 -0
- package/src/features/presentation/sync/model/replication.test.ts +72 -0
- package/src/features/presentation/sync/model/replication.ts +113 -0
- package/src/features/presentation/sync/model/status.test.ts +52 -0
- package/src/features/presentation/sync/model/status.ts +33 -0
- package/src/features/presentation/types.ts +1 -0
- package/src/features/presentation/usePresentationRecorder.ts +194 -0
- package/src/features/presentation/usePresentationSync.ts +423 -0
- package/src/index.ts +7 -0
- package/src/main.tsx +12 -0
- package/src/theme/ThemeProvider.test.ts +36 -0
- package/src/theme/ThemeProvider.tsx +79 -0
- package/src/theme/__mocks__/active-theme.ts +3 -0
- package/src/theme/base.css +14 -0
- package/src/theme/components.css +231 -0
- package/src/theme/index.css +11 -0
- package/src/theme/layouts/center.tsx +9 -0
- package/src/theme/layouts/cover.tsx +9 -0
- package/src/theme/layouts/default.tsx +5 -0
- package/src/theme/layouts/defaultLayouts.ts +20 -0
- package/src/theme/layouts/helpers.tsx +12 -0
- package/src/theme/layouts/image-right.tsx +21 -0
- package/src/theme/layouts/immersive.tsx +9 -0
- package/src/theme/layouts/resolveLayout.ts +9 -0
- package/src/theme/layouts/section.tsx +9 -0
- package/src/theme/layouts/statement.tsx +9 -0
- package/src/theme/layouts/two-cols.tsx +21 -0
- package/src/theme/layouts/types.ts +1 -0
- package/src/theme/layouts.css +133 -0
- package/src/theme/mark.css +379 -0
- package/src/theme/print.css +106 -0
- package/src/theme/prose.css +263 -0
- package/src/theme/registry.test.ts +21 -0
- package/src/theme/registry.ts +40 -0
- package/src/theme/tokens.css +148 -0
- package/src/theme/transitions.css +141 -0
- package/src/theme/types.ts +9 -0
- package/src/theme/useResolvedLayout.ts +24 -0
- package/src/types/generated-slides.d.ts +7 -0
- package/src/types/mdx-components.ts +7 -0
- package/src/types/plantuml-encoder.d.ts +7 -0
- package/src/ui/diagrams/PlantUmlDiagram.tsx +33 -0
- package/src/ui/mdx/MagicMoveDemo.tsx +114 -0
- package/src/ui/mdx/index.ts +21 -0
- package/src/ui/primitives/Annotate.test.tsx +64 -0
- package/src/ui/primitives/Annotate.tsx +82 -0
- package/src/ui/primitives/Badge.tsx +5 -0
- package/src/ui/primitives/Callout.tsx +24 -0
- package/src/ui/primitives/ChromeIconButton.tsx +58 -0
- package/src/ui/primitives/ChromePanel.tsx +79 -0
- package/src/ui/primitives/ChromeTag.tsx +70 -0
- package/src/ui/primitives/FormSelect.tsx +51 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
export const SHORTCUT_HELP_DOUBLE_SHIFT_MS = 400;
|
|
2
|
+
|
|
3
|
+
export type NavigationShortcutAction = "advance" | "retreat" | "first" | "last";
|
|
4
|
+
|
|
5
|
+
export type ShortcutHelpItem = {
|
|
6
|
+
keys: string;
|
|
7
|
+
action: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ShortcutHelpSection = {
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
items: ShortcutHelpItem[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ShortcutHelpTriggerState = {
|
|
17
|
+
shiftPressed: boolean;
|
|
18
|
+
shiftChordActive: boolean;
|
|
19
|
+
lastIsolatedShiftAt: number | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function resolveNavigationShortcutAction({
|
|
23
|
+
key,
|
|
24
|
+
shiftKey,
|
|
25
|
+
}: {
|
|
26
|
+
key: string;
|
|
27
|
+
shiftKey: boolean;
|
|
28
|
+
}): NavigationShortcutAction | null {
|
|
29
|
+
if (key === "ArrowRight" || key === "PageDown") return "advance";
|
|
30
|
+
if (key === "ArrowLeft" || key === "PageUp") return "retreat";
|
|
31
|
+
if (key === "Home") return "first";
|
|
32
|
+
if (key === "End") return "last";
|
|
33
|
+
if (key === " ") return shiftKey ? "retreat" : "advance";
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createShortcutHelpTriggerState(): ShortcutHelpTriggerState {
|
|
39
|
+
return {
|
|
40
|
+
shiftPressed: false,
|
|
41
|
+
shiftChordActive: false,
|
|
42
|
+
lastIsolatedShiftAt: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerShortcutHelpKeyDown(
|
|
47
|
+
state: ShortcutHelpTriggerState,
|
|
48
|
+
key: string,
|
|
49
|
+
): ShortcutHelpTriggerState {
|
|
50
|
+
if (key === "Shift") {
|
|
51
|
+
return {
|
|
52
|
+
...state,
|
|
53
|
+
shiftPressed: true,
|
|
54
|
+
shiftChordActive: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!state.shiftPressed) return state;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
...state,
|
|
62
|
+
shiftChordActive: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function registerShortcutHelpKeyUp(
|
|
67
|
+
state: ShortcutHelpTriggerState,
|
|
68
|
+
key: string,
|
|
69
|
+
releasedAt: number,
|
|
70
|
+
thresholdMs = SHORTCUT_HELP_DOUBLE_SHIFT_MS,
|
|
71
|
+
) {
|
|
72
|
+
if (key !== "Shift") {
|
|
73
|
+
return {
|
|
74
|
+
nextState: state,
|
|
75
|
+
shouldToggle: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const isolatedTap = state.shiftPressed && !state.shiftChordActive;
|
|
80
|
+
const shouldToggle =
|
|
81
|
+
isolatedTap &&
|
|
82
|
+
state.lastIsolatedShiftAt != null &&
|
|
83
|
+
releasedAt - state.lastIsolatedShiftAt <= thresholdMs;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
nextState: {
|
|
87
|
+
shiftPressed: false,
|
|
88
|
+
shiftChordActive: false,
|
|
89
|
+
lastIsolatedShiftAt: isolatedTap
|
|
90
|
+
? shouldToggle
|
|
91
|
+
? null
|
|
92
|
+
: releasedAt
|
|
93
|
+
: state.lastIsolatedShiftAt,
|
|
94
|
+
},
|
|
95
|
+
shouldToggle,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isShortcutHelpOpenKey({
|
|
100
|
+
key,
|
|
101
|
+
shiftKey,
|
|
102
|
+
metaKey,
|
|
103
|
+
ctrlKey,
|
|
104
|
+
altKey,
|
|
105
|
+
}: {
|
|
106
|
+
key: string;
|
|
107
|
+
shiftKey: boolean;
|
|
108
|
+
metaKey: boolean;
|
|
109
|
+
ctrlKey: boolean;
|
|
110
|
+
altKey: boolean;
|
|
111
|
+
}) {
|
|
112
|
+
if (metaKey || ctrlKey || altKey) return false;
|
|
113
|
+
|
|
114
|
+
return key === "?" || (key === "/" && shiftKey);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function buildShortcutHelpSections({
|
|
118
|
+
canControl,
|
|
119
|
+
canOpenOverview,
|
|
120
|
+
}: {
|
|
121
|
+
canControl: boolean;
|
|
122
|
+
canOpenOverview: boolean;
|
|
123
|
+
}): ShortcutHelpSection[] {
|
|
124
|
+
const sections: ShortcutHelpSection[] = [
|
|
125
|
+
{
|
|
126
|
+
title: "Navigation",
|
|
127
|
+
description: "Move through the slides without leaving the keyboard.",
|
|
128
|
+
items: [
|
|
129
|
+
{
|
|
130
|
+
keys: "Right / Space / PageDown",
|
|
131
|
+
action: "Next cue or next slide",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
keys: "Left / Shift + Space / PageUp",
|
|
135
|
+
action: "Previous cue or previous slide",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
keys: "Home",
|
|
139
|
+
action: "Jump to the first slide",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
keys: "End",
|
|
143
|
+
action: "Jump to the last slide",
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
title: "Overlays",
|
|
149
|
+
description: "Open, switch, and close the main workspaces.",
|
|
150
|
+
items: [
|
|
151
|
+
{
|
|
152
|
+
keys: "?",
|
|
153
|
+
action: "Toggle keyboard shortcuts help",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
keys: "Shift Shift",
|
|
157
|
+
action: "Toggle keyboard shortcuts help",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
keys: "Esc",
|
|
161
|
+
action: "Close the current overlay",
|
|
162
|
+
},
|
|
163
|
+
...(canOpenOverview
|
|
164
|
+
? [
|
|
165
|
+
{
|
|
166
|
+
keys: "O",
|
|
167
|
+
action: "Toggle quick overview",
|
|
168
|
+
},
|
|
169
|
+
]
|
|
170
|
+
: []),
|
|
171
|
+
...(canControl
|
|
172
|
+
? [
|
|
173
|
+
{
|
|
174
|
+
keys: "N",
|
|
175
|
+
action: "Toggle notes workspace",
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
: []),
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
if (canControl) {
|
|
184
|
+
sections.push({
|
|
185
|
+
title: "Draw",
|
|
186
|
+
description: "Control live annotation tools while presenting.",
|
|
187
|
+
items: [
|
|
188
|
+
{
|
|
189
|
+
keys: "D",
|
|
190
|
+
action: "Toggle draw mode",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
keys: "P",
|
|
194
|
+
action: "Switch to pen",
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
keys: "B",
|
|
198
|
+
action: "Switch to circle",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
keys: "R",
|
|
202
|
+
action: "Switch to rectangle",
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
keys: "E",
|
|
206
|
+
action: "Switch to eraser",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
keys: "C",
|
|
210
|
+
action: "Clear strokes on the current slide",
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
keys: "Cmd/Ctrl + Z",
|
|
214
|
+
action: "Undo the last stroke",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return sections;
|
|
221
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useSlidesState } from "../../../app/providers/SlidesNavigationProvider";
|
|
2
|
+
|
|
3
|
+
export function useSlidesNavigation() {
|
|
4
|
+
const slides = useSlidesState();
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
currentIndex: slides.currentIndex,
|
|
8
|
+
total: slides.total,
|
|
9
|
+
next: slides.next,
|
|
10
|
+
prev: slides.prev,
|
|
11
|
+
first: slides.first,
|
|
12
|
+
last: slides.last,
|
|
13
|
+
goTo: slides.goTo,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { NotebookText, X } from "lucide-react";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import type { CompiledSlide } from "../presenter/types";
|
|
4
|
+
import { ChromePanel } from "../../../ui/primitives/ChromePanel";
|
|
5
|
+
|
|
6
|
+
function summarizeNotes(notes?: string) {
|
|
7
|
+
if (!notes) return "No speaker notes yet.";
|
|
8
|
+
|
|
9
|
+
const normalized = notes.replace(/\s+/g, " ").trim();
|
|
10
|
+
if (normalized.length <= 140) return normalized;
|
|
11
|
+
|
|
12
|
+
return `${normalized.slice(0, 137)}...`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toParagraphs(notes?: string) {
|
|
16
|
+
if (!notes) return [];
|
|
17
|
+
|
|
18
|
+
return notes
|
|
19
|
+
.split(/\n\s*\n/g)
|
|
20
|
+
.map((section) => section.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function NotesOverview({
|
|
25
|
+
open,
|
|
26
|
+
slides,
|
|
27
|
+
currentIndex,
|
|
28
|
+
onClose,
|
|
29
|
+
onSelect,
|
|
30
|
+
}: {
|
|
31
|
+
open: boolean;
|
|
32
|
+
slides: CompiledSlide[];
|
|
33
|
+
currentIndex: number;
|
|
34
|
+
onClose: () => void;
|
|
35
|
+
onSelect: (index: number) => void;
|
|
36
|
+
}) {
|
|
37
|
+
const [selectedIndex, setSelectedIndex] = useState(currentIndex);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!open) return;
|
|
41
|
+
|
|
42
|
+
setSelectedIndex(currentIndex);
|
|
43
|
+
}, [currentIndex, open]);
|
|
44
|
+
|
|
45
|
+
const selectedSlide = slides[selectedIndex] ?? slides[currentIndex] ?? null;
|
|
46
|
+
const notedSlidesCount = useMemo(
|
|
47
|
+
() => slides.filter((slide) => Boolean(slide.meta.notes?.trim())).length,
|
|
48
|
+
[slides],
|
|
49
|
+
);
|
|
50
|
+
const selectedNotes = toParagraphs(selectedSlide?.meta.notes);
|
|
51
|
+
|
|
52
|
+
if (!open) return null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="absolute inset-0 z-50 bg-slate-100/84 backdrop-blur-sm">
|
|
56
|
+
<div className="mx-auto flex h-full w-full max-w-[1800px] flex-col px-6 py-6">
|
|
57
|
+
<header className="mb-4 flex items-start justify-between gap-6">
|
|
58
|
+
<div className="text-slate-900">
|
|
59
|
+
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 py-1 text-xs font-medium text-slate-600">
|
|
60
|
+
<NotebookText size={14} />
|
|
61
|
+
<span>
|
|
62
|
+
{notedSlidesCount}/{slides.length} slides have notes
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
<h2 className="text-lg font-semibold">Notes Workspace</h2>
|
|
66
|
+
<p className="text-sm text-slate-600">
|
|
67
|
+
Review the whole narrative, then jump directly to the slide you want to rehearse.
|
|
68
|
+
Press `N` or `Esc` to close.
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={onClose}
|
|
74
|
+
aria-label="Close notes workspace"
|
|
75
|
+
className="inline-flex size-10 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-700 hover:bg-slate-50"
|
|
76
|
+
>
|
|
77
|
+
<X size={18} />
|
|
78
|
+
</button>
|
|
79
|
+
</header>
|
|
80
|
+
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[minmax(340px,420px)_minmax(0,1fr)]">
|
|
81
|
+
<ChromePanel tone="solid" radius="section" padding="none" className="overflow-hidden">
|
|
82
|
+
<div className="border-b border-slate-200/80 px-5 py-4">
|
|
83
|
+
<h3 className="text-sm font-semibold text-slate-900">Slide Notes Index</h3>
|
|
84
|
+
<p className="mt-1 text-sm text-slate-500">
|
|
85
|
+
Every slide is listed here, including the ones that still need notes.
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="min-h-0 overflow-auto p-3">
|
|
89
|
+
<div className="space-y-2">
|
|
90
|
+
{slides.map((slide, index) => {
|
|
91
|
+
const active = index === selectedIndex;
|
|
92
|
+
const isCurrent = index === currentIndex;
|
|
93
|
+
const hasNotes = Boolean(slide.meta.notes?.trim());
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
key={slide.id}
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => setSelectedIndex(index)}
|
|
100
|
+
className={`w-full rounded-lg border p-4 text-left transition ${
|
|
101
|
+
active
|
|
102
|
+
? "border-emerald-400 bg-emerald-50 "
|
|
103
|
+
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50"
|
|
104
|
+
}`}
|
|
105
|
+
>
|
|
106
|
+
<div className="mb-2 flex items-center justify-between gap-3">
|
|
107
|
+
<div className="flex items-center gap-2">
|
|
108
|
+
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-700">
|
|
109
|
+
{index + 1}
|
|
110
|
+
</span>
|
|
111
|
+
{isCurrent && (
|
|
112
|
+
<span className="rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
|
113
|
+
Current
|
|
114
|
+
</span>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
<span
|
|
118
|
+
className={`rounded-md px-2 py-0.5 text-xs font-medium ${
|
|
119
|
+
hasNotes
|
|
120
|
+
? "bg-emerald-100 text-emerald-700"
|
|
121
|
+
: "bg-slate-100 text-slate-500"
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
{hasNotes ? "Notes ready" : "No notes"}
|
|
125
|
+
</span>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="text-sm font-semibold text-slate-900">
|
|
128
|
+
{slide.meta.title ?? `Slide ${index + 1}`}
|
|
129
|
+
</div>
|
|
130
|
+
<p className="mt-2 text-sm leading-6 text-slate-500">
|
|
131
|
+
{summarizeNotes(slide.meta.notes)}
|
|
132
|
+
</p>
|
|
133
|
+
</button>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</ChromePanel>
|
|
139
|
+
<ChromePanel
|
|
140
|
+
tone="solid"
|
|
141
|
+
radius="section"
|
|
142
|
+
padding="none"
|
|
143
|
+
className="flex flex-col overflow-hidden"
|
|
144
|
+
>
|
|
145
|
+
<div className="border-b border-slate-200/80 px-6 py-5">
|
|
146
|
+
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
147
|
+
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-700">
|
|
148
|
+
{selectedIndex + 1}
|
|
149
|
+
</span>
|
|
150
|
+
{selectedSlide?.meta.layout && (
|
|
151
|
+
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-xs text-slate-500">
|
|
152
|
+
{selectedSlide.meta.layout}
|
|
153
|
+
</span>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
157
|
+
<div>
|
|
158
|
+
<h3 className="text-lg font-semibold text-slate-900">
|
|
159
|
+
{selectedSlide?.meta.title ?? `Slide ${selectedIndex + 1}`}
|
|
160
|
+
</h3>
|
|
161
|
+
<p className="mt-1 text-sm text-slate-500">
|
|
162
|
+
{selectedSlide?.meta.notes?.trim()
|
|
163
|
+
? "Full speaker notes for the selected slide."
|
|
164
|
+
: "This slide still needs presenter notes."}
|
|
165
|
+
</p>
|
|
166
|
+
</div>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={() => onSelect(selectedIndex)}
|
|
170
|
+
className="inline-flex items-center justify-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
|
|
171
|
+
>
|
|
172
|
+
Jump To Slide
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<div className="min-h-0 flex-1 overflow-auto px-6 py-5">
|
|
177
|
+
{selectedNotes.length > 0 ? (
|
|
178
|
+
<div className="space-y-4 text-[15px] leading-7 text-slate-600">
|
|
179
|
+
{selectedNotes.map((paragraph, index) => (
|
|
180
|
+
<p key={`${index}-${paragraph.slice(0, 24)}`} className="whitespace-pre-wrap">
|
|
181
|
+
{paragraph}
|
|
182
|
+
</p>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
) : (
|
|
186
|
+
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-6">
|
|
187
|
+
<p className="font-medium text-slate-900">No notes on this slide yet.</p>
|
|
188
|
+
<p className="mt-2 text-sm leading-6 text-slate-500">
|
|
189
|
+
Add slide frontmatter with <code>notes: |</code> to capture your framing,
|
|
190
|
+
punchlines, and the line you do not want to improvise live.
|
|
191
|
+
</p>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</ChromePanel>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { formatViewportAspectRatio } from "@slidev-react/core/slides/viewport";
|
|
4
|
+
import type { CompiledSlide, SlidesConfig } from "../presenter/types";
|
|
5
|
+
import { ChromeIconButton } from "../../../ui/primitives/ChromeIconButton";
|
|
6
|
+
import { ChromePanel } from "../../../ui/primitives/ChromePanel";
|
|
7
|
+
import { ChromeTag } from "../../../ui/primitives/ChromeTag";
|
|
8
|
+
import { SlidePreviewSurface } from "../stage/SlidePreviewSurface";
|
|
9
|
+
|
|
10
|
+
function OverviewSlidePreview({
|
|
11
|
+
index,
|
|
12
|
+
active,
|
|
13
|
+
slide,
|
|
14
|
+
slidesConfig,
|
|
15
|
+
}: {
|
|
16
|
+
index: number;
|
|
17
|
+
active: boolean;
|
|
18
|
+
slide: CompiledSlide;
|
|
19
|
+
slidesConfig: Pick<SlidesConfig, "slidesViewport" | "slidesLayout" | "slidesBackground">;
|
|
20
|
+
}) {
|
|
21
|
+
const { slidesViewport, slidesLayout, slidesBackground } = slidesConfig;
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
style={{ aspectRatio: formatViewportAspectRatio(slidesViewport) }}
|
|
25
|
+
className={`relative mb-0 w-full overflow-hidden rounded-t-md rounded-b-none bg-slate-100/72 ${active ? "ring-1 ring-emerald-200/80" : ""}`}
|
|
26
|
+
>
|
|
27
|
+
<span className="absolute top-2 left-2 z-10">
|
|
28
|
+
<ChromeTag tone={active ? "active" : "default"} size="xs" weight="semibold">
|
|
29
|
+
{index + 1}
|
|
30
|
+
</ChromeTag>
|
|
31
|
+
</span>
|
|
32
|
+
{slide.meta.layout && (
|
|
33
|
+
<span className="absolute top-2 right-2 z-10">
|
|
34
|
+
<ChromeTag size="xs">{slide.meta.layout}</ChromeTag>
|
|
35
|
+
</span>
|
|
36
|
+
)}
|
|
37
|
+
<SlidePreviewSurface
|
|
38
|
+
Slide={slide.component}
|
|
39
|
+
meta={slide.meta}
|
|
40
|
+
slidesConfig={slidesConfig}
|
|
41
|
+
viewportClassName="size-full"
|
|
42
|
+
stageClassName="pointer-events-none select-none"
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function QuickOverview({
|
|
49
|
+
open,
|
|
50
|
+
slides,
|
|
51
|
+
currentIndex,
|
|
52
|
+
slidesConfig,
|
|
53
|
+
onClose,
|
|
54
|
+
onSelect,
|
|
55
|
+
}: {
|
|
56
|
+
open: boolean;
|
|
57
|
+
slides: CompiledSlide[];
|
|
58
|
+
currentIndex: number;
|
|
59
|
+
slidesConfig: Pick<SlidesConfig, "slidesViewport" | "slidesLayout" | "slidesBackground">;
|
|
60
|
+
onClose: () => void;
|
|
61
|
+
onSelect: (index: number) => void;
|
|
62
|
+
}) {
|
|
63
|
+
function handleSelectKeyDown(event: KeyboardEvent<HTMLElement>, index: number) {
|
|
64
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
onSelect(index);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!open) return null;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="absolute inset-0 z-50 bg-slate-100/84 backdrop-blur-md">
|
|
73
|
+
<div className="mx-auto flex h-full w-full max-w-[2200px] flex-col px-6 py-6">
|
|
74
|
+
<header className="mb-5 flex items-center justify-between">
|
|
75
|
+
<div className="text-slate-900">
|
|
76
|
+
<h2 className="text-lg font-semibold">Quick Overview</h2>
|
|
77
|
+
<p className="text-sm text-slate-600">
|
|
78
|
+
Click a slide to jump. Press `O` or `Esc` to close.
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
<ChromeIconButton
|
|
82
|
+
onClick={onClose}
|
|
83
|
+
aria-label="Close quick overview"
|
|
84
|
+
className="rounded-full "
|
|
85
|
+
>
|
|
86
|
+
<X size={18} />
|
|
87
|
+
</ChromeIconButton>
|
|
88
|
+
</header>
|
|
89
|
+
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
|
90
|
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(360px,1fr))] gap-5">
|
|
91
|
+
{slides.map((slide, index) => {
|
|
92
|
+
const active = index === currentIndex;
|
|
93
|
+
return (
|
|
94
|
+
<ChromePanel
|
|
95
|
+
key={slide.id}
|
|
96
|
+
as="article"
|
|
97
|
+
role="button"
|
|
98
|
+
tabIndex={0}
|
|
99
|
+
onClick={() => onSelect(index)}
|
|
100
|
+
onKeyDown={(event: KeyboardEvent<HTMLElement>) =>
|
|
101
|
+
handleSelectKeyDown(event, index)
|
|
102
|
+
}
|
|
103
|
+
className={`group cursor-pointer overflow-hidden p-0 text-left transition focus:outline-none focus:ring-2 focus:ring-emerald-300/70 ${active ? "bg-white ring-1 ring-emerald-300/70" : "bg-white/90 ring-1 ring-transparent hover:bg-white/92 hover:ring-slate-300/70"}`}
|
|
104
|
+
aria-label={`Go to slide ${index + 1}`}
|
|
105
|
+
tone="solid"
|
|
106
|
+
radius="section"
|
|
107
|
+
padding="none"
|
|
108
|
+
>
|
|
109
|
+
<OverviewSlidePreview
|
|
110
|
+
index={index}
|
|
111
|
+
active={active}
|
|
112
|
+
slide={slide}
|
|
113
|
+
slidesConfig={slidesConfig}
|
|
114
|
+
/>
|
|
115
|
+
<div className="truncate px-2.5 py-2 text-sm font-medium text-slate-900">
|
|
116
|
+
{slide.meta.title ?? `Slide ${index + 1}`}
|
|
117
|
+
</div>
|
|
118
|
+
</ChromePanel>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export type PresentationPathRole = "presenter";
|
|
2
|
+
|
|
3
|
+
const ROLE_SET: ReadonlySet<PresentationPathRole> = new Set(["presenter"]);
|
|
4
|
+
|
|
5
|
+
export interface ParsedPresentationPath {
|
|
6
|
+
role: PresentationPathRole;
|
|
7
|
+
slideNumber: number | null;
|
|
8
|
+
basePath: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ParsedStandalonePath {
|
|
12
|
+
slideNumber: number;
|
|
13
|
+
basePath: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizePath(pathname: string) {
|
|
17
|
+
if (!pathname) return "/";
|
|
18
|
+
|
|
19
|
+
if (pathname.length > 1 && pathname.endsWith("/")) return pathname.slice(0, -1);
|
|
20
|
+
|
|
21
|
+
return pathname;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeBasePath(basePath: string) {
|
|
25
|
+
const normalized = normalizePath(basePath);
|
|
26
|
+
if (normalized === "/") return "";
|
|
27
|
+
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toSegments(pathname: string) {
|
|
32
|
+
return normalizePath(pathname).split("/").filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toPath(segments: string[]) {
|
|
36
|
+
if (segments.length === 0) return "";
|
|
37
|
+
|
|
38
|
+
return `/${segments.join("/")}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parsePresentationPath(pathname: string): ParsedPresentationPath | null {
|
|
42
|
+
const segments = toSegments(pathname);
|
|
43
|
+
if (segments.length === 0) return null;
|
|
44
|
+
|
|
45
|
+
const last = segments[segments.length - 1];
|
|
46
|
+
if (ROLE_SET.has(last as PresentationPathRole)) {
|
|
47
|
+
return {
|
|
48
|
+
role: last as PresentationPathRole,
|
|
49
|
+
slideNumber: null,
|
|
50
|
+
basePath: toPath(segments.slice(0, -1)),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const maybeSlideNumber = Number.parseInt(last, 10);
|
|
55
|
+
if (!Number.isNaN(maybeSlideNumber) && maybeSlideNumber > 0 && segments.length >= 2) {
|
|
56
|
+
const maybeRole = segments[segments.length - 2];
|
|
57
|
+
if (ROLE_SET.has(maybeRole as PresentationPathRole)) {
|
|
58
|
+
return {
|
|
59
|
+
role: maybeRole as PresentationPathRole,
|
|
60
|
+
slideNumber: maybeSlideNumber,
|
|
61
|
+
basePath: toPath(segments.slice(0, -2)),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseStandalonePath(pathname: string): ParsedStandalonePath | null {
|
|
70
|
+
const parsedPresentation = parsePresentationPath(pathname);
|
|
71
|
+
if (parsedPresentation) return null;
|
|
72
|
+
|
|
73
|
+
const segments = toSegments(pathname);
|
|
74
|
+
if (segments.length === 0) return null;
|
|
75
|
+
|
|
76
|
+
const maybeSlideNumber = Number.parseInt(segments[segments.length - 1], 10);
|
|
77
|
+
if (Number.isNaN(maybeSlideNumber) || maybeSlideNumber < 1) return null;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
slideNumber: maybeSlideNumber,
|
|
81
|
+
basePath: toPath(segments.slice(0, -1)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolvePresentationBasePath(pathname: string) {
|
|
86
|
+
const parsed = parsePresentationPath(pathname);
|
|
87
|
+
if (parsed) return normalizeBasePath(parsed.basePath);
|
|
88
|
+
|
|
89
|
+
const parsedStandalone = parseStandalonePath(pathname);
|
|
90
|
+
if (parsedStandalone) return normalizeBasePath(parsedStandalone.basePath);
|
|
91
|
+
|
|
92
|
+
const normalized = normalizePath(pathname);
|
|
93
|
+
if (normalized === "/") return "";
|
|
94
|
+
|
|
95
|
+
if (normalized === "/index.html") return "";
|
|
96
|
+
|
|
97
|
+
if (normalized.endsWith("/index.html"))
|
|
98
|
+
return normalizeBasePath(normalized.slice(0, -"/index.html".length));
|
|
99
|
+
|
|
100
|
+
return normalizeBasePath(normalized);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildRolePathFromBase(
|
|
104
|
+
basePath: string,
|
|
105
|
+
role: PresentationPathRole,
|
|
106
|
+
slideNumber: number,
|
|
107
|
+
) {
|
|
108
|
+
const normalizedBase = normalizeBasePath(basePath);
|
|
109
|
+
const safeSlideNumber =
|
|
110
|
+
Number.isFinite(slideNumber) && slideNumber > 0 ? Math.floor(slideNumber) : 1;
|
|
111
|
+
|
|
112
|
+
if (!normalizedBase) return `/${role}/${safeSlideNumber}`;
|
|
113
|
+
|
|
114
|
+
return `${normalizedBase}/${role}/${safeSlideNumber}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function buildRolePathFromPathname(
|
|
118
|
+
pathname: string,
|
|
119
|
+
role: PresentationPathRole,
|
|
120
|
+
slideNumber: number,
|
|
121
|
+
) {
|
|
122
|
+
return buildRolePathFromBase(resolvePresentationBasePath(pathname), role, slideNumber);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildStandalonePathFromBase(basePath: string, slideNumber: number) {
|
|
126
|
+
const normalizedBase = normalizeBasePath(basePath);
|
|
127
|
+
const safeSlideNumber =
|
|
128
|
+
Number.isFinite(slideNumber) && slideNumber > 0 ? Math.floor(slideNumber) : 1;
|
|
129
|
+
|
|
130
|
+
if (!normalizedBase) return `/${safeSlideNumber}`;
|
|
131
|
+
|
|
132
|
+
return `${normalizedBase}/${safeSlideNumber}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function buildStandalonePathFromPathname(pathname: string, slideNumber: number) {
|
|
136
|
+
return buildStandalonePathFromBase(resolvePresentationBasePath(pathname), slideNumber);
|
|
137
|
+
}
|