@open-slide/core 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-1Rqivz0d.js → build-tLrkKUHr.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-s0YUbmUe.d.ts → config-CfMThYN9.d.ts} +1 -1
- package/dist/{config-XZJnC_fu.js → config-PwUHqZ_X.js} +2312 -1654
- package/dist/{dev-0W8gYiSa.js → dev-DpCIRbhT.js} +1 -1
- package/dist/{en-7GU-DHbJ.js → en-BDnM5zKJ.js} +18 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.js +20 -4
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +55 -4
- package/dist/{preview-DT9hJvzM.js → preview-BSGlM6Se.js} +1 -1
- package/dist/{types-QCpkHkiS.d.ts → types-B-KrjgX8.d.ts} +21 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +30 -22
- package/skills/slide-authoring/SKILL.md +26 -2
- package/src/app/components/asset-view.tsx +83 -10
- package/src/app/components/inspector/inspector-panel.tsx +16 -1
- package/src/app/components/panel/panel-shell.tsx +5 -3
- package/src/app/components/player.tsx +6 -1
- package/src/app/components/present/laser-pointer.tsx +3 -4
- package/src/app/components/present/overview-grid.tsx +4 -1
- package/src/app/components/present/progress-bar.tsx +4 -4
- package/src/app/components/themes/theme-detail.tsx +7 -2
- package/src/app/components/themes/themes-gallery.tsx +4 -1
- package/src/app/components/thumbnail-rail.tsx +10 -2
- package/src/app/lib/assets.ts +23 -0
- package/src/app/lib/export-html.ts +7 -2
- package/src/app/lib/export-pdf.ts +34 -2
- package/src/app/lib/folders.ts +35 -1
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/sdk.ts +2 -0
- package/src/app/lib/slides.ts +2 -0
- package/src/app/lib/use-wheel-page-navigation.ts +7 -0
- package/src/app/routes/home-shell.tsx +13 -2
- package/src/app/routes/home.tsx +129 -5
- package/src/app/routes/presenter.tsx +7 -2
- package/src/app/routes/slide.tsx +49 -1
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +18 -1
- package/src/locale/ja.ts +18 -1
- package/src/locale/types.ts +21 -0
- package/src/locale/zh-cn.ts +18 -1
- package/src/locale/zh-tw.ts +18 -1
|
@@ -33,8 +33,11 @@ import {
|
|
|
33
33
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
34
34
|
import {
|
|
35
35
|
type AssetEntry,
|
|
36
|
+
type AssetUsage,
|
|
36
37
|
fetchSvgAsFile,
|
|
38
|
+
listAssetUsages,
|
|
37
39
|
renamedCopy,
|
|
40
|
+
revertAssetUsage,
|
|
38
41
|
type SvglItem,
|
|
39
42
|
searchSvgl,
|
|
40
43
|
useAssets,
|
|
@@ -62,6 +65,7 @@ export function AssetView({ slideId }: Props) {
|
|
|
62
65
|
const [conflict, setConflict] = useState<ConflictState | null>(null);
|
|
63
66
|
const [preview, setPreview] = useState<AssetEntry | null>(null);
|
|
64
67
|
const [confirmDelete, setConfirmDelete] = useState<AssetEntry | null>(null);
|
|
68
|
+
const [confirmDeleteUsages, setConfirmDeleteUsages] = useState<AssetUsage[] | null>(null);
|
|
65
69
|
const [renaming, setRenaming] = useState<string | null>(null);
|
|
66
70
|
const [logoSearchOpen, setLogoSearchOpen] = useState(false);
|
|
67
71
|
const dragDepth = useRef(0);
|
|
@@ -245,7 +249,13 @@ export function AssetView({ slideId }: Props) {
|
|
|
245
249
|
asset={asset}
|
|
246
250
|
onPreview={() => setPreview(asset)}
|
|
247
251
|
onRename={() => setRenaming(asset.name)}
|
|
248
|
-
onDelete={() =>
|
|
252
|
+
onDelete={() => {
|
|
253
|
+
setConfirmDelete(asset);
|
|
254
|
+
setConfirmDeleteUsages(null);
|
|
255
|
+
listAssetUsages(effectiveSlideId, asset.name)
|
|
256
|
+
.then((u) => setConfirmDeleteUsages(u))
|
|
257
|
+
.catch(() => setConfirmDeleteUsages([]));
|
|
258
|
+
}}
|
|
249
259
|
/>
|
|
250
260
|
),
|
|
251
261
|
)}
|
|
@@ -282,13 +292,41 @@ export function AssetView({ slideId }: Props) {
|
|
|
282
292
|
{confirmDelete && (
|
|
283
293
|
<DeleteDialog
|
|
284
294
|
asset={confirmDelete}
|
|
285
|
-
|
|
295
|
+
usages={confirmDeleteUsages}
|
|
296
|
+
onCancel={() => {
|
|
297
|
+
setConfirmDelete(null);
|
|
298
|
+
setConfirmDeleteUsages(null);
|
|
299
|
+
}}
|
|
286
300
|
onConfirm={async () => {
|
|
287
301
|
const target = confirmDelete;
|
|
302
|
+
const usages = confirmDeleteUsages ?? [];
|
|
288
303
|
setConfirmDelete(null);
|
|
304
|
+
setConfirmDeleteUsages(null);
|
|
305
|
+
const assetPath =
|
|
306
|
+
scope === 'global' ? `@assets/${target.name}` : `./assets/${target.name}`;
|
|
307
|
+
for (const u of usages) {
|
|
308
|
+
const rev = await revertAssetUsage(u.slideId, assetPath);
|
|
309
|
+
if (!rev.ok) {
|
|
310
|
+
toast.error(format(t.asset.toastRevertFailed, { slideId: u.slideId }));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
289
314
|
const res = await remove(target.name);
|
|
290
|
-
if (!res.ok)
|
|
291
|
-
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const totalUsages = usages.reduce((acc, u) => acc + u.count, 0);
|
|
320
|
+
if (totalUsages > 0) {
|
|
321
|
+
toast.success(
|
|
322
|
+
format(t.asset.toastDeletedWithRevert, {
|
|
323
|
+
name: target.name,
|
|
324
|
+
count: totalUsages,
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
} else {
|
|
328
|
+
toast.success(format(t.asset.toastDeleted, { name: target.name }));
|
|
329
|
+
}
|
|
292
330
|
}}
|
|
293
331
|
/>
|
|
294
332
|
)}
|
|
@@ -376,7 +414,14 @@ function AssetCard({
|
|
|
376
414
|
<div className="truncate text-[12.5px] font-medium" title={asset.name}>
|
|
377
415
|
{asset.name}
|
|
378
416
|
</div>
|
|
379
|
-
<div className="folio
|
|
417
|
+
<div className="folio flex items-center gap-1.5">
|
|
418
|
+
<span className="truncate">{formatSize(asset.size)}</span>
|
|
419
|
+
{asset.unused ? (
|
|
420
|
+
<span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[10px] font-medium text-muted-foreground leading-none">
|
|
421
|
+
{t.asset.usageUnused}
|
|
422
|
+
</span>
|
|
423
|
+
) : null}
|
|
424
|
+
</div>
|
|
380
425
|
</div>
|
|
381
426
|
<DropdownMenu>
|
|
382
427
|
<DropdownMenuTrigger
|
|
@@ -517,14 +562,19 @@ function ConflictDialog({
|
|
|
517
562
|
|
|
518
563
|
function DeleteDialog({
|
|
519
564
|
asset,
|
|
565
|
+
usages,
|
|
520
566
|
onCancel,
|
|
521
567
|
onConfirm,
|
|
522
568
|
}: {
|
|
523
569
|
asset: AssetEntry;
|
|
570
|
+
usages: AssetUsage[] | null;
|
|
524
571
|
onCancel: () => void;
|
|
525
572
|
onConfirm: () => void;
|
|
526
573
|
}) {
|
|
527
574
|
const t = useLocale();
|
|
575
|
+
const inUse = (usages?.length ?? 0) > 0;
|
|
576
|
+
const totalUses = usages?.reduce((acc, u) => acc + u.count, 0) ?? 0;
|
|
577
|
+
const slideCount = usages?.length ?? 0;
|
|
528
578
|
const [descPrefix, descSuffix] = t.asset.deleteAssetDescription.split('{name}');
|
|
529
579
|
return (
|
|
530
580
|
<Dialog open onOpenChange={(open) => !open && onCancel()}>
|
|
@@ -532,17 +582,40 @@ function DeleteDialog({
|
|
|
532
582
|
<DialogHeader>
|
|
533
583
|
<DialogTitle>{t.asset.deleteAssetTitle}</DialogTitle>
|
|
534
584
|
<DialogDescription>
|
|
535
|
-
{
|
|
536
|
-
|
|
537
|
-
|
|
585
|
+
{inUse ? (
|
|
586
|
+
<>
|
|
587
|
+
{format(t.asset.deleteAssetInUseDescription, {
|
|
588
|
+
name: asset.name,
|
|
589
|
+
count: totalUses,
|
|
590
|
+
slides: slideCount,
|
|
591
|
+
})}{' '}
|
|
592
|
+
{t.asset.deleteAssetInUseHint}
|
|
593
|
+
</>
|
|
594
|
+
) : (
|
|
595
|
+
<>
|
|
596
|
+
{descPrefix}
|
|
597
|
+
<span className="font-mono">{asset.name}</span>
|
|
598
|
+
{descSuffix}
|
|
599
|
+
</>
|
|
600
|
+
)}
|
|
538
601
|
</DialogDescription>
|
|
539
602
|
</DialogHeader>
|
|
603
|
+
{inUse && usages && (
|
|
604
|
+
<ul className="max-h-40 overflow-y-auto rounded-[5px] border border-hairline bg-muted/40 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
|
|
605
|
+
{usages.map((u) => (
|
|
606
|
+
<li key={u.slideId} className="flex items-center justify-between gap-3">
|
|
607
|
+
<span className="truncate">{u.slideId}</span>
|
|
608
|
+
<span className="text-muted-foreground">×{u.count}</span>
|
|
609
|
+
</li>
|
|
610
|
+
))}
|
|
611
|
+
</ul>
|
|
612
|
+
)}
|
|
540
613
|
<DialogFooter>
|
|
541
614
|
<Button variant="outline" onClick={onCancel}>
|
|
542
615
|
{t.common.cancel}
|
|
543
616
|
</Button>
|
|
544
|
-
<Button variant="destructive" onClick={onConfirm}>
|
|
545
|
-
{t.common.delete}
|
|
617
|
+
<Button variant="destructive" onClick={onConfirm} disabled={usages === null}>
|
|
618
|
+
{inUse ? t.asset.deleteAndRevert : t.common.delete}
|
|
546
619
|
</Button>
|
|
547
620
|
</DialogFooter>
|
|
548
621
|
</DialogContent>
|
|
@@ -1116,8 +1116,23 @@ function CommentsSection({
|
|
|
1116
1116
|
}) {
|
|
1117
1117
|
const [draft, setDraft] = useState('');
|
|
1118
1118
|
const [submitting, setSubmitting] = useState(false);
|
|
1119
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
1119
1120
|
const t = useLocale();
|
|
1120
1121
|
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1124
|
+
if (e.key !== '/') return;
|
|
1125
|
+
if (!(e.metaKey || e.ctrlKey)) return;
|
|
1126
|
+
if (e.altKey || e.shiftKey) return;
|
|
1127
|
+
const ta = wrapRef.current?.querySelector('textarea');
|
|
1128
|
+
if (!ta) return;
|
|
1129
|
+
e.preventDefault();
|
|
1130
|
+
ta.focus({ preventScroll: true });
|
|
1131
|
+
};
|
|
1132
|
+
window.addEventListener('keydown', onKey);
|
|
1133
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
1134
|
+
}, []);
|
|
1135
|
+
|
|
1121
1136
|
const submit = async () => {
|
|
1122
1137
|
const trimmed = draft.trim();
|
|
1123
1138
|
if (!trimmed) return;
|
|
@@ -1133,7 +1148,7 @@ function CommentsSection({
|
|
|
1133
1148
|
return (
|
|
1134
1149
|
<Section title={t.inspector.leaveComment}>
|
|
1135
1150
|
<div className="flex flex-col gap-2">
|
|
1136
|
-
<div className="comment-cue rounded-[6px]">
|
|
1151
|
+
<div ref={wrapRef} className="comment-cue rounded-[6px]">
|
|
1137
1152
|
<Textarea
|
|
1138
1153
|
value={draft}
|
|
1139
1154
|
onChange={(e) => setDraft(e.target.value)}
|
|
@@ -68,10 +68,12 @@ export function PanelShell({
|
|
|
68
68
|
{header}
|
|
69
69
|
</header>
|
|
70
70
|
{banner}
|
|
71
|
-
<ScrollArea className="
|
|
72
|
-
<div className="flex min-h-full flex-col">
|
|
71
|
+
<ScrollArea className="min-h-0 flex-1">
|
|
72
|
+
<div className="flex min-h-full flex-col">
|
|
73
|
+
{children}
|
|
74
|
+
{footer && <div className="mt-auto border-t border-hairline">{footer}</div>}
|
|
75
|
+
</div>
|
|
73
76
|
</ScrollArea>
|
|
74
|
-
{footer && <div className="shrink-0 border-t border-hairline">{footer}</div>}
|
|
75
77
|
</div>
|
|
76
78
|
</aside>
|
|
77
79
|
);
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
2
2
|
import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import type { DesignSystem } from '../lib/design';
|
|
5
|
+
import { SlidePageProvider } from '../lib/page-context';
|
|
5
6
|
import type { Page } from '../lib/sdk';
|
|
6
7
|
import { PresentBlackoutOverlay } from './present/blackout-overlay';
|
|
7
8
|
import { PresentControlBar } from './present/control-bar';
|
|
@@ -295,7 +296,11 @@ export function Player({
|
|
|
295
296
|
)}
|
|
296
297
|
>
|
|
297
298
|
<SlideCanvas flat design={design}>
|
|
298
|
-
{PageComp ?
|
|
299
|
+
{PageComp ? (
|
|
300
|
+
<SlidePageProvider index={index} total={pages.length}>
|
|
301
|
+
<PageComp />
|
|
302
|
+
</SlidePageProvider>
|
|
303
|
+
) : null}
|
|
299
304
|
</SlideCanvas>
|
|
300
305
|
|
|
301
306
|
<button
|
|
@@ -24,13 +24,12 @@ export function PresentLaserPointer({ enabled }: { enabled: boolean }) {
|
|
|
24
24
|
return (
|
|
25
25
|
<div
|
|
26
26
|
aria-hidden
|
|
27
|
-
className="pointer-events-none fixed z-[60]"
|
|
27
|
+
className="pointer-events-none fixed top-0 left-0 z-[60]"
|
|
28
28
|
style={{
|
|
29
|
-
left: pos.x,
|
|
30
|
-
top: pos.y,
|
|
31
29
|
width: 18,
|
|
32
30
|
height: 18,
|
|
33
|
-
transform:
|
|
31
|
+
transform: `translate3d(${pos.x - 9}px, ${pos.y - 9}px, 0)`,
|
|
32
|
+
willChange: 'transform',
|
|
34
33
|
borderRadius: '50%',
|
|
35
34
|
background: 'radial-gradient(circle, oklch(0.66 0.24 28 / 0.95) 30%, transparent 70%)',
|
|
36
35
|
boxShadow: '0 0 18px 4px oklch(0.66 0.24 28 / 0.55)',
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { format, useLocale } from '@/lib/use-locale';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import type { DesignSystem } from '../../lib/design';
|
|
5
|
+
import { SlidePageProvider } from '../../lib/page-context';
|
|
5
6
|
import type { Page } from '../../lib/sdk';
|
|
6
7
|
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../../lib/sdk';
|
|
7
8
|
import { SlideCanvas } from '../slide-canvas';
|
|
@@ -136,7 +137,9 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
136
137
|
freezeMotion
|
|
137
138
|
design={design}
|
|
138
139
|
>
|
|
139
|
-
<
|
|
140
|
+
<SlidePageProvider index={i} total={pages.length}>
|
|
141
|
+
<PageComp />
|
|
142
|
+
</SlidePageProvider>
|
|
140
143
|
</SlideCanvas>
|
|
141
144
|
{isCurrent && (
|
|
142
145
|
<span
|
|
@@ -7,19 +7,19 @@ type Props = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
export function PresentProgressBar({ index, total, visible }: Props) {
|
|
10
|
-
const pct = total > 0 ? (
|
|
10
|
+
const pct = total > 0 ? (index + 1) / total : 0;
|
|
11
11
|
return (
|
|
12
12
|
<div
|
|
13
13
|
aria-hidden
|
|
14
14
|
className={cn(
|
|
15
|
-
'pointer-events-none absolute inset-x-0 top-0 z-30 h-[2px] bg-white/8',
|
|
15
|
+
'pointer-events-none absolute inset-x-0 top-0 z-30 h-[2px] overflow-hidden bg-white/8',
|
|
16
16
|
'motion-safe:transition-opacity motion-safe:duration-200',
|
|
17
17
|
visible ? 'opacity-100' : 'opacity-0',
|
|
18
18
|
)}
|
|
19
19
|
>
|
|
20
20
|
<div
|
|
21
|
-
className="h-full bg-[var(--brand,#ef4444)] transition-
|
|
22
|
-
style={{
|
|
21
|
+
className="h-full w-full origin-left bg-[var(--brand,#ef4444)] transition-transform duration-200 ease-out"
|
|
22
|
+
style={{ transform: `scaleX(${pct})` }}
|
|
23
23
|
/>
|
|
24
24
|
</div>
|
|
25
25
|
);
|
|
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
5
|
import { format, useLocale } from '@/lib/use-locale';
|
|
6
6
|
import { cn } from '@/lib/utils';
|
|
7
|
+
import { SlidePageProvider } from '../../lib/page-context';
|
|
7
8
|
import type { SlideModule } from '../../lib/sdk';
|
|
8
9
|
import { loadSlide, slidesByTheme } from '../../lib/slides';
|
|
9
10
|
import { loadThemeDemo, type ThemeDemoModule, themes } from '../../lib/themes';
|
|
@@ -106,7 +107,9 @@ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () =
|
|
|
106
107
|
</div>
|
|
107
108
|
) : Current ? (
|
|
108
109
|
<SlideCanvas flat freezeMotion design={demo.design}>
|
|
109
|
-
<
|
|
110
|
+
<SlidePageProvider index={pageIndex} total={totalPages}>
|
|
111
|
+
<Current />
|
|
112
|
+
</SlidePageProvider>
|
|
110
113
|
</SlideCanvas>
|
|
111
114
|
) : null}
|
|
112
115
|
</div>
|
|
@@ -227,7 +230,9 @@ function ThemeSlideCard({ id }: { id: string }) {
|
|
|
227
230
|
{FirstPage ? (
|
|
228
231
|
<div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
|
|
229
232
|
<SlideCanvas flat freezeMotion design={slide?.design}>
|
|
230
|
-
<
|
|
233
|
+
<SlidePageProvider index={0} total={slide?.default.length ?? 1}>
|
|
234
|
+
<FirstPage />
|
|
235
|
+
</SlidePageProvider>
|
|
231
236
|
</SlideCanvas>
|
|
232
237
|
</div>
|
|
233
238
|
) : (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { format, useLocale } from '@/lib/use-locale';
|
|
3
|
+
import { SlidePageProvider } from '../../lib/page-context';
|
|
3
4
|
import { loadThemeDemo, type Theme, type ThemeDemoModule, themes } from '../../lib/themes';
|
|
4
5
|
import { SlideCanvas } from '../slide-canvas';
|
|
5
6
|
|
|
@@ -78,7 +79,9 @@ function ThemePreview({ theme }: { theme: Theme }) {
|
|
|
78
79
|
return (
|
|
79
80
|
<div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
|
|
80
81
|
<SlideCanvas flat freezeMotion design={demo.design}>
|
|
81
|
-
<
|
|
82
|
+
<SlidePageProvider index={0} total={demo.default.length}>
|
|
83
|
+
<FirstPage />
|
|
84
|
+
</SlidePageProvider>
|
|
82
85
|
</SlideCanvas>
|
|
83
86
|
</div>
|
|
84
87
|
);
|
|
@@ -28,6 +28,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|
|
28
28
|
import { format, useLocale } from '@/lib/use-locale';
|
|
29
29
|
import { cn } from '@/lib/utils';
|
|
30
30
|
import type { DesignSystem } from '../lib/design';
|
|
31
|
+
import { SlidePageProvider } from '../lib/page-context';
|
|
31
32
|
import type { Page } from '../lib/sdk';
|
|
32
33
|
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
33
34
|
import { SlideCanvas } from './slide-canvas';
|
|
@@ -118,7 +119,9 @@ export function ThumbnailRail({
|
|
|
118
119
|
style={{ width, height: HORIZONTAL_THUMB_HEIGHT }}
|
|
119
120
|
>
|
|
120
121
|
<SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
|
|
121
|
-
<
|
|
122
|
+
<SlidePageProvider index={i} total={pages.length}>
|
|
123
|
+
<PageComp />
|
|
124
|
+
</SlidePageProvider>
|
|
122
125
|
</SlideCanvas>
|
|
123
126
|
</div>
|
|
124
127
|
</button>
|
|
@@ -155,6 +158,7 @@ export function ThumbnailRail({
|
|
|
155
158
|
const inner = (
|
|
156
159
|
<ThumbContents
|
|
157
160
|
index={i}
|
|
161
|
+
total={pages.length}
|
|
158
162
|
active={active}
|
|
159
163
|
page={PageComp}
|
|
160
164
|
design={design}
|
|
@@ -236,6 +240,7 @@ function thumbButtonClass(active: boolean): string {
|
|
|
236
240
|
|
|
237
241
|
function ThumbContents({
|
|
238
242
|
index,
|
|
243
|
+
total,
|
|
239
244
|
active,
|
|
240
245
|
page: PageComp,
|
|
241
246
|
design,
|
|
@@ -244,6 +249,7 @@ function ThumbContents({
|
|
|
244
249
|
height,
|
|
245
250
|
}: {
|
|
246
251
|
index: number;
|
|
252
|
+
total: number;
|
|
247
253
|
active: boolean;
|
|
248
254
|
page: Page;
|
|
249
255
|
design?: DesignSystem;
|
|
@@ -271,7 +277,9 @@ function ThumbContents({
|
|
|
271
277
|
style={{ width: thumbWidth, height }}
|
|
272
278
|
>
|
|
273
279
|
<SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
|
|
274
|
-
<
|
|
280
|
+
<SlidePageProvider index={index} total={total}>
|
|
281
|
+
<PageComp />
|
|
282
|
+
</SlidePageProvider>
|
|
275
283
|
</SlideCanvas>
|
|
276
284
|
{active && (
|
|
277
285
|
<span
|
package/src/app/lib/assets.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type AssetEntry = {
|
|
|
6
6
|
mtime: number;
|
|
7
7
|
mime: string;
|
|
8
8
|
url: string;
|
|
9
|
+
unused: boolean;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
export type UploadOptions = { overwrite?: boolean };
|
|
@@ -45,6 +46,27 @@ async function deleteAsset(slideId: string, name: string): Promise<Response> {
|
|
|
45
46
|
return fetch(`/__assets/${slideId}/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
export type AssetUsage = { slideId: string; count: number };
|
|
50
|
+
|
|
51
|
+
export async function listAssetUsages(slideId: string, name: string): Promise<AssetUsage[]> {
|
|
52
|
+
const res = await fetch(`/__assets/${slideId}/${encodeURIComponent(name)}/usages`);
|
|
53
|
+
if (!res.ok) return [];
|
|
54
|
+
const data = (await res.json().catch(() => null)) as { usages?: AssetUsage[] } | null;
|
|
55
|
+
return data?.usages ?? [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function revertAssetUsage(
|
|
59
|
+
slideId: string,
|
|
60
|
+
assetPath: string,
|
|
61
|
+
): Promise<{ ok: boolean; status: number }> {
|
|
62
|
+
const res = await fetch('/__edit/revert-asset', {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ slideId, assetPath }),
|
|
66
|
+
});
|
|
67
|
+
return { ok: res.ok, status: res.status };
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
export async function uploadWithAutoRename(
|
|
49
71
|
slideId: string,
|
|
50
72
|
file: File,
|
|
@@ -69,6 +91,7 @@ export async function uploadWithAutoRename(
|
|
|
69
91
|
mtime: body?.mtime ?? Date.now(),
|
|
70
92
|
mime: body?.mime ?? uploaded.type ?? 'application/octet-stream',
|
|
71
93
|
url: body?.url ?? `/__assets/${slideId}/${encodeURIComponent(uploaded.name)}`,
|
|
94
|
+
unused: body?.unused ?? false,
|
|
72
95
|
};
|
|
73
96
|
return { ok: true, status: res.status, entry };
|
|
74
97
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
2
|
import { createRoot } from 'react-dom/client';
|
|
3
3
|
import { designToCssVars } from './design';
|
|
4
|
+
import { SlidePageProvider } from './page-context';
|
|
4
5
|
import type { SlideModule } from './sdk';
|
|
5
6
|
|
|
6
7
|
type AssetEntry = { name: string; bytes: Uint8Array };
|
|
@@ -82,13 +83,17 @@ async function renderPagesToHtml(pages: NonNullable<SlideModule['default']>): Pr
|
|
|
82
83
|
|
|
83
84
|
const result: string[] = [];
|
|
84
85
|
try {
|
|
85
|
-
for (
|
|
86
|
+
for (let i = 0; i < pages.length; i++) {
|
|
87
|
+
const Page = pages[i];
|
|
88
|
+
if (!Page) continue;
|
|
86
89
|
const host = document.createElement('div');
|
|
87
90
|
host.style.width = '1920px';
|
|
88
91
|
host.style.height = '1080px';
|
|
89
92
|
container.appendChild(host);
|
|
90
93
|
const root = createRoot(host);
|
|
91
|
-
root.render(
|
|
94
|
+
root.render(
|
|
95
|
+
createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)),
|
|
96
|
+
);
|
|
92
97
|
await nextPaint();
|
|
93
98
|
await nextPaint();
|
|
94
99
|
result.push(host.innerHTML);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
2
|
import { createRoot, type Root } from 'react-dom/client';
|
|
3
3
|
import { designToCssVars } from './design';
|
|
4
|
+
import { SlidePageProvider } from './page-context';
|
|
4
5
|
import { isFrameAnimationSettled, waitForDataWaitfor, waitForFonts } from './print-ready';
|
|
5
6
|
import type { SlideModule } from './sdk';
|
|
6
7
|
|
|
@@ -62,6 +63,20 @@ const PRINT_STYLES = `
|
|
|
62
63
|
transform: scale(0.5);
|
|
63
64
|
transform-origin: top left;
|
|
64
65
|
}
|
|
66
|
+
/* Chromium serializes box-shadow and CSS gradients as PDF transparency
|
|
67
|
+
groups / soft masks. macOS Preview re-composites those on every page
|
|
68
|
+
turn, causing 0.5–2s per-page lag. Strip them in the print container
|
|
69
|
+
only — gradients on pseudo-elements via CSS (DOM walk can't reach them),
|
|
70
|
+
inline-style gradients via neutralizeGradientBackgrounds() below. */
|
|
71
|
+
#${PRINT_ROOT_ID} *,
|
|
72
|
+
#${PRINT_ROOT_ID} *::before,
|
|
73
|
+
#${PRINT_ROOT_ID} *::after {
|
|
74
|
+
box-shadow: none !important;
|
|
75
|
+
}
|
|
76
|
+
#${PRINT_ROOT_ID} *::before,
|
|
77
|
+
#${PRINT_ROOT_ID} *::after {
|
|
78
|
+
background-image: none !important;
|
|
79
|
+
}
|
|
65
80
|
}
|
|
66
81
|
`;
|
|
67
82
|
|
|
@@ -109,7 +124,9 @@ export async function exportSlideAsPdf(
|
|
|
109
124
|
|
|
110
125
|
const reactRoots: Root[] = [];
|
|
111
126
|
const frames: HTMLElement[] = [];
|
|
112
|
-
for (
|
|
127
|
+
for (let i = 0; i < pages.length; i++) {
|
|
128
|
+
const Page = pages[i];
|
|
129
|
+
if (!Page) continue;
|
|
113
130
|
const host = document.createElement('div');
|
|
114
131
|
host.className = 'os-print-frame';
|
|
115
132
|
host.setAttribute('data-osd-canvas', '');
|
|
@@ -126,7 +143,9 @@ export async function exportSlideAsPdf(
|
|
|
126
143
|
root.appendChild(host);
|
|
127
144
|
frames.push(host);
|
|
128
145
|
const r = createRoot(inner);
|
|
129
|
-
r.render(
|
|
146
|
+
r.render(
|
|
147
|
+
createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)),
|
|
148
|
+
);
|
|
130
149
|
reactRoots.push(r);
|
|
131
150
|
}
|
|
132
151
|
// Yield once so React commits all pages and CSS animations actually start
|
|
@@ -155,6 +174,7 @@ export async function exportSlideAsPdf(
|
|
|
155
174
|
}
|
|
156
175
|
|
|
157
176
|
await waitForDataWaitfor(root);
|
|
177
|
+
neutralizeGradientBackgrounds(root);
|
|
158
178
|
await sleep(100); // flush layout
|
|
159
179
|
|
|
160
180
|
onProgress?.({ phase: 'printing', current: total, total, percent: 99 });
|
|
@@ -170,6 +190,18 @@ export async function exportSlideAsPdf(
|
|
|
170
190
|
}
|
|
171
191
|
}
|
|
172
192
|
|
|
193
|
+
// Strip inline-style gradients from background-image so Chromium does not
|
|
194
|
+
// emit them as PDF soft masks. url(...) backgrounds are preserved.
|
|
195
|
+
function neutralizeGradientBackgrounds(root: HTMLElement): void {
|
|
196
|
+
const elements = root.querySelectorAll<HTMLElement>('*');
|
|
197
|
+
for (const el of elements) {
|
|
198
|
+
const bg = getComputedStyle(el).backgroundImage;
|
|
199
|
+
if (bg?.includes('gradient(')) {
|
|
200
|
+
el.style.backgroundImage = 'none';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
173
205
|
function sleep(ms: number): Promise<void> {
|
|
174
206
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
175
207
|
}
|
package/src/app/lib/folders.ts
CHANGED
|
@@ -33,6 +33,19 @@ async function patchSlideName(slideId: string, name: string): Promise<void> {
|
|
|
33
33
|
if (!res.ok) throw new Error(`PATCH /__slides/${slideId} ${res.status}`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
async function duplicateSlideReq(slideId: string, newId?: string): Promise<string> {
|
|
37
|
+
const init: RequestInit = { method: 'POST' };
|
|
38
|
+
if (newId !== undefined) {
|
|
39
|
+
init.headers = { 'content-type': 'application/json' };
|
|
40
|
+
init.body = JSON.stringify({ newId });
|
|
41
|
+
}
|
|
42
|
+
const res = await fetch(`/__slides/${slideId}/duplicate`, init);
|
|
43
|
+
if (!res.ok) throw new Error(`POST /__slides/${slideId}/duplicate ${res.status}`);
|
|
44
|
+
const body = (await res.json()) as { slideId?: unknown };
|
|
45
|
+
if (typeof body.slideId !== 'string') throw new Error('duplicate response missing slideId');
|
|
46
|
+
return body.slideId;
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
async function deleteSlideReq(slideId: string): Promise<void> {
|
|
37
50
|
const res = await fetch(`/__slides/${slideId}`, { method: 'DELETE' });
|
|
38
51
|
if (!res.ok) throw new Error(`DELETE /__slides/${slideId} ${res.status}`);
|
|
@@ -83,6 +96,7 @@ export type UseFoldersResult = {
|
|
|
83
96
|
remove: (id: string) => Promise<void>;
|
|
84
97
|
assign: (slideId: string, folderId: string | null) => Promise<void>;
|
|
85
98
|
renameSlide: (slideId: string, name: string) => Promise<void>;
|
|
99
|
+
duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
|
|
86
100
|
deleteSlide: (slideId: string) => Promise<void>;
|
|
87
101
|
refresh: () => Promise<void>;
|
|
88
102
|
};
|
|
@@ -165,6 +179,15 @@ export function useFolders(): UseFoldersResult {
|
|
|
165
179
|
[refresh],
|
|
166
180
|
);
|
|
167
181
|
|
|
182
|
+
const duplicateSlide = useCallback(
|
|
183
|
+
async (slideId: string, newId?: string) => {
|
|
184
|
+
const duplicatedId = await duplicateSlideReq(slideId, newId);
|
|
185
|
+
await refresh();
|
|
186
|
+
return duplicatedId;
|
|
187
|
+
},
|
|
188
|
+
[refresh],
|
|
189
|
+
);
|
|
190
|
+
|
|
168
191
|
const deleteSlide = useCallback(
|
|
169
192
|
async (slideId: string) => {
|
|
170
193
|
await deleteSlideReq(slideId);
|
|
@@ -173,5 +196,16 @@ export function useFolders(): UseFoldersResult {
|
|
|
173
196
|
[refresh],
|
|
174
197
|
);
|
|
175
198
|
|
|
176
|
-
return {
|
|
199
|
+
return {
|
|
200
|
+
manifest,
|
|
201
|
+
loading,
|
|
202
|
+
create,
|
|
203
|
+
update,
|
|
204
|
+
remove,
|
|
205
|
+
assign,
|
|
206
|
+
renameSlide,
|
|
207
|
+
duplicateSlide,
|
|
208
|
+
deleteSlide,
|
|
209
|
+
refresh,
|
|
210
|
+
};
|
|
177
211
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Context, createContext, type PropsWithChildren, useContext, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
type SlidePageContextValue = {
|
|
4
|
+
index: number;
|
|
5
|
+
total: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// Stored on globalThis so dev (src) and published (dist) copies of this module
|
|
9
|
+
// share one context instance — otherwise the provider writes to one context and
|
|
10
|
+
// the hook reads from another, and `useSlidePageNumber` always sees null.
|
|
11
|
+
const GLOBAL_KEY = '__open_slide_page_context__';
|
|
12
|
+
type GlobalWithCtx = typeof globalThis & {
|
|
13
|
+
[GLOBAL_KEY]?: Context<SlidePageContextValue | null>;
|
|
14
|
+
};
|
|
15
|
+
const g = globalThis as GlobalWithCtx;
|
|
16
|
+
if (!g[GLOBAL_KEY]) {
|
|
17
|
+
g[GLOBAL_KEY] = createContext<SlidePageContextValue | null>(null);
|
|
18
|
+
}
|
|
19
|
+
const SlidePageContext = g[GLOBAL_KEY];
|
|
20
|
+
|
|
21
|
+
export function SlidePageProvider({
|
|
22
|
+
index,
|
|
23
|
+
total,
|
|
24
|
+
children,
|
|
25
|
+
}: PropsWithChildren<{ index: number; total: number }>) {
|
|
26
|
+
const value = useMemo(() => ({ index, total }), [index, total]);
|
|
27
|
+
return <SlidePageContext.Provider value={value}>{children}</SlidePageContext.Provider>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useSlidePageNumber(): { current: number; total: number } {
|
|
31
|
+
const ctx = useContext(SlidePageContext);
|
|
32
|
+
if (!ctx) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'useSlidePageNumber must be called from a slide page rendered by @open-slide/core',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return { current: ctx.index + 1, total: ctx.total };
|
|
38
|
+
}
|
package/src/app/lib/sdk.ts
CHANGED
package/src/app/lib/slides.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
slideCreatedAt as createdAt,
|
|
2
3
|
slideIds as ids,
|
|
3
4
|
loadSlide as load,
|
|
4
5
|
slideThemes as themes,
|
|
@@ -7,6 +8,7 @@ import type { SlideModule } from './sdk';
|
|
|
7
8
|
|
|
8
9
|
export const slideIds: string[] = ids;
|
|
9
10
|
export const slideThemes: Record<string, string> = themes;
|
|
11
|
+
export const slideCreatedAt: Record<string, number> = createdAt;
|
|
10
12
|
|
|
11
13
|
export function slidesByTheme(themeId: string): string[] {
|
|
12
14
|
return slideIds.filter((id) => slideThemes[id] === themeId);
|