@open-slide/core 1.5.0 → 1.7.0

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 (48) hide show
  1. package/dist/{build-DZhbjQpQ.js → build-tLrkKUHr.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-BQdTMho4.d.ts → config-CfMThYN9.d.ts} +1 -1
  4. package/dist/{config-iKjqaX08.js → config-PwUHqZ_X.js} +246 -2
  5. package/dist/{dev-BjLGk5nN.js → dev-DpCIRbhT.js} +1 -1
  6. package/dist/{en-DDGqyNaW.js → en-BDnM5zKJ.js} +4 -0
  7. package/dist/index.d.ts +29 -4
  8. package/dist/index.js +20 -4
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +13 -1
  11. package/dist/{preview-jwLWHWkQ.js → preview-BSGlM6Se.js} +1 -1
  12. package/dist/{types-Dpr8nbih.d.ts → types-B-KrjgX8.d.ts} +5 -0
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/create-theme/SKILL.md +30 -22
  17. package/skills/slide-authoring/SKILL.md +186 -0
  18. package/src/app/components/asset-view.tsx +8 -1
  19. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  20. package/src/app/components/inspector/inspect-overlay.tsx +132 -35
  21. package/src/app/components/inspector/inspector-panel.tsx +19 -256
  22. package/src/app/components/inspector/inspector-provider.tsx +102 -1
  23. package/src/app/components/panel/save-card.tsx +4 -4
  24. package/src/app/components/player.tsx +13 -3
  25. package/src/app/components/present/overview-grid.tsx +4 -1
  26. package/src/app/components/slide-transition-layer.tsx +154 -0
  27. package/src/app/components/style-panel/style-panel.tsx +3 -0
  28. package/src/app/components/themes/theme-detail.tsx +7 -2
  29. package/src/app/components/themes/themes-gallery.tsx +4 -1
  30. package/src/app/components/thumbnail-rail.tsx +10 -2
  31. package/src/app/lib/assets.ts +2 -0
  32. package/src/app/lib/export-html.ts +7 -2
  33. package/src/app/lib/export-pdf.ts +34 -2
  34. package/src/app/lib/folders.ts +35 -1
  35. package/src/app/lib/page-context.tsx +38 -0
  36. package/src/app/lib/sdk.ts +3 -1
  37. package/src/app/lib/transition.ts +23 -0
  38. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  39. package/src/app/lib/use-wheel-page-navigation.ts +7 -0
  40. package/src/app/routes/home-shell.tsx +13 -2
  41. package/src/app/routes/home.tsx +28 -2
  42. package/src/app/routes/presenter.tsx +7 -2
  43. package/src/app/routes/slide.tsx +19 -8
  44. package/src/locale/en.ts +4 -0
  45. package/src/locale/ja.ts +4 -0
  46. package/src/locale/types.ts +5 -0
  47. package/src/locale/zh-cn.ts +4 -0
  48. package/src/locale/zh-tw.ts +4 -0
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
15
15
  import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
16
16
  import { type Edit, type EditOp, type EditResult, useEditor } from '@/lib/inspector/use-editor';
17
17
  import { useLocale } from '@/lib/use-locale';
18
+ import { AssetPickerDialog } from './asset-picker-dialog';
18
19
  import { ImageCropDialog, type ImageCropRect } from './image-crop-dialog';
19
20
 
20
21
  export type SelectedTarget = {
@@ -263,6 +264,7 @@ type InspectorCtx = {
263
264
  cancelEdits: () => void;
264
265
  committing: boolean;
265
266
  openCrop: (anchor: HTMLImageElement) => void;
267
+ openReplace: (anchor: HTMLElement) => void;
266
268
  };
267
269
 
268
270
  const Ctx = createContext<InspectorCtx | null>(null);
@@ -273,7 +275,15 @@ export function useInspector(): InspectorCtx {
273
275
  return v;
274
276
  }
275
277
 
276
- export function InspectorProvider({ slideId, children }: { slideId: string; children: ReactNode }) {
278
+ export function InspectorProvider({
279
+ slideId,
280
+ pageIndex,
281
+ children,
282
+ }: {
283
+ slideId: string;
284
+ pageIndex: number;
285
+ children: ReactNode;
286
+ }) {
277
287
  const [active, setActive] = useState(false);
278
288
  const [selected, setSelected] = useState<SelectedTarget | null>(null);
279
289
  const { comments, error, refetch, add, remove } = useComments(slideId);
@@ -296,6 +306,11 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
296
306
  initialPosition: { x: number; y: number };
297
307
  initialRect: ImageCropRect | null;
298
308
  } | null>(null);
309
+ const [replaceTarget, setReplaceTarget] = useState<{
310
+ line: number;
311
+ column: number;
312
+ anchor: HTMLElement;
313
+ } | null>(null);
299
314
  const t = useLocale();
300
315
 
301
316
  const ensureInstanceId = useCallback((el: HTMLElement): string => {
@@ -871,6 +886,35 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
871
886
  return () => observer?.disconnect();
872
887
  }, []);
873
888
 
889
+ useEffect(() => {
890
+ void pageIndex;
891
+ setSelected(null);
892
+ }, [pageIndex]);
893
+
894
+ // Never clear `selected` on a miss: the observer can fire between an
895
+ // "old removed" and "new added" mutation batch, and clearing then would
896
+ // drop a selection that's about to reattach on the next fire.
897
+ useEffect(() => {
898
+ if (!selected) return;
899
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
900
+ if (!root) return;
901
+
902
+ const revalidate = () => {
903
+ if (selected.anchor.isConnected) return;
904
+ const next = root.querySelector<HTMLElement>(
905
+ `[data-slide-loc="${selected.line}:${selected.column}"]`,
906
+ );
907
+ if (next && next !== selected.anchor) {
908
+ setSelected({ ...selected, anchor: next });
909
+ }
910
+ };
911
+
912
+ revalidate();
913
+ const observer = new MutationObserver(revalidate);
914
+ observer.observe(root, { childList: true, subtree: true });
915
+ return () => observer.disconnect();
916
+ }, [selected]);
917
+
874
918
  const toggle = useCallback(() => {
875
919
  setActive((a) => {
876
920
  if (a) setSelected(null);
@@ -883,6 +927,27 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
883
927
  setSelected(null);
884
928
  }, []);
885
929
 
930
+ const openReplace = useCallback((anchor: HTMLElement) => {
931
+ const loc = anchor.dataset.slideLoc;
932
+ if (!loc) return;
933
+ const [lineStr, columnStr] = loc.split(':');
934
+ const line = Number(lineStr);
935
+ const column = Number(columnStr);
936
+ if (!Number.isFinite(line) || !Number.isFinite(column)) return;
937
+ setReplaceTarget({ line, column, anchor });
938
+ }, []);
939
+
940
+ useEffect(() => {
941
+ if (import.meta.env.PROD) return;
942
+ const onKey = (e: KeyboardEvent) => {
943
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
944
+ if (e.key !== 'i' && e.key !== 'I') return;
945
+ toggle();
946
+ };
947
+ window.addEventListener('keydown', onKey);
948
+ return () => window.removeEventListener('keydown', onKey);
949
+ }, [toggle]);
950
+
886
951
  const openCrop = useCallback((anchor: HTMLImageElement) => {
887
952
  const loc = anchor.dataset.slideLoc;
888
953
  if (!loc) return;
@@ -925,6 +990,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
925
990
  cancelEdits,
926
991
  committing,
927
992
  openCrop,
993
+ openReplace,
928
994
  }),
929
995
  [
930
996
  slideId,
@@ -945,12 +1011,44 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
945
1011
  cancelEdits,
946
1012
  committing,
947
1013
  openCrop,
1014
+ openReplace,
948
1015
  ],
949
1016
  );
950
1017
 
951
1018
  return (
952
1019
  <Ctx.Provider value={value}>
953
1020
  {children}
1021
+ {replaceTarget && (
1022
+ <AssetPickerDialog
1023
+ slideId={slideId}
1024
+ onClose={() => setReplaceTarget(null)}
1025
+ onPick={(asset, scope) => {
1026
+ const { line, column, anchor } = replaceTarget;
1027
+ const assetPath =
1028
+ scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
1029
+ const ops: EditOp[] = [
1030
+ {
1031
+ kind: 'set-attr-asset',
1032
+ attr: 'src',
1033
+ assetPath,
1034
+ previewUrl: asset.url,
1035
+ },
1036
+ ];
1037
+ if (anchor.tagName === 'IMG' && anchor.isConnected) {
1038
+ const cs = window.getComputedStyle(anchor);
1039
+ if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
1040
+ ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
1041
+ }
1042
+ const op = cs.objectPosition.trim();
1043
+ if (!op || op === '0% 0%' || op === 'auto') {
1044
+ ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
1045
+ }
1046
+ }
1047
+ bufferOps(line, column, anchor, ops);
1048
+ setReplaceTarget(null);
1049
+ }}
1050
+ />
1051
+ )}
954
1052
  {cropTarget && (
955
1053
  <ImageCropDialog
956
1054
  src={cropTarget.src}
@@ -1064,6 +1162,9 @@ export function InspectToggleButton() {
1064
1162
  >
1065
1163
  <Crosshair className="size-3.5" />
1066
1164
  <span className="hidden md:inline">{t.inspector.inspect}</span>
1165
+ <kbd className="ml-1 hidden rounded-[3px] bg-foreground/10 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
1166
+ I
1167
+ </kbd>
1067
1168
  </Button>
1068
1169
  );
1069
1170
  }
@@ -91,15 +91,15 @@ export function SaveCard({
91
91
  </div>
92
92
  )}
93
93
  {justSaved ? (
94
- <span className="flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
95
- <Check className="size-3.5 text-[oklch(0.55_0.13_165)]" strokeWidth={2.5} />
94
+ <span className="flex items-center gap-1.5 whitespace-nowrap px-2.5 text-[12px] font-medium text-foreground">
95
+ <Check className="size-3.5 shrink-0 text-[oklch(0.55_0.13_165)]" strokeWidth={2.5} />
96
96
  {resolvedSavedLabel}
97
97
  </span>
98
98
  ) : dirty || committing ? (
99
- <span className="inline-flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
99
+ <span className="inline-flex items-center gap-1.5 whitespace-nowrap px-2.5 text-[12px] font-medium text-foreground">
100
100
  <span
101
101
  aria-hidden
102
- className="size-1.5 rounded-full bg-brand shadow-[0_0_0_3px_var(--brand-soft)]"
102
+ className="size-1.5 shrink-0 rounded-full bg-brand shadow-[0_0_0_3px_var(--brand-soft)]"
103
103
  />
104
104
  <span className="nums">{unsavedLabel}</span>
105
105
  </span>
@@ -3,6 +3,8 @@ import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
3
3
  import { cn } from '@/lib/utils';
4
4
  import type { DesignSystem } from '../lib/design';
5
5
  import type { Page } from '../lib/sdk';
6
+ import type { SlideTransition } from '../lib/transition';
7
+ import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
6
8
  import { PresentBlackoutOverlay } from './present/blackout-overlay';
7
9
  import { PresentControlBar } from './present/control-bar';
8
10
  import { PresentHelpOverlay } from './present/help-overlay';
@@ -19,6 +21,7 @@ import {
19
21
  } from './present/use-presenter-channel';
20
22
  import { useTouchSwipe } from './present/use-touch-swipe';
21
23
  import { SlideCanvas } from './slide-canvas';
24
+ import { SlideTransitionLayer } from './slide-transition-layer';
22
25
 
23
26
  const IDLE_HIDE_MS = 2000;
24
27
  const BAR_HOTZONE_PX = 160;
@@ -26,6 +29,7 @@ const BAR_HOTZONE_PX = 160;
26
29
  type Props = {
27
30
  pages: Page[];
28
31
  design?: DesignSystem;
32
+ transition?: SlideTransition;
29
33
  index: number;
30
34
  onIndexChange: (index: number) => void;
31
35
  onExit: () => void;
@@ -43,6 +47,7 @@ type Props = {
43
47
  export function Player({
44
48
  pages,
45
49
  design,
50
+ transition,
46
51
  index,
47
52
  onIndexChange,
48
53
  onExit,
@@ -51,6 +56,7 @@ export function Player({
51
56
  slideId,
52
57
  fullscreen = true,
53
58
  }: Props) {
59
+ const prefersReducedMotion = usePrefersReducedMotion();
54
60
  const rootRef = useRef<HTMLDivElement | null>(null);
55
61
  // Mirrored as state so descendants portaling *into* the player subtree
56
62
  // (tooltips, popovers — the body is outside the fullscreen tree) re-render
@@ -283,8 +289,6 @@ export function Player({
283
289
  const hideCursor =
284
290
  controls && (laser || keyboardDriven || (idle && !overlayActive && !pointerNearBottom));
285
291
 
286
- const PageComp = pages[index];
287
-
288
292
  return (
289
293
  <div
290
294
  ref={setRoot}
@@ -295,7 +299,13 @@ export function Player({
295
299
  )}
296
300
  >
297
301
  <SlideCanvas flat design={design}>
298
- {PageComp ? <PageComp /> : null}
302
+ <SlideTransitionLayer
303
+ pages={pages}
304
+ index={index}
305
+ total={pages.length}
306
+ moduleTransition={transition}
307
+ disabled={prefersReducedMotion}
308
+ />
299
309
  </SlideCanvas>
300
310
 
301
311
  <button
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
2
2
  import { format, useLocale } from '@/lib/use-locale';
3
3
  import { cn } from '@/lib/utils';
4
4
  import type { DesignSystem } from '../../lib/design';
5
+ import { SlidePageProvider } from '../../lib/page-context';
5
6
  import type { Page } from '../../lib/sdk';
6
7
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../../lib/sdk';
7
8
  import { SlideCanvas } from '../slide-canvas';
@@ -136,7 +137,9 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
136
137
  freezeMotion
137
138
  design={design}
138
139
  >
139
- <PageComp />
140
+ <SlidePageProvider index={i} total={pages.length}>
141
+ <PageComp />
142
+ </SlidePageProvider>
140
143
  </SlideCanvas>
141
144
  {isCurrent && (
142
145
  <span
@@ -0,0 +1,154 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { SlidePageProvider } from '../lib/page-context';
3
+ import type { Page } from '../lib/sdk';
4
+ import { resolveTransition, type SlideTransition, type TransitionPhase } from '../lib/transition';
5
+
6
+ type Props = {
7
+ pages: Page[];
8
+ index: number;
9
+ total: number;
10
+ moduleTransition?: SlideTransition;
11
+ disabled?: boolean;
12
+ };
13
+
14
+ type Direction = 'forward' | 'backward';
15
+
16
+ const DEFAULT_EASING = 'cubic-bezier(.4, 0, .2, 1)';
17
+
18
+ function runPhase(
19
+ el: HTMLElement,
20
+ phase: TransitionPhase | undefined,
21
+ fallbackDuration: number,
22
+ fallbackEasing: string,
23
+ ): Animation | null {
24
+ if (!phase) return null;
25
+ return el.animate(phase.keyframes, {
26
+ duration: phase.duration ?? fallbackDuration,
27
+ easing: phase.easing ?? fallbackEasing,
28
+ delay: phase.delay ?? 0,
29
+ fill: 'both',
30
+ });
31
+ }
32
+
33
+ export function SlideTransitionLayer({ pages, index, total, moduleTransition, disabled }: Props) {
34
+ const [current, setCurrent] = useState(index);
35
+ const [outgoing, setOutgoing] = useState<number | null>(null);
36
+ const [direction, setDirection] = useState<Direction>('forward');
37
+
38
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
39
+ const outgoingLayerRef = useRef<HTMLDivElement | null>(null);
40
+ const incomingLayerRef = useRef<HTMLDivElement | null>(null);
41
+ const animsRef = useRef<Animation[]>([]);
42
+ const currentRef = useRef(current);
43
+ currentRef.current = current;
44
+
45
+ useEffect(() => {
46
+ if (index === currentRef.current) return;
47
+
48
+ const prev = currentRef.current;
49
+ const next = index;
50
+
51
+ // Interrupt: cancel in-flight animations. The previously-incoming page
52
+ // (currentRef) becomes the new outgoing; React reuses its DOM slot.
53
+ for (const a of animsRef.current) {
54
+ try {
55
+ a.cancel();
56
+ } catch {}
57
+ }
58
+ animsRef.current = [];
59
+
60
+ const transition = resolveTransition(pages, next, moduleTransition);
61
+ if (disabled || !transition) {
62
+ setCurrent(next);
63
+ setOutgoing(null);
64
+ return;
65
+ }
66
+
67
+ setDirection(next > prev ? 'forward' : 'backward');
68
+ setOutgoing(prev);
69
+ setCurrent(next);
70
+ }, [index, pages, moduleTransition, disabled]);
71
+
72
+ useEffect(() => {
73
+ if (outgoing === null) return;
74
+
75
+ const transition = resolveTransition(pages, current, moduleTransition);
76
+ const wrapper = wrapperRef.current;
77
+ const out = outgoingLayerRef.current;
78
+ const inc = incomingLayerRef.current;
79
+ if (!transition || !wrapper || !out || !inc) {
80
+ setOutgoing(null);
81
+ return;
82
+ }
83
+
84
+ wrapper.dataset.osdDir = direction;
85
+ wrapper.style.setProperty('--osd-dir', direction === 'forward' ? '1' : '-1');
86
+
87
+ const easing = transition.easing ?? DEFAULT_EASING;
88
+ const duration = transition.duration;
89
+
90
+ const anims: Animation[] = [];
91
+ const exitAnim = runPhase(out, transition.exit, duration, easing);
92
+ const enterAnim = runPhase(inc, transition.enter, duration, easing);
93
+ if (exitAnim) anims.push(exitAnim);
94
+ if (enterAnim) anims.push(enterAnim);
95
+ animsRef.current = anims;
96
+
97
+ if (anims.length === 0) {
98
+ setOutgoing(null);
99
+ return;
100
+ }
101
+
102
+ let cancelled = false;
103
+ Promise.all(anims.map((a) => a.finished))
104
+ .then(() => {
105
+ if (cancelled) return;
106
+ animsRef.current = [];
107
+ setOutgoing(null);
108
+ })
109
+ .catch(() => {
110
+ // AbortError fires when we cancel mid-flight on an interrupt.
111
+ });
112
+
113
+ return () => {
114
+ cancelled = true;
115
+ };
116
+ }, [outgoing, current, direction, pages, moduleTransition]);
117
+
118
+ useEffect(() => {
119
+ return () => {
120
+ for (const a of animsRef.current) {
121
+ try {
122
+ a.cancel();
123
+ } catch {}
124
+ }
125
+ animsRef.current = [];
126
+ };
127
+ }, []);
128
+
129
+ const CurrentPage = pages[current];
130
+ const OutgoingPage = outgoing !== null ? pages[outgoing] : null;
131
+
132
+ return (
133
+ <div
134
+ ref={wrapperRef}
135
+ className="relative h-full w-full"
136
+ style={{ background: 'var(--osd-bg)' }}
137
+ >
138
+ {OutgoingPage && outgoing !== null ? (
139
+ <div ref={outgoingLayerRef} className="absolute inset-0">
140
+ <SlidePageProvider index={outgoing} total={total}>
141
+ <OutgoingPage />
142
+ </SlidePageProvider>
143
+ </div>
144
+ ) : null}
145
+ {CurrentPage ? (
146
+ <div ref={incomingLayerRef} className="absolute inset-0">
147
+ <SlidePageProvider index={current} total={total}>
148
+ <CurrentPage />
149
+ </SlidePageProvider>
150
+ </div>
151
+ ) : null}
152
+ </div>
153
+ );
154
+ }
@@ -211,6 +211,9 @@ export function DesignToggleButton({
211
211
  >
212
212
  <Palette className="size-3.5" />
213
213
  <span className="hidden md:inline">{t.stylePanel.designToggle}</span>
214
+ <kbd className="ml-1 hidden rounded-[3px] bg-foreground/10 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
215
+ D
216
+ </kbd>
214
217
  </Button>
215
218
  );
216
219
  }
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
4
4
  import { Button } from '@/components/ui/button';
5
5
  import { format, useLocale } from '@/lib/use-locale';
6
6
  import { cn } from '@/lib/utils';
7
+ import { SlidePageProvider } from '../../lib/page-context';
7
8
  import type { SlideModule } from '../../lib/sdk';
8
9
  import { loadSlide, slidesByTheme } from '../../lib/slides';
9
10
  import { loadThemeDemo, type ThemeDemoModule, themes } from '../../lib/themes';
@@ -106,7 +107,9 @@ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () =
106
107
  </div>
107
108
  ) : Current ? (
108
109
  <SlideCanvas flat freezeMotion design={demo.design}>
109
- <Current />
110
+ <SlidePageProvider index={pageIndex} total={totalPages}>
111
+ <Current />
112
+ </SlidePageProvider>
110
113
  </SlideCanvas>
111
114
  ) : null}
112
115
  </div>
@@ -227,7 +230,9 @@ function ThemeSlideCard({ id }: { id: string }) {
227
230
  {FirstPage ? (
228
231
  <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
229
232
  <SlideCanvas flat freezeMotion design={slide?.design}>
230
- <FirstPage />
233
+ <SlidePageProvider index={0} total={slide?.default.length ?? 1}>
234
+ <FirstPage />
235
+ </SlidePageProvider>
231
236
  </SlideCanvas>
232
237
  </div>
233
238
  ) : (
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { format, useLocale } from '@/lib/use-locale';
3
+ import { SlidePageProvider } from '../../lib/page-context';
3
4
  import { loadThemeDemo, type Theme, type ThemeDemoModule, themes } from '../../lib/themes';
4
5
  import { SlideCanvas } from '../slide-canvas';
5
6
 
@@ -78,7 +79,9 @@ function ThemePreview({ theme }: { theme: Theme }) {
78
79
  return (
79
80
  <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
80
81
  <SlideCanvas flat freezeMotion design={demo.design}>
81
- <FirstPage />
82
+ <SlidePageProvider index={0} total={demo.default.length}>
83
+ <FirstPage />
84
+ </SlidePageProvider>
82
85
  </SlideCanvas>
83
86
  </div>
84
87
  );
@@ -28,6 +28,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
28
28
  import { format, useLocale } from '@/lib/use-locale';
29
29
  import { cn } from '@/lib/utils';
30
30
  import type { DesignSystem } from '../lib/design';
31
+ import { SlidePageProvider } from '../lib/page-context';
31
32
  import type { Page } from '../lib/sdk';
32
33
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
33
34
  import { SlideCanvas } from './slide-canvas';
@@ -118,7 +119,9 @@ export function ThumbnailRail({
118
119
  style={{ width, height: HORIZONTAL_THUMB_HEIGHT }}
119
120
  >
120
121
  <SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
121
- <PageComp />
122
+ <SlidePageProvider index={i} total={pages.length}>
123
+ <PageComp />
124
+ </SlidePageProvider>
122
125
  </SlideCanvas>
123
126
  </div>
124
127
  </button>
@@ -155,6 +158,7 @@ export function ThumbnailRail({
155
158
  const inner = (
156
159
  <ThumbContents
157
160
  index={i}
161
+ total={pages.length}
158
162
  active={active}
159
163
  page={PageComp}
160
164
  design={design}
@@ -236,6 +240,7 @@ function thumbButtonClass(active: boolean): string {
236
240
 
237
241
  function ThumbContents({
238
242
  index,
243
+ total,
239
244
  active,
240
245
  page: PageComp,
241
246
  design,
@@ -244,6 +249,7 @@ function ThumbContents({
244
249
  height,
245
250
  }: {
246
251
  index: number;
252
+ total: number;
247
253
  active: boolean;
248
254
  page: Page;
249
255
  design?: DesignSystem;
@@ -271,7 +277,9 @@ function ThumbContents({
271
277
  style={{ width: thumbWidth, height }}
272
278
  >
273
279
  <SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
274
- <PageComp />
280
+ <SlidePageProvider index={index} total={total}>
281
+ <PageComp />
282
+ </SlidePageProvider>
275
283
  </SlideCanvas>
276
284
  {active && (
277
285
  <span
@@ -6,6 +6,7 @@ export type AssetEntry = {
6
6
  mtime: number;
7
7
  mime: string;
8
8
  url: string;
9
+ unused: boolean;
9
10
  };
10
11
 
11
12
  export type UploadOptions = { overwrite?: boolean };
@@ -90,6 +91,7 @@ export async function uploadWithAutoRename(
90
91
  mtime: body?.mtime ?? Date.now(),
91
92
  mime: body?.mime ?? uploaded.type ?? 'application/octet-stream',
92
93
  url: body?.url ?? `/__assets/${slideId}/${encodeURIComponent(uploaded.name)}`,
94
+ unused: body?.unused ?? false,
93
95
  };
94
96
  return { ok: true, status: res.status, entry };
95
97
  }
@@ -1,6 +1,7 @@
1
1
  import { createElement } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
3
  import { designToCssVars } from './design';
4
+ import { SlidePageProvider } from './page-context';
4
5
  import type { SlideModule } from './sdk';
5
6
 
6
7
  type AssetEntry = { name: string; bytes: Uint8Array };
@@ -82,13 +83,17 @@ async function renderPagesToHtml(pages: NonNullable<SlideModule['default']>): Pr
82
83
 
83
84
  const result: string[] = [];
84
85
  try {
85
- for (const Page of pages) {
86
+ for (let i = 0; i < pages.length; i++) {
87
+ const Page = pages[i];
88
+ if (!Page) continue;
86
89
  const host = document.createElement('div');
87
90
  host.style.width = '1920px';
88
91
  host.style.height = '1080px';
89
92
  container.appendChild(host);
90
93
  const root = createRoot(host);
91
- root.render(createElement(Page));
94
+ root.render(
95
+ createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)),
96
+ );
92
97
  await nextPaint();
93
98
  await nextPaint();
94
99
  result.push(host.innerHTML);
@@ -1,6 +1,7 @@
1
1
  import { createElement } from 'react';
2
2
  import { createRoot, type Root } from 'react-dom/client';
3
3
  import { designToCssVars } from './design';
4
+ import { SlidePageProvider } from './page-context';
4
5
  import { isFrameAnimationSettled, waitForDataWaitfor, waitForFonts } from './print-ready';
5
6
  import type { SlideModule } from './sdk';
6
7
 
@@ -62,6 +63,20 @@ const PRINT_STYLES = `
62
63
  transform: scale(0.5);
63
64
  transform-origin: top left;
64
65
  }
66
+ /* Chromium serializes box-shadow and CSS gradients as PDF transparency
67
+ groups / soft masks. macOS Preview re-composites those on every page
68
+ turn, causing 0.5–2s per-page lag. Strip them in the print container
69
+ only — gradients on pseudo-elements via CSS (DOM walk can't reach them),
70
+ inline-style gradients via neutralizeGradientBackgrounds() below. */
71
+ #${PRINT_ROOT_ID} *,
72
+ #${PRINT_ROOT_ID} *::before,
73
+ #${PRINT_ROOT_ID} *::after {
74
+ box-shadow: none !important;
75
+ }
76
+ #${PRINT_ROOT_ID} *::before,
77
+ #${PRINT_ROOT_ID} *::after {
78
+ background-image: none !important;
79
+ }
65
80
  }
66
81
  `;
67
82
 
@@ -109,7 +124,9 @@ export async function exportSlideAsPdf(
109
124
 
110
125
  const reactRoots: Root[] = [];
111
126
  const frames: HTMLElement[] = [];
112
- for (const Page of pages) {
127
+ for (let i = 0; i < pages.length; i++) {
128
+ const Page = pages[i];
129
+ if (!Page) continue;
113
130
  const host = document.createElement('div');
114
131
  host.className = 'os-print-frame';
115
132
  host.setAttribute('data-osd-canvas', '');
@@ -126,7 +143,9 @@ export async function exportSlideAsPdf(
126
143
  root.appendChild(host);
127
144
  frames.push(host);
128
145
  const r = createRoot(inner);
129
- r.render(createElement(Page));
146
+ r.render(
147
+ createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)),
148
+ );
130
149
  reactRoots.push(r);
131
150
  }
132
151
  // Yield once so React commits all pages and CSS animations actually start
@@ -155,6 +174,7 @@ export async function exportSlideAsPdf(
155
174
  }
156
175
 
157
176
  await waitForDataWaitfor(root);
177
+ neutralizeGradientBackgrounds(root);
158
178
  await sleep(100); // flush layout
159
179
 
160
180
  onProgress?.({ phase: 'printing', current: total, total, percent: 99 });
@@ -170,6 +190,18 @@ export async function exportSlideAsPdf(
170
190
  }
171
191
  }
172
192
 
193
+ // Strip inline-style gradients from background-image so Chromium does not
194
+ // emit them as PDF soft masks. url(...) backgrounds are preserved.
195
+ function neutralizeGradientBackgrounds(root: HTMLElement): void {
196
+ const elements = root.querySelectorAll<HTMLElement>('*');
197
+ for (const el of elements) {
198
+ const bg = getComputedStyle(el).backgroundImage;
199
+ if (bg?.includes('gradient(')) {
200
+ el.style.backgroundImage = 'none';
201
+ }
202
+ }
203
+ }
204
+
173
205
  function sleep(ms: number): Promise<void> {
174
206
  return new Promise((resolve) => setTimeout(resolve, ms));
175
207
  }