@marimo-team/islands 0.23.7-dev64 → 0.23.7-dev66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{code-visibility-CVTtZuy-.js → code-visibility-Dj3Cpe1F.js} +73 -70
- package/dist/main.js +985 -979
- package/dist/reveal-component-CcvoUUhQ.js +7444 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/dependency-graph/minimap-content.tsx +14 -3
- package/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts +5 -4
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +55 -15
- package/src/components/editor/renderers/slides-layout/plugin.tsx +8 -25
- package/src/components/editor/renderers/slides-layout/slides-layout.tsx +18 -5
- package/src/components/editor/renderers/slides-layout/types.ts +40 -31
- package/src/components/slides/__tests__/slide-notes.test.ts +131 -0
- package/src/components/slides/reveal-component.tsx +242 -147
- package/src/components/slides/slide-notes-editor.tsx +127 -0
- package/src/components/slides/slide-notes.ts +64 -0
- package/src/components/slides/slides.css +14 -0
- package/src/core/edit-app.tsx +1 -1
- package/dist/reveal-component-pMF7xAP-.js +0 -4863
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
import useEvent from "react-use-event-hook";
|
|
12
12
|
import { CodeIcon, ExpandIcon, EyeOffIcon } from "lucide-react";
|
|
13
13
|
import { Deck, Fragment, Slide, Stack } from "@revealjs/react";
|
|
14
|
+
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
|
14
15
|
import { Slide as CellOutputSlide } from "@/components/slides/slide";
|
|
15
16
|
import { Button } from "@/components/ui/button";
|
|
16
17
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
18
|
+
import type { CellId } from "@/core/cells/ids";
|
|
17
19
|
import type { RuntimeCell } from "@/core/cells/types";
|
|
18
20
|
import type { RevealApi, RevealConfig } from "reveal.js";
|
|
19
21
|
import { useEventListener } from "@/hooks/useEventListener";
|
|
@@ -21,7 +23,10 @@ import { Events } from "@/utils/events";
|
|
|
21
23
|
import { Logger } from "@/utils/Logger";
|
|
22
24
|
import "./slides.css";
|
|
23
25
|
import "./reveal-slides.css";
|
|
24
|
-
import type {
|
|
26
|
+
import type {
|
|
27
|
+
SlideConfig,
|
|
28
|
+
SlidesLayout,
|
|
29
|
+
} from "../editor/renderers/slides-layout/types";
|
|
25
30
|
import {
|
|
26
31
|
buildSlideIndices,
|
|
27
32
|
composeSlides,
|
|
@@ -39,10 +44,14 @@ import {
|
|
|
39
44
|
SlideCellReadOnlyView,
|
|
40
45
|
SlideCellView,
|
|
41
46
|
} from "@/components/slides/slide-cell-view";
|
|
47
|
+
import { SlideNotesEditor } from "./slide-notes-editor";
|
|
48
|
+
import { buildSubslideNotes, NOTES_DIVIDER } from "./slide-notes";
|
|
42
49
|
import { cn } from "@/utils/cn";
|
|
43
50
|
import { isIslands } from "@/core/islands/utils";
|
|
44
51
|
import { useNotebookCodeAvailable } from "@/core/meta/code-visibility";
|
|
45
|
-
import type
|
|
52
|
+
import { type AppMode, kioskModeAtom } from "@/core/mode";
|
|
53
|
+
import { useAtomValue } from "jotai";
|
|
54
|
+
import RevealNotes from "reveal.js/plugin/notes";
|
|
46
55
|
|
|
47
56
|
const ASPECT_RATIO = 16 / 9;
|
|
48
57
|
|
|
@@ -124,56 +133,93 @@ function triggerResize(deck: RevealApi | null) {
|
|
|
124
133
|
}
|
|
125
134
|
}
|
|
126
135
|
|
|
136
|
+
// The speaker view renders this via innerHTML with `white-space: normal`, so
|
|
137
|
+
// we materialize `\n` as `<br>` and a lone `---` line as `<hr>`.
|
|
138
|
+
const NotesAside = ({ text }: { text: string }) => {
|
|
139
|
+
const lines = text.split("\n");
|
|
140
|
+
return (
|
|
141
|
+
<aside className="notes">
|
|
142
|
+
{lines.map((line, idx) => {
|
|
143
|
+
const isLast = idx === lines.length - 1;
|
|
144
|
+
if (line === NOTES_DIVIDER) {
|
|
145
|
+
return <hr key={idx} />;
|
|
146
|
+
}
|
|
147
|
+
return (
|
|
148
|
+
<ReactFragment key={idx}>
|
|
149
|
+
{line}
|
|
150
|
+
{!isLast && <br />}
|
|
151
|
+
</ReactFragment>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
</aside>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
127
158
|
const SubslideView = ({
|
|
128
159
|
subslide,
|
|
129
160
|
showCode,
|
|
130
161
|
isEditable,
|
|
162
|
+
slideConfigs,
|
|
131
163
|
}: {
|
|
132
164
|
subslide: ComposedSubslide<RuntimeCell>;
|
|
133
165
|
showCode: boolean;
|
|
134
166
|
isEditable: boolean;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
167
|
+
slideConfigs: ReadonlyMap<CellId, SlideConfig>;
|
|
168
|
+
}) => {
|
|
169
|
+
const { slideLevel, cumulativeByBlock } = buildSubslideNotes(
|
|
170
|
+
subslide,
|
|
171
|
+
slideConfigs,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Slide>
|
|
176
|
+
<div className="h-full w-full overflow-auto flex">
|
|
177
|
+
<div
|
|
178
|
+
className={
|
|
179
|
+
showCode
|
|
180
|
+
? "mo-slide-content flex flex-col gap-3"
|
|
181
|
+
: "mo-slide-content"
|
|
182
|
+
}
|
|
183
|
+
style={{
|
|
184
|
+
margin: "auto 20px",
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{subslide.blocks.map((block, i) => {
|
|
188
|
+
const rendered = block.cells.map((cell) => {
|
|
189
|
+
if (!showCode) {
|
|
190
|
+
return (
|
|
191
|
+
<CellOutputSlide
|
|
192
|
+
key={cell.id}
|
|
193
|
+
cellId={cell.id}
|
|
194
|
+
status={cell.status}
|
|
195
|
+
output={cell.output}
|
|
196
|
+
/>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return isEditable ? (
|
|
200
|
+
<SlideCellView key={cell.id} cell={cell} />
|
|
201
|
+
) : (
|
|
202
|
+
<SlideCellReadOnlyView key={cell.id} cell={cell} />
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
if (block.isFragment) {
|
|
206
|
+
const cumulative = cumulativeByBlock.get(i);
|
|
149
207
|
return (
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
output={cell.output}
|
|
155
|
-
/>
|
|
208
|
+
<Fragment key={i} as="div">
|
|
209
|
+
{rendered}
|
|
210
|
+
{cumulative && <NotesAside text={cumulative} />}
|
|
211
|
+
</Fragment>
|
|
156
212
|
);
|
|
157
213
|
}
|
|
158
|
-
return
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
<SlideCellReadOnlyView key={cell.id} cell={cell} />
|
|
162
|
-
);
|
|
163
|
-
});
|
|
164
|
-
if (block.isFragment) {
|
|
165
|
-
return (
|
|
166
|
-
<Fragment key={i} as="div">
|
|
167
|
-
{rendered}
|
|
168
|
-
</Fragment>
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
return <ReactFragment key={i}>{rendered}</ReactFragment>;
|
|
172
|
-
})}
|
|
214
|
+
return <ReactFragment key={i}>{rendered}</ReactFragment>;
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
173
217
|
</div>
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
218
|
+
{/* Outside any `.fragment`: shown only before any fragment is revealed. */}
|
|
219
|
+
{slideLevel && <NotesAside text={slideLevel} />}
|
|
220
|
+
</Slide>
|
|
221
|
+
);
|
|
222
|
+
};
|
|
177
223
|
|
|
178
224
|
// There is an upstream react bug in dev mode (https://github.com/facebook/react/issues/34840)
|
|
179
225
|
// Uncaught SecurityError: Failed to read a named property '$$typeof' from 'Window'
|
|
@@ -200,6 +246,13 @@ const RevealSlidesComponent = ({
|
|
|
200
246
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
201
247
|
const deckRef = useRef<RevealApi | null>(null);
|
|
202
248
|
const { width, height } = useSlideDimensions(containerRef);
|
|
249
|
+
// Skip the Notes plugin inside reveal's own speaker-view iframes so pressing
|
|
250
|
+
// `S` there doesn't try to spawn another popup.
|
|
251
|
+
const kioskMode = useAtomValue(kioskModeAtom);
|
|
252
|
+
const deckPlugins = useMemo(
|
|
253
|
+
() => (kioskMode ? [] : [RevealNotes]),
|
|
254
|
+
[kioskMode],
|
|
255
|
+
);
|
|
203
256
|
|
|
204
257
|
const [showCode, setShowCode] = useState(false);
|
|
205
258
|
const codeAvailable = useNotebookCodeAvailable(cellsWithOutput);
|
|
@@ -241,6 +294,16 @@ const RevealSlidesComponent = ({
|
|
|
241
294
|
);
|
|
242
295
|
|
|
243
296
|
const deckTransition = layout.deck?.transition ?? DEFAULT_DECK_TRANSITION;
|
|
297
|
+
// Reveal's Notes plugin iframes the deck for the current/upcoming-slide
|
|
298
|
+
// previews. We load the same URL but as a read-only kiosk client with the
|
|
299
|
+
// app chrome hidden, which `<SlidesLayoutRenderer>` interprets the same as
|
|
300
|
+
// read mode (no minimap, sidebar, or notes editor).
|
|
301
|
+
const kioskUrl = useMemo(() => {
|
|
302
|
+
const url = new URL(window.location.href);
|
|
303
|
+
url.searchParams.set("kiosk", "true");
|
|
304
|
+
url.searchParams.set("show-chrome", "false");
|
|
305
|
+
return url.toString();
|
|
306
|
+
}, []);
|
|
244
307
|
const revealConfig: RevealConfig = useMemo(
|
|
245
308
|
() => ({
|
|
246
309
|
embedded: true,
|
|
@@ -251,8 +314,9 @@ const RevealSlidesComponent = ({
|
|
|
251
314
|
maxScale: 2,
|
|
252
315
|
transition: deckTransition,
|
|
253
316
|
keyboardCondition: (event: KeyboardEvent) => !Events.fromInput(event),
|
|
317
|
+
url: kioskUrl,
|
|
254
318
|
}),
|
|
255
|
-
[width, height, deckTransition],
|
|
319
|
+
[width, height, deckTransition, kioskUrl],
|
|
256
320
|
);
|
|
257
321
|
|
|
258
322
|
const navigateDeckToActiveCell = useEvent((deck: RevealApi) => {
|
|
@@ -357,126 +421,157 @@ const RevealSlidesComponent = ({
|
|
|
357
421
|
|
|
358
422
|
useEventListener(document, "keydown", handleParkedNavKey, { capture: true });
|
|
359
423
|
|
|
360
|
-
|
|
361
|
-
<div
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
>
|
|
366
|
-
<
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
return (
|
|
381
|
-
<SubslideView
|
|
382
|
-
key={h}
|
|
383
|
-
subslide={stack.subslides[0]}
|
|
384
|
-
showCode={codeShown && isActive}
|
|
385
|
-
isEditable={isEditable}
|
|
386
|
-
/>
|
|
387
|
-
);
|
|
388
|
-
}
|
|
424
|
+
const slideArea = (
|
|
425
|
+
<div
|
|
426
|
+
ref={containerRef}
|
|
427
|
+
className="h-full w-full min-w-0 flex items-center justify-center overflow-hidden"
|
|
428
|
+
>
|
|
429
|
+
<div className="group relative" style={{ width, height }}>
|
|
430
|
+
<Deck
|
|
431
|
+
deckRef={deckRef}
|
|
432
|
+
className="aspect-video w-full overflow-hidden border rounded bg-background mo-slides-theme prose-slides"
|
|
433
|
+
config={revealConfig}
|
|
434
|
+
onReady={handleDeckReady}
|
|
435
|
+
onSlideChange={handleSlideChange}
|
|
436
|
+
onFragmentShown={reportCurrentCell}
|
|
437
|
+
onFragmentHidden={reportCurrentCell}
|
|
438
|
+
plugins={deckPlugins}
|
|
439
|
+
>
|
|
440
|
+
{composition.stacks.map((stack, h) => {
|
|
441
|
+
if (stack.subslides.length === 1) {
|
|
442
|
+
const isActive =
|
|
443
|
+
activeSubslide?.h === h && activeSubslide?.v === 0;
|
|
389
444
|
return (
|
|
390
|
-
<
|
|
391
|
-
{
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
subslide={sub}
|
|
398
|
-
showCode={codeShown && isActive}
|
|
399
|
-
isEditable={isEditable}
|
|
400
|
-
/>
|
|
401
|
-
);
|
|
402
|
-
})}
|
|
403
|
-
</Stack>
|
|
445
|
+
<SubslideView
|
|
446
|
+
key={h}
|
|
447
|
+
subslide={stack.subslides[0]}
|
|
448
|
+
showCode={codeShown && isActive}
|
|
449
|
+
isEditable={isEditable}
|
|
450
|
+
slideConfigs={layout.cells}
|
|
451
|
+
/>
|
|
404
452
|
);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
453
|
+
}
|
|
454
|
+
return (
|
|
455
|
+
<Stack key={h}>
|
|
456
|
+
{stack.subslides.map((sub, v) => {
|
|
457
|
+
const isActive =
|
|
458
|
+
activeSubslide?.h === h && activeSubslide?.v === v;
|
|
459
|
+
return (
|
|
460
|
+
<SubslideView
|
|
461
|
+
key={v}
|
|
462
|
+
subslide={sub}
|
|
463
|
+
showCode={codeShown && isActive}
|
|
464
|
+
isEditable={isEditable}
|
|
465
|
+
slideConfigs={layout.cells}
|
|
466
|
+
/>
|
|
467
|
+
);
|
|
468
|
+
})}
|
|
469
|
+
</Stack>
|
|
470
|
+
);
|
|
471
|
+
})}
|
|
472
|
+
</Deck>
|
|
473
|
+
{skippedPreviewCell && (
|
|
474
|
+
<div
|
|
475
|
+
className="absolute inset-0 z-10 border rounded bg-background flex flex-col overflow-hidden"
|
|
476
|
+
aria-label="Skipped in presentation"
|
|
477
|
+
>
|
|
478
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground border-b bg-muted/40">
|
|
479
|
+
<EyeOffIcon className="h-3.5 w-3.5" />
|
|
480
|
+
<span>Skipped in presentation</span>
|
|
481
|
+
</div>
|
|
482
|
+
<div className="flex-1 overflow-auto flex">
|
|
483
|
+
<div className="mo-slide-content" style={{ margin: "auto 20px" }}>
|
|
484
|
+
<CellOutputSlide
|
|
485
|
+
cellId={skippedPreviewCell.id}
|
|
486
|
+
status={skippedPreviewCell.status}
|
|
487
|
+
output={skippedPreviewCell.output}
|
|
488
|
+
/>
|
|
427
489
|
</div>
|
|
428
490
|
</div>
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
data-testid="marimo-plugin-slides-toggle-code"
|
|
435
|
-
variant="ghost"
|
|
436
|
-
size="icon"
|
|
437
|
-
className={cn(
|
|
438
|
-
"text-muted-foreground h-7 w-7",
|
|
439
|
-
codeShown && "text-foreground bg-muted",
|
|
440
|
-
)}
|
|
441
|
-
aria-pressed={codeShown}
|
|
442
|
-
aria-label={codeShown ? "Hide code" : "Show code"}
|
|
443
|
-
onClick={toggleShowCode}
|
|
444
|
-
>
|
|
445
|
-
<CodeIcon className="h-4 w-4" />
|
|
446
|
-
</Button>
|
|
447
|
-
</Tooltip>
|
|
448
|
-
)}
|
|
449
|
-
<Tooltip content="Fullscreen (F)">
|
|
491
|
+
</div>
|
|
492
|
+
)}
|
|
493
|
+
<div className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-70 text-muted-foreground transition-opacity">
|
|
494
|
+
{codeToggleEnabled && (
|
|
495
|
+
<Tooltip content={codeShown ? "Hide code (C)" : "Show code (C)"}>
|
|
450
496
|
<Button
|
|
451
|
-
data-testid="marimo-plugin-slides-
|
|
497
|
+
data-testid="marimo-plugin-slides-toggle-code"
|
|
452
498
|
variant="ghost"
|
|
453
499
|
size="icon"
|
|
454
|
-
className=
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
Logger.error("Failed to request fullscreen", error);
|
|
462
|
-
});
|
|
463
|
-
}}
|
|
500
|
+
className={cn(
|
|
501
|
+
"text-muted-foreground h-7 w-7",
|
|
502
|
+
codeShown && "text-foreground bg-muted",
|
|
503
|
+
)}
|
|
504
|
+
aria-pressed={codeShown}
|
|
505
|
+
aria-label={codeShown ? "Hide code" : "Show code"}
|
|
506
|
+
onClick={toggleShowCode}
|
|
464
507
|
>
|
|
465
|
-
<
|
|
508
|
+
<CodeIcon className="h-4 w-4" />
|
|
466
509
|
</Button>
|
|
467
510
|
</Tooltip>
|
|
468
|
-
|
|
511
|
+
)}
|
|
512
|
+
<Tooltip content="Fullscreen (F)">
|
|
513
|
+
<Button
|
|
514
|
+
data-testid="marimo-plugin-slides-fullscreen"
|
|
515
|
+
variant="ghost"
|
|
516
|
+
size="icon"
|
|
517
|
+
className="text-muted-foreground h-7 w-7"
|
|
518
|
+
aria-label="Enter fullscreen"
|
|
519
|
+
onClick={() => {
|
|
520
|
+
deckRef.current
|
|
521
|
+
?.getViewportElement()
|
|
522
|
+
?.requestFullscreen()
|
|
523
|
+
.catch((error) => {
|
|
524
|
+
Logger.error("Failed to request fullscreen", error);
|
|
525
|
+
});
|
|
526
|
+
}}
|
|
527
|
+
>
|
|
528
|
+
<ExpandIcon className="h-4 w-4" />
|
|
529
|
+
</Button>
|
|
530
|
+
</Tooltip>
|
|
469
531
|
</div>
|
|
470
532
|
</div>
|
|
533
|
+
</div>
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
if (mode === "read") {
|
|
537
|
+
return (
|
|
538
|
+
<div className="flex-1 min-w-0 flex flex-row gap-3">{slideArea}</div>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
471
541
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
542
|
+
return (
|
|
543
|
+
<div className="flex-1 min-w-0 flex flex-row gap-3">
|
|
544
|
+
<PanelGroup
|
|
545
|
+
direction="vertical"
|
|
546
|
+
autoSaveId="marimo:slides:notes-panel"
|
|
547
|
+
className="flex-1 min-w-0"
|
|
548
|
+
>
|
|
549
|
+
<Panel defaultSize={92} minSize={60}>
|
|
550
|
+
{slideArea}
|
|
551
|
+
</Panel>
|
|
552
|
+
<PanelResizeHandle
|
|
553
|
+
className="mo-slides-notes-resize"
|
|
554
|
+
hitAreaMargins={{ coarse: 12, fine: 4 }}
|
|
478
555
|
/>
|
|
479
|
-
|
|
556
|
+
<Panel
|
|
557
|
+
defaultSize={10}
|
|
558
|
+
minSize={4}
|
|
559
|
+
collapsible={true}
|
|
560
|
+
collapsedSize={4}
|
|
561
|
+
>
|
|
562
|
+
<SlideNotesEditor
|
|
563
|
+
layout={layout}
|
|
564
|
+
setLayout={setLayout}
|
|
565
|
+
cellId={activeConfigCell?.id}
|
|
566
|
+
/>
|
|
567
|
+
</Panel>
|
|
568
|
+
</PanelGroup>
|
|
569
|
+
<SlideSidebar
|
|
570
|
+
configWidth={configWidth}
|
|
571
|
+
layout={layout}
|
|
572
|
+
setLayout={setLayout}
|
|
573
|
+
activeConfigCell={activeConfigCell}
|
|
574
|
+
/>
|
|
480
575
|
</div>
|
|
481
576
|
);
|
|
482
577
|
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { StickyNoteIcon } from "lucide-react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import useEvent from "react-use-event-hook";
|
|
6
|
+
import type { CellId } from "@/core/cells/ids";
|
|
7
|
+
import { useDebouncedCallback } from "@/hooks/useDebounce";
|
|
8
|
+
import { cn } from "@/utils/cn";
|
|
9
|
+
import { Events } from "@/utils/events";
|
|
10
|
+
import type { SlidesLayout } from "../editor/renderers/slides-layout/types";
|
|
11
|
+
|
|
12
|
+
interface SlideNotesEditorProps {
|
|
13
|
+
layout: SlidesLayout;
|
|
14
|
+
setLayout: (layout: SlidesLayout) => void;
|
|
15
|
+
cellId: CellId | undefined;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PERSIST_DELAY_MS = 300;
|
|
20
|
+
|
|
21
|
+
export const SlideNotesEditor = ({
|
|
22
|
+
layout,
|
|
23
|
+
setLayout,
|
|
24
|
+
cellId,
|
|
25
|
+
className,
|
|
26
|
+
}: SlideNotesEditorProps) => {
|
|
27
|
+
const initialValue = cellId
|
|
28
|
+
? (layout.cells.get(cellId)?.speakerNotes ?? "")
|
|
29
|
+
: "";
|
|
30
|
+
|
|
31
|
+
const [draft, setDraft] = useState(initialValue);
|
|
32
|
+
|
|
33
|
+
// Tracks whether the user has typed something that hasn't been persisted
|
|
34
|
+
// yet. Used to decide if the textarea is safe to overwrite from props.
|
|
35
|
+
const hasPendingEditRef = useRef(false);
|
|
36
|
+
|
|
37
|
+
// The debounced callback takes `(cellId, text)` so a `flush()` replays with
|
|
38
|
+
// the latest args — which means the in-flight text lands on the slide it
|
|
39
|
+
// was typed for, even if `cellId` has since changed.
|
|
40
|
+
const persistImmediate = useEvent((targetCellId: CellId, next: string) => {
|
|
41
|
+
hasPendingEditRef.current = false;
|
|
42
|
+
const existing = layout.cells.get(targetCellId);
|
|
43
|
+
if ((existing?.speakerNotes ?? "") === next) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const newCells = new Map(layout.cells);
|
|
47
|
+
newCells.set(targetCellId, { ...existing, speakerNotes: next });
|
|
48
|
+
setLayout({ ...layout, cells: newCells });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const persistDebounced = useDebouncedCallback(
|
|
52
|
+
persistImmediate,
|
|
53
|
+
PERSIST_DELAY_MS,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Keep the textarea in sync with `layout`:
|
|
57
|
+
// - On slide switch, flush any in-flight edit to the *previous* slide before
|
|
58
|
+
// adopting the new slide's notes.
|
|
59
|
+
// - On same-slide updates (e.g. future undo/redo or external setLayout
|
|
60
|
+
// writers), adopt the new value only when the user isn't mid-edit so
|
|
61
|
+
// pending keystrokes aren't clobbered.
|
|
62
|
+
const prevCellIdRef = useRef(cellId);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (prevCellIdRef.current !== cellId) {
|
|
65
|
+
persistDebounced.flush();
|
|
66
|
+
hasPendingEditRef.current = false;
|
|
67
|
+
setDraft(initialValue);
|
|
68
|
+
prevCellIdRef.current = cellId;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!hasPendingEditRef.current && initialValue !== draft) {
|
|
72
|
+
setDraft(initialValue);
|
|
73
|
+
}
|
|
74
|
+
}, [cellId, initialValue, draft, persistDebounced]);
|
|
75
|
+
|
|
76
|
+
// Flush on unmount so closing the panel / navigating away doesn't lose text.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
return () => {
|
|
79
|
+
persistDebounced.flush();
|
|
80
|
+
};
|
|
81
|
+
}, [persistDebounced]);
|
|
82
|
+
|
|
83
|
+
const handleChange = (next: string) => {
|
|
84
|
+
setDraft(next);
|
|
85
|
+
if (cellId) {
|
|
86
|
+
hasPendingEditRef.current = true;
|
|
87
|
+
persistDebounced(cellId, next);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<section
|
|
93
|
+
className={cn(
|
|
94
|
+
"h-full min-h-0 flex flex-col bg-muted/40 dark:bg-muted/20 border-t",
|
|
95
|
+
className,
|
|
96
|
+
)}
|
|
97
|
+
aria-label="Speaker notes"
|
|
98
|
+
// Keep keystrokes inside the textarea from advancing the reveal.js deck.
|
|
99
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
100
|
+
>
|
|
101
|
+
<header className="flex items-center gap-1.5 px-3 h-8 shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
102
|
+
<StickyNoteIcon className="h-3.5 w-3.5" />
|
|
103
|
+
<span>Speaker notes</span>
|
|
104
|
+
</header>
|
|
105
|
+
<div className="flex-1 min-h-0 p-2">
|
|
106
|
+
{cellId ? (
|
|
107
|
+
<textarea
|
|
108
|
+
value={draft}
|
|
109
|
+
onChange={(event) => handleChange(event.target.value)}
|
|
110
|
+
onClick={Events.stopPropagation()}
|
|
111
|
+
placeholder="Add notes for this slide. Visible to you in speaker view (press S during presentation)."
|
|
112
|
+
className={cn(
|
|
113
|
+
"h-full w-full resize-none rounded-sm border border-input/25 bg-background",
|
|
114
|
+
"px-3 py-2 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground",
|
|
115
|
+
"ring-offset-background focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-accent",
|
|
116
|
+
)}
|
|
117
|
+
aria-label="Speaker notes for the current slide"
|
|
118
|
+
/>
|
|
119
|
+
) : (
|
|
120
|
+
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
|
|
121
|
+
Select a slide to add notes.
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
</section>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { CellId } from "@/core/cells/ids";
|
|
4
|
+
import type { SlideConfig } from "../editor/renderers/slides-layout/types";
|
|
5
|
+
import type { ComposedSubslide } from "./compose-slides";
|
|
6
|
+
|
|
7
|
+
/** Lone-line marker between blocks; `<NotesAside>` renders this as `<hr>`. */
|
|
8
|
+
export const NOTES_DIVIDER = "---";
|
|
9
|
+
|
|
10
|
+
const BLOCK_JOIN = `\n\n${NOTES_DIVIDER}\n\n`;
|
|
11
|
+
|
|
12
|
+
export const collectBlockNotes = <C extends { id: CellId }>(
|
|
13
|
+
cells: readonly C[],
|
|
14
|
+
slideConfigs: ReadonlyMap<CellId, SlideConfig>,
|
|
15
|
+
): string =>
|
|
16
|
+
cells
|
|
17
|
+
.map((cell) => slideConfigs.get(cell.id)?.speakerNotes ?? "")
|
|
18
|
+
.filter((note) => note.trim().length > 0)
|
|
19
|
+
.join("\n\n");
|
|
20
|
+
|
|
21
|
+
export interface SubslideNotes {
|
|
22
|
+
/** Notes shown when no fragment is current. */
|
|
23
|
+
slideLevel: string;
|
|
24
|
+
/** Cumulative notes for each fragment block, keyed by block index. */
|
|
25
|
+
cumulativeByBlock: Map<number, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Per-fragment cumulative notes: slide-level notes plus each revealed
|
|
30
|
+
* fragment's notes, separated by a horizontal-rule line.
|
|
31
|
+
*/
|
|
32
|
+
export const buildSubslideNotes = <C extends { id: CellId }>(
|
|
33
|
+
subslide: ComposedSubslide<C>,
|
|
34
|
+
slideConfigs: ReadonlyMap<CellId, SlideConfig>,
|
|
35
|
+
): SubslideNotes => {
|
|
36
|
+
const blockNotes = subslide.blocks.map((block) =>
|
|
37
|
+
collectBlockNotes(block.cells, slideConfigs),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const slideLevel = subslide.blocks
|
|
41
|
+
.map((block, i) => (block.isFragment ? "" : blockNotes[i]))
|
|
42
|
+
.filter((note) => note.length > 0)
|
|
43
|
+
.join("\n\n");
|
|
44
|
+
|
|
45
|
+
const cumulativeByBlock = new Map<number, string>();
|
|
46
|
+
const revealsSoFar: string[] = [];
|
|
47
|
+
subslide.blocks.forEach((block, i) => {
|
|
48
|
+
if (!block.isFragment) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const myNotes = blockNotes[i];
|
|
52
|
+
if (myNotes.length > 0) {
|
|
53
|
+
revealsSoFar.push(myNotes);
|
|
54
|
+
}
|
|
55
|
+
const accumulated = [slideLevel, ...revealsSoFar]
|
|
56
|
+
.filter((s) => s.length > 0)
|
|
57
|
+
.join(BLOCK_JOIN);
|
|
58
|
+
if (accumulated.length > 0) {
|
|
59
|
+
cumulativeByBlock.set(i, accumulated);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { slideLevel, cumulativeByBlock };
|
|
64
|
+
};
|