@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,159 @@
|
|
|
1
|
+
import { useMemo, type PointerEvent as ReactPointerEvent } from "react";
|
|
2
|
+
import type { SlidesViewport } from "@slidev-react/core/slides/viewport";
|
|
3
|
+
import type { SlideComponent, SlideMeta } from "@slidev-react/core/slides/slide";
|
|
4
|
+
import { DrawOverlay } from "../draw/DrawOverlay";
|
|
5
|
+
import { useDraw } from "../draw/DrawProvider";
|
|
6
|
+
import type { PresentationCursorState } from "../types";
|
|
7
|
+
import type { SlidesConfig } from "../presenter/types";
|
|
8
|
+
import { resolveSlideSurface, resolveSlideSurfaceClassName } from "./slideSurface";
|
|
9
|
+
import { useSlideScale } from "./slideViewport";
|
|
10
|
+
import { useResolvedLayout } from "../../../theme/useResolvedLayout";
|
|
11
|
+
|
|
12
|
+
function clamp(value: number, min: number, max: number) {
|
|
13
|
+
return Math.min(Math.max(value, min), max);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function shouldIgnoreStageAdvance(target: EventTarget | null) {
|
|
17
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
18
|
+
|
|
19
|
+
return !!target.closest('a, button, input, textarea, select, [contenteditable="true"]');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toSlidePoint(
|
|
23
|
+
event: ReactPointerEvent<HTMLElement>,
|
|
24
|
+
offset: { x: number; y: number },
|
|
25
|
+
scale: number,
|
|
26
|
+
viewport: SlidesViewport,
|
|
27
|
+
): PresentationCursorState {
|
|
28
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
29
|
+
return {
|
|
30
|
+
x: clamp((event.clientX - rect.left - offset.x) / scale, 0, viewport.width),
|
|
31
|
+
y: clamp((event.clientY - rect.top - offset.y) / scale, 0, viewport.height),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toViewportPoint(
|
|
36
|
+
point: PresentationCursorState,
|
|
37
|
+
offset: { x: number; y: number },
|
|
38
|
+
scale: number,
|
|
39
|
+
) {
|
|
40
|
+
return {
|
|
41
|
+
x: offset.x + point.x * scale,
|
|
42
|
+
y: offset.y + point.y * scale,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toTransitionClassName(transition: string | undefined) {
|
|
47
|
+
switch (transition) {
|
|
48
|
+
case "slide-left":
|
|
49
|
+
return "slide-transition slide-transition--slide-left";
|
|
50
|
+
case "slide-up":
|
|
51
|
+
return "slide-transition slide-transition--slide-up";
|
|
52
|
+
case "zoom":
|
|
53
|
+
return "slide-transition slide-transition--zoom";
|
|
54
|
+
case "fade":
|
|
55
|
+
return "slide-transition slide-transition--fade";
|
|
56
|
+
default:
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveStageContentClassName(transitionClassName: string | undefined) {
|
|
62
|
+
return transitionClassName ? `size-full ${transitionClassName}` : "size-full";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function SlideStage({
|
|
66
|
+
Slide,
|
|
67
|
+
slideId,
|
|
68
|
+
meta,
|
|
69
|
+
slidesConfig,
|
|
70
|
+
remoteCursor,
|
|
71
|
+
onCursorChange,
|
|
72
|
+
onStageAdvance,
|
|
73
|
+
scaleMultiplier = 1,
|
|
74
|
+
}: {
|
|
75
|
+
Slide: SlideComponent;
|
|
76
|
+
slideId: string;
|
|
77
|
+
meta: SlideMeta;
|
|
78
|
+
slidesConfig: SlidesConfig;
|
|
79
|
+
remoteCursor?: PresentationCursorState | null;
|
|
80
|
+
onCursorChange?: (cursor: PresentationCursorState | null) => void;
|
|
81
|
+
onStageAdvance?: () => void;
|
|
82
|
+
scaleMultiplier?: number;
|
|
83
|
+
}) {
|
|
84
|
+
const { slidesViewport, slidesLayout, slidesBackground, slidesTransition } = slidesConfig;
|
|
85
|
+
const Layout = useResolvedLayout(meta.layout ?? slidesLayout);
|
|
86
|
+
const draw = useDraw();
|
|
87
|
+
const { viewportRef, scale, offset } = useSlideScale(scaleMultiplier, "center", slidesViewport);
|
|
88
|
+
const surface = resolveSlideSurface({
|
|
89
|
+
meta,
|
|
90
|
+
slidesBackground,
|
|
91
|
+
className: resolveSlideSurfaceClassName({
|
|
92
|
+
layout: meta.layout ?? slidesLayout,
|
|
93
|
+
shadowClass: "shadow-[0_20px_60px_rgba(21,42,82,0.12)]",
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
const viewportStageStyle = useMemo(
|
|
97
|
+
() => ({
|
|
98
|
+
width: `${slidesViewport.width}px`,
|
|
99
|
+
height: `${slidesViewport.height}px`,
|
|
100
|
+
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
|
101
|
+
transformOrigin: "top left",
|
|
102
|
+
}),
|
|
103
|
+
[slidesViewport.height, slidesViewport.width, offset.x, offset.y, scale],
|
|
104
|
+
);
|
|
105
|
+
const transitionClassName = toTransitionClassName(meta.transition ?? slidesTransition);
|
|
106
|
+
const stageContentClassName = resolveStageContentClassName(transitionClassName);
|
|
107
|
+
const remoteCursorPosition = useMemo(() => {
|
|
108
|
+
if (!remoteCursor) return null;
|
|
109
|
+
|
|
110
|
+
return toViewportPoint(remoteCursor, offset, scale);
|
|
111
|
+
}, [offset, remoteCursor, scale]);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<main
|
|
115
|
+
ref={viewportRef}
|
|
116
|
+
className="relative size-full min-h-0 min-w-0 overflow-hidden p-0"
|
|
117
|
+
onPointerMove={(event) => {
|
|
118
|
+
if (!onCursorChange) return;
|
|
119
|
+
|
|
120
|
+
onCursorChange(toSlidePoint(event, offset, scale, slidesViewport));
|
|
121
|
+
}}
|
|
122
|
+
onPointerLeave={() => {
|
|
123
|
+
onCursorChange?.(null);
|
|
124
|
+
}}
|
|
125
|
+
onClick={(event) => {
|
|
126
|
+
if (!onStageAdvance || draw.enabled) return;
|
|
127
|
+
|
|
128
|
+
if (shouldIgnoreStageAdvance(event.target)) return;
|
|
129
|
+
|
|
130
|
+
onStageAdvance();
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<div style={viewportStageStyle}>
|
|
134
|
+
<article
|
|
135
|
+
key={`${slideId}:${meta.transition ?? slidesTransition ?? "none"}`}
|
|
136
|
+
className={surface.className}
|
|
137
|
+
style={surface.style}
|
|
138
|
+
>
|
|
139
|
+
<div className={stageContentClassName}>
|
|
140
|
+
<Layout>
|
|
141
|
+
<Slide />
|
|
142
|
+
</Layout>
|
|
143
|
+
<DrawOverlay slideId={slideId} scale={scale} viewport={slidesViewport} />
|
|
144
|
+
</div>
|
|
145
|
+
</article>
|
|
146
|
+
</div>
|
|
147
|
+
{remoteCursorPosition && (
|
|
148
|
+
<span
|
|
149
|
+
aria-hidden
|
|
150
|
+
className="pointer-events-none absolute z-30 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-rose-500 bg-rose-300/40 shadow-[0_0_0_3px_rgba(244,63,94,0.15)]"
|
|
151
|
+
style={{
|
|
152
|
+
left: `${remoteCursorPosition.x}px`,
|
|
153
|
+
top: `${remoteCursorPosition.y}px`,
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
157
|
+
</main>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { SlideMeta } from "@slidev-react/core/slides/slide";
|
|
3
|
+
|
|
4
|
+
function joinClassNames(...names: Array<string | undefined>) {
|
|
5
|
+
return names.filter(Boolean).join(" ");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function looksLikeBareImageSource(value: string) {
|
|
9
|
+
return (
|
|
10
|
+
/^(?:https?:\/\/|data:image\/|\/|\.\.?\/)/.test(value) ||
|
|
11
|
+
/\.(?:avif|gif|jpe?g|png|svg|webp)(?:[?#].*)?$/i.test(value)
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveBackgroundStyle(background: string | undefined): CSSProperties {
|
|
16
|
+
const style: CSSProperties = {
|
|
17
|
+
backgroundColor: "#ffffff",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (!background) return style;
|
|
21
|
+
|
|
22
|
+
const trimmed = background.trim();
|
|
23
|
+
if (!trimmed) return style;
|
|
24
|
+
|
|
25
|
+
if (looksLikeBareImageSource(trimmed)) {
|
|
26
|
+
style.backgroundImage = `url(${JSON.stringify(trimmed)})`;
|
|
27
|
+
style.backgroundPosition = "center";
|
|
28
|
+
style.backgroundRepeat = "no-repeat";
|
|
29
|
+
style.backgroundSize = "cover";
|
|
30
|
+
return style;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
style.background = trimmed;
|
|
34
|
+
return style;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveSurfacePaddingClass(layout: SlideMeta["layout"]) {
|
|
38
|
+
return layout === "immersive" ? "px-0 py-0" : "slide-surface-frame";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveSlideSurfaceClassName({
|
|
42
|
+
layout,
|
|
43
|
+
shadowClass,
|
|
44
|
+
overflowHidden = false,
|
|
45
|
+
}: {
|
|
46
|
+
layout: SlideMeta["layout"];
|
|
47
|
+
shadowClass?: string;
|
|
48
|
+
overflowHidden?: boolean;
|
|
49
|
+
}) {
|
|
50
|
+
return joinClassNames(
|
|
51
|
+
"slide-prose relative box-border size-full",
|
|
52
|
+
overflowHidden ? "overflow-hidden" : undefined,
|
|
53
|
+
resolveSurfacePaddingClass(layout),
|
|
54
|
+
shadowClass,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveSlideSurface({
|
|
59
|
+
meta,
|
|
60
|
+
slidesBackground,
|
|
61
|
+
className,
|
|
62
|
+
}: {
|
|
63
|
+
meta: SlideMeta;
|
|
64
|
+
slidesBackground?: string;
|
|
65
|
+
className?: string;
|
|
66
|
+
}) {
|
|
67
|
+
return {
|
|
68
|
+
className: joinClassNames(className, meta.class),
|
|
69
|
+
style: resolveBackgroundStyle(meta.background ?? slidesBackground),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { SlidesViewport } from "@slidev-react/core/slides/viewport";
|
|
3
|
+
|
|
4
|
+
type SlideScaleAlignment = "center" | "top-left";
|
|
5
|
+
|
|
6
|
+
export function useSlideScale(
|
|
7
|
+
scaleMultiplier: number,
|
|
8
|
+
alignment: SlideScaleAlignment = "center",
|
|
9
|
+
viewport: SlidesViewport,
|
|
10
|
+
) {
|
|
11
|
+
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
12
|
+
const [scale, setScale] = useState(1);
|
|
13
|
+
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const element = viewportRef.current;
|
|
17
|
+
if (!element || typeof ResizeObserver === "undefined") return;
|
|
18
|
+
|
|
19
|
+
const updateScale = () => {
|
|
20
|
+
const { width, height } = element.getBoundingClientRect();
|
|
21
|
+
if (!width || !height) return;
|
|
22
|
+
|
|
23
|
+
const nextScale =
|
|
24
|
+
Math.min(width / viewport.width, height / viewport.height) * scaleMultiplier;
|
|
25
|
+
const scaledWidth = viewport.width * nextScale;
|
|
26
|
+
const scaledHeight = viewport.height * nextScale;
|
|
27
|
+
|
|
28
|
+
setScale(nextScale);
|
|
29
|
+
setOffset({
|
|
30
|
+
x: alignment === "top-left" ? 0 : (width - scaledWidth) / 2,
|
|
31
|
+
y: alignment === "top-left" ? 0 : (height - scaledHeight) / 2,
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
updateScale();
|
|
36
|
+
|
|
37
|
+
const observer = new ResizeObserver(updateScale);
|
|
38
|
+
observer.observe(element);
|
|
39
|
+
|
|
40
|
+
return () => {
|
|
41
|
+
observer.disconnect();
|
|
42
|
+
};
|
|
43
|
+
}, [alignment, scaleMultiplier, viewport.height, viewport.width]);
|
|
44
|
+
|
|
45
|
+
return { viewportRef, scale, offset };
|
|
46
|
+
}
|
|
47
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PresentationEnvelope } from "../../types";
|
|
2
|
+
|
|
3
|
+
export interface BroadcastChannelTransport {
|
|
4
|
+
send: (envelope: PresentationEnvelope) => void;
|
|
5
|
+
dispose: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createBroadcastChannelTransport({
|
|
9
|
+
channelName,
|
|
10
|
+
onMessage,
|
|
11
|
+
onConnectedChange,
|
|
12
|
+
}: {
|
|
13
|
+
channelName: string;
|
|
14
|
+
onMessage: (incoming: unknown) => void;
|
|
15
|
+
onConnectedChange?: (connected: boolean) => void;
|
|
16
|
+
}): BroadcastChannelTransport | null {
|
|
17
|
+
if (typeof BroadcastChannel === "undefined") {
|
|
18
|
+
onConnectedChange?.(false);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const channel = new BroadcastChannel(channelName);
|
|
23
|
+
const handleMessage = (event: MessageEvent<unknown>) => {
|
|
24
|
+
onMessage(event.data);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
channel.addEventListener("message", handleMessage);
|
|
28
|
+
onConnectedChange?.(true);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
send: (envelope) => {
|
|
32
|
+
channel.postMessage(envelope);
|
|
33
|
+
},
|
|
34
|
+
dispose: () => {
|
|
35
|
+
channel.removeEventListener("message", handleMessage);
|
|
36
|
+
channel.close();
|
|
37
|
+
onConnectedChange?.(false);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { PresentationTransportState } from "../model/status";
|
|
2
|
+
|
|
3
|
+
const MAX_RECONNECT_DELAY_MS = 5000;
|
|
4
|
+
const BASE_RECONNECT_DELAY_MS = 800;
|
|
5
|
+
|
|
6
|
+
export interface WebSocketTransport {
|
|
7
|
+
send: (payload: string) => void;
|
|
8
|
+
dispose: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createWebSocketTransport({
|
|
12
|
+
sessionWsUrl,
|
|
13
|
+
sessionId,
|
|
14
|
+
senderId,
|
|
15
|
+
onMessage,
|
|
16
|
+
onStateChange,
|
|
17
|
+
onOpen,
|
|
18
|
+
}: {
|
|
19
|
+
sessionWsUrl: string;
|
|
20
|
+
sessionId: string;
|
|
21
|
+
senderId: string;
|
|
22
|
+
onMessage: (incoming: unknown) => void;
|
|
23
|
+
onStateChange: (state: PresentationTransportState) => void;
|
|
24
|
+
onOpen?: () => void;
|
|
25
|
+
}): WebSocketTransport {
|
|
26
|
+
let disposed = false;
|
|
27
|
+
let reconnectAttempt = 0;
|
|
28
|
+
let reconnectTimeoutId: number | null = null;
|
|
29
|
+
let socket: WebSocket | null = null;
|
|
30
|
+
let listeners: {
|
|
31
|
+
open: () => void;
|
|
32
|
+
message: (event: MessageEvent<unknown>) => void;
|
|
33
|
+
error: () => void;
|
|
34
|
+
close: () => void;
|
|
35
|
+
} | null = null;
|
|
36
|
+
|
|
37
|
+
const closeSocket = () => {
|
|
38
|
+
if (!socket) return;
|
|
39
|
+
|
|
40
|
+
if (listeners) {
|
|
41
|
+
socket.removeEventListener("open", listeners.open);
|
|
42
|
+
socket.removeEventListener("message", listeners.message);
|
|
43
|
+
socket.removeEventListener("error", listeners.error);
|
|
44
|
+
socket.removeEventListener("close", listeners.close);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
socket.close();
|
|
48
|
+
socket = null;
|
|
49
|
+
listeners = null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const scheduleReconnect = () => {
|
|
53
|
+
if (disposed) return;
|
|
54
|
+
|
|
55
|
+
onStateChange("reconnecting");
|
|
56
|
+
const delay = Math.min(BASE_RECONNECT_DELAY_MS * 2 ** reconnectAttempt, MAX_RECONNECT_DELAY_MS);
|
|
57
|
+
reconnectAttempt += 1;
|
|
58
|
+
reconnectTimeoutId = window.setTimeout(() => {
|
|
59
|
+
reconnectTimeoutId = null;
|
|
60
|
+
connect();
|
|
61
|
+
}, delay);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const connect = () => {
|
|
65
|
+
if (disposed) return;
|
|
66
|
+
|
|
67
|
+
closeSocket();
|
|
68
|
+
onStateChange("connecting");
|
|
69
|
+
|
|
70
|
+
const connectionUrl = new URL(sessionWsUrl);
|
|
71
|
+
connectionUrl.searchParams.set("session", sessionId);
|
|
72
|
+
connectionUrl.searchParams.set("sender", senderId);
|
|
73
|
+
|
|
74
|
+
const nextSocket = new WebSocket(connectionUrl.toString());
|
|
75
|
+
socket = nextSocket;
|
|
76
|
+
|
|
77
|
+
const handleOpen = () => {
|
|
78
|
+
reconnectAttempt = 0;
|
|
79
|
+
onStateChange("connected");
|
|
80
|
+
onOpen?.();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleMessage = (event: MessageEvent<unknown>) => {
|
|
84
|
+
if (typeof event.data !== "string") return;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
onMessage(JSON.parse(event.data));
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore malformed websocket payloads.
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleError = () => {
|
|
94
|
+
onStateChange("reconnecting");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleClose = () => {
|
|
98
|
+
if (disposed) return;
|
|
99
|
+
|
|
100
|
+
scheduleReconnect();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
listeners = {
|
|
104
|
+
open: handleOpen,
|
|
105
|
+
message: handleMessage,
|
|
106
|
+
error: handleError,
|
|
107
|
+
close: handleClose,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
nextSocket.addEventListener("open", handleOpen);
|
|
111
|
+
nextSocket.addEventListener("message", handleMessage);
|
|
112
|
+
nextSocket.addEventListener("error", handleError);
|
|
113
|
+
nextSocket.addEventListener("close", handleClose);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
connect();
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
send: (payload) => {
|
|
120
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(payload);
|
|
121
|
+
},
|
|
122
|
+
dispose: () => {
|
|
123
|
+
disposed = true;
|
|
124
|
+
if (reconnectTimeoutId !== null) window.clearTimeout(reconnectTimeoutId);
|
|
125
|
+
closeSocket();
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
countPeers,
|
|
4
|
+
markPeerSeen,
|
|
5
|
+
removePeer,
|
|
6
|
+
resolveRemoteActive,
|
|
7
|
+
sweepStalePeers,
|
|
8
|
+
} from "./presence";
|
|
9
|
+
|
|
10
|
+
describe("presence model", () => {
|
|
11
|
+
it("tracks peer activity and counts peers", () => {
|
|
12
|
+
const peers = new Map<string, number>();
|
|
13
|
+
|
|
14
|
+
markPeerSeen(peers, "viewer-1", 100);
|
|
15
|
+
markPeerSeen(peers, "viewer-2", 200);
|
|
16
|
+
|
|
17
|
+
expect(countPeers(peers)).toBe(2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("removes stale peers during sweeps", () => {
|
|
21
|
+
const peers = new Map<string, number>([
|
|
22
|
+
["viewer-1", 100],
|
|
23
|
+
["viewer-2", 5000],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
sweepStalePeers(peers, 6000, 2000);
|
|
27
|
+
|
|
28
|
+
expect([...peers.keys()]).toEqual(["viewer-2"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns whether a peer was removed", () => {
|
|
32
|
+
const peers = new Map<string, number>([["viewer-1", 100]]);
|
|
33
|
+
|
|
34
|
+
expect(removePeer(peers, "viewer-1")).toBe(true);
|
|
35
|
+
expect(removePeer(peers, "viewer-1")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("marks remote activity as stale outside the active window", () => {
|
|
39
|
+
expect(resolveRemoteActive(2000, 3000, 1500)).toBe(true);
|
|
40
|
+
expect(resolveRemoteActive(2000, 5000, 1500)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function countPeers(peerLastSeen: Map<string, number>) {
|
|
2
|
+
return peerLastSeen.size;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function markPeerSeen(
|
|
6
|
+
peerLastSeen: Map<string, number>,
|
|
7
|
+
peerId: string,
|
|
8
|
+
activityAt: number,
|
|
9
|
+
) {
|
|
10
|
+
peerLastSeen.set(peerId, activityAt);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function removePeer(peerLastSeen: Map<string, number>, peerId: string) {
|
|
14
|
+
return peerLastSeen.delete(peerId);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sweepStalePeers(
|
|
18
|
+
peerLastSeen: Map<string, number>,
|
|
19
|
+
now: number,
|
|
20
|
+
staleAfterMs: number,
|
|
21
|
+
) {
|
|
22
|
+
for (const [peerId, lastSeenAt] of peerLastSeen) {
|
|
23
|
+
if (now - lastSeenAt > staleAfterMs) peerLastSeen.delete(peerId);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveRemoteActive(
|
|
28
|
+
lastRemoteActivityAt: number,
|
|
29
|
+
now: number,
|
|
30
|
+
activeWindowMs: number,
|
|
31
|
+
) {
|
|
32
|
+
return now - lastRemoteActivityAt <= activeWindowMs;
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
canAuthorState,
|
|
4
|
+
canReceive,
|
|
5
|
+
canSend,
|
|
6
|
+
createEnvelope,
|
|
7
|
+
createSnapshotState,
|
|
8
|
+
isCursorEqual,
|
|
9
|
+
} from "./replication";
|
|
10
|
+
|
|
11
|
+
describe("replication model", () => {
|
|
12
|
+
it("creates join envelopes with protocol metadata", () => {
|
|
13
|
+
expect(
|
|
14
|
+
createEnvelope({
|
|
15
|
+
sessionId: "deck-default",
|
|
16
|
+
senderId: "sender-1",
|
|
17
|
+
seq: 3,
|
|
18
|
+
timestamp: 42,
|
|
19
|
+
message: {
|
|
20
|
+
type: "session/join",
|
|
21
|
+
payload: {
|
|
22
|
+
role: "presenter",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
).toMatchObject({
|
|
27
|
+
sessionId: "deck-default",
|
|
28
|
+
senderId: "sender-1",
|
|
29
|
+
seq: 3,
|
|
30
|
+
timestamp: 42,
|
|
31
|
+
type: "session/join",
|
|
32
|
+
payload: {
|
|
33
|
+
role: "presenter",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("stamps snapshots with the current time", () => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
vi.setSystemTime(new Date("2026-03-08T09:00:00.000Z"));
|
|
41
|
+
|
|
42
|
+
expect(
|
|
43
|
+
createSnapshotState({
|
|
44
|
+
page: 1,
|
|
45
|
+
cue: 2,
|
|
46
|
+
cueTotal: 3,
|
|
47
|
+
timer: 4,
|
|
48
|
+
cursor: null,
|
|
49
|
+
drawings: {},
|
|
50
|
+
drawingsRevision: 5,
|
|
51
|
+
lastUpdate: 0,
|
|
52
|
+
}).lastUpdate,
|
|
53
|
+
).toBe(new Date("2026-03-08T09:00:00.000Z").getTime());
|
|
54
|
+
|
|
55
|
+
vi.useRealTimers();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("evaluates sync permissions by mode and role", () => {
|
|
59
|
+
expect(canSend("send")).toBe(true);
|
|
60
|
+
expect(canSend("receive")).toBe(false);
|
|
61
|
+
expect(canReceive("receive")).toBe(true);
|
|
62
|
+
expect(canReceive("off")).toBe(false);
|
|
63
|
+
expect(canAuthorState("presenter")).toBe(true);
|
|
64
|
+
expect(canAuthorState("viewer")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("compares cursor positions structurally", () => {
|
|
68
|
+
expect(isCursorEqual(null, null)).toBe(true);
|
|
69
|
+
expect(isCursorEqual({ x: 1, y: 2 }, { x: 1, y: 2 })).toBe(true);
|
|
70
|
+
expect(isCursorEqual({ x: 1, y: 2 }, { x: 2, y: 1 })).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PRESENTATION_PROTOCOL_VERSION,
|
|
3
|
+
type PresentationEnvelope,
|
|
4
|
+
type PresentationRole,
|
|
5
|
+
type PresentationSharedState,
|
|
6
|
+
type PresentationSyncMode,
|
|
7
|
+
type SyncedPresentationRole,
|
|
8
|
+
} from "../../types";
|
|
9
|
+
|
|
10
|
+
export type EnvelopeInput = Omit<
|
|
11
|
+
PresentationEnvelope,
|
|
12
|
+
"version" | "sessionId" | "senderId" | "seq" | "timestamp"
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
export function createEnvelope({
|
|
16
|
+
sessionId,
|
|
17
|
+
senderId,
|
|
18
|
+
seq,
|
|
19
|
+
timestamp,
|
|
20
|
+
message,
|
|
21
|
+
}: {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
senderId: string;
|
|
24
|
+
seq: number;
|
|
25
|
+
timestamp: number;
|
|
26
|
+
message: EnvelopeInput;
|
|
27
|
+
}): PresentationEnvelope {
|
|
28
|
+
switch (message.type) {
|
|
29
|
+
case "session/join":
|
|
30
|
+
case "session/leave":
|
|
31
|
+
case "heartbeat": {
|
|
32
|
+
const { role } = message.payload as {
|
|
33
|
+
role: SyncedPresentationRole;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
version: PRESENTATION_PROTOCOL_VERSION,
|
|
38
|
+
sessionId,
|
|
39
|
+
senderId,
|
|
40
|
+
seq,
|
|
41
|
+
timestamp,
|
|
42
|
+
type: message.type,
|
|
43
|
+
payload: {
|
|
44
|
+
role,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
case "state/snapshot": {
|
|
49
|
+
const { state } = message.payload as {
|
|
50
|
+
state: PresentationSharedState;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
version: PRESENTATION_PROTOCOL_VERSION,
|
|
55
|
+
sessionId,
|
|
56
|
+
senderId,
|
|
57
|
+
seq,
|
|
58
|
+
timestamp,
|
|
59
|
+
type: "state/snapshot",
|
|
60
|
+
payload: {
|
|
61
|
+
state,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
case "state/patch": {
|
|
66
|
+
const { state } = message.payload as {
|
|
67
|
+
state: Partial<PresentationSharedState>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
version: PRESENTATION_PROTOCOL_VERSION,
|
|
72
|
+
sessionId,
|
|
73
|
+
senderId,
|
|
74
|
+
seq,
|
|
75
|
+
timestamp,
|
|
76
|
+
type: "state/patch",
|
|
77
|
+
payload: {
|
|
78
|
+
state,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function canSend(syncMode: PresentationSyncMode) {
|
|
86
|
+
return syncMode === "send" || syncMode === "both";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function canReceive(syncMode: PresentationSyncMode) {
|
|
90
|
+
return syncMode === "receive" || syncMode === "both";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function canAuthorState(role: PresentationRole) {
|
|
94
|
+
return role === "presenter";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isCursorEqual(
|
|
98
|
+
left: PresentationSharedState["cursor"],
|
|
99
|
+
right: PresentationSharedState["cursor"],
|
|
100
|
+
) {
|
|
101
|
+
if (left === right) return true;
|
|
102
|
+
|
|
103
|
+
if (!left || !right) return false;
|
|
104
|
+
|
|
105
|
+
return left.x === right.x && left.y === right.y;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createSnapshotState(localState: PresentationSharedState): PresentationSharedState {
|
|
109
|
+
return {
|
|
110
|
+
...localState,
|
|
111
|
+
lastUpdate: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
}
|