@open-slide/core 1.8.0 → 1.9.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.
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-C7sZtiY2.js";
2
+ import { createViteConfig } from "./config-BAZeaz2P.js";
3
3
  import { mergeConfig, preview as preview$1 } from "vite";
4
4
 
5
5
  //#region src/cli/preview.ts
@@ -44,6 +44,7 @@ type Locale = {
44
44
  folders: string;
45
45
  newFolder: string;
46
46
  folderName: string;
47
+ updateAvailable: string;
47
48
  changeIcon: string;
48
49
  iconEmojiTab: string;
49
50
  iconColorTab: string;
@@ -106,7 +107,12 @@ type Locale = {
106
107
  toastCopyLinkFailed: string;
107
108
  exportAsHtml: string;
108
109
  exportAsPdf: string;
110
+ exportAsImagePptx: string;
111
+ exportAsPptx: string;
112
+ comingSoon: string;
113
+ pptxComingSoonTooltip: string;
109
114
  pdfExportFailed: string;
115
+ imagePptxExportFailed: string;
110
116
  pdfExportSafariUnsupported: string;
111
117
  present: string;
112
118
  presentMenuAria: string;
@@ -359,6 +365,13 @@ type Locale = {
359
365
  printing: string;
360
366
  done: string;
361
367
  };
368
+ pptxToast: {
369
+ title: string;
370
+ /** template: "Rendering page {current} of {total}" */
371
+ processing: string;
372
+ generating: string;
373
+ done: string;
374
+ };
362
375
  themeToggle: {
363
376
  toggleAria: string;
364
377
  title: string;
@@ -366,6 +379,10 @@ type Locale = {
366
379
  dark: string;
367
380
  system: string;
368
381
  };
382
+ languageToggle: {
383
+ toggleAria: string;
384
+ title: string;
385
+ };
369
386
  imagePlaceholder: {
370
387
  dropOverlay: string;
371
388
  uploading: string;
@@ -1,5 +1,5 @@
1
- import "../types-Bvk1pM70.js";
2
- import { OpenSlideConfig } from "../config-D1bANimZ.js";
1
+ import "../types-AalTbxMj.js";
2
+ import { OpenSlideConfig } from "../config-D_5nlXFU.js";
3
3
  import { InlineConfig } from "vite";
4
4
 
5
5
  //#region src/vite/config.d.ts
@@ -1,4 +1,4 @@
1
1
  import "../design-cpzS8aud.js";
2
- import { createViteConfig } from "../config-C7sZtiY2.js";
2
+ import { createViteConfig } from "../config-BAZeaz2P.js";
3
3
 
4
4
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -74,6 +74,7 @@
74
74
  "emoji-picker-react": "^4.18.0",
75
75
  "fast-glob": "^3.3.2",
76
76
  "fflate": "^0.8.2",
77
+ "html-to-image": "^1.11.13",
77
78
  "lucide-react": "^1.8.0",
78
79
  "next-themes": "^0.4.6",
79
80
  "radix-ui": "^1.4.3",
@@ -0,0 +1,39 @@
1
+ import { Languages } from 'lucide-react';
2
+ import { buttonVariants } from '@/components/ui/button';
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuTrigger,
8
+ } from '@/components/ui/dropdown-menu';
9
+ import { LOCALE_OPTIONS, setLocale } from '@/lib/locale-store';
10
+ import { useLocale } from '@/lib/use-locale';
11
+ import { cn } from '@/lib/utils';
12
+
13
+ export function LanguageToggle() {
14
+ const t = useLocale();
15
+
16
+ return (
17
+ <DropdownMenu>
18
+ <DropdownMenuTrigger
19
+ type="button"
20
+ aria-label={t.languageToggle.toggleAria}
21
+ title={t.languageToggle.title}
22
+ className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }))}
23
+ >
24
+ <Languages className="size-3.5" />
25
+ </DropdownMenuTrigger>
26
+ <DropdownMenuContent align="end" className="min-w-[140px]">
27
+ {LOCALE_OPTIONS.map((option) => (
28
+ <DropdownMenuItem
29
+ key={option.id}
30
+ onSelect={() => setLocale(option.id)}
31
+ data-active={t.id === option.id}
32
+ >
33
+ {option.label}
34
+ </DropdownMenuItem>
35
+ ))}
36
+ </DropdownMenuContent>
37
+ </DropdownMenu>
38
+ );
39
+ }
@@ -0,0 +1,32 @@
1
+ import { Loader2 } from 'lucide-react';
2
+ import { format, useLocale } from '@/lib/use-locale';
3
+ import type { PptxExportProgress } from '../lib/export-pptx';
4
+ import { Progress } from './ui/progress';
5
+
6
+ export function PptxProgressToast({ progress }: { progress: PptxExportProgress }) {
7
+ const t = useLocale();
8
+ const text =
9
+ progress.phase === 'processing'
10
+ ? format(t.pptxToast.processing, {
11
+ current: progress.current.toString().padStart(2, '0'),
12
+ total: progress.total.toString().padStart(2, '0'),
13
+ })
14
+ : progress.phase === 'generating'
15
+ ? t.pptxToast.generating
16
+ : t.pptxToast.done;
17
+
18
+ return (
19
+ <div className="flex w-80 items-start gap-3 rounded-[8px] border border-border bg-popover px-3.5 py-3 text-popover-foreground shadow-floating">
20
+ <Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-brand" />
21
+ <div className="min-w-0 flex-1">
22
+ <p className="font-heading text-[12.5px] font-semibold tracking-tight">
23
+ {t.pptxToast.title}
24
+ </p>
25
+ <p className="truncate font-mono text-[10.5px] tracking-[0.04em] text-muted-foreground">
26
+ {text}
27
+ </p>
28
+ <Progress value={Math.round(progress.percent)} className="mt-2 h-[3px]" />
29
+ </div>
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,51 @@
1
+ import config from 'virtual:open-slide/config';
2
+ import { useEffect, useState } from 'react';
3
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
4
+ import { format, useLocale } from '@/lib/use-locale';
5
+
6
+ type UpdateCheck = { current: string; latest: string | null; outdated: boolean };
7
+
8
+ export function SidebarFooter() {
9
+ const t = useLocale();
10
+ const [update, setUpdate] = useState<UpdateCheck | null>(null);
11
+
12
+ useEffect(() => {
13
+ if (!import.meta.env.DEV) return;
14
+ let cancelled = false;
15
+ fetch('/__update-check')
16
+ .then((res) => (res.ok ? (res.json() as Promise<UpdateCheck>) : null))
17
+ .then((data) => {
18
+ if (!cancelled && data?.outdated) setUpdate(data);
19
+ })
20
+ .catch(() => {});
21
+ return () => {
22
+ cancelled = true;
23
+ };
24
+ }, []);
25
+
26
+ const label = `v${config.version}`;
27
+
28
+ const versionRow = (
29
+ <span className="inline-flex items-center gap-1.5">
30
+ {update?.latest && <span className="size-1.5 rounded-full bg-brand" aria-hidden />}
31
+ {label}
32
+ </span>
33
+ );
34
+
35
+ return (
36
+ <div className="px-4 py-3 text-[11px] text-muted-foreground/70 tabular-nums">
37
+ {update?.latest ? (
38
+ <TooltipProvider delayDuration={200}>
39
+ <Tooltip>
40
+ <TooltipTrigger asChild>{versionRow}</TooltipTrigger>
41
+ <TooltipContent side="top" sideOffset={6} className="max-w-56">
42
+ {format(t.home.updateAvailable, { version: update.latest })}
43
+ </TooltipContent>
44
+ </Tooltip>
45
+ </TooltipProvider>
46
+ ) : (
47
+ versionRow
48
+ )}
49
+ </div>
50
+ );
51
+ }
@@ -1,6 +1,7 @@
1
1
  import { Plus } from 'lucide-react';
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { toast } from 'sonner';
4
+ import { LanguageToggle } from '@/components/language-toggle';
4
5
  import { ThemeToggle } from '@/components/theme-toggle';
5
6
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
6
7
  import type { Folder, FolderIcon } from '@/lib/sdk';
@@ -8,6 +9,7 @@ import { format, useLocale } from '@/lib/use-locale';
8
9
  import { cn } from '@/lib/utils';
9
10
  import { FolderIconChip, FolderItem } from './folder-item';
10
11
  import { IconPicker, PRESET_COLORS } from './icon-picker';
12
+ import { SidebarFooter } from './sidebar-footer';
11
13
 
12
14
  export const DRAFT_ID = 'draft';
13
15
  export const THEMES_ID = '__themes__';
@@ -124,7 +126,8 @@ export function Sidebar({
124
126
  <aside className="paper relative flex h-full w-[16.5rem] shrink-0 flex-col border-r border-hairline bg-sidebar text-sidebar-foreground">
125
127
  <div className="flex items-center justify-between px-4 pt-5 pb-4">
126
128
  <h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
127
- <div className="-mr-1.5">
129
+ <div className="-mr-1.5 flex items-center">
130
+ <LanguageToggle />
128
131
  <ThemeToggle />
129
132
  </div>
130
133
  </div>
@@ -271,6 +274,10 @@ export function Sidebar({
271
274
  </button>
272
275
  ))}
273
276
  </div>
277
+
278
+ <div className="border-t border-hairline">
279
+ <SidebarFooter />
280
+ </div>
274
281
  </aside>
275
282
  );
276
283
  }
@@ -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
+ }
@@ -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';