@open-slide/core 1.4.0 → 1.6.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 (45) hide show
  1. package/dist/{build-1Rqivz0d.js → build-tLrkKUHr.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-s0YUbmUe.d.ts → config-CfMThYN9.d.ts} +1 -1
  4. package/dist/{config-XZJnC_fu.js → config-PwUHqZ_X.js} +2312 -1654
  5. package/dist/{dev-0W8gYiSa.js → dev-DpCIRbhT.js} +1 -1
  6. package/dist/{en-7GU-DHbJ.js → en-BDnM5zKJ.js} +18 -1
  7. package/dist/index.d.ts +12 -3
  8. package/dist/index.js +20 -4
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +55 -4
  11. package/dist/{preview-DT9hJvzM.js → preview-BSGlM6Se.js} +1 -1
  12. package/dist/{types-QCpkHkiS.d.ts → types-B-KrjgX8.d.ts} +21 -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 +26 -2
  18. package/src/app/components/asset-view.tsx +83 -10
  19. package/src/app/components/inspector/inspector-panel.tsx +16 -1
  20. package/src/app/components/panel/panel-shell.tsx +5 -3
  21. package/src/app/components/player.tsx +6 -1
  22. package/src/app/components/present/laser-pointer.tsx +3 -4
  23. package/src/app/components/present/overview-grid.tsx +4 -1
  24. package/src/app/components/present/progress-bar.tsx +4 -4
  25. package/src/app/components/themes/theme-detail.tsx +7 -2
  26. package/src/app/components/themes/themes-gallery.tsx +4 -1
  27. package/src/app/components/thumbnail-rail.tsx +10 -2
  28. package/src/app/lib/assets.ts +23 -0
  29. package/src/app/lib/export-html.ts +7 -2
  30. package/src/app/lib/export-pdf.ts +34 -2
  31. package/src/app/lib/folders.ts +35 -1
  32. package/src/app/lib/page-context.tsx +38 -0
  33. package/src/app/lib/sdk.ts +2 -0
  34. package/src/app/lib/slides.ts +2 -0
  35. package/src/app/lib/use-wheel-page-navigation.ts +7 -0
  36. package/src/app/routes/home-shell.tsx +13 -2
  37. package/src/app/routes/home.tsx +129 -5
  38. package/src/app/routes/presenter.tsx +7 -2
  39. package/src/app/routes/slide.tsx +49 -1
  40. package/src/app/virtual.d.ts +1 -0
  41. package/src/locale/en.ts +18 -1
  42. package/src/locale/ja.ts +18 -1
  43. package/src/locale/types.ts +21 -0
  44. package/src/locale/zh-cn.ts +18 -1
  45. package/src/locale/zh-tw.ts +18 -1
@@ -33,8 +33,11 @@ import {
33
33
  import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
34
34
  import {
35
35
  type AssetEntry,
36
+ type AssetUsage,
36
37
  fetchSvgAsFile,
38
+ listAssetUsages,
37
39
  renamedCopy,
40
+ revertAssetUsage,
38
41
  type SvglItem,
39
42
  searchSvgl,
40
43
  useAssets,
@@ -62,6 +65,7 @@ export function AssetView({ slideId }: Props) {
62
65
  const [conflict, setConflict] = useState<ConflictState | null>(null);
63
66
  const [preview, setPreview] = useState<AssetEntry | null>(null);
64
67
  const [confirmDelete, setConfirmDelete] = useState<AssetEntry | null>(null);
68
+ const [confirmDeleteUsages, setConfirmDeleteUsages] = useState<AssetUsage[] | null>(null);
65
69
  const [renaming, setRenaming] = useState<string | null>(null);
66
70
  const [logoSearchOpen, setLogoSearchOpen] = useState(false);
67
71
  const dragDepth = useRef(0);
@@ -245,7 +249,13 @@ export function AssetView({ slideId }: Props) {
245
249
  asset={asset}
246
250
  onPreview={() => setPreview(asset)}
247
251
  onRename={() => setRenaming(asset.name)}
248
- onDelete={() => setConfirmDelete(asset)}
252
+ onDelete={() => {
253
+ setConfirmDelete(asset);
254
+ setConfirmDeleteUsages(null);
255
+ listAssetUsages(effectiveSlideId, asset.name)
256
+ .then((u) => setConfirmDeleteUsages(u))
257
+ .catch(() => setConfirmDeleteUsages([]));
258
+ }}
249
259
  />
250
260
  ),
251
261
  )}
@@ -282,13 +292,41 @@ export function AssetView({ slideId }: Props) {
282
292
  {confirmDelete && (
283
293
  <DeleteDialog
284
294
  asset={confirmDelete}
285
- onCancel={() => setConfirmDelete(null)}
295
+ usages={confirmDeleteUsages}
296
+ onCancel={() => {
297
+ setConfirmDelete(null);
298
+ setConfirmDeleteUsages(null);
299
+ }}
286
300
  onConfirm={async () => {
287
301
  const target = confirmDelete;
302
+ const usages = confirmDeleteUsages ?? [];
288
303
  setConfirmDelete(null);
304
+ setConfirmDeleteUsages(null);
305
+ const assetPath =
306
+ scope === 'global' ? `@assets/${target.name}` : `./assets/${target.name}`;
307
+ for (const u of usages) {
308
+ const rev = await revertAssetUsage(u.slideId, assetPath);
309
+ if (!rev.ok) {
310
+ toast.error(format(t.asset.toastRevertFailed, { slideId: u.slideId }));
311
+ return;
312
+ }
313
+ }
289
314
  const res = await remove(target.name);
290
- if (!res.ok) toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
291
- else toast.success(format(t.asset.toastDeleted, { name: target.name }));
315
+ if (!res.ok) {
316
+ toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
317
+ return;
318
+ }
319
+ const totalUsages = usages.reduce((acc, u) => acc + u.count, 0);
320
+ if (totalUsages > 0) {
321
+ toast.success(
322
+ format(t.asset.toastDeletedWithRevert, {
323
+ name: target.name,
324
+ count: totalUsages,
325
+ }),
326
+ );
327
+ } else {
328
+ toast.success(format(t.asset.toastDeleted, { name: target.name }));
329
+ }
292
330
  }}
293
331
  />
294
332
  )}
@@ -376,7 +414,14 @@ function AssetCard({
376
414
  <div className="truncate text-[12.5px] font-medium" title={asset.name}>
377
415
  {asset.name}
378
416
  </div>
379
- <div className="folio truncate">{formatSize(asset.size)}</div>
417
+ <div className="folio flex items-center gap-1.5">
418
+ <span className="truncate">{formatSize(asset.size)}</span>
419
+ {asset.unused ? (
420
+ <span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[10px] font-medium text-muted-foreground leading-none">
421
+ {t.asset.usageUnused}
422
+ </span>
423
+ ) : null}
424
+ </div>
380
425
  </div>
381
426
  <DropdownMenu>
382
427
  <DropdownMenuTrigger
@@ -517,14 +562,19 @@ function ConflictDialog({
517
562
 
518
563
  function DeleteDialog({
519
564
  asset,
565
+ usages,
520
566
  onCancel,
521
567
  onConfirm,
522
568
  }: {
523
569
  asset: AssetEntry;
570
+ usages: AssetUsage[] | null;
524
571
  onCancel: () => void;
525
572
  onConfirm: () => void;
526
573
  }) {
527
574
  const t = useLocale();
575
+ const inUse = (usages?.length ?? 0) > 0;
576
+ const totalUses = usages?.reduce((acc, u) => acc + u.count, 0) ?? 0;
577
+ const slideCount = usages?.length ?? 0;
528
578
  const [descPrefix, descSuffix] = t.asset.deleteAssetDescription.split('{name}');
529
579
  return (
530
580
  <Dialog open onOpenChange={(open) => !open && onCancel()}>
@@ -532,17 +582,40 @@ function DeleteDialog({
532
582
  <DialogHeader>
533
583
  <DialogTitle>{t.asset.deleteAssetTitle}</DialogTitle>
534
584
  <DialogDescription>
535
- {descPrefix}
536
- <span className="font-mono">{asset.name}</span>
537
- {descSuffix}
585
+ {inUse ? (
586
+ <>
587
+ {format(t.asset.deleteAssetInUseDescription, {
588
+ name: asset.name,
589
+ count: totalUses,
590
+ slides: slideCount,
591
+ })}{' '}
592
+ {t.asset.deleteAssetInUseHint}
593
+ </>
594
+ ) : (
595
+ <>
596
+ {descPrefix}
597
+ <span className="font-mono">{asset.name}</span>
598
+ {descSuffix}
599
+ </>
600
+ )}
538
601
  </DialogDescription>
539
602
  </DialogHeader>
603
+ {inUse && usages && (
604
+ <ul className="max-h-40 overflow-y-auto rounded-[5px] border border-hairline bg-muted/40 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
605
+ {usages.map((u) => (
606
+ <li key={u.slideId} className="flex items-center justify-between gap-3">
607
+ <span className="truncate">{u.slideId}</span>
608
+ <span className="text-muted-foreground">×{u.count}</span>
609
+ </li>
610
+ ))}
611
+ </ul>
612
+ )}
540
613
  <DialogFooter>
541
614
  <Button variant="outline" onClick={onCancel}>
542
615
  {t.common.cancel}
543
616
  </Button>
544
- <Button variant="destructive" onClick={onConfirm}>
545
- {t.common.delete}
617
+ <Button variant="destructive" onClick={onConfirm} disabled={usages === null}>
618
+ {inUse ? t.asset.deleteAndRevert : t.common.delete}
546
619
  </Button>
547
620
  </DialogFooter>
548
621
  </DialogContent>
@@ -1116,8 +1116,23 @@ function CommentsSection({
1116
1116
  }) {
1117
1117
  const [draft, setDraft] = useState('');
1118
1118
  const [submitting, setSubmitting] = useState(false);
1119
+ const wrapRef = useRef<HTMLDivElement>(null);
1119
1120
  const t = useLocale();
1120
1121
 
1122
+ useEffect(() => {
1123
+ const onKey = (e: KeyboardEvent) => {
1124
+ if (e.key !== '/') return;
1125
+ if (!(e.metaKey || e.ctrlKey)) return;
1126
+ if (e.altKey || e.shiftKey) return;
1127
+ const ta = wrapRef.current?.querySelector('textarea');
1128
+ if (!ta) return;
1129
+ e.preventDefault();
1130
+ ta.focus({ preventScroll: true });
1131
+ };
1132
+ window.addEventListener('keydown', onKey);
1133
+ return () => window.removeEventListener('keydown', onKey);
1134
+ }, []);
1135
+
1121
1136
  const submit = async () => {
1122
1137
  const trimmed = draft.trim();
1123
1138
  if (!trimmed) return;
@@ -1133,7 +1148,7 @@ function CommentsSection({
1133
1148
  return (
1134
1149
  <Section title={t.inspector.leaveComment}>
1135
1150
  <div className="flex flex-col gap-2">
1136
- <div className="comment-cue rounded-[6px]">
1151
+ <div ref={wrapRef} className="comment-cue rounded-[6px]">
1137
1152
  <Textarea
1138
1153
  value={draft}
1139
1154
  onChange={(e) => setDraft(e.target.value)}
@@ -68,10 +68,12 @@ export function PanelShell({
68
68
  {header}
69
69
  </header>
70
70
  {banner}
71
- <ScrollArea className="flex flex-1 flex-col">
72
- <div className="flex min-h-full flex-col">{children}</div>
71
+ <ScrollArea className="min-h-0 flex-1">
72
+ <div className="flex min-h-full flex-col">
73
+ {children}
74
+ {footer && <div className="mt-auto border-t border-hairline">{footer}</div>}
75
+ </div>
73
76
  </ScrollArea>
74
- {footer && <div className="shrink-0 border-t border-hairline">{footer}</div>}
75
77
  </div>
76
78
  </aside>
77
79
  );
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
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 { PresentBlackoutOverlay } from './present/blackout-overlay';
7
8
  import { PresentControlBar } from './present/control-bar';
@@ -295,7 +296,11 @@ export function Player({
295
296
  )}
296
297
  >
297
298
  <SlideCanvas flat design={design}>
298
- {PageComp ? <PageComp /> : null}
299
+ {PageComp ? (
300
+ <SlidePageProvider index={index} total={pages.length}>
301
+ <PageComp />
302
+ </SlidePageProvider>
303
+ ) : null}
299
304
  </SlideCanvas>
300
305
 
301
306
  <button
@@ -24,13 +24,12 @@ export function PresentLaserPointer({ enabled }: { enabled: boolean }) {
24
24
  return (
25
25
  <div
26
26
  aria-hidden
27
- className="pointer-events-none fixed z-[60]"
27
+ className="pointer-events-none fixed top-0 left-0 z-[60]"
28
28
  style={{
29
- left: pos.x,
30
- top: pos.y,
31
29
  width: 18,
32
30
  height: 18,
33
- transform: 'translate(-50%, -50%)',
31
+ transform: `translate3d(${pos.x - 9}px, ${pos.y - 9}px, 0)`,
32
+ willChange: 'transform',
34
33
  borderRadius: '50%',
35
34
  background: 'radial-gradient(circle, oklch(0.66 0.24 28 / 0.95) 30%, transparent 70%)',
36
35
  boxShadow: '0 0 18px 4px oklch(0.66 0.24 28 / 0.55)',
@@ -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
@@ -7,19 +7,19 @@ type Props = {
7
7
  };
8
8
 
9
9
  export function PresentProgressBar({ index, total, visible }: Props) {
10
- const pct = total > 0 ? ((index + 1) / total) * 100 : 0;
10
+ const pct = total > 0 ? (index + 1) / total : 0;
11
11
  return (
12
12
  <div
13
13
  aria-hidden
14
14
  className={cn(
15
- 'pointer-events-none absolute inset-x-0 top-0 z-30 h-[2px] bg-white/8',
15
+ 'pointer-events-none absolute inset-x-0 top-0 z-30 h-[2px] overflow-hidden bg-white/8',
16
16
  'motion-safe:transition-opacity motion-safe:duration-200',
17
17
  visible ? 'opacity-100' : 'opacity-0',
18
18
  )}
19
19
  >
20
20
  <div
21
- className="h-full bg-[var(--brand,#ef4444)] transition-[width] duration-200 ease-out"
22
- style={{ width: `${pct}%` }}
21
+ className="h-full w-full origin-left bg-[var(--brand,#ef4444)] transition-transform duration-200 ease-out"
22
+ style={{ transform: `scaleX(${pct})` }}
23
23
  />
24
24
  </div>
25
25
  );
@@ -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 };
@@ -45,6 +46,27 @@ async function deleteAsset(slideId: string, name: string): Promise<Response> {
45
46
  return fetch(`/__assets/${slideId}/${encodeURIComponent(name)}`, { method: 'DELETE' });
46
47
  }
47
48
 
49
+ export type AssetUsage = { slideId: string; count: number };
50
+
51
+ export async function listAssetUsages(slideId: string, name: string): Promise<AssetUsage[]> {
52
+ const res = await fetch(`/__assets/${slideId}/${encodeURIComponent(name)}/usages`);
53
+ if (!res.ok) return [];
54
+ const data = (await res.json().catch(() => null)) as { usages?: AssetUsage[] } | null;
55
+ return data?.usages ?? [];
56
+ }
57
+
58
+ export async function revertAssetUsage(
59
+ slideId: string,
60
+ assetPath: string,
61
+ ): Promise<{ ok: boolean; status: number }> {
62
+ const res = await fetch('/__edit/revert-asset', {
63
+ method: 'POST',
64
+ headers: { 'content-type': 'application/json' },
65
+ body: JSON.stringify({ slideId, assetPath }),
66
+ });
67
+ return { ok: res.ok, status: res.status };
68
+ }
69
+
48
70
  export async function uploadWithAutoRename(
49
71
  slideId: string,
50
72
  file: File,
@@ -69,6 +91,7 @@ export async function uploadWithAutoRename(
69
91
  mtime: body?.mtime ?? Date.now(),
70
92
  mime: body?.mime ?? uploaded.type ?? 'application/octet-stream',
71
93
  url: body?.url ?? `/__assets/${slideId}/${encodeURIComponent(uploaded.name)}`,
94
+ unused: body?.unused ?? false,
72
95
  };
73
96
  return { ok: true, status: res.status, entry };
74
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
  }
@@ -33,6 +33,19 @@ async function patchSlideName(slideId: string, name: string): Promise<void> {
33
33
  if (!res.ok) throw new Error(`PATCH /__slides/${slideId} ${res.status}`);
34
34
  }
35
35
 
36
+ async function duplicateSlideReq(slideId: string, newId?: string): Promise<string> {
37
+ const init: RequestInit = { method: 'POST' };
38
+ if (newId !== undefined) {
39
+ init.headers = { 'content-type': 'application/json' };
40
+ init.body = JSON.stringify({ newId });
41
+ }
42
+ const res = await fetch(`/__slides/${slideId}/duplicate`, init);
43
+ if (!res.ok) throw new Error(`POST /__slides/${slideId}/duplicate ${res.status}`);
44
+ const body = (await res.json()) as { slideId?: unknown };
45
+ if (typeof body.slideId !== 'string') throw new Error('duplicate response missing slideId');
46
+ return body.slideId;
47
+ }
48
+
36
49
  async function deleteSlideReq(slideId: string): Promise<void> {
37
50
  const res = await fetch(`/__slides/${slideId}`, { method: 'DELETE' });
38
51
  if (!res.ok) throw new Error(`DELETE /__slides/${slideId} ${res.status}`);
@@ -83,6 +96,7 @@ export type UseFoldersResult = {
83
96
  remove: (id: string) => Promise<void>;
84
97
  assign: (slideId: string, folderId: string | null) => Promise<void>;
85
98
  renameSlide: (slideId: string, name: string) => Promise<void>;
99
+ duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
86
100
  deleteSlide: (slideId: string) => Promise<void>;
87
101
  refresh: () => Promise<void>;
88
102
  };
@@ -165,6 +179,15 @@ export function useFolders(): UseFoldersResult {
165
179
  [refresh],
166
180
  );
167
181
 
182
+ const duplicateSlide = useCallback(
183
+ async (slideId: string, newId?: string) => {
184
+ const duplicatedId = await duplicateSlideReq(slideId, newId);
185
+ await refresh();
186
+ return duplicatedId;
187
+ },
188
+ [refresh],
189
+ );
190
+
168
191
  const deleteSlide = useCallback(
169
192
  async (slideId: string) => {
170
193
  await deleteSlideReq(slideId);
@@ -173,5 +196,16 @@ export function useFolders(): UseFoldersResult {
173
196
  [refresh],
174
197
  );
175
198
 
176
- return { manifest, loading, create, update, remove, assign, renameSlide, deleteSlide, refresh };
199
+ return {
200
+ manifest,
201
+ loading,
202
+ create,
203
+ update,
204
+ remove,
205
+ assign,
206
+ renameSlide,
207
+ duplicateSlide,
208
+ deleteSlide,
209
+ refresh,
210
+ };
177
211
  }
@@ -0,0 +1,38 @@
1
+ import { type Context, createContext, type PropsWithChildren, useContext, useMemo } from 'react';
2
+
3
+ type SlidePageContextValue = {
4
+ index: number;
5
+ total: number;
6
+ };
7
+
8
+ // Stored on globalThis so dev (src) and published (dist) copies of this module
9
+ // share one context instance — otherwise the provider writes to one context and
10
+ // the hook reads from another, and `useSlidePageNumber` always sees null.
11
+ const GLOBAL_KEY = '__open_slide_page_context__';
12
+ type GlobalWithCtx = typeof globalThis & {
13
+ [GLOBAL_KEY]?: Context<SlidePageContextValue | null>;
14
+ };
15
+ const g = globalThis as GlobalWithCtx;
16
+ if (!g[GLOBAL_KEY]) {
17
+ g[GLOBAL_KEY] = createContext<SlidePageContextValue | null>(null);
18
+ }
19
+ const SlidePageContext = g[GLOBAL_KEY];
20
+
21
+ export function SlidePageProvider({
22
+ index,
23
+ total,
24
+ children,
25
+ }: PropsWithChildren<{ index: number; total: number }>) {
26
+ const value = useMemo(() => ({ index, total }), [index, total]);
27
+ return <SlidePageContext.Provider value={value}>{children}</SlidePageContext.Provider>;
28
+ }
29
+
30
+ export function useSlidePageNumber(): { current: number; total: number } {
31
+ const ctx = useContext(SlidePageContext);
32
+ if (!ctx) {
33
+ throw new Error(
34
+ 'useSlidePageNumber must be called from a slide page rendered by @open-slide/core',
35
+ );
36
+ }
37
+ return { current: ctx.index + 1, total: ctx.total };
38
+ }
@@ -6,6 +6,8 @@ export type Page = ComponentType;
6
6
  export type SlideMeta = {
7
7
  title?: string;
8
8
  theme?: string;
9
+ /** ISO 8601 timestamp. Set once at scaffold time; used to sort the slide list. */
10
+ createdAt?: string;
9
11
  };
10
12
 
11
13
  export type SlideModule = {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ slideCreatedAt as createdAt,
2
3
  slideIds as ids,
3
4
  loadSlide as load,
4
5
  slideThemes as themes,
@@ -7,6 +8,7 @@ import type { SlideModule } from './sdk';
7
8
 
8
9
  export const slideIds: string[] = ids;
9
10
  export const slideThemes: Record<string, string> = themes;
11
+ export const slideCreatedAt: Record<string, number> = createdAt;
10
12
 
11
13
  export function slidesByTheme(themeId: string): string[] {
12
14
  return slideIds.filter((id) => slideThemes[id] === themeId);