@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,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createRecordingDownloadName,
|
|
4
|
+
resolvePresentationFileNameBase,
|
|
5
|
+
resolveRecordingFileNameBase,
|
|
6
|
+
} from "./recordingFilename";
|
|
7
|
+
|
|
8
|
+
describe("recording filename", () => {
|
|
9
|
+
it("prefers exportFilename when present", () => {
|
|
10
|
+
expect(
|
|
11
|
+
resolveRecordingFileNameBase({
|
|
12
|
+
exportFilename: "client-demo",
|
|
13
|
+
slidesTitle: "Ignored Title",
|
|
14
|
+
}),
|
|
15
|
+
).toBe("client-demo");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("removes known video extensions from exportFilename", () => {
|
|
19
|
+
expect(
|
|
20
|
+
resolveRecordingFileNameBase({
|
|
21
|
+
exportFilename: "client-demo.webm",
|
|
22
|
+
}),
|
|
23
|
+
).toBe("client-demo");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("falls back to a slugified title", () => {
|
|
27
|
+
expect(
|
|
28
|
+
resolveRecordingFileNameBase({
|
|
29
|
+
slidesTitle: "Q4 Review Deck",
|
|
30
|
+
}),
|
|
31
|
+
).toBe("q4-review-deck");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("uses a configurable fallback for non-recording exports", () => {
|
|
35
|
+
expect(resolvePresentationFileNameBase({})).toBe("slide-react-slides");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("creates a deterministic download name", () => {
|
|
39
|
+
expect(
|
|
40
|
+
createRecordingDownloadName({
|
|
41
|
+
exportFilename: "client-demo",
|
|
42
|
+
recordedAt: new Date("2026-03-06T12:34:56.000Z"),
|
|
43
|
+
}),
|
|
44
|
+
).toBe("client-demo-2026-03-06T12-34-56.webm");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
function slugifySegment(value: string) {
|
|
2
|
+
return value
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.trim()
|
|
5
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
6
|
+
.replace(/^-+|-+$/g, "");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function trimKnownExtension(value: string) {
|
|
10
|
+
return value.replace(/\.(webm|mp4|mov)$/i, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolvePresentationFileNameBase({
|
|
14
|
+
exportFilename,
|
|
15
|
+
slidesTitle,
|
|
16
|
+
fallback = "slide-react-slides",
|
|
17
|
+
}: {
|
|
18
|
+
exportFilename?: string;
|
|
19
|
+
slidesTitle?: string;
|
|
20
|
+
fallback?: string;
|
|
21
|
+
}) {
|
|
22
|
+
const preferred = trimKnownExtension(exportFilename?.trim() ?? "");
|
|
23
|
+
if (preferred) return preferred;
|
|
24
|
+
|
|
25
|
+
const titleSlug = slugifySegment(slidesTitle ?? "");
|
|
26
|
+
if (titleSlug) return titleSlug;
|
|
27
|
+
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveRecordingFileNameBase({
|
|
32
|
+
exportFilename,
|
|
33
|
+
slidesTitle,
|
|
34
|
+
}: {
|
|
35
|
+
exportFilename?: string;
|
|
36
|
+
slidesTitle?: string;
|
|
37
|
+
}) {
|
|
38
|
+
return resolvePresentationFileNameBase({
|
|
39
|
+
exportFilename,
|
|
40
|
+
slidesTitle,
|
|
41
|
+
fallback: "slide-react-recording",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createRecordingDownloadName(options: {
|
|
46
|
+
exportFilename?: string;
|
|
47
|
+
slidesTitle?: string;
|
|
48
|
+
recordedAt?: Date;
|
|
49
|
+
}) {
|
|
50
|
+
const stamp = (options.recordedAt ?? new Date())
|
|
51
|
+
.toISOString()
|
|
52
|
+
.replaceAll(":", "-")
|
|
53
|
+
.replace(/\..+$/, "");
|
|
54
|
+
|
|
55
|
+
return `${resolveRecordingFileNameBase(options)}-${stamp}.webm`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { normalizeCueStep } from "@slidev-react/core/presentation/flow/step";
|
|
10
|
+
import { useRevealStep } from "./useRevealStep";
|
|
11
|
+
|
|
12
|
+
export type RevealPreset = "fade" | "fade-up" | "scale-in";
|
|
13
|
+
|
|
14
|
+
function joinClassNames(...names: Array<string | undefined>) {
|
|
15
|
+
return names.filter(Boolean).join(" ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toRevealClassName(preset: RevealPreset) {
|
|
19
|
+
return `slide-reveal slide-reveal--${preset}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cloneWithRevealClass(
|
|
23
|
+
child: ReactElement<Record<string, unknown>>,
|
|
24
|
+
className: string,
|
|
25
|
+
hidden?: boolean,
|
|
26
|
+
) {
|
|
27
|
+
const childClassName =
|
|
28
|
+
typeof child.props.className === "string" ? child.props.className : undefined;
|
|
29
|
+
const childStyle = child.props.style as CSSProperties | undefined;
|
|
30
|
+
|
|
31
|
+
return cloneElement(child, {
|
|
32
|
+
"aria-hidden": hidden,
|
|
33
|
+
className: joinClassNames(childClassName, className),
|
|
34
|
+
style: childStyle,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Reveal({
|
|
39
|
+
step,
|
|
40
|
+
preset = "fade-up",
|
|
41
|
+
asChild = false,
|
|
42
|
+
reserveSpace = false,
|
|
43
|
+
children,
|
|
44
|
+
}: {
|
|
45
|
+
step: number;
|
|
46
|
+
preset?: RevealPreset;
|
|
47
|
+
asChild?: boolean;
|
|
48
|
+
reserveSpace?: boolean;
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
}) {
|
|
51
|
+
const { reveal, isVisible } = useRevealStep(step);
|
|
52
|
+
|
|
53
|
+
if (!reveal) return <>{children}</>;
|
|
54
|
+
|
|
55
|
+
const className = isVisible ? toRevealClassName(preset) : "slide-reveal slide-reveal--reserve";
|
|
56
|
+
|
|
57
|
+
if (!isVisible && !reserveSpace) return null;
|
|
58
|
+
|
|
59
|
+
if (asChild && Children.count(children) === 1 && isValidElement(children)) {
|
|
60
|
+
return cloneWithRevealClass(
|
|
61
|
+
children as ReactElement<Record<string, unknown>>,
|
|
62
|
+
className,
|
|
63
|
+
!isVisible,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div aria-hidden={!isVisible} className={className}>
|
|
69
|
+
{children}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function RevealGroup({
|
|
75
|
+
start = 1,
|
|
76
|
+
increment = 1,
|
|
77
|
+
preset = "fade-up",
|
|
78
|
+
reserveSpace = false,
|
|
79
|
+
children,
|
|
80
|
+
}: {
|
|
81
|
+
start?: number;
|
|
82
|
+
increment?: number;
|
|
83
|
+
preset?: RevealPreset;
|
|
84
|
+
reserveSpace?: boolean;
|
|
85
|
+
children: ReactNode;
|
|
86
|
+
}) {
|
|
87
|
+
let index = 0;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
{Children.map(children, (child) => {
|
|
92
|
+
if (child === null || child === undefined || typeof child === "boolean") return child;
|
|
93
|
+
|
|
94
|
+
const step = normalizeCueStep(start + index * increment) ?? 1;
|
|
95
|
+
index += 1;
|
|
96
|
+
|
|
97
|
+
if (isValidElement(child)) {
|
|
98
|
+
return (
|
|
99
|
+
<Reveal
|
|
100
|
+
key={child.key ?? step}
|
|
101
|
+
step={step}
|
|
102
|
+
preset={preset}
|
|
103
|
+
reserveSpace={reserveSpace}
|
|
104
|
+
asChild
|
|
105
|
+
>
|
|
106
|
+
{child}
|
|
107
|
+
</Reveal>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Reveal key={step} step={step} preset={preset} reserveSpace={reserveSpace}>
|
|
113
|
+
{child}
|
|
114
|
+
</Reveal>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface RevealContextValue {
|
|
4
|
+
slideId: string;
|
|
5
|
+
clicks: number;
|
|
6
|
+
clicksTotal: number;
|
|
7
|
+
setClicks: (next: number) => void;
|
|
8
|
+
registerStep: (step: number) => () => void;
|
|
9
|
+
advance: () => void;
|
|
10
|
+
retreat: () => void;
|
|
11
|
+
canAdvance: boolean;
|
|
12
|
+
canRetreat: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RevealContext = createContext<RevealContextValue | null>(null);
|
|
16
|
+
|
|
17
|
+
export function RevealProvider({
|
|
18
|
+
value,
|
|
19
|
+
children,
|
|
20
|
+
}: {
|
|
21
|
+
value: RevealContextValue;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}) {
|
|
24
|
+
return <RevealContext.Provider value={value}>{children}</RevealContext.Provider>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useReveal() {
|
|
28
|
+
return useContext(RevealContext);
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useLayoutEffect } from "react";
|
|
2
|
+
import { useReveal } from "./RevealContext";
|
|
3
|
+
import { normalizeCueStep } from "@slidev-react/core/presentation/flow/step";
|
|
4
|
+
|
|
5
|
+
export function useRevealStep(step: number | undefined) {
|
|
6
|
+
const reveal = useReveal();
|
|
7
|
+
const registerStep = reveal?.registerStep;
|
|
8
|
+
const slideId = reveal?.slideId;
|
|
9
|
+
const normalizedStep = normalizeCueStep(step);
|
|
10
|
+
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
if (!registerStep || normalizedStep === undefined) return;
|
|
13
|
+
|
|
14
|
+
return registerStep(normalizedStep);
|
|
15
|
+
}, [normalizedStep, registerStep, slideId]);
|
|
16
|
+
|
|
17
|
+
const isVisible = normalizedStep === undefined || !reveal || reveal.clicks >= normalizedStep;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
reveal,
|
|
21
|
+
normalizedStep,
|
|
22
|
+
isVisible,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useRevealProgress(maxStep: number) {
|
|
27
|
+
const { reveal, normalizedStep } = useRevealStep(maxStep);
|
|
28
|
+
const normalizedMaxStep = normalizedStep ?? 1;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
reveal,
|
|
32
|
+
step: Math.min(reveal?.clicks ?? 0, normalizedMaxStep),
|
|
33
|
+
maxStep: normalizedMaxStep,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildPresentationEntryUrl,
|
|
4
|
+
resolvePresentationSession,
|
|
5
|
+
updateSyncModeInUrl,
|
|
6
|
+
} from "./session";
|
|
7
|
+
|
|
8
|
+
const originalWindow = globalThis.window;
|
|
9
|
+
|
|
10
|
+
function installWindow(url: string) {
|
|
11
|
+
const parsed = new URL(url);
|
|
12
|
+
const location = {
|
|
13
|
+
origin: parsed.origin,
|
|
14
|
+
pathname: parsed.pathname,
|
|
15
|
+
search: parsed.search,
|
|
16
|
+
hash: parsed.hash,
|
|
17
|
+
hostname: parsed.hostname,
|
|
18
|
+
protocol: parsed.protocol,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const history = {
|
|
22
|
+
replaceState: vi.fn((_state: unknown, _title: string, nextUrl: string) => {
|
|
23
|
+
const resolved = new URL(nextUrl, location.origin);
|
|
24
|
+
location.origin = resolved.origin;
|
|
25
|
+
location.pathname = resolved.pathname;
|
|
26
|
+
location.search = resolved.search;
|
|
27
|
+
location.hash = resolved.hash;
|
|
28
|
+
location.hostname = resolved.hostname;
|
|
29
|
+
location.protocol = resolved.protocol;
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
Object.defineProperty(globalThis, "window", {
|
|
34
|
+
configurable: true,
|
|
35
|
+
value: {
|
|
36
|
+
location,
|
|
37
|
+
history,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
history,
|
|
43
|
+
location,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
Object.defineProperty(globalThis, "window", {
|
|
49
|
+
configurable: true,
|
|
50
|
+
value: originalWindow,
|
|
51
|
+
});
|
|
52
|
+
vi.restoreAllMocks();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("resolvePresentationSession", () => {
|
|
56
|
+
it("keeps non-slide routes in standalone mode", () => {
|
|
57
|
+
const { location, history } = installWindow("http://localhost:3000/");
|
|
58
|
+
|
|
59
|
+
const session = resolvePresentationSession("deckhash");
|
|
60
|
+
|
|
61
|
+
expect(session.enabled).toBe(false);
|
|
62
|
+
expect(session.role).toBe("standalone");
|
|
63
|
+
expect(session.sessionId).toBeNull();
|
|
64
|
+
expect(location.pathname).toBe("/");
|
|
65
|
+
expect(location.search).toBe("");
|
|
66
|
+
expect(history.replaceState).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("treats numbered routes as viewer mode without query params", () => {
|
|
70
|
+
const { location } = installWindow("http://localhost:3000/4?foo=bar");
|
|
71
|
+
|
|
72
|
+
const session = resolvePresentationSession("deckhash");
|
|
73
|
+
|
|
74
|
+
expect(session.enabled).toBe(true);
|
|
75
|
+
expect(session.role).toBe("viewer");
|
|
76
|
+
expect(session.syncMode).toBe("receive");
|
|
77
|
+
expect(session.sessionId).toBe("deckhash-default");
|
|
78
|
+
expect(location.pathname).toBe("/4");
|
|
79
|
+
expect(location.search).toBe("");
|
|
80
|
+
expect(session.viewerUrl).toBe("http://localhost:3000/4");
|
|
81
|
+
expect(session.presenterUrl).toBe("http://localhost:3000/presenter/4");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("normalizes presenter routes without adding query params", () => {
|
|
85
|
+
const { location } = installWindow("http://localhost:3000/presenter/2?sync=both");
|
|
86
|
+
|
|
87
|
+
const session = resolvePresentationSession("deckhash");
|
|
88
|
+
|
|
89
|
+
expect(session.enabled).toBe(true);
|
|
90
|
+
expect(session.role).toBe("presenter");
|
|
91
|
+
expect(session.syncMode).toBe("send");
|
|
92
|
+
expect(location.pathname).toBe("/presenter/2");
|
|
93
|
+
expect(location.search).toBe("");
|
|
94
|
+
expect(session.viewerUrl).toBe("http://localhost:3000/2");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("presentation entry urls", () => {
|
|
99
|
+
it("uses path-only presenter urls", () => {
|
|
100
|
+
installWindow("http://localhost:3000/5");
|
|
101
|
+
|
|
102
|
+
expect(buildPresentationEntryUrl("presenter", "deckhash")).toBe(
|
|
103
|
+
"http://localhost:3000/presenter/5",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("ignores legacy hash routes when building presenter urls", () => {
|
|
108
|
+
installWindow("http://localhost:3000/#/5");
|
|
109
|
+
|
|
110
|
+
expect(buildPresentationEntryUrl("presenter", "deckhash")).toBe(
|
|
111
|
+
"http://localhost:3000/presenter/1",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("keeps sync mode changes out of the url", () => {
|
|
116
|
+
const { location } = installWindow("http://localhost:3000/presenter/3");
|
|
117
|
+
|
|
118
|
+
updateSyncModeInUrl("both");
|
|
119
|
+
expect(location.pathname).toBe("/presenter/3");
|
|
120
|
+
expect(location.search).toBe("");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { PresentationRole, PresentationSyncMode } from "./types";
|
|
2
|
+
import { buildRolePathFromPathname, buildStandalonePathFromPathname } from "./path";
|
|
3
|
+
import { resolveSessionLocationState } from "./location";
|
|
4
|
+
|
|
5
|
+
export interface PresentationSession {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
role: PresentationRole;
|
|
8
|
+
syncMode: PresentationSyncMode;
|
|
9
|
+
sessionId: string | null;
|
|
10
|
+
senderId: string;
|
|
11
|
+
wsUrl: string | null;
|
|
12
|
+
presenterUrl: string | null;
|
|
13
|
+
viewerUrl: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function defaultSyncModeForRole(role: PresentationRole): PresentationSyncMode {
|
|
17
|
+
switch (role) {
|
|
18
|
+
case "presenter":
|
|
19
|
+
return "send";
|
|
20
|
+
case "viewer":
|
|
21
|
+
return "receive";
|
|
22
|
+
default:
|
|
23
|
+
return "off";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createSenderId() {
|
|
28
|
+
return crypto.randomUUID();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createDefaultSessionId(seed: string) {
|
|
32
|
+
const prefix =
|
|
33
|
+
seed
|
|
34
|
+
.replace(/[^a-z0-9]/gi, "")
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.slice(0, 12) || "slides";
|
|
37
|
+
return `${prefix}-default`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseWsUrl(value: string | null): string | null {
|
|
41
|
+
if (!value) return null;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const parsed = new URL(value);
|
|
45
|
+
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") return null;
|
|
46
|
+
|
|
47
|
+
return parsed.toString();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createDefaultWsUrl(): string | null {
|
|
54
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
55
|
+
return parseWsUrl(`${protocol}//${window.location.hostname}:4860/ws`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildUrl(role: "presenter" | "viewer", slideNumber: number) {
|
|
59
|
+
const pathname =
|
|
60
|
+
role === "presenter"
|
|
61
|
+
? buildRolePathFromPathname(window.location.pathname, role, slideNumber)
|
|
62
|
+
: buildStandalonePathFromPathname(window.location.pathname, slideNumber);
|
|
63
|
+
return `${window.location.origin}${pathname}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function updateSyncModeInUrl(mode: PresentationSyncMode) {
|
|
67
|
+
void mode;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createDisabledPresentationSession(senderId: string): PresentationSession {
|
|
71
|
+
return {
|
|
72
|
+
enabled: false,
|
|
73
|
+
role: "standalone",
|
|
74
|
+
syncMode: "off",
|
|
75
|
+
sessionId: null,
|
|
76
|
+
senderId,
|
|
77
|
+
wsUrl: null,
|
|
78
|
+
presenterUrl: null,
|
|
79
|
+
viewerUrl: null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createPrintExportSession(): PresentationSession {
|
|
84
|
+
return createDisabledPresentationSession("print-export");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildPresentationEntryUrl(role: "presenter", deckKey: string) {
|
|
88
|
+
void deckKey;
|
|
89
|
+
|
|
90
|
+
const currentSlideNumber = resolveSessionLocationState(window.location.pathname).currentSlideNumber;
|
|
91
|
+
return buildUrl(role, currentSlideNumber);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolvePresentationSession(deckKey: string): PresentationSession {
|
|
95
|
+
const senderId = createSenderId();
|
|
96
|
+
const locationState = resolveSessionLocationState(window.location.pathname);
|
|
97
|
+
if (!locationState.enabled) return createDisabledPresentationSession(senderId);
|
|
98
|
+
|
|
99
|
+
const initialRole: PresentationRole = locationState.role;
|
|
100
|
+
const parsedWsUrl = createDefaultWsUrl();
|
|
101
|
+
const sessionId = createDefaultSessionId(deckKey);
|
|
102
|
+
const syncMode = defaultSyncModeForRole(initialRole);
|
|
103
|
+
const currentSlideNumber = locationState.currentSlideNumber;
|
|
104
|
+
const normalizedPath = locationState.normalizedPath;
|
|
105
|
+
const shouldNormalizeUrl =
|
|
106
|
+
(!!normalizedPath && window.location.pathname !== normalizedPath) ||
|
|
107
|
+
!!window.location.search ||
|
|
108
|
+
!!window.location.hash;
|
|
109
|
+
|
|
110
|
+
if (shouldNormalizeUrl && normalizedPath) {
|
|
111
|
+
window.history.replaceState(null, "", normalizedPath);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
enabled: true,
|
|
116
|
+
role: initialRole,
|
|
117
|
+
syncMode,
|
|
118
|
+
sessionId,
|
|
119
|
+
senderId,
|
|
120
|
+
wsUrl: parsedWsUrl,
|
|
121
|
+
presenterUrl: buildUrl("presenter", currentSlideNumber),
|
|
122
|
+
viewerUrl: buildUrl("viewer", currentSlideNumber),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useMemo, type CSSProperties, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import type { SlideMeta, SlideComponent } from "@slidev-react/core/slides/slide";
|
|
3
|
+
import type { SlidesConfig } from "../presenter/types";
|
|
4
|
+
import { useResolvedLayout } from "../../../theme/useResolvedLayout";
|
|
5
|
+
import { resolveSlideSurface, resolveSlideSurfaceClassName } from "./slideSurface";
|
|
6
|
+
import { useSlideScale } from "./slideViewport";
|
|
7
|
+
|
|
8
|
+
function joinClassNames(...classNames: Array<string | undefined>) {
|
|
9
|
+
return classNames.filter(Boolean).join(" ");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type SlideArticleProps = HTMLAttributes<HTMLElement> & {
|
|
13
|
+
"data-export-surface"?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function SlidePreviewSurface({
|
|
17
|
+
Slide,
|
|
18
|
+
meta,
|
|
19
|
+
slidesConfig,
|
|
20
|
+
content,
|
|
21
|
+
viewportClassName,
|
|
22
|
+
viewportStyle,
|
|
23
|
+
stageClassName,
|
|
24
|
+
shadowClass,
|
|
25
|
+
overflowHidden = false,
|
|
26
|
+
scaleMultiplier = 1,
|
|
27
|
+
alignment = "center",
|
|
28
|
+
articleProps,
|
|
29
|
+
}: {
|
|
30
|
+
Slide: SlideComponent;
|
|
31
|
+
meta: SlideMeta;
|
|
32
|
+
slidesConfig: Pick<SlidesConfig, "slidesViewport" | "slidesLayout" | "slidesBackground">;
|
|
33
|
+
content?: ReactNode;
|
|
34
|
+
viewportClassName?: string;
|
|
35
|
+
viewportStyle?: CSSProperties;
|
|
36
|
+
stageClassName?: string;
|
|
37
|
+
shadowClass?: string;
|
|
38
|
+
overflowHidden?: boolean;
|
|
39
|
+
scaleMultiplier?: number;
|
|
40
|
+
alignment?: "center" | "top-left";
|
|
41
|
+
articleProps?: SlideArticleProps;
|
|
42
|
+
}) {
|
|
43
|
+
const { slidesViewport, slidesLayout, slidesBackground } = slidesConfig;
|
|
44
|
+
const Layout = useResolvedLayout(meta.layout ?? slidesLayout);
|
|
45
|
+
const { viewportRef, scale, offset } = useSlideScale(scaleMultiplier, alignment, slidesViewport);
|
|
46
|
+
const viewportStageStyle = useMemo(
|
|
47
|
+
() => ({
|
|
48
|
+
width: `${slidesViewport.width}px`,
|
|
49
|
+
height: `${slidesViewport.height}px`,
|
|
50
|
+
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
|
51
|
+
transformOrigin: "top left",
|
|
52
|
+
}),
|
|
53
|
+
[slidesViewport.height, slidesViewport.width, offset.x, offset.y, scale],
|
|
54
|
+
);
|
|
55
|
+
const {
|
|
56
|
+
className: articleClassName,
|
|
57
|
+
style: articleStyle,
|
|
58
|
+
...restArticleProps
|
|
59
|
+
} = articleProps ?? {};
|
|
60
|
+
const surface = resolveSlideSurface({
|
|
61
|
+
meta,
|
|
62
|
+
slidesBackground,
|
|
63
|
+
className: resolveSlideSurfaceClassName({
|
|
64
|
+
layout: meta.layout ?? slidesLayout,
|
|
65
|
+
shadowClass,
|
|
66
|
+
overflowHidden,
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div ref={viewportRef} className={viewportClassName} style={viewportStyle}>
|
|
72
|
+
<div className={stageClassName} style={viewportStageStyle}>
|
|
73
|
+
<article
|
|
74
|
+
{...restArticleProps}
|
|
75
|
+
className={joinClassNames(surface.className, articleClassName)}
|
|
76
|
+
style={{
|
|
77
|
+
...surface.style,
|
|
78
|
+
...articleStyle,
|
|
79
|
+
width: `${slidesViewport.width}px`,
|
|
80
|
+
height: `${slidesViewport.height}px`,
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{content ?? (
|
|
84
|
+
<Layout>
|
|
85
|
+
<Slide />
|
|
86
|
+
</Layout>
|
|
87
|
+
)}
|
|
88
|
+
</article>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|