@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.
Files changed (131) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/package.json +44 -0
  5. package/src/addons/AddonProvider.tsx +25 -0
  6. package/src/addons/g2/G2Chart.tsx +370 -0
  7. package/src/addons/g2/chartPresets.ts +43 -0
  8. package/src/addons/g2/chartThemeTokens.ts +124 -0
  9. package/src/addons/g2/index.ts +36 -0
  10. package/src/addons/g2/style.css +31 -0
  11. package/src/addons/insight/Insight.tsx +10 -0
  12. package/src/addons/insight/InsightAddonProvider.tsx +20 -0
  13. package/src/addons/insight/SpotlightLayout.tsx +11 -0
  14. package/src/addons/insight/index.ts +17 -0
  15. package/src/addons/insight/style.css +34 -0
  16. package/src/addons/mermaid/MermaidDiagram.tsx +379 -0
  17. package/src/addons/mermaid/index.ts +10 -0
  18. package/src/addons/registry.test.ts +28 -0
  19. package/src/addons/registry.ts +61 -0
  20. package/src/addons/types.ts +6 -0
  21. package/src/app/App.tsx +125 -0
  22. package/src/app/README.md +18 -0
  23. package/src/app/providers/SlidesNavigationProvider.tsx +82 -0
  24. package/src/app/usePresentationBootstrap.ts +85 -0
  25. package/src/features/presentation/PresentationStatus.tsx +514 -0
  26. package/src/features/presentation/PrintSlidesView.tsx +350 -0
  27. package/src/features/presentation/browser.ts +5 -0
  28. package/src/features/presentation/draw/DrawOverlay.tsx +170 -0
  29. package/src/features/presentation/draw/DrawProvider.tsx +394 -0
  30. package/src/features/presentation/draw/persistence.test.ts +80 -0
  31. package/src/features/presentation/draw/persistence.ts +54 -0
  32. package/src/features/presentation/exportArtifacts.test.ts +48 -0
  33. package/src/features/presentation/exportArtifacts.ts +6 -0
  34. package/src/features/presentation/location.test.ts +73 -0
  35. package/src/features/presentation/location.ts +113 -0
  36. package/src/features/presentation/navigation/KeyboardController.tsx +73 -0
  37. package/src/features/presentation/navigation/PresentationNavbar.tsx +162 -0
  38. package/src/features/presentation/navigation/ShortcutsHelpOverlay.test.tsx +24 -0
  39. package/src/features/presentation/navigation/ShortcutsHelpOverlay.tsx +111 -0
  40. package/src/features/presentation/navigation/keyboardShortcuts.test.ts +74 -0
  41. package/src/features/presentation/navigation/keyboardShortcuts.ts +221 -0
  42. package/src/features/presentation/navigation/useSlidesNavigation.ts +15 -0
  43. package/src/features/presentation/overview/NotesOverview.tsx +200 -0
  44. package/src/features/presentation/overview/QuickOverview.tsx +126 -0
  45. package/src/features/presentation/path.ts +137 -0
  46. package/src/features/presentation/presenter/FlowTimelinePreview.test.tsx +54 -0
  47. package/src/features/presentation/presenter/FlowTimelinePreview.tsx +274 -0
  48. package/src/features/presentation/presenter/PresenterModeView.tsx +93 -0
  49. package/src/features/presentation/presenter/PresenterShell.tsx +286 -0
  50. package/src/features/presentation/presenter/PresenterSidePreview.tsx +68 -0
  51. package/src/features/presentation/presenter/PresenterTopProgress.tsx +28 -0
  52. package/src/features/presentation/presenter/SpeakerNotesPanel.tsx +51 -0
  53. package/src/features/presentation/presenter/StandaloneModeView.tsx +36 -0
  54. package/src/features/presentation/presenter/persistence.test.ts +26 -0
  55. package/src/features/presentation/presenter/persistence.ts +31 -0
  56. package/src/features/presentation/presenter/presentationSyncBridge.test.ts +87 -0
  57. package/src/features/presentation/presenter/presentationSyncBridge.ts +82 -0
  58. package/src/features/presentation/presenter/stage.ts +15 -0
  59. package/src/features/presentation/presenter/types.ts +30 -0
  60. package/src/features/presentation/presenter/useFullscreen.ts +58 -0
  61. package/src/features/presentation/presenter/useIdleCursor.ts +37 -0
  62. package/src/features/presentation/presenter/usePresentationFlowRuntime.ts +238 -0
  63. package/src/features/presentation/presenter/usePresenterChromeRuntime.ts +358 -0
  64. package/src/features/presentation/presenter/usePresenterSessionState.ts +226 -0
  65. package/src/features/presentation/presenter/useWakeLock.ts +110 -0
  66. package/src/features/presentation/recordingFilename.test.ts +46 -0
  67. package/src/features/presentation/recordingFilename.ts +56 -0
  68. package/src/features/presentation/reveal/Reveal.tsx +119 -0
  69. package/src/features/presentation/reveal/RevealContext.tsx +29 -0
  70. package/src/features/presentation/reveal/useRevealStep.ts +35 -0
  71. package/src/features/presentation/session.test.ts +122 -0
  72. package/src/features/presentation/session.ts +124 -0
  73. package/src/features/presentation/stage/SlidePreviewSurface.tsx +92 -0
  74. package/src/features/presentation/stage/SlideStage.tsx +159 -0
  75. package/src/features/presentation/stage/slideSurface.ts +71 -0
  76. package/src/features/presentation/stage/slideViewport.tsx +47 -0
  77. package/src/features/presentation/sync/adapters/broadcastChannelTransport.ts +40 -0
  78. package/src/features/presentation/sync/adapters/websocketTransport.ts +128 -0
  79. package/src/features/presentation/sync/model/presence.test.ts +42 -0
  80. package/src/features/presentation/sync/model/presence.ts +33 -0
  81. package/src/features/presentation/sync/model/replication.test.ts +72 -0
  82. package/src/features/presentation/sync/model/replication.ts +113 -0
  83. package/src/features/presentation/sync/model/status.test.ts +52 -0
  84. package/src/features/presentation/sync/model/status.ts +33 -0
  85. package/src/features/presentation/types.ts +1 -0
  86. package/src/features/presentation/usePresentationRecorder.ts +194 -0
  87. package/src/features/presentation/usePresentationSync.ts +423 -0
  88. package/src/index.ts +7 -0
  89. package/src/main.tsx +12 -0
  90. package/src/theme/ThemeProvider.test.ts +36 -0
  91. package/src/theme/ThemeProvider.tsx +79 -0
  92. package/src/theme/__mocks__/active-theme.ts +3 -0
  93. package/src/theme/base.css +14 -0
  94. package/src/theme/components.css +231 -0
  95. package/src/theme/index.css +11 -0
  96. package/src/theme/layouts/center.tsx +9 -0
  97. package/src/theme/layouts/cover.tsx +9 -0
  98. package/src/theme/layouts/default.tsx +5 -0
  99. package/src/theme/layouts/defaultLayouts.ts +20 -0
  100. package/src/theme/layouts/helpers.tsx +12 -0
  101. package/src/theme/layouts/image-right.tsx +21 -0
  102. package/src/theme/layouts/immersive.tsx +9 -0
  103. package/src/theme/layouts/resolveLayout.ts +9 -0
  104. package/src/theme/layouts/section.tsx +9 -0
  105. package/src/theme/layouts/statement.tsx +9 -0
  106. package/src/theme/layouts/two-cols.tsx +21 -0
  107. package/src/theme/layouts/types.ts +1 -0
  108. package/src/theme/layouts.css +133 -0
  109. package/src/theme/mark.css +379 -0
  110. package/src/theme/print.css +106 -0
  111. package/src/theme/prose.css +263 -0
  112. package/src/theme/registry.test.ts +21 -0
  113. package/src/theme/registry.ts +40 -0
  114. package/src/theme/tokens.css +148 -0
  115. package/src/theme/transitions.css +141 -0
  116. package/src/theme/types.ts +9 -0
  117. package/src/theme/useResolvedLayout.ts +24 -0
  118. package/src/types/generated-slides.d.ts +7 -0
  119. package/src/types/mdx-components.ts +7 -0
  120. package/src/types/plantuml-encoder.d.ts +7 -0
  121. package/src/ui/diagrams/PlantUmlDiagram.tsx +33 -0
  122. package/src/ui/mdx/MagicMoveDemo.tsx +114 -0
  123. package/src/ui/mdx/index.ts +21 -0
  124. package/src/ui/primitives/Annotate.test.tsx +64 -0
  125. package/src/ui/primitives/Annotate.tsx +82 -0
  126. package/src/ui/primitives/Badge.tsx +5 -0
  127. package/src/ui/primitives/Callout.tsx +24 -0
  128. package/src/ui/primitives/ChromeIconButton.tsx +58 -0
  129. package/src/ui/primitives/ChromePanel.tsx +79 -0
  130. package/src/ui/primitives/ChromeTag.tsx +70 -0
  131. package/src/ui/primitives/FormSelect.tsx +51 -0
@@ -0,0 +1,350 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { ArrowLeft, Printer } from "lucide-react";
11
+ import type { LayoutName } from "@slidev-react/core/slides/layout";
12
+ import type { SlidesViewport } from "@slidev-react/core/slides/viewport";
13
+ import {
14
+ formatViewportAspectRatio,
15
+ isPortraitViewport,
16
+ resolvePrintPageSize,
17
+ } from "@slidev-react/core/slides/viewport";
18
+ import { resolveCueTotal } from "@slidev-react/core/presentation/flow/cue";
19
+ import { SlidePreviewSurface } from "./stage/SlidePreviewSurface";
20
+ import type { CompiledSlide } from "./presenter/types";
21
+ import { RevealProvider, type RevealContextValue } from "./reveal/RevealContext";
22
+ import { useResolvedLayout } from "../../theme/useResolvedLayout";
23
+
24
+ function noopCleanup() { }
25
+
26
+ function noopRegisterStep() {
27
+ return noopCleanup;
28
+ }
29
+
30
+ function createRevealContextValue({
31
+ slideId,
32
+ clicks,
33
+ clicksTotal,
34
+ registerStep,
35
+ }: {
36
+ slideId: string;
37
+ clicks: number;
38
+ clicksTotal: number;
39
+ registerStep: RevealContextValue["registerStep"];
40
+ }): RevealContextValue {
41
+ return {
42
+ slideId,
43
+ clicks,
44
+ clicksTotal,
45
+ setClicks: () => { },
46
+ registerStep,
47
+ advance: () => { },
48
+ retreat: () => { },
49
+ canAdvance: false,
50
+ canRetreat: false,
51
+ };
52
+ }
53
+
54
+ function PrintSlideSnapshot({
55
+ Slide,
56
+ slideId,
57
+ clicks,
58
+ clicksTotal,
59
+ registerStep,
60
+ children,
61
+ }: {
62
+ Slide: CompiledSlide["component"];
63
+ slideId: string;
64
+ clicks: number;
65
+ clicksTotal: number;
66
+ registerStep: RevealContextValue["registerStep"];
67
+ children: (content: ReactNode) => ReactNode;
68
+ }) {
69
+ const revealContextValue = useMemo(
70
+ () =>
71
+ createRevealContextValue({
72
+ slideId,
73
+ clicks,
74
+ clicksTotal,
75
+ registerStep,
76
+ }),
77
+ [clicks, clicksTotal, registerStep, slideId],
78
+ );
79
+
80
+ return <RevealProvider value={revealContextValue}>{children(<Slide />)}</RevealProvider>;
81
+ }
82
+
83
+ function PrintSlideGroup({
84
+ slide,
85
+ slideNumber,
86
+ totalSlides,
87
+ slidesViewport,
88
+ slidesLayout,
89
+ slidesBackground,
90
+ withClicks,
91
+ }: {
92
+ slide: CompiledSlide;
93
+ slideNumber: number;
94
+ totalSlides: number;
95
+ slidesViewport: SlidesViewport;
96
+ slidesLayout?: LayoutName;
97
+ slidesBackground?: string;
98
+ withClicks: boolean;
99
+ }) {
100
+ const Slide = slide.component;
101
+ const Layout = useResolvedLayout(slide.meta.layout ?? slidesLayout);
102
+ const probeStepsRef = useRef(new Map<number, number>());
103
+ const [detectedClicks, setDetectedClicks] = useState(0);
104
+ const [measurementReady, setMeasurementReady] = useState(!withClicks);
105
+ const clicksTotal = resolveCueTotal({
106
+ configuredCues: slide.meta.clicks,
107
+ detectedCues: detectedClicks,
108
+ });
109
+ const clickSteps = useMemo(() => {
110
+ if (!withClicks) return [null];
111
+
112
+ return Array.from({ length: clicksTotal + 1 }, (_, index) => index);
113
+ }, [clicksTotal, withClicks]);
114
+ const registerProbeStep = useCallback<RevealContextValue["registerStep"]>((step) => {
115
+ const normalizedStep = Math.max(Math.floor(step), 1);
116
+ const next = probeStepsRef.current.get(normalizedStep) ?? 0;
117
+ probeStepsRef.current.set(normalizedStep, next + 1);
118
+ setDetectedClicks((value) => Math.max(value, normalizedStep));
119
+
120
+ return () => {
121
+ const current = probeStepsRef.current.get(normalizedStep) ?? 1;
122
+ if (current <= 1) probeStepsRef.current.delete(normalizedStep);
123
+ else probeStepsRef.current.set(normalizedStep, current - 1);
124
+ };
125
+ }, []);
126
+
127
+ useEffect(() => {
128
+ if (!withClicks) {
129
+ setMeasurementReady(true);
130
+ return;
131
+ }
132
+
133
+ setMeasurementReady(false);
134
+ let secondFrame = 0;
135
+ const firstFrame = window.requestAnimationFrame(() => {
136
+ secondFrame = window.requestAnimationFrame(() => {
137
+ setMeasurementReady(true);
138
+ });
139
+ });
140
+
141
+ return () => {
142
+ window.cancelAnimationFrame(firstFrame);
143
+ if (secondFrame) window.cancelAnimationFrame(secondFrame);
144
+ };
145
+ }, [clicksTotal, withClicks]);
146
+
147
+ return (
148
+ <>
149
+ {withClicks && (
150
+ <div aria-hidden className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">
151
+ <PrintSlideSnapshot
152
+ Slide={Slide}
153
+ slideId={`${slide.id}:probe`}
154
+ clicks={0}
155
+ clicksTotal={clicksTotal}
156
+ registerStep={registerProbeStep}
157
+ >
158
+ {(content) => <Layout>{content}</Layout>}
159
+ </PrintSlideSnapshot>
160
+ </div>
161
+ )}
162
+ {clickSteps.map((clickStep) => {
163
+ const clickValue = typeof clickStep === "number" ? clickStep : "all";
164
+ const clickLabel =
165
+ typeof clickStep === "number"
166
+ ? `Click ${clickStep}/${clicksTotal}`
167
+ : `Slide ${slideNumber}`;
168
+
169
+ return (
170
+ <section
171
+ key={`${slide.id}:${clickValue}`}
172
+ data-export-snapshot="slide"
173
+ data-export-slide={String(slideNumber)}
174
+ data-export-slide-title={slide.meta.title ?? ""}
175
+ data-export-click={String(clickValue)}
176
+ data-export-clicks-total={String(clicksTotal)}
177
+ data-export-slide-ready={measurementReady ? "true" : "false"}
178
+ className="print-slide-shell"
179
+ >
180
+ <div className="print-slide-meta mb-3 flex items-center justify-between px-1 text-xs font-medium tracking-[0.18em] text-slate-500 uppercase">
181
+ <span>{slide.meta.title ?? `Slide ${slideNumber}`}</span>
182
+ <span>
183
+ {withClicks
184
+ ? `${slideNumber}/${totalSlides} • ${clickLabel}`
185
+ : `${slideNumber} / ${totalSlides}`}
186
+ </span>
187
+ </div>
188
+ <div className="print-slide-sheet rounded-[24px] border border-slate-200/80 bg-white ">
189
+ <div className="print-slide-frame p-3">
190
+ {withClicks && typeof clickStep === "number" ? (
191
+ <SlidePreviewSurface
192
+ Slide={Slide}
193
+ meta={slide.meta}
194
+ slidesViewport={slidesViewport}
195
+ slidesLayout={slidesLayout}
196
+ slidesBackground={slidesBackground}
197
+ content={
198
+ <PrintSlideSnapshot
199
+ Slide={Slide}
200
+ slideId={`${slide.id}:${clickStep}`}
201
+ clicks={clickStep}
202
+ clicksTotal={clicksTotal}
203
+ registerStep={noopRegisterStep}
204
+ >
205
+ {(content) => <Layout>{content}</Layout>}
206
+ </PrintSlideSnapshot>
207
+ }
208
+ viewportClassName="print-slide-viewport relative w-full overflow-hidden"
209
+ viewportStyle={{ aspectRatio: formatViewportAspectRatio(slidesViewport) }}
210
+ overflowHidden
211
+ shadowClass=""
212
+ articleProps={{
213
+ "data-export-surface": "slide",
214
+ }}
215
+ />
216
+ ) : (
217
+ <SlidePreviewSurface
218
+ Slide={Slide}
219
+ meta={slide.meta}
220
+ slidesViewport={slidesViewport}
221
+ slidesLayout={slidesLayout}
222
+ slidesBackground={slidesBackground}
223
+ content={
224
+ <Layout>
225
+ <Slide />
226
+ </Layout>
227
+ }
228
+ viewportClassName="print-slide-viewport relative w-full overflow-hidden"
229
+ viewportStyle={{ aspectRatio: formatViewportAspectRatio(slidesViewport) }}
230
+ overflowHidden
231
+ shadowClass=""
232
+ articleProps={{
233
+ "data-export-surface": "slide",
234
+ }}
235
+ />
236
+ )}
237
+ </div>
238
+ </div>
239
+ </section>
240
+ );
241
+ })}
242
+ </>
243
+ );
244
+ }
245
+
246
+ export function PrintSlidesView({
247
+ slides,
248
+ slidesTitle,
249
+ slidesViewport,
250
+ slidesLayout,
251
+ slidesBackground,
252
+ exportBaseName,
253
+ withClicks = false,
254
+ onBack,
255
+ }: {
256
+ slides: CompiledSlide[];
257
+ slidesTitle?: string;
258
+ slidesViewport: SlidesViewport;
259
+ slidesLayout?: LayoutName;
260
+ slidesBackground?: string;
261
+ exportBaseName: string;
262
+ withClicks?: boolean;
263
+ onBack: () => void;
264
+ }) {
265
+ const pageSize = useMemo(() => resolvePrintPageSize(slidesViewport), [slidesViewport]);
266
+ const printScale = useMemo(() => {
267
+ const CSS_PX_PER_MM = 96 / 25.4;
268
+ const pageWidthPx = pageSize.widthMm * CSS_PX_PER_MM;
269
+ const pageHeightPx = pageSize.heightMm * CSS_PX_PER_MM;
270
+ return Math.min(pageWidthPx / slidesViewport.width, pageHeightPx / slidesViewport.height);
271
+ }, [pageSize.widthMm, pageSize.heightMm, slidesViewport.width, slidesViewport.height]);
272
+ const rootStyle = useMemo(
273
+ () =>
274
+ ({
275
+ "--print-page-width": `${pageSize.widthMm}mm`,
276
+ "--print-page-height": `${pageSize.heightMm}mm`,
277
+ "--print-page-aspect-ratio": formatViewportAspectRatio(slidesViewport),
278
+ }) as CSSProperties,
279
+ [slidesViewport, pageSize.heightMm, pageSize.widthMm],
280
+ );
281
+
282
+ return (
283
+ <div
284
+ data-export-view="print"
285
+ data-export-with-clicks={withClicks ? "true" : "false"}
286
+ data-export-orientation={isPortraitViewport(slidesViewport) ? "portrait" : "landscape"}
287
+ data-export-viewport-width={String(slidesViewport.width)}
288
+ data-export-viewport-height={String(slidesViewport.height)}
289
+ className="print-slides-view min-h-screen bg-[linear-gradient(180deg,#dcfce7_0%,#f0fdf4_26%,#f8fafc_100%)] text-slate-900"
290
+ style={rootStyle}
291
+ >
292
+ <style>{`@page { size: ${pageSize.widthMm}mm ${pageSize.heightMm}mm; margin: 0; }
293
+ @media print {
294
+ .print-slide-viewport > div {
295
+ transform: scale(${printScale}) !important;
296
+ transform-origin: top left !important;
297
+ }
298
+ }`}</style>
299
+ <header className="print-slides-toolbar sticky top-0 z-20 border-b border-slate-200/80 bg-white/88 px-5 py-4 backdrop-blur-xl">
300
+ <div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3">
301
+ <div className="min-w-0">
302
+ <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-emerald-700">
303
+ Print Export
304
+ </p>
305
+ <h1 className="truncate text-lg font-semibold text-slate-900">
306
+ {slidesTitle ?? "Untitled slides"}
307
+ </h1>
308
+ <p className="text-sm text-slate-500">
309
+ {slides.length} slides
310
+ {withClicks ? " • reveal snapshots included" : ""}
311
+ {" • "}suggested file name: {exportBaseName}.pdf
312
+ </p>
313
+ </div>
314
+ <div className="flex flex-wrap items-center gap-2">
315
+ <button
316
+ type="button"
317
+ onClick={onBack}
318
+ className="inline-flex items-center justify-center gap-2 rounded-[8px] border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
319
+ >
320
+ <ArrowLeft size={15} />
321
+ Back to slides
322
+ </button>
323
+ <button
324
+ type="button"
325
+ onClick={() => window.print()}
326
+ className="inline-flex items-center justify-center gap-2 rounded-[8px] border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700 transition hover:bg-emerald-100"
327
+ >
328
+ <Printer size={15} />
329
+ Print / Save PDF
330
+ </button>
331
+ </div>
332
+ </div>
333
+ </header>
334
+ <main className="print-slides-content mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 md:px-6 md:py-10">
335
+ {slides.map((slide, index) => (
336
+ <PrintSlideGroup
337
+ key={slide.id}
338
+ slide={slide}
339
+ slideNumber={index + 1}
340
+ totalSlides={slides.length}
341
+ slidesViewport={slidesViewport}
342
+ slidesLayout={slidesLayout}
343
+ slidesBackground={slidesBackground}
344
+ withClicks={withClicks}
345
+ />
346
+ ))}
347
+ </main>
348
+ </div>
349
+ );
350
+ }
@@ -0,0 +1,5 @@
1
+ export function isTypingElement(target: EventTarget | null): boolean {
2
+ if (!(target instanceof HTMLElement)) return false;
3
+
4
+ return target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
5
+ }
@@ -0,0 +1,170 @@
1
+ import { useMemo, useRef, type PointerEvent as ReactPointerEvent } from "react";
2
+ import type { SlidesViewport } from "@slidev-react/core/slides/viewport";
3
+ import { useDraw, type DrawPoint, type DrawStroke } from "./DrawProvider";
4
+
5
+ function clamp(value: number, min: number, max: number) {
6
+ return Math.min(Math.max(value, min), max);
7
+ }
8
+
9
+ function polylinePoints(points: DrawPoint[]): string {
10
+ return points.map((point) => `${point.x},${point.y}`).join(" ");
11
+ }
12
+
13
+ function rectFromPoints(start: DrawPoint, end: DrawPoint) {
14
+ return {
15
+ x: Math.min(start.x, end.x),
16
+ y: Math.min(start.y, end.y),
17
+ width: Math.abs(end.x - start.x),
18
+ height: Math.abs(end.y - start.y),
19
+ };
20
+ }
21
+
22
+ function drawHintByTool(tool: ReturnType<typeof useDraw>["tool"]) {
23
+ if (tool === "eraser") return "Eraser ON";
24
+ if (tool === "circle") return "Circle ON";
25
+ if (tool === "rectangle") return "Rectangle ON";
26
+
27
+ return "Pen ON";
28
+ }
29
+
30
+ function ShapeStroke({ stroke }: { stroke: DrawStroke }) {
31
+ const kind = stroke.kind ?? "pen";
32
+
33
+ if (kind === "circle") {
34
+ const center = stroke.points[0];
35
+ const edge = stroke.points[stroke.points.length - 1] ?? center;
36
+ const radius = Math.hypot(edge.x - center.x, edge.y - center.y);
37
+
38
+ return (
39
+ <circle
40
+ cx={center.x}
41
+ cy={center.y}
42
+ r={radius}
43
+ fill="none"
44
+ stroke={stroke.color}
45
+ strokeWidth={stroke.width}
46
+ />
47
+ );
48
+ }
49
+
50
+ if (kind === "rectangle") {
51
+ const start = stroke.points[0];
52
+ const end = stroke.points[stroke.points.length - 1] ?? start;
53
+ const rect = rectFromPoints(start, end);
54
+
55
+ return (
56
+ <rect
57
+ x={rect.x}
58
+ y={rect.y}
59
+ width={rect.width}
60
+ height={rect.height}
61
+ fill="none"
62
+ stroke={stroke.color}
63
+ strokeWidth={stroke.width}
64
+ />
65
+ );
66
+ }
67
+
68
+ if (stroke.points.length === 1) {
69
+ return (
70
+ <circle
71
+ cx={stroke.points[0].x}
72
+ cy={stroke.points[0].y}
73
+ r={stroke.width / 2}
74
+ fill={stroke.color}
75
+ />
76
+ );
77
+ }
78
+
79
+ return (
80
+ <polyline
81
+ points={polylinePoints(stroke.points)}
82
+ fill="none"
83
+ stroke={stroke.color}
84
+ strokeWidth={stroke.width}
85
+ strokeLinecap="round"
86
+ strokeLinejoin="round"
87
+ />
88
+ );
89
+ }
90
+
91
+ export function DrawOverlay({
92
+ slideId,
93
+ scale,
94
+ viewport,
95
+ }: {
96
+ slideId: string;
97
+ scale: number;
98
+ viewport: SlidesViewport;
99
+ }) {
100
+ const draw = useDraw();
101
+ const activeStrokeIdRef = useRef<string | null>(null);
102
+ const strokes = draw.strokesBySlideId[slideId] ?? [];
103
+
104
+ const className = draw.enabled
105
+ ? `absolute inset-0 z-20 touch-none ${draw.tool === "eraser" ? "cursor-cell" : "cursor-crosshair"}`
106
+ : "pointer-events-none absolute inset-0 z-20";
107
+
108
+ const stageHint = useMemo(() => {
109
+ if (!draw.enabled) return null;
110
+
111
+ return (
112
+ <div className="pointer-events-none absolute right-5 top-5 rounded-md bg-slate-900/80 px-2 py-1 text-xs font-medium tracking-wide text-slate-100">
113
+ {drawHintByTool(draw.tool)}
114
+ </div>
115
+ );
116
+ }, [draw.enabled, draw.tool]);
117
+
118
+ const toPoint = (event: ReactPointerEvent<SVGSVGElement>): DrawPoint => {
119
+ const rect = event.currentTarget.getBoundingClientRect();
120
+ return {
121
+ x: clamp((event.clientX - rect.left) / scale, 0, viewport.width),
122
+ y: clamp((event.clientY - rect.top) / scale, 0, viewport.height),
123
+ };
124
+ };
125
+
126
+ const onPointerDown = (event: ReactPointerEvent<SVGSVGElement>) => {
127
+ if (!draw.enabled || event.button !== 0) return;
128
+
129
+ event.preventDefault();
130
+ event.currentTarget.setPointerCapture(event.pointerId);
131
+ activeStrokeIdRef.current = draw.startStroke(slideId, toPoint(event));
132
+ };
133
+
134
+ const onPointerMove = (event: ReactPointerEvent<SVGSVGElement>) => {
135
+ const activeStrokeId = activeStrokeIdRef.current;
136
+ if (!draw.enabled || !activeStrokeId) return;
137
+
138
+ event.preventDefault();
139
+ draw.appendStrokePoint(slideId, activeStrokeId, toPoint(event));
140
+ };
141
+
142
+ const onPointerEnd = (event: ReactPointerEvent<SVGSVGElement>) => {
143
+ if (!activeStrokeIdRef.current) return;
144
+
145
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
146
+ event.currentTarget.releasePointerCapture(event.pointerId);
147
+
148
+ activeStrokeIdRef.current = null;
149
+ };
150
+
151
+ return (
152
+ <div className={className}>
153
+ <svg
154
+ width={viewport.width}
155
+ height={viewport.height}
156
+ viewBox={`0 0 ${viewport.width} ${viewport.height}`}
157
+ className="size-full"
158
+ onPointerDown={onPointerDown}
159
+ onPointerMove={onPointerMove}
160
+ onPointerUp={onPointerEnd}
161
+ onPointerCancel={onPointerEnd}
162
+ >
163
+ {strokes.map((stroke) => (
164
+ <ShapeStroke key={stroke.id} stroke={stroke} />
165
+ ))}
166
+ </svg>
167
+ {stageHint}
168
+ </div>
169
+ );
170
+ }