@open-slide/core 1.8.0 → 1.10.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 (36) hide show
  1. package/dist/{build-CCZDC8eF.js → build-ZM7IfDO-.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-C7sZtiY2.js → config-BAZeaz2P.js} +248 -232
  4. package/dist/{config-D1bANimZ.d.ts → config-mwmC1XI1.d.ts} +6 -1
  5. package/dist/{dev-kLS_4CAI.js → dev-BQkNTG_t.js} +1 -1
  6. package/dist/format-BvBmqbNW.js +1581 -0
  7. package/dist/index.d.ts +24 -4
  8. package/dist/index.js +120 -10
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +1 -1135
  11. package/dist/{preview-DUkOjOx8.js → preview-D8hUtbRA.js} +1 -1
  12. package/dist/{types-Bvk1pM70.d.ts → types-D_q_ylIe.d.ts} +19 -0
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +2 -1
  16. package/skills/slide-authoring/SKILL.md +42 -0
  17. package/src/app/components/language-toggle.tsx +39 -0
  18. package/src/app/components/player.tsx +30 -11
  19. package/src/app/components/pptx-progress-toast.tsx +32 -0
  20. package/src/app/components/sidebar/sidebar-footer.tsx +51 -0
  21. package/src/app/components/sidebar/sidebar.tsx +8 -1
  22. package/src/app/components/slide-transition-layer.tsx +36 -4
  23. package/src/app/components/thumbnail-rail.tsx +77 -15
  24. package/src/app/lib/design-presets.ts +1 -1
  25. package/src/app/lib/export-pptx.ts +284 -0
  26. package/src/app/lib/locale-store.ts +67 -0
  27. package/src/app/lib/step-context.tsx +169 -0
  28. package/src/app/lib/use-locale.ts +4 -16
  29. package/src/app/routes/slide.tsx +70 -0
  30. package/src/app/virtual.d.ts +1 -0
  31. package/src/locale/en.ts +21 -0
  32. package/src/locale/ja.ts +22 -0
  33. package/src/locale/types.ts +21 -0
  34. package/src/locale/zh-cn.ts +20 -0
  35. package/src/locale/zh-tw.ts +20 -0
  36. package/dist/en-hyGpmL1O.js +0 -375
@@ -7,7 +7,7 @@ const SERIF_GEORGIA = 'Georgia, "Times New Roman", serif';
7
7
  const SERIF_TIMES = '"Times New Roman", Times, serif';
8
8
  const MONO_SF = '"SF Mono", "JetBrains Mono", Menlo, monospace';
9
9
 
10
- export const designPresets: DesignSystem[] = [
10
+ const designPresets: DesignSystem[] = [
11
11
  defaultDesign,
12
12
  {
13
13
  palette: { bg: '#0f1115', text: '#f5f3ee', accent: '#7cc4ff' },
@@ -0,0 +1,284 @@
1
+ import { createElement } from 'react';
2
+ import { createRoot, type Root } from 'react-dom/client';
3
+ import { designToCssVars } from './design';
4
+ import { SlidePageProvider } from './page-context';
5
+ import { isFrameAnimationSettled, waitForDataWaitfor, waitForFonts } from './print-ready';
6
+ import type { SlideModule } from './sdk';
7
+
8
+ const SLIDE_W = 1920;
9
+ const SLIDE_H = 1080;
10
+ // 16:9 widescreen in English Metric Units (914400 EMU per inch → 13.333in × 7.5in).
11
+ const EMU_W = 12192000;
12
+ const EMU_H = 6858000;
13
+ const CAPTURE_PIXEL_RATIO = 2;
14
+
15
+ const ANIMATION_TIMEOUT_MS = 15_000;
16
+ const POLL_INTERVAL_MS = 100;
17
+
18
+ const CAPTURE_CLASS = 'os-pptx-capture';
19
+ const CAPTURE_STYLE_ID = 'os-pptx-capture-style';
20
+ // Properties intro animations drive from a hidden start state to a visible end
21
+ // state. We read them back once settled and pin them inline so the capture clone
22
+ // can't re-run the keyframes from their invisible 0% frame (see freezeForCapture).
23
+ const FROZEN_PROPS = ['opacity', 'transform', 'filter', 'clip-path'] as const;
24
+
25
+ export type PptxExportProgress = {
26
+ phase: 'processing' | 'generating' | 'done';
27
+ /** Number of pages captured so far (0..total). */
28
+ current: number;
29
+ total: number;
30
+ /** 0–95 while capturing, 98 while assembling, 100 when done. */
31
+ percent: number;
32
+ };
33
+
34
+ export async function exportSlideAsImagePptx(
35
+ slide: SlideModule,
36
+ slideId: string,
37
+ onProgress?: (progress: PptxExportProgress) => void,
38
+ ): Promise<void> {
39
+ const pages = slide.default ?? [];
40
+ if (pages.length === 0) return;
41
+
42
+ const total = pages.length;
43
+ onProgress?.({ phase: 'processing', current: 0, total, percent: 0 });
44
+
45
+ const container = document.createElement('div');
46
+ container.className = CAPTURE_CLASS;
47
+ container.setAttribute('aria-hidden', 'true');
48
+ Object.assign(container.style, {
49
+ position: 'fixed',
50
+ left: '-99999px',
51
+ top: '0',
52
+ pointerEvents: 'none',
53
+ });
54
+ document.body.appendChild(container);
55
+
56
+ // html-to-image clones each frame and copies its computed style — including the
57
+ // intro animation — into the clone, which then re-runs the keyframes from their
58
+ // hidden 0% frame in the rasterised SVG. Fast-forward every animation to its end
59
+ // frame in the live DOM (a large negative delay lands past a 1ms duration, so
60
+ // even pseudo-elements paint their final state on the first frame).
61
+ const captureStyle = document.createElement('style');
62
+ captureStyle.id = CAPTURE_STYLE_ID;
63
+ captureStyle.textContent = `.${CAPTURE_CLASS} *, .${CAPTURE_CLASS} *::before, .${CAPTURE_CLASS} *::after {
64
+ animation-delay: -1s !important;
65
+ animation-duration: 1ms !important;
66
+ animation-iteration-count: 1 !important;
67
+ animation-fill-mode: forwards !important;
68
+ transition: none !important;
69
+ }`;
70
+ document.head.appendChild(captureStyle);
71
+
72
+ const designVars = slide.design ? designToCssVars(slide.design) : null;
73
+
74
+ const reactRoots: Root[] = [];
75
+ const frames: HTMLElement[] = [];
76
+ for (let i = 0; i < pages.length; i++) {
77
+ const Page = pages[i];
78
+ if (!Page) continue;
79
+ const host = document.createElement('div');
80
+ host.setAttribute('data-osd-canvas', '');
81
+ host.style.width = `${SLIDE_W}px`;
82
+ host.style.height = `${SLIDE_H}px`;
83
+ host.style.overflow = 'hidden';
84
+ host.style.background = '#fff';
85
+ if (designVars) {
86
+ for (const [k, v] of Object.entries(designVars)) host.style.setProperty(k, v);
87
+ }
88
+ container.appendChild(host);
89
+ frames.push(host);
90
+ const r = createRoot(host);
91
+ r.render(
92
+ createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)),
93
+ );
94
+ reactRoots.push(r);
95
+ }
96
+ // Yield once so React commits all pages and intro animations actually start.
97
+ await nextPaint();
98
+
99
+ try {
100
+ await waitForFonts();
101
+
102
+ const deadline = performance.now() + ANIMATION_TIMEOUT_MS;
103
+ while (performance.now() < deadline) {
104
+ const settled = frames.every((frame) => isFrameAnimationSettled(frame));
105
+ if (settled) break;
106
+ await sleep(POLL_INTERVAL_MS);
107
+ }
108
+ await waitForDataWaitfor(container);
109
+
110
+ const { toBlob } = await import('html-to-image');
111
+ const images: Uint8Array[] = [];
112
+ for (let i = 0; i < frames.length; i++) {
113
+ freezeForCapture(frames[i]);
114
+ const blob = await toBlob(frames[i], {
115
+ width: SLIDE_W,
116
+ height: SLIDE_H,
117
+ pixelRatio: CAPTURE_PIXEL_RATIO,
118
+ backgroundColor: '#ffffff',
119
+ cacheBust: true,
120
+ });
121
+ if (!blob) throw new Error(`failed to capture page ${i + 1}`);
122
+ images.push(new Uint8Array(await blob.arrayBuffer()));
123
+ onProgress?.({
124
+ phase: 'processing',
125
+ current: i + 1,
126
+ total,
127
+ percent: Math.min(95, ((i + 1) / total) * 95),
128
+ });
129
+ }
130
+
131
+ onProgress?.({ phase: 'generating', current: total, total, percent: 98 });
132
+ const pptx = await buildImagePptx(images);
133
+ downloadBlob(
134
+ new Blob([pptx as BlobPart], {
135
+ type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
136
+ }),
137
+ `${slideId}.pptx`,
138
+ );
139
+ } finally {
140
+ onProgress?.({ phase: 'done', current: total, total, percent: 100 });
141
+ for (const r of reactRoots) r.unmount();
142
+ container.remove();
143
+ captureStyle.remove();
144
+ }
145
+ }
146
+
147
+ // Pin each element's settled visual state inline and remove its animation so the
148
+ // clone html-to-image rasterises renders the final frame instead of replaying the
149
+ // (initially invisible) keyframes. Pseudo-elements are handled by CAPTURE_STYLE_ID.
150
+ function freezeForCapture(root: HTMLElement): void {
151
+ for (const el of root.querySelectorAll<HTMLElement>('*')) {
152
+ const cs = getComputedStyle(el);
153
+ for (const prop of FROZEN_PROPS) {
154
+ el.style.setProperty(prop, cs.getPropertyValue(prop), 'important');
155
+ }
156
+ el.style.setProperty('animation', 'none', 'important');
157
+ el.style.setProperty('transition', 'none', 'important');
158
+ }
159
+ }
160
+
161
+ const XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
162
+ const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships';
163
+ const OD_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
164
+
165
+ async function buildImagePptx(images: Uint8Array[]): Promise<Uint8Array> {
166
+ const { zipSync, strToU8 } = await import('fflate');
167
+ const n = images.length;
168
+ const files: Record<string, Uint8Array> = {};
169
+
170
+ files['[Content_Types].xml'] = strToU8(contentTypesXml(n));
171
+ files['_rels/.rels'] = strToU8(rootRelsXml());
172
+ files['ppt/presentation.xml'] = strToU8(presentationXml(n));
173
+ files['ppt/_rels/presentation.xml.rels'] = strToU8(presentationRelsXml(n));
174
+ files['ppt/presProps.xml'] = strToU8(presPropsXml());
175
+ files['ppt/theme/theme1.xml'] = strToU8(themeXml());
176
+ files['ppt/slideMasters/slideMaster1.xml'] = strToU8(slideMasterXml());
177
+ files['ppt/slideMasters/_rels/slideMaster1.xml.rels'] = strToU8(slideMasterRelsXml());
178
+ files['ppt/slideLayouts/slideLayout1.xml'] = strToU8(slideLayoutXml());
179
+ files['ppt/slideLayouts/_rels/slideLayout1.xml.rels'] = strToU8(slideLayoutRelsXml());
180
+
181
+ for (let i = 0; i < n; i++) {
182
+ const idx = i + 1;
183
+ files[`ppt/slides/slide${idx}.xml`] = strToU8(slideXml());
184
+ files[`ppt/slides/_rels/slide${idx}.xml.rels`] = strToU8(slideRelsXml(idx));
185
+ files[`ppt/media/image${idx}.png`] = images[i];
186
+ }
187
+
188
+ return zipSync(files);
189
+ }
190
+
191
+ function contentTypesXml(n: number): string {
192
+ const slideOverrides = Array.from(
193
+ { length: n },
194
+ (_, i) =>
195
+ `<Override PartName="/ppt/slides/slide${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`,
196
+ ).join('');
197
+ return `${XML_DECL}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/presProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/><Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>${slideOverrides}</Types>`;
198
+ }
199
+
200
+ function rootRelsXml(): string {
201
+ return `${XML_DECL}<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/officeDocument" Target="ppt/presentation.xml"/></Relationships>`;
202
+ }
203
+
204
+ function presentationXml(n: number): string {
205
+ const sldIds = Array.from(
206
+ { length: n },
207
+ (_, i) => `<p:sldId id="${256 + i}" r:id="rId${i + 3}"/>`,
208
+ ).join('');
209
+ return `${XML_DECL}<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst>${sldIds}</p:sldIdLst><p:sldSz cx="${EMU_W}" cy="${EMU_H}"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`;
210
+ }
211
+
212
+ function presentationRelsXml(n: number): string {
213
+ const rels = [
214
+ `<Relationship Id="rId1" Type="${OD_REL}/slideMaster" Target="slideMasters/slideMaster1.xml"/>`,
215
+ `<Relationship Id="rId2" Type="${OD_REL}/presProps" Target="presProps.xml"/>`,
216
+ ];
217
+ for (let i = 0; i < n; i++) {
218
+ rels.push(
219
+ `<Relationship Id="rId${i + 3}" Type="${OD_REL}/slide" Target="slides/slide${i + 1}.xml"/>`,
220
+ );
221
+ }
222
+ return `${XML_DECL}<Relationships xmlns="${REL_NS}">${rels.join('')}</Relationships>`;
223
+ }
224
+
225
+ function presPropsXml(): string {
226
+ return `${XML_DECL}<p:presentationPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`;
227
+ }
228
+
229
+ function slideMasterXml(): string {
230
+ return `${XML_DECL}<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>`;
231
+ }
232
+
233
+ function slideMasterRelsXml(): string {
234
+ return `${XML_DECL}<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideLayout" Target="../slideLayouts/slideLayout1.xml"/><Relationship Id="rId2" Type="${OD_REL}/theme" Target="../theme/theme1.xml"/></Relationships>`;
235
+ }
236
+
237
+ function slideLayoutXml(): string {
238
+ return `${XML_DECL}<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1"><p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>`;
239
+ }
240
+
241
+ function slideLayoutRelsXml(): string {
242
+ return `${XML_DECL}<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideMaster" Target="../slideMasters/slideMaster1.xml"/></Relationships>`;
243
+ }
244
+
245
+ function slideXml(): string {
246
+ return `${XML_DECL}<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${OD_REL}" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:pic><p:nvPicPr><p:cNvPr id="2" name="Slide"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${EMU_W}" cy="${EMU_H}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`;
247
+ }
248
+
249
+ function slideRelsXml(idx: number): string {
250
+ return `${XML_DECL}<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideLayout" Target="../slideLayouts/slideLayout1.xml"/><Relationship Id="rId2" Type="${OD_REL}/image" Target="../media/image${idx}.png"/></Relationships>`;
251
+ }
252
+
253
+ function themeXml(): string {
254
+ return `${XML_DECL}<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme"><a:themeElements><a:clrScheme name="Office"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="44546A"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="Office"><a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:lumMod val="110000"/><a:satMod val="105000"/><a:tint val="67000"/></a:schemeClr></a:gs><a:gs pos="50000"><a:schemeClr val="phClr"><a:lumMod val="105000"/><a:satMod val="103000"/><a:tint val="73000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:lumMod val="105000"/><a:satMod val="109000"/><a:tint val="81000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:fillStyleLst><a:lnStyleLst><a:ln w="6350" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/><a:miter lim="800000"/></a:ln><a:ln w="12700" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/><a:miter lim="800000"/></a:ln><a:ln w="19050" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/><a:miter lim="800000"/></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"><a:tint val="95000"/><a:satMod val="170000"/></a:schemeClr></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="93000"/><a:satMod val="150000"/><a:shade val="98000"/><a:lumMod val="102000"/></a:schemeClr></a:gs><a:gs pos="50000"><a:schemeClr val="phClr"><a:tint val="98000"/><a:satMod val="130000"/><a:shade val="90000"/><a:lumMod val="103000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="63000"/><a:satMod val="120000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>`;
255
+ }
256
+
257
+ function sleep(ms: number): Promise<void> {
258
+ return new Promise((resolve) => setTimeout(resolve, ms));
259
+ }
260
+
261
+ function nextPaint(): Promise<void> {
262
+ return new Promise((resolve) => {
263
+ let settled = false;
264
+ const settle = () => {
265
+ if (settled) return;
266
+ settled = true;
267
+ resolve();
268
+ };
269
+ requestAnimationFrame(settle);
270
+ setTimeout(settle, 50);
271
+ });
272
+ }
273
+
274
+ function downloadBlob(blob: Blob, filename: string): void {
275
+ const url = URL.createObjectURL(blob);
276
+ const a = document.createElement('a');
277
+ a.href = url;
278
+ a.download = filename;
279
+ a.rel = 'noopener';
280
+ document.body.appendChild(a);
281
+ a.click();
282
+ a.remove();
283
+ setTimeout(() => URL.revokeObjectURL(url), 0);
284
+ }
@@ -0,0 +1,67 @@
1
+ import config from 'virtual:open-slide/config';
2
+ import { useSyncExternalStore } from 'react';
3
+ import { en } from '../../locale/en';
4
+ import { ja } from '../../locale/ja';
5
+ import type { Locale } from '../../locale/types';
6
+ import { zhCN } from '../../locale/zh-cn';
7
+ import { zhTW } from '../../locale/zh-tw';
8
+
9
+ export type LocaleId = Locale['id'];
10
+
11
+ const LOCALES: Record<LocaleId, Locale> = {
12
+ en,
13
+ 'zh-TW': zhTW,
14
+ 'zh-CN': zhCN,
15
+ ja,
16
+ };
17
+
18
+ export const LOCALE_OPTIONS: ReadonlyArray<{ id: LocaleId; label: string }> = [
19
+ { id: 'en', label: 'English' },
20
+ { id: 'zh-TW', label: '繁體中文' },
21
+ { id: 'zh-CN', label: '简体中文' },
22
+ { id: 'ja', label: '日本語' },
23
+ ];
24
+
25
+ const STORAGE_KEY = 'open-slide:locale';
26
+ const configLocale = config.locale as Locale | undefined;
27
+
28
+ function isLocaleId(value: string | null): value is LocaleId {
29
+ return value === 'en' || value === 'zh-TW' || value === 'zh-CN' || value === 'ja';
30
+ }
31
+
32
+ function readStored(): Locale {
33
+ try {
34
+ const stored = localStorage.getItem(STORAGE_KEY);
35
+ if (isLocaleId(stored)) return LOCALES[stored];
36
+ } catch {}
37
+ return configLocale ?? en;
38
+ }
39
+
40
+ // A module-level store (rather than React context) so every React root the
41
+ // runtime mounts — the app shell plus the standalone roots used for HTML/PDF
42
+ // export — shares one locale without needing a provider above each of them.
43
+ let current: Locale = readStored();
44
+ const listeners = new Set<() => void>();
45
+
46
+ function subscribe(listener: () => void): () => void {
47
+ listeners.add(listener);
48
+ return () => {
49
+ listeners.delete(listener);
50
+ };
51
+ }
52
+
53
+ function getSnapshot(): Locale {
54
+ return current;
55
+ }
56
+
57
+ export function setLocale(id: LocaleId): void {
58
+ current = LOCALES[id];
59
+ try {
60
+ localStorage.setItem(STORAGE_KEY, id);
61
+ } catch {}
62
+ for (const listener of listeners) listener();
63
+ }
64
+
65
+ export function useLocaleValue(): Locale {
66
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
67
+ }
@@ -0,0 +1,169 @@
1
+ import {
2
+ Children,
3
+ type Context,
4
+ cloneElement,
5
+ createContext,
6
+ isValidElement,
7
+ type MutableRefObject,
8
+ type PropsWithChildren,
9
+ type ReactElement,
10
+ useContext,
11
+ useEffect,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'react';
17
+ import { usePrefersReducedMotion } from './use-prefers-reduced-motion';
18
+
19
+ export type EntryDirection = 'forward' | 'backward' | 'jump';
20
+
21
+ export type StepController = {
22
+ advance: () => boolean;
23
+ retreat: () => boolean;
24
+ };
25
+
26
+ type StepHostContextValue = {
27
+ register: (ctrl: StepController) => () => void;
28
+ entryDirection: EntryDirection;
29
+ };
30
+
31
+ const GLOBAL_KEY = '__open_slide_step_host_context__';
32
+ type GlobalWithCtx = typeof globalThis & {
33
+ [GLOBAL_KEY]?: Context<StepHostContextValue | null>;
34
+ };
35
+ const g = globalThis as GlobalWithCtx;
36
+ if (!g[GLOBAL_KEY]) {
37
+ g[GLOBAL_KEY] = createContext<StepHostContextValue | null>(null);
38
+ }
39
+ const StepHostContext = g[GLOBAL_KEY];
40
+
41
+ type StepHostProps = PropsWithChildren<{
42
+ isActivePage: boolean;
43
+ entryDirection: EntryDirection;
44
+ controllerRef: MutableRefObject<StepController | null>;
45
+ }>;
46
+
47
+ export function StepHost({ isActivePage, entryDirection, controllerRef, children }: StepHostProps) {
48
+ const controllersRef = useRef<StepController[]>([]);
49
+
50
+ const composite = useMemo<StepController>(
51
+ () => ({
52
+ advance: () => {
53
+ for (const c of controllersRef.current) {
54
+ if (c.advance()) return true;
55
+ }
56
+ return false;
57
+ },
58
+ retreat: () => {
59
+ for (let i = controllersRef.current.length - 1; i >= 0; i--) {
60
+ if (controllersRef.current[i].retreat()) return true;
61
+ }
62
+ return false;
63
+ },
64
+ }),
65
+ [],
66
+ );
67
+
68
+ // useLayoutEffect cleanup-then-mount ordering keeps the registry slot
69
+ // continuous across page swaps — the outgoing host clears its composite
70
+ // before the next active host installs its own, with no gap and no overlap.
71
+ useLayoutEffect(() => {
72
+ if (!isActivePage) return;
73
+ controllerRef.current = composite;
74
+ return () => {
75
+ if (controllerRef.current === composite) controllerRef.current = null;
76
+ };
77
+ }, [isActivePage, composite, controllerRef]);
78
+
79
+ const value = useMemo<StepHostContextValue>(
80
+ () => ({
81
+ register: (ctrl) => {
82
+ if (!isActivePage) return () => {};
83
+ controllersRef.current.push(ctrl);
84
+ return () => {
85
+ const i = controllersRef.current.indexOf(ctrl);
86
+ if (i !== -1) controllersRef.current.splice(i, 1);
87
+ };
88
+ },
89
+ entryDirection,
90
+ }),
91
+ [isActivePage, entryDirection],
92
+ );
93
+
94
+ return <StepHostContext.Provider value={value}>{children}</StepHostContext.Provider>;
95
+ }
96
+
97
+ export type StepsProps = PropsWithChildren;
98
+
99
+ export function Steps({ children }: StepsProps) {
100
+ const host = useContext(StepHostContext);
101
+ const flat = Children.toArray(children);
102
+ const stepCount = flat.filter((c) => isValidElement(c) && c.type === Step).length;
103
+
104
+ const initial = host?.entryDirection === 'forward' ? 0 : stepCount;
105
+ const revealedRef = useRef(initial);
106
+ const [revealed, setRevealed] = useState(initial);
107
+
108
+ useEffect(() => {
109
+ if (!host) return;
110
+ const ctrl: StepController = {
111
+ advance: () => {
112
+ if (revealedRef.current >= stepCount) return false;
113
+ revealedRef.current += 1;
114
+ setRevealed(revealedRef.current);
115
+ return true;
116
+ },
117
+ retreat: () => {
118
+ if (revealedRef.current <= 0) return false;
119
+ revealedRef.current -= 1;
120
+ setRevealed(revealedRef.current);
121
+ return true;
122
+ },
123
+ };
124
+ return host.register(ctrl);
125
+ }, [host, stepCount]);
126
+
127
+ const effectiveRevealed = host ? revealed : stepCount;
128
+
129
+ let stepIdx = 0;
130
+ return (
131
+ <>
132
+ {flat.map((child, key) => {
133
+ if (isValidElement(child) && child.type === Step) {
134
+ const idx = stepIdx++;
135
+ return cloneElement(child as ReactElement<{ _revealed?: boolean }>, {
136
+ key: child.key ?? key,
137
+ _revealed: idx < effectiveRevealed,
138
+ });
139
+ }
140
+ return child;
141
+ })}
142
+ </>
143
+ );
144
+ }
145
+
146
+ export type StepProps = PropsWithChildren<{
147
+ duration?: number;
148
+ }>;
149
+
150
+ type InternalStepProps = StepProps & { _revealed?: boolean };
151
+
152
+ export function Step({ children, duration = 180, _revealed }: InternalStepProps) {
153
+ const reduceMotion = usePrefersReducedMotion();
154
+ const revealed = _revealed ?? true;
155
+ const ms = reduceMotion ? 0 : duration;
156
+
157
+ return (
158
+ <div
159
+ data-osd-step={revealed ? 'revealed' : 'pending'}
160
+ style={{
161
+ opacity: revealed ? 1 : 0,
162
+ visibility: revealed ? 'visible' : 'hidden',
163
+ transition: `opacity ${ms}ms cubic-bezier(0, 0, 0.2, 1)`,
164
+ }}
165
+ >
166
+ {children}
167
+ </div>
168
+ );
169
+ }
@@ -1,20 +1,8 @@
1
- import config from 'virtual:open-slide/config';
2
- import { en } from '../../locale/en';
3
- import type { Locale, Plural } from '../../locale/types';
4
-
5
- const resolved: Locale = (config.locale as Locale | undefined) ?? en;
1
+ import type { Locale } from '../../locale/types';
2
+ import { useLocaleValue } from './locale-store';
6
3
 
7
4
  export function useLocale(): Locale {
8
- return resolved;
9
- }
10
-
11
- export function format(template: string, vars: Record<string, string | number>): string {
12
- return template.replace(/\{(\w+)\}/g, (m, key) => {
13
- const v = vars[key];
14
- return v === undefined ? m : String(v);
15
- });
5
+ return useLocaleValue();
16
6
  }
17
7
 
18
- export function plural(count: number, forms: Plural): string {
19
- return count === 1 ? forms.one : forms.other;
20
- }
8
+ export { format, plural } from '../../locale/format';