@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.
- package/dist/{build-CCZDC8eF.js → build-ZM7IfDO-.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-C7sZtiY2.js → config-BAZeaz2P.js} +248 -232
- package/dist/{config-D1bANimZ.d.ts → config-D_5nlXFU.d.ts} +6 -1
- package/dist/{dev-kLS_4CAI.js → dev-BQkNTG_t.js} +1 -1
- package/dist/format-CYOb2cAQ.js +1573 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +38 -4
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +1 -1135
- package/dist/{preview-DUkOjOx8.js → preview-D8hUtbRA.js} +1 -1
- package/dist/{types-Bvk1pM70.d.ts → types-AalTbxMj.d.ts} +17 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +2 -1
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +51 -0
- package/src/app/components/sidebar/sidebar.tsx +8 -1
- package/src/app/lib/design-presets.ts +1 -1
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/use-locale.ts +4 -16
- package/src/app/routes/slide.tsx +68 -0
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +19 -0
- package/src/locale/ja.ts +20 -0
- package/src/locale/types.ts +19 -0
- package/src/locale/zh-cn.ts +18 -0
- package/src/locale/zh-tw.ts +18 -0
- package/dist/en-hyGpmL1O.js +0 -375
|
@@ -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;
|
package/dist/vite/index.d.ts
CHANGED
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-slide/core",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
19
|
-
return count === 1 ? forms.one : forms.other;
|
|
20
|
-
}
|
|
8
|
+
export { format, plural } from '../../locale/format';
|