@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.
- package/dist/{build-DZhbjQpQ.js → build-tLrkKUHr.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-BQdTMho4.d.ts → config-CfMThYN9.d.ts} +1 -1
- package/dist/{config-iKjqaX08.js → config-PwUHqZ_X.js} +246 -2
- package/dist/{dev-BjLGk5nN.js → dev-DpCIRbhT.js} +1 -1
- package/dist/{en-DDGqyNaW.js → en-BDnM5zKJ.js} +4 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +20 -4
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +13 -1
- package/dist/{preview-jwLWHWkQ.js → preview-BSGlM6Se.js} +1 -1
- package/dist/{types-Dpr8nbih.d.ts → types-B-KrjgX8.d.ts} +5 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +30 -22
- package/skills/slide-authoring/SKILL.md +17 -0
- package/src/app/components/asset-view.tsx +8 -1
- package/src/app/components/player.tsx +6 -1
- package/src/app/components/present/overview-grid.tsx +4 -1
- package/src/app/components/themes/theme-detail.tsx +7 -2
- package/src/app/components/themes/themes-gallery.tsx +4 -1
- package/src/app/components/thumbnail-rail.tsx +10 -2
- package/src/app/lib/assets.ts +2 -0
- package/src/app/lib/export-html.ts +7 -2
- package/src/app/lib/export-pdf.ts +34 -2
- package/src/app/lib/folders.ts +35 -1
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/use-wheel-page-navigation.ts +7 -0
- package/src/app/routes/home-shell.tsx +13 -2
- package/src/app/routes/home.tsx +28 -2
- package/src/app/routes/presenter.tsx +7 -2
- package/src/app/routes/slide.tsx +4 -1
- package/src/locale/en.ts +4 -0
- package/src/locale/ja.ts +4 -0
- package/src/locale/types.ts +5 -0
- package/src/locale/zh-cn.ts +4 -0
- 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
|
|
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 ?
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
280
|
+
<SlidePageProvider index={index} total={total}>
|
|
281
|
+
<PageComp />
|
|
282
|
+
</SlidePageProvider>
|
|
275
283
|
</SlideCanvas>
|
|
276
284
|
{active && (
|
|
277
285
|
<span
|
package/src/app/lib/assets.ts
CHANGED
|
@@ -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 (
|
|
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(
|
|
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 (
|
|
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(
|
|
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
|
}
|
package/src/app/lib/folders.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
36
|
-
|
|
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
|
|
package/src/app/routes/home.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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">
|
package/src/app/routes/slide.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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: 'ドロップでアップロード',
|