@open-slide/core 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-DZhbjQpQ.js → build-tLrkKUHr.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-BQdTMho4.d.ts → config-CfMThYN9.d.ts} +1 -1
- package/dist/{config-iKjqaX08.js → config-PwUHqZ_X.js} +246 -2
- package/dist/{dev-BjLGk5nN.js → dev-DpCIRbhT.js} +1 -1
- package/dist/{en-DDGqyNaW.js → en-BDnM5zKJ.js} +4 -0
- package/dist/index.d.ts +29 -4
- package/dist/index.js +20 -4
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +13 -1
- package/dist/{preview-jwLWHWkQ.js → preview-BSGlM6Se.js} +1 -1
- package/dist/{types-Dpr8nbih.d.ts → types-B-KrjgX8.d.ts} +5 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +30 -22
- package/skills/slide-authoring/SKILL.md +186 -0
- package/src/app/components/asset-view.tsx +8 -1
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/inspect-overlay.tsx +132 -35
- package/src/app/components/inspector/inspector-panel.tsx +19 -256
- package/src/app/components/inspector/inspector-provider.tsx +102 -1
- package/src/app/components/panel/save-card.tsx +4 -4
- package/src/app/components/player.tsx +13 -3
- package/src/app/components/present/overview-grid.tsx +4 -1
- package/src/app/components/slide-transition-layer.tsx +154 -0
- package/src/app/components/style-panel/style-panel.tsx +3 -0
- package/src/app/components/themes/theme-detail.tsx +7 -2
- package/src/app/components/themes/themes-gallery.tsx +4 -1
- package/src/app/components/thumbnail-rail.tsx +10 -2
- package/src/app/lib/assets.ts +2 -0
- package/src/app/lib/export-html.ts +7 -2
- package/src/app/lib/export-pdf.ts +34 -2
- package/src/app/lib/folders.ts +35 -1
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/sdk.ts +3 -1
- package/src/app/lib/transition.ts +23 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-wheel-page-navigation.ts +7 -0
- package/src/app/routes/home-shell.tsx +13 -2
- package/src/app/routes/home.tsx +28 -2
- package/src/app/routes/presenter.tsx +7 -2
- package/src/app/routes/slide.tsx +19 -8
- package/src/locale/en.ts +4 -0
- package/src/locale/ja.ts +4 -0
- package/src/locale/types.ts +5 -0
- package/src/locale/zh-cn.ts +4 -0
- package/src/locale/zh-tw.ts +4 -0
|
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
|
|
15
15
|
import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
|
|
16
16
|
import { type Edit, type EditOp, type EditResult, useEditor } from '@/lib/inspector/use-editor';
|
|
17
17
|
import { useLocale } from '@/lib/use-locale';
|
|
18
|
+
import { AssetPickerDialog } from './asset-picker-dialog';
|
|
18
19
|
import { ImageCropDialog, type ImageCropRect } from './image-crop-dialog';
|
|
19
20
|
|
|
20
21
|
export type SelectedTarget = {
|
|
@@ -263,6 +264,7 @@ type InspectorCtx = {
|
|
|
263
264
|
cancelEdits: () => void;
|
|
264
265
|
committing: boolean;
|
|
265
266
|
openCrop: (anchor: HTMLImageElement) => void;
|
|
267
|
+
openReplace: (anchor: HTMLElement) => void;
|
|
266
268
|
};
|
|
267
269
|
|
|
268
270
|
const Ctx = createContext<InspectorCtx | null>(null);
|
|
@@ -273,7 +275,15 @@ export function useInspector(): InspectorCtx {
|
|
|
273
275
|
return v;
|
|
274
276
|
}
|
|
275
277
|
|
|
276
|
-
export function InspectorProvider({
|
|
278
|
+
export function InspectorProvider({
|
|
279
|
+
slideId,
|
|
280
|
+
pageIndex,
|
|
281
|
+
children,
|
|
282
|
+
}: {
|
|
283
|
+
slideId: string;
|
|
284
|
+
pageIndex: number;
|
|
285
|
+
children: ReactNode;
|
|
286
|
+
}) {
|
|
277
287
|
const [active, setActive] = useState(false);
|
|
278
288
|
const [selected, setSelected] = useState<SelectedTarget | null>(null);
|
|
279
289
|
const { comments, error, refetch, add, remove } = useComments(slideId);
|
|
@@ -296,6 +306,11 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
296
306
|
initialPosition: { x: number; y: number };
|
|
297
307
|
initialRect: ImageCropRect | null;
|
|
298
308
|
} | null>(null);
|
|
309
|
+
const [replaceTarget, setReplaceTarget] = useState<{
|
|
310
|
+
line: number;
|
|
311
|
+
column: number;
|
|
312
|
+
anchor: HTMLElement;
|
|
313
|
+
} | null>(null);
|
|
299
314
|
const t = useLocale();
|
|
300
315
|
|
|
301
316
|
const ensureInstanceId = useCallback((el: HTMLElement): string => {
|
|
@@ -871,6 +886,35 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
871
886
|
return () => observer?.disconnect();
|
|
872
887
|
}, []);
|
|
873
888
|
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
void pageIndex;
|
|
891
|
+
setSelected(null);
|
|
892
|
+
}, [pageIndex]);
|
|
893
|
+
|
|
894
|
+
// Never clear `selected` on a miss: the observer can fire between an
|
|
895
|
+
// "old removed" and "new added" mutation batch, and clearing then would
|
|
896
|
+
// drop a selection that's about to reattach on the next fire.
|
|
897
|
+
useEffect(() => {
|
|
898
|
+
if (!selected) return;
|
|
899
|
+
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
900
|
+
if (!root) return;
|
|
901
|
+
|
|
902
|
+
const revalidate = () => {
|
|
903
|
+
if (selected.anchor.isConnected) return;
|
|
904
|
+
const next = root.querySelector<HTMLElement>(
|
|
905
|
+
`[data-slide-loc="${selected.line}:${selected.column}"]`,
|
|
906
|
+
);
|
|
907
|
+
if (next && next !== selected.anchor) {
|
|
908
|
+
setSelected({ ...selected, anchor: next });
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
revalidate();
|
|
913
|
+
const observer = new MutationObserver(revalidate);
|
|
914
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
915
|
+
return () => observer.disconnect();
|
|
916
|
+
}, [selected]);
|
|
917
|
+
|
|
874
918
|
const toggle = useCallback(() => {
|
|
875
919
|
setActive((a) => {
|
|
876
920
|
if (a) setSelected(null);
|
|
@@ -883,6 +927,27 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
883
927
|
setSelected(null);
|
|
884
928
|
}, []);
|
|
885
929
|
|
|
930
|
+
const openReplace = useCallback((anchor: HTMLElement) => {
|
|
931
|
+
const loc = anchor.dataset.slideLoc;
|
|
932
|
+
if (!loc) return;
|
|
933
|
+
const [lineStr, columnStr] = loc.split(':');
|
|
934
|
+
const line = Number(lineStr);
|
|
935
|
+
const column = Number(columnStr);
|
|
936
|
+
if (!Number.isFinite(line) || !Number.isFinite(column)) return;
|
|
937
|
+
setReplaceTarget({ line, column, anchor });
|
|
938
|
+
}, []);
|
|
939
|
+
|
|
940
|
+
useEffect(() => {
|
|
941
|
+
if (import.meta.env.PROD) return;
|
|
942
|
+
const onKey = (e: KeyboardEvent) => {
|
|
943
|
+
if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
|
|
944
|
+
if (e.key !== 'i' && e.key !== 'I') return;
|
|
945
|
+
toggle();
|
|
946
|
+
};
|
|
947
|
+
window.addEventListener('keydown', onKey);
|
|
948
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
949
|
+
}, [toggle]);
|
|
950
|
+
|
|
886
951
|
const openCrop = useCallback((anchor: HTMLImageElement) => {
|
|
887
952
|
const loc = anchor.dataset.slideLoc;
|
|
888
953
|
if (!loc) return;
|
|
@@ -925,6 +990,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
925
990
|
cancelEdits,
|
|
926
991
|
committing,
|
|
927
992
|
openCrop,
|
|
993
|
+
openReplace,
|
|
928
994
|
}),
|
|
929
995
|
[
|
|
930
996
|
slideId,
|
|
@@ -945,12 +1011,44 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
945
1011
|
cancelEdits,
|
|
946
1012
|
committing,
|
|
947
1013
|
openCrop,
|
|
1014
|
+
openReplace,
|
|
948
1015
|
],
|
|
949
1016
|
);
|
|
950
1017
|
|
|
951
1018
|
return (
|
|
952
1019
|
<Ctx.Provider value={value}>
|
|
953
1020
|
{children}
|
|
1021
|
+
{replaceTarget && (
|
|
1022
|
+
<AssetPickerDialog
|
|
1023
|
+
slideId={slideId}
|
|
1024
|
+
onClose={() => setReplaceTarget(null)}
|
|
1025
|
+
onPick={(asset, scope) => {
|
|
1026
|
+
const { line, column, anchor } = replaceTarget;
|
|
1027
|
+
const assetPath =
|
|
1028
|
+
scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
|
|
1029
|
+
const ops: EditOp[] = [
|
|
1030
|
+
{
|
|
1031
|
+
kind: 'set-attr-asset',
|
|
1032
|
+
attr: 'src',
|
|
1033
|
+
assetPath,
|
|
1034
|
+
previewUrl: asset.url,
|
|
1035
|
+
},
|
|
1036
|
+
];
|
|
1037
|
+
if (anchor.tagName === 'IMG' && anchor.isConnected) {
|
|
1038
|
+
const cs = window.getComputedStyle(anchor);
|
|
1039
|
+
if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
|
|
1040
|
+
ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
|
|
1041
|
+
}
|
|
1042
|
+
const op = cs.objectPosition.trim();
|
|
1043
|
+
if (!op || op === '0% 0%' || op === 'auto') {
|
|
1044
|
+
ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
bufferOps(line, column, anchor, ops);
|
|
1048
|
+
setReplaceTarget(null);
|
|
1049
|
+
}}
|
|
1050
|
+
/>
|
|
1051
|
+
)}
|
|
954
1052
|
{cropTarget && (
|
|
955
1053
|
<ImageCropDialog
|
|
956
1054
|
src={cropTarget.src}
|
|
@@ -1064,6 +1162,9 @@ export function InspectToggleButton() {
|
|
|
1064
1162
|
>
|
|
1065
1163
|
<Crosshair className="size-3.5" />
|
|
1066
1164
|
<span className="hidden md:inline">{t.inspector.inspect}</span>
|
|
1165
|
+
<kbd className="ml-1 hidden rounded-[3px] bg-foreground/10 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
|
|
1166
|
+
I
|
|
1167
|
+
</kbd>
|
|
1067
1168
|
</Button>
|
|
1068
1169
|
);
|
|
1069
1170
|
}
|
|
@@ -91,15 +91,15 @@ export function SaveCard({
|
|
|
91
91
|
</div>
|
|
92
92
|
)}
|
|
93
93
|
{justSaved ? (
|
|
94
|
-
<span className="flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
|
|
95
|
-
<Check className="size-3.5 text-[oklch(0.55_0.13_165)]" strokeWidth={2.5} />
|
|
94
|
+
<span className="flex items-center gap-1.5 whitespace-nowrap px-2.5 text-[12px] font-medium text-foreground">
|
|
95
|
+
<Check className="size-3.5 shrink-0 text-[oklch(0.55_0.13_165)]" strokeWidth={2.5} />
|
|
96
96
|
{resolvedSavedLabel}
|
|
97
97
|
</span>
|
|
98
98
|
) : dirty || committing ? (
|
|
99
|
-
<span className="inline-flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
|
|
99
|
+
<span className="inline-flex items-center gap-1.5 whitespace-nowrap px-2.5 text-[12px] font-medium text-foreground">
|
|
100
100
|
<span
|
|
101
101
|
aria-hidden
|
|
102
|
-
className="size-1.5 rounded-full bg-brand shadow-[0_0_0_3px_var(--brand-soft)]"
|
|
102
|
+
className="size-1.5 shrink-0 rounded-full bg-brand shadow-[0_0_0_3px_var(--brand-soft)]"
|
|
103
103
|
/>
|
|
104
104
|
<span className="nums">{unsavedLabel}</span>
|
|
105
105
|
</span>
|
|
@@ -3,6 +3,8 @@ import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
|
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import type { DesignSystem } from '../lib/design';
|
|
5
5
|
import type { Page } from '../lib/sdk';
|
|
6
|
+
import type { SlideTransition } from '../lib/transition';
|
|
7
|
+
import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
|
|
6
8
|
import { PresentBlackoutOverlay } from './present/blackout-overlay';
|
|
7
9
|
import { PresentControlBar } from './present/control-bar';
|
|
8
10
|
import { PresentHelpOverlay } from './present/help-overlay';
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
} from './present/use-presenter-channel';
|
|
20
22
|
import { useTouchSwipe } from './present/use-touch-swipe';
|
|
21
23
|
import { SlideCanvas } from './slide-canvas';
|
|
24
|
+
import { SlideTransitionLayer } from './slide-transition-layer';
|
|
22
25
|
|
|
23
26
|
const IDLE_HIDE_MS = 2000;
|
|
24
27
|
const BAR_HOTZONE_PX = 160;
|
|
@@ -26,6 +29,7 @@ const BAR_HOTZONE_PX = 160;
|
|
|
26
29
|
type Props = {
|
|
27
30
|
pages: Page[];
|
|
28
31
|
design?: DesignSystem;
|
|
32
|
+
transition?: SlideTransition;
|
|
29
33
|
index: number;
|
|
30
34
|
onIndexChange: (index: number) => void;
|
|
31
35
|
onExit: () => void;
|
|
@@ -43,6 +47,7 @@ type Props = {
|
|
|
43
47
|
export function Player({
|
|
44
48
|
pages,
|
|
45
49
|
design,
|
|
50
|
+
transition,
|
|
46
51
|
index,
|
|
47
52
|
onIndexChange,
|
|
48
53
|
onExit,
|
|
@@ -51,6 +56,7 @@ export function Player({
|
|
|
51
56
|
slideId,
|
|
52
57
|
fullscreen = true,
|
|
53
58
|
}: Props) {
|
|
59
|
+
const prefersReducedMotion = usePrefersReducedMotion();
|
|
54
60
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
55
61
|
// Mirrored as state so descendants portaling *into* the player subtree
|
|
56
62
|
// (tooltips, popovers — the body is outside the fullscreen tree) re-render
|
|
@@ -283,8 +289,6 @@ export function Player({
|
|
|
283
289
|
const hideCursor =
|
|
284
290
|
controls && (laser || keyboardDriven || (idle && !overlayActive && !pointerNearBottom));
|
|
285
291
|
|
|
286
|
-
const PageComp = pages[index];
|
|
287
|
-
|
|
288
292
|
return (
|
|
289
293
|
<div
|
|
290
294
|
ref={setRoot}
|
|
@@ -295,7 +299,13 @@ export function Player({
|
|
|
295
299
|
)}
|
|
296
300
|
>
|
|
297
301
|
<SlideCanvas flat design={design}>
|
|
298
|
-
|
|
302
|
+
<SlideTransitionLayer
|
|
303
|
+
pages={pages}
|
|
304
|
+
index={index}
|
|
305
|
+
total={pages.length}
|
|
306
|
+
moduleTransition={transition}
|
|
307
|
+
disabled={prefersReducedMotion}
|
|
308
|
+
/>
|
|
299
309
|
</SlideCanvas>
|
|
300
310
|
|
|
301
311
|
<button
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { format, useLocale } from '@/lib/use-locale';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import type { DesignSystem } from '../../lib/design';
|
|
5
|
+
import { SlidePageProvider } from '../../lib/page-context';
|
|
5
6
|
import type { Page } from '../../lib/sdk';
|
|
6
7
|
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../../lib/sdk';
|
|
7
8
|
import { SlideCanvas } from '../slide-canvas';
|
|
@@ -136,7 +137,9 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
136
137
|
freezeMotion
|
|
137
138
|
design={design}
|
|
138
139
|
>
|
|
139
|
-
<
|
|
140
|
+
<SlidePageProvider index={i} total={pages.length}>
|
|
141
|
+
<PageComp />
|
|
142
|
+
</SlidePageProvider>
|
|
140
143
|
</SlideCanvas>
|
|
141
144
|
{isCurrent && (
|
|
142
145
|
<span
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { SlidePageProvider } from '../lib/page-context';
|
|
3
|
+
import type { Page } from '../lib/sdk';
|
|
4
|
+
import { resolveTransition, type SlideTransition, type TransitionPhase } from '../lib/transition';
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
pages: Page[];
|
|
8
|
+
index: number;
|
|
9
|
+
total: number;
|
|
10
|
+
moduleTransition?: SlideTransition;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type Direction = 'forward' | 'backward';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_EASING = 'cubic-bezier(.4, 0, .2, 1)';
|
|
17
|
+
|
|
18
|
+
function runPhase(
|
|
19
|
+
el: HTMLElement,
|
|
20
|
+
phase: TransitionPhase | undefined,
|
|
21
|
+
fallbackDuration: number,
|
|
22
|
+
fallbackEasing: string,
|
|
23
|
+
): Animation | null {
|
|
24
|
+
if (!phase) return null;
|
|
25
|
+
return el.animate(phase.keyframes, {
|
|
26
|
+
duration: phase.duration ?? fallbackDuration,
|
|
27
|
+
easing: phase.easing ?? fallbackEasing,
|
|
28
|
+
delay: phase.delay ?? 0,
|
|
29
|
+
fill: 'both',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function SlideTransitionLayer({ pages, index, total, moduleTransition, disabled }: Props) {
|
|
34
|
+
const [current, setCurrent] = useState(index);
|
|
35
|
+
const [outgoing, setOutgoing] = useState<number | null>(null);
|
|
36
|
+
const [direction, setDirection] = useState<Direction>('forward');
|
|
37
|
+
|
|
38
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
39
|
+
const outgoingLayerRef = useRef<HTMLDivElement | null>(null);
|
|
40
|
+
const incomingLayerRef = useRef<HTMLDivElement | null>(null);
|
|
41
|
+
const animsRef = useRef<Animation[]>([]);
|
|
42
|
+
const currentRef = useRef(current);
|
|
43
|
+
currentRef.current = current;
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (index === currentRef.current) return;
|
|
47
|
+
|
|
48
|
+
const prev = currentRef.current;
|
|
49
|
+
const next = index;
|
|
50
|
+
|
|
51
|
+
// Interrupt: cancel in-flight animations. The previously-incoming page
|
|
52
|
+
// (currentRef) becomes the new outgoing; React reuses its DOM slot.
|
|
53
|
+
for (const a of animsRef.current) {
|
|
54
|
+
try {
|
|
55
|
+
a.cancel();
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
animsRef.current = [];
|
|
59
|
+
|
|
60
|
+
const transition = resolveTransition(pages, next, moduleTransition);
|
|
61
|
+
if (disabled || !transition) {
|
|
62
|
+
setCurrent(next);
|
|
63
|
+
setOutgoing(null);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setDirection(next > prev ? 'forward' : 'backward');
|
|
68
|
+
setOutgoing(prev);
|
|
69
|
+
setCurrent(next);
|
|
70
|
+
}, [index, pages, moduleTransition, disabled]);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (outgoing === null) return;
|
|
74
|
+
|
|
75
|
+
const transition = resolveTransition(pages, current, moduleTransition);
|
|
76
|
+
const wrapper = wrapperRef.current;
|
|
77
|
+
const out = outgoingLayerRef.current;
|
|
78
|
+
const inc = incomingLayerRef.current;
|
|
79
|
+
if (!transition || !wrapper || !out || !inc) {
|
|
80
|
+
setOutgoing(null);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
wrapper.dataset.osdDir = direction;
|
|
85
|
+
wrapper.style.setProperty('--osd-dir', direction === 'forward' ? '1' : '-1');
|
|
86
|
+
|
|
87
|
+
const easing = transition.easing ?? DEFAULT_EASING;
|
|
88
|
+
const duration = transition.duration;
|
|
89
|
+
|
|
90
|
+
const anims: Animation[] = [];
|
|
91
|
+
const exitAnim = runPhase(out, transition.exit, duration, easing);
|
|
92
|
+
const enterAnim = runPhase(inc, transition.enter, duration, easing);
|
|
93
|
+
if (exitAnim) anims.push(exitAnim);
|
|
94
|
+
if (enterAnim) anims.push(enterAnim);
|
|
95
|
+
animsRef.current = anims;
|
|
96
|
+
|
|
97
|
+
if (anims.length === 0) {
|
|
98
|
+
setOutgoing(null);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let cancelled = false;
|
|
103
|
+
Promise.all(anims.map((a) => a.finished))
|
|
104
|
+
.then(() => {
|
|
105
|
+
if (cancelled) return;
|
|
106
|
+
animsRef.current = [];
|
|
107
|
+
setOutgoing(null);
|
|
108
|
+
})
|
|
109
|
+
.catch(() => {
|
|
110
|
+
// AbortError fires when we cancel mid-flight on an interrupt.
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return () => {
|
|
114
|
+
cancelled = true;
|
|
115
|
+
};
|
|
116
|
+
}, [outgoing, current, direction, pages, moduleTransition]);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
return () => {
|
|
120
|
+
for (const a of animsRef.current) {
|
|
121
|
+
try {
|
|
122
|
+
a.cancel();
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
animsRef.current = [];
|
|
126
|
+
};
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const CurrentPage = pages[current];
|
|
130
|
+
const OutgoingPage = outgoing !== null ? pages[outgoing] : null;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
ref={wrapperRef}
|
|
135
|
+
className="relative h-full w-full"
|
|
136
|
+
style={{ background: 'var(--osd-bg)' }}
|
|
137
|
+
>
|
|
138
|
+
{OutgoingPage && outgoing !== null ? (
|
|
139
|
+
<div ref={outgoingLayerRef} className="absolute inset-0">
|
|
140
|
+
<SlidePageProvider index={outgoing} total={total}>
|
|
141
|
+
<OutgoingPage />
|
|
142
|
+
</SlidePageProvider>
|
|
143
|
+
</div>
|
|
144
|
+
) : null}
|
|
145
|
+
{CurrentPage ? (
|
|
146
|
+
<div ref={incomingLayerRef} className="absolute inset-0">
|
|
147
|
+
<SlidePageProvider index={current} total={total}>
|
|
148
|
+
<CurrentPage />
|
|
149
|
+
</SlidePageProvider>
|
|
150
|
+
</div>
|
|
151
|
+
) : null}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -211,6 +211,9 @@ export function DesignToggleButton({
|
|
|
211
211
|
>
|
|
212
212
|
<Palette className="size-3.5" />
|
|
213
213
|
<span className="hidden md:inline">{t.stylePanel.designToggle}</span>
|
|
214
|
+
<kbd className="ml-1 hidden rounded-[3px] bg-foreground/10 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
|
|
215
|
+
D
|
|
216
|
+
</kbd>
|
|
214
217
|
</Button>
|
|
215
218
|
);
|
|
216
219
|
}
|
|
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
5
|
import { format, useLocale } from '@/lib/use-locale';
|
|
6
6
|
import { cn } from '@/lib/utils';
|
|
7
|
+
import { SlidePageProvider } from '../../lib/page-context';
|
|
7
8
|
import type { SlideModule } from '../../lib/sdk';
|
|
8
9
|
import { loadSlide, slidesByTheme } from '../../lib/slides';
|
|
9
10
|
import { loadThemeDemo, type ThemeDemoModule, themes } from '../../lib/themes';
|
|
@@ -106,7 +107,9 @@ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () =
|
|
|
106
107
|
</div>
|
|
107
108
|
) : Current ? (
|
|
108
109
|
<SlideCanvas flat freezeMotion design={demo.design}>
|
|
109
|
-
<
|
|
110
|
+
<SlidePageProvider index={pageIndex} total={totalPages}>
|
|
111
|
+
<Current />
|
|
112
|
+
</SlidePageProvider>
|
|
110
113
|
</SlideCanvas>
|
|
111
114
|
) : null}
|
|
112
115
|
</div>
|
|
@@ -227,7 +230,9 @@ function ThemeSlideCard({ id }: { id: string }) {
|
|
|
227
230
|
{FirstPage ? (
|
|
228
231
|
<div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
|
|
229
232
|
<SlideCanvas flat freezeMotion design={slide?.design}>
|
|
230
|
-
<
|
|
233
|
+
<SlidePageProvider index={0} total={slide?.default.length ?? 1}>
|
|
234
|
+
<FirstPage />
|
|
235
|
+
</SlidePageProvider>
|
|
231
236
|
</SlideCanvas>
|
|
232
237
|
</div>
|
|
233
238
|
) : (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { format, useLocale } from '@/lib/use-locale';
|
|
3
|
+
import { SlidePageProvider } from '../../lib/page-context';
|
|
3
4
|
import { loadThemeDemo, type Theme, type ThemeDemoModule, themes } from '../../lib/themes';
|
|
4
5
|
import { SlideCanvas } from '../slide-canvas';
|
|
5
6
|
|
|
@@ -78,7 +79,9 @@ function ThemePreview({ theme }: { theme: Theme }) {
|
|
|
78
79
|
return (
|
|
79
80
|
<div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
|
|
80
81
|
<SlideCanvas flat freezeMotion design={demo.design}>
|
|
81
|
-
<
|
|
82
|
+
<SlidePageProvider index={0} total={demo.default.length}>
|
|
83
|
+
<FirstPage />
|
|
84
|
+
</SlidePageProvider>
|
|
82
85
|
</SlideCanvas>
|
|
83
86
|
</div>
|
|
84
87
|
);
|
|
@@ -28,6 +28,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|
|
28
28
|
import { format, useLocale } from '@/lib/use-locale';
|
|
29
29
|
import { cn } from '@/lib/utils';
|
|
30
30
|
import type { DesignSystem } from '../lib/design';
|
|
31
|
+
import { SlidePageProvider } from '../lib/page-context';
|
|
31
32
|
import type { Page } from '../lib/sdk';
|
|
32
33
|
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
33
34
|
import { SlideCanvas } from './slide-canvas';
|
|
@@ -118,7 +119,9 @@ export function ThumbnailRail({
|
|
|
118
119
|
style={{ width, height: HORIZONTAL_THUMB_HEIGHT }}
|
|
119
120
|
>
|
|
120
121
|
<SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
|
|
121
|
-
<
|
|
122
|
+
<SlidePageProvider index={i} total={pages.length}>
|
|
123
|
+
<PageComp />
|
|
124
|
+
</SlidePageProvider>
|
|
122
125
|
</SlideCanvas>
|
|
123
126
|
</div>
|
|
124
127
|
</button>
|
|
@@ -155,6 +158,7 @@ export function ThumbnailRail({
|
|
|
155
158
|
const inner = (
|
|
156
159
|
<ThumbContents
|
|
157
160
|
index={i}
|
|
161
|
+
total={pages.length}
|
|
158
162
|
active={active}
|
|
159
163
|
page={PageComp}
|
|
160
164
|
design={design}
|
|
@@ -236,6 +240,7 @@ function thumbButtonClass(active: boolean): string {
|
|
|
236
240
|
|
|
237
241
|
function ThumbContents({
|
|
238
242
|
index,
|
|
243
|
+
total,
|
|
239
244
|
active,
|
|
240
245
|
page: PageComp,
|
|
241
246
|
design,
|
|
@@ -244,6 +249,7 @@ function ThumbContents({
|
|
|
244
249
|
height,
|
|
245
250
|
}: {
|
|
246
251
|
index: number;
|
|
252
|
+
total: number;
|
|
247
253
|
active: boolean;
|
|
248
254
|
page: Page;
|
|
249
255
|
design?: DesignSystem;
|
|
@@ -271,7 +277,9 @@ function ThumbContents({
|
|
|
271
277
|
style={{ width: thumbWidth, height }}
|
|
272
278
|
>
|
|
273
279
|
<SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
|
|
274
|
-
<
|
|
280
|
+
<SlidePageProvider index={index} total={total}>
|
|
281
|
+
<PageComp />
|
|
282
|
+
</SlidePageProvider>
|
|
275
283
|
</SlideCanvas>
|
|
276
284
|
{active && (
|
|
277
285
|
<span
|
package/src/app/lib/assets.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type AssetEntry = {
|
|
|
6
6
|
mtime: number;
|
|
7
7
|
mime: string;
|
|
8
8
|
url: string;
|
|
9
|
+
unused: boolean;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
export type UploadOptions = { overwrite?: boolean };
|
|
@@ -90,6 +91,7 @@ export async function uploadWithAutoRename(
|
|
|
90
91
|
mtime: body?.mtime ?? Date.now(),
|
|
91
92
|
mime: body?.mime ?? uploaded.type ?? 'application/octet-stream',
|
|
92
93
|
url: body?.url ?? `/__assets/${slideId}/${encodeURIComponent(uploaded.name)}`,
|
|
94
|
+
unused: body?.unused ?? false,
|
|
93
95
|
};
|
|
94
96
|
return { ok: true, status: res.status, entry };
|
|
95
97
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
2
|
import { createRoot } from 'react-dom/client';
|
|
3
3
|
import { designToCssVars } from './design';
|
|
4
|
+
import { SlidePageProvider } from './page-context';
|
|
4
5
|
import type { SlideModule } from './sdk';
|
|
5
6
|
|
|
6
7
|
type AssetEntry = { name: string; bytes: Uint8Array };
|
|
@@ -82,13 +83,17 @@ async function renderPagesToHtml(pages: NonNullable<SlideModule['default']>): Pr
|
|
|
82
83
|
|
|
83
84
|
const result: string[] = [];
|
|
84
85
|
try {
|
|
85
|
-
for (
|
|
86
|
+
for (let i = 0; i < pages.length; i++) {
|
|
87
|
+
const Page = pages[i];
|
|
88
|
+
if (!Page) continue;
|
|
86
89
|
const host = document.createElement('div');
|
|
87
90
|
host.style.width = '1920px';
|
|
88
91
|
host.style.height = '1080px';
|
|
89
92
|
container.appendChild(host);
|
|
90
93
|
const root = createRoot(host);
|
|
91
|
-
root.render(
|
|
94
|
+
root.render(
|
|
95
|
+
createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)),
|
|
96
|
+
);
|
|
92
97
|
await nextPaint();
|
|
93
98
|
await nextPaint();
|
|
94
99
|
result.push(host.innerHTML);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
2
|
import { createRoot, type Root } from 'react-dom/client';
|
|
3
3
|
import { designToCssVars } from './design';
|
|
4
|
+
import { SlidePageProvider } from './page-context';
|
|
4
5
|
import { isFrameAnimationSettled, waitForDataWaitfor, waitForFonts } from './print-ready';
|
|
5
6
|
import type { SlideModule } from './sdk';
|
|
6
7
|
|
|
@@ -62,6 +63,20 @@ const PRINT_STYLES = `
|
|
|
62
63
|
transform: scale(0.5);
|
|
63
64
|
transform-origin: top left;
|
|
64
65
|
}
|
|
66
|
+
/* Chromium serializes box-shadow and CSS gradients as PDF transparency
|
|
67
|
+
groups / soft masks. macOS Preview re-composites those on every page
|
|
68
|
+
turn, causing 0.5–2s per-page lag. Strip them in the print container
|
|
69
|
+
only — gradients on pseudo-elements via CSS (DOM walk can't reach them),
|
|
70
|
+
inline-style gradients via neutralizeGradientBackgrounds() below. */
|
|
71
|
+
#${PRINT_ROOT_ID} *,
|
|
72
|
+
#${PRINT_ROOT_ID} *::before,
|
|
73
|
+
#${PRINT_ROOT_ID} *::after {
|
|
74
|
+
box-shadow: none !important;
|
|
75
|
+
}
|
|
76
|
+
#${PRINT_ROOT_ID} *::before,
|
|
77
|
+
#${PRINT_ROOT_ID} *::after {
|
|
78
|
+
background-image: none !important;
|
|
79
|
+
}
|
|
65
80
|
}
|
|
66
81
|
`;
|
|
67
82
|
|
|
@@ -109,7 +124,9 @@ export async function exportSlideAsPdf(
|
|
|
109
124
|
|
|
110
125
|
const reactRoots: Root[] = [];
|
|
111
126
|
const frames: HTMLElement[] = [];
|
|
112
|
-
for (
|
|
127
|
+
for (let i = 0; i < pages.length; i++) {
|
|
128
|
+
const Page = pages[i];
|
|
129
|
+
if (!Page) continue;
|
|
113
130
|
const host = document.createElement('div');
|
|
114
131
|
host.className = 'os-print-frame';
|
|
115
132
|
host.setAttribute('data-osd-canvas', '');
|
|
@@ -126,7 +143,9 @@ export async function exportSlideAsPdf(
|
|
|
126
143
|
root.appendChild(host);
|
|
127
144
|
frames.push(host);
|
|
128
145
|
const r = createRoot(inner);
|
|
129
|
-
r.render(
|
|
146
|
+
r.render(
|
|
147
|
+
createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)),
|
|
148
|
+
);
|
|
130
149
|
reactRoots.push(r);
|
|
131
150
|
}
|
|
132
151
|
// Yield once so React commits all pages and CSS animations actually start
|
|
@@ -155,6 +174,7 @@ export async function exportSlideAsPdf(
|
|
|
155
174
|
}
|
|
156
175
|
|
|
157
176
|
await waitForDataWaitfor(root);
|
|
177
|
+
neutralizeGradientBackgrounds(root);
|
|
158
178
|
await sleep(100); // flush layout
|
|
159
179
|
|
|
160
180
|
onProgress?.({ phase: 'printing', current: total, total, percent: 99 });
|
|
@@ -170,6 +190,18 @@ export async function exportSlideAsPdf(
|
|
|
170
190
|
}
|
|
171
191
|
}
|
|
172
192
|
|
|
193
|
+
// Strip inline-style gradients from background-image so Chromium does not
|
|
194
|
+
// emit them as PDF soft masks. url(...) backgrounds are preserved.
|
|
195
|
+
function neutralizeGradientBackgrounds(root: HTMLElement): void {
|
|
196
|
+
const elements = root.querySelectorAll<HTMLElement>('*');
|
|
197
|
+
for (const el of elements) {
|
|
198
|
+
const bg = getComputedStyle(el).backgroundImage;
|
|
199
|
+
if (bg?.includes('gradient(')) {
|
|
200
|
+
el.style.backgroundImage = 'none';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
173
205
|
function sleep(ms: number): Promise<void> {
|
|
174
206
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
175
207
|
}
|