@open-slide/core 1.5.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 (38) 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 +10 -3
  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 +17 -0
  18. package/src/app/components/asset-view.tsx +8 -1
  19. package/src/app/components/player.tsx +6 -1
  20. package/src/app/components/present/overview-grid.tsx +4 -1
  21. package/src/app/components/themes/theme-detail.tsx +7 -2
  22. package/src/app/components/themes/themes-gallery.tsx +4 -1
  23. package/src/app/components/thumbnail-rail.tsx +10 -2
  24. package/src/app/lib/assets.ts +2 -0
  25. package/src/app/lib/export-html.ts +7 -2
  26. package/src/app/lib/export-pdf.ts +34 -2
  27. package/src/app/lib/folders.ts +35 -1
  28. package/src/app/lib/page-context.tsx +38 -0
  29. package/src/app/lib/use-wheel-page-navigation.ts +7 -0
  30. package/src/app/routes/home-shell.tsx +13 -2
  31. package/src/app/routes/home.tsx +28 -2
  32. package/src/app/routes/presenter.tsx +7 -2
  33. package/src/app/routes/slide.tsx +4 -1
  34. package/src/locale/en.ts +4 -0
  35. package/src/locale/ja.ts +4 -0
  36. package/src/locale/types.ts +5 -0
  37. package/src/locale/zh-cn.ts +4 -0
  38. package/src/locale/zh-tw.ts +4 -0
@@ -291,6 +291,23 @@ The user uploads the real file via the Assets panel, then clicks the placeholder
291
291
 
292
292
  Size the placeholder to the slot it occupies. Pass `width`/`height` when the layout has a fixed image box; omit them when the placeholder fills a flex/grid cell. The `hint` should describe the *content* the user needs ("Q3 revenue chart") not the *role* ("hero image").
293
293
 
294
+ ## Page numbers
295
+
296
+ If a footer shows the current page (`03 / 12`, `Page 3`, etc.), read it from `useSlidePageNumber()` — **never hardcode** `n` / `TOTAL`. Inserting, reordering, or deleting a page would otherwise force you to retouch every footer.
297
+
298
+ ```tsx
299
+ import { useSlidePageNumber } from '@open-slide/core';
300
+
301
+ const Footer = () => {
302
+ const { current, total } = useSlidePageNumber();
303
+ return (
304
+ <span>{String(current).padStart(2, '0')} / {String(total).padStart(2, '0')}</span>
305
+ );
306
+ };
307
+ ```
308
+
309
+ `current` is 1-indexed (matches what readers see) and `total` is the slide's page count. The hook works in every render context (main viewer, thumbnails, overview grid, present mode, presenter window, HTML/PDF export) — the same `<Footer />` JSX is correct everywhere. Call the hook inside a component that's used **per page**; don't try to call it at module top level.
310
+
294
311
  ## Repeated elements: component, not `map`
295
312
 
296
313
  When a page has visually repeated items — cards, logo rows, gallery tiles, list rows, step indicators — **define a small component and instantiate it once per item**. Do **not** render the group with `array.map` over a data array.
@@ -414,7 +414,14 @@ function AssetCard({
414
414
  <div className="truncate text-[12.5px] font-medium" title={asset.name}>
415
415
  {asset.name}
416
416
  </div>
417
- <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>
418
425
  </div>
419
426
  <DropdownMenu>
420
427
  <DropdownMenuTrigger
@@ -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
@@ -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
@@ -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
  }
@@ -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
+ }
@@ -31,6 +31,7 @@ export function useWheelPageNavigation<T extends HTMLElement>({
31
31
 
32
32
  const onWheel = (event: WheelEvent) => {
33
33
  if (event.defaultPrevented || event.ctrlKey || shouldIgnoreWheelTarget(event.target)) return;
34
+ if (isVisualViewportZoomed()) return;
34
35
 
35
36
  const deltaY = normalizeDeltaY(event);
36
37
  if (Math.abs(deltaY) <= Math.abs(normalizeDeltaX(event))) return;
@@ -84,6 +85,12 @@ function normalizeWheelDelta(delta: number, deltaMode: number) {
84
85
  return delta;
85
86
  }
86
87
 
88
+ function isVisualViewportZoomed() {
89
+ if (typeof window === 'undefined') return false;
90
+ const vv = window.visualViewport;
91
+ return vv != null && vv.scale > 1.01;
92
+ }
93
+
87
94
  function shouldIgnoreWheelTarget(target: EventTarget | null) {
88
95
  if (!(target instanceof HTMLElement)) return false;
89
96
  return Boolean(
@@ -22,6 +22,7 @@ export type HomeOutletContext = {
22
22
  titleMap: Record<string, string>;
23
23
  assign: (slideId: string, folderId: string | null) => Promise<void>;
24
24
  renameSlide: (slideId: string, name: string) => Promise<void>;
25
+ duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
25
26
  deleteSlide: (slideId: string) => Promise<void>;
26
27
  };
27
28
 
@@ -32,8 +33,17 @@ function pathToSelectedId(pathname: string, search: URLSearchParams): string {
32
33
  }
33
34
 
34
35
  export function HomeShell() {
35
- const { manifest, loading, create, update, remove, assign, renameSlide, deleteSlide } =
36
- useFolders();
36
+ const {
37
+ manifest,
38
+ loading,
39
+ create,
40
+ update,
41
+ remove,
42
+ assign,
43
+ renameSlide,
44
+ duplicateSlide,
45
+ deleteSlide,
46
+ } = useFolders();
37
47
  const navigate = useNavigate();
38
48
  const location = useLocation();
39
49
  const [searchParams] = useSearchParams();
@@ -108,6 +118,7 @@ export function HomeShell() {
108
118
  titleMap,
109
119
  assign,
110
120
  renameSlide,
121
+ duplicateSlide,
111
122
  deleteSlide,
112
123
  };
113
124
 
@@ -2,6 +2,7 @@ import {
2
2
  ArrowDownAZ,
3
3
  ChevronDown,
4
4
  Clock,
5
+ Copy,
5
6
  FolderInput,
6
7
  FolderPlus,
7
8
  MoreHorizontal,
@@ -13,6 +14,7 @@ import {
13
14
  } from 'lucide-react';
14
15
  import { useEffect, useMemo, useRef, useState } from 'react';
15
16
  import { Link, useOutletContext } from 'react-router-dom';
17
+ import { toast } from 'sonner';
16
18
  import { Button } from '@/components/ui/button';
17
19
  import {
18
20
  Dialog,
@@ -28,11 +30,12 @@ import {
28
30
  DropdownMenuItem,
29
31
  DropdownMenuTrigger,
30
32
  } from '@/components/ui/dropdown-menu';
31
- import { useLocale } from '@/lib/use-locale';
33
+ import { format, useLocale } from '@/lib/use-locale';
32
34
  import { cn } from '@/lib/utils';
33
35
  import { FolderIconChip, SLIDE_DND_MIME } from '../components/sidebar/folder-item';
34
36
  import { DRAFT_ID } from '../components/sidebar/sidebar';
35
37
  import { SlideCanvas } from '../components/slide-canvas';
38
+ import { SlidePageProvider } from '../lib/page-context';
36
39
  import type { Folder, FolderIcon, SlideModule } from '../lib/sdk';
37
40
  import { loadSlide, slideCreatedAt } from '../lib/slides';
38
41
  import type { HomeOutletContext } from './home-shell';
@@ -77,6 +80,7 @@ export function Home() {
77
80
  titleMap,
78
81
  assign,
79
82
  renameSlide,
83
+ duplicateSlide,
80
84
  deleteSlide,
81
85
  } = useOutletContext<HomeOutletContext>();
82
86
  const t = useLocale();
@@ -163,6 +167,20 @@ export function Home() {
163
167
  folders={manifest.folders}
164
168
  currentFolderId={manifest.assignments[id] ?? null}
165
169
  onRename={(name) => renameSlide(id, name)}
170
+ onDuplicate={async () => {
171
+ const slideName = titleMap[id] ?? id;
172
+ try {
173
+ const newSlideId = await duplicateSlide(id);
174
+ toast.success(
175
+ format(t.home.toastSlideDuplicated, {
176
+ slide: slideName,
177
+ newSlide: newSlideId,
178
+ }),
179
+ );
180
+ } catch {
181
+ toast.error(t.home.toastSlideDuplicateFailed);
182
+ }
183
+ }}
166
184
  onMove={(folderId) => assign(id, folderId)}
167
185
  onDelete={() => deleteSlide(id)}
168
186
  onTitleResolved={reportTitle}
@@ -381,6 +399,7 @@ function SlideCard({
381
399
  folders,
382
400
  currentFolderId,
383
401
  onRename,
402
+ onDuplicate,
384
403
  onMove,
385
404
  onDelete,
386
405
  onTitleResolved,
@@ -389,6 +408,7 @@ function SlideCard({
389
408
  folders: Folder[];
390
409
  currentFolderId: string | null;
391
410
  onRename: (name: string) => Promise<void> | void;
411
+ onDuplicate: () => Promise<void> | void;
392
412
  onMove: (folderId: string | null) => Promise<void> | void;
393
413
  onDelete: () => Promise<void> | void;
394
414
  onTitleResolved?: (id: string, title: string) => void;
@@ -441,7 +461,9 @@ function SlideCard({
441
461
  {FirstPage ? (
442
462
  <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
443
463
  <SlideCanvas flat freezeMotion design={slide?.design}>
444
- <FirstPage />
464
+ <SlidePageProvider index={0} total={slide?.default.length ?? 1}>
465
+ <FirstPage />
466
+ </SlidePageProvider>
445
467
  </SlideCanvas>
446
468
  </div>
447
469
  ) : (
@@ -489,6 +511,10 @@ function SlideCard({
489
511
  <Pencil />
490
512
  {tCard.common.rename}
491
513
  </DropdownMenuItem>
514
+ <DropdownMenuItem onSelect={() => onDuplicate()}>
515
+ <Copy />
516
+ {tCard.home.duplicate}
517
+ </DropdownMenuItem>
492
518
  <DropdownMenuItem onSelect={() => setDialog('move')}>
493
519
  <FolderInput />
494
520
  {tCard.home.moveToFolder}
@@ -9,6 +9,7 @@ import {
9
9
  usePresenterChannel,
10
10
  } from '../components/present/use-presenter-channel';
11
11
  import { SlideCanvas } from '../components/slide-canvas';
12
+ import { SlidePageProvider } from '../lib/page-context';
12
13
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
13
14
  import { useSlideModule } from '../lib/use-slide-module';
14
15
 
@@ -138,7 +139,9 @@ export function Presenter() {
138
139
  <SectionLabel>{t.presenter.nowShowing}</SectionLabel>
139
140
  <div className="relative min-h-0 flex-1 overflow-hidden rounded-[8px] bg-black ring-1 ring-border">
140
141
  <SlideCanvas flat design={slide.design}>
141
- <CurrentPage />
142
+ <SlidePageProvider index={index} total={total}>
143
+ <CurrentPage />
144
+ </SlidePageProvider>
142
145
  </SlideCanvas>
143
146
  {blackout && (
144
147
  <div
@@ -164,7 +167,9 @@ export function Presenter() {
164
167
  >
165
168
  {NextPage ? (
166
169
  <SlideCanvas flat freezeMotion design={slide.design}>
167
- <NextPage />
170
+ <SlidePageProvider index={nextIndex} total={total}>
171
+ <NextPage />
172
+ </SlidePageProvider>
168
173
  </SlideCanvas>
169
174
  ) : (
170
175
  <div className="grid h-full place-items-center text-[11.5px] text-muted-foreground">
@@ -52,6 +52,7 @@ import { type ThumbnailActions, ThumbnailRail } from '../components/thumbnail-ra
52
52
  import { exportSlideAsHtml } from '../lib/export-html';
53
53
  import { exportSlideAsPdf, isSafari } from '../lib/export-pdf';
54
54
  import { remapNotesSessionCacheAfterReorder } from '../lib/inspector/use-notes';
55
+ import { SlidePageProvider } from '../lib/page-context';
55
56
  import type { SlideModule } from '../lib/sdk';
56
57
  import { useSlideModule } from '../lib/use-slide-module';
57
58
 
@@ -584,7 +585,9 @@ export function Slide() {
584
585
  canNext={index < pageCount - 1}
585
586
  />
586
587
  <SlideCanvas design={slide.design}>
587
- <CurrentPage />
588
+ <SlidePageProvider index={index} total={pageCount}>
589
+ <CurrentPage />
590
+ </SlidePageProvider>
588
591
  </SlideCanvas>
589
592
  <ClickNavZones
590
593
  onPrev={() => goTo(index - 1)}
package/src/locale/en.ts CHANGED
@@ -38,6 +38,7 @@ export const en: Locale = {
38
38
  home: {
39
39
  appTitle: 'open-slide',
40
40
  draft: 'Draft',
41
+ duplicate: 'Duplicate',
41
42
  themes: 'Themes',
42
43
  assets: 'Assets',
43
44
  folders: 'Folders',
@@ -79,6 +80,8 @@ export const en: Locale = {
79
80
  deleteDialogDescriptionSuffix: 'This action cannot be undone.',
80
81
  toastFolderCreated: 'Created folder “{name}”',
81
82
  toastFolderCreateFailed: 'Failed to create folder',
83
+ toastSlideDuplicated: 'Duplicated “{slide}” as {newSlide}',
84
+ toastSlideDuplicateFailed: 'Could not duplicate slide',
82
85
  toastSlideMoved: 'Moved “{slide}” to {folder}',
83
86
  toastSlideMoveFailed: 'Failed to move slide',
84
87
  toastFolderDeleted: 'Deleted folder “{name}”',
@@ -270,6 +273,7 @@ export const en: Locale = {
270
273
  scopeSlide: 'This slide',
271
274
  scopeGlobal: 'Global',
272
275
  fileCount: { one: '{count} file', other: '{count} files' },
276
+ usageUnused: 'Unused',
273
277
  searchLogos: 'Search logos',
274
278
  upload: 'Upload',
275
279
  dropToUpload: 'Drop to upload',
package/src/locale/ja.ts CHANGED
@@ -38,6 +38,7 @@ export const ja: Locale = {
38
38
  home: {
39
39
  appTitle: 'open-slide',
40
40
  draft: '下書き',
41
+ duplicate: '複製',
41
42
  themes: 'テーマ',
42
43
  assets: 'アセット',
43
44
  folders: 'フォルダ',
@@ -79,6 +80,8 @@ export const ja: Locale = {
79
80
  deleteDialogDescriptionSuffix: 'この操作は元に戻せません。',
80
81
  toastFolderCreated: 'フォルダ「{name}」を作成しました',
81
82
  toastFolderCreateFailed: 'フォルダの作成に失敗しました',
83
+ toastSlideDuplicated: '「{slide}」を {newSlide} として複製しました',
84
+ toastSlideDuplicateFailed: 'スライドを複製できませんでした',
82
85
  toastSlideMoved: '「{slide}」を {folder} に移動しました',
83
86
  toastSlideMoveFailed: 'スライドの移動に失敗しました',
84
87
  toastFolderDeleted: 'フォルダ「{name}」を削除しました',
@@ -272,6 +275,7 @@ export const ja: Locale = {
272
275
  scopeSlide: 'このスライド',
273
276
  scopeGlobal: 'グローバル',
274
277
  fileCount: { one: 'ファイル {count} 件', other: 'ファイル {count} 件' },
278
+ usageUnused: '未使用',
275
279
  searchLogos: 'ロゴを検索',
276
280
  upload: 'アップロード',
277
281
  dropToUpload: 'ドロップでアップロード',