@open-slide/core 1.6.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/index.d.ts +20 -2
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +169 -0
- 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 -8
- package/src/app/components/slide-transition-layer.tsx +154 -0
- package/src/app/components/style-panel/style-panel.tsx +3 -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/routes/slide.tsx +19 -11
|
@@ -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
|
}
|
package/src/app/lib/sdk.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
2
|
import type { DesignSystem } from './design.ts';
|
|
3
|
+
import type { SlideTransition } from './transition.ts';
|
|
3
4
|
|
|
4
|
-
export type Page = ComponentType;
|
|
5
|
+
export type Page = ComponentType & { transition?: SlideTransition };
|
|
5
6
|
|
|
6
7
|
export type SlideMeta = {
|
|
7
8
|
title?: string;
|
|
@@ -16,6 +17,7 @@ export type SlideModule = {
|
|
|
16
17
|
design?: DesignSystem;
|
|
17
18
|
// Index-aligned with `default`.
|
|
18
19
|
notes?: (string | undefined)[];
|
|
20
|
+
transition?: SlideTransition;
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Page } from './sdk';
|
|
2
|
+
|
|
3
|
+
export type TransitionPhase = {
|
|
4
|
+
keyframes: Keyframe[] | PropertyIndexedKeyframes;
|
|
5
|
+
easing?: string;
|
|
6
|
+
duration?: number;
|
|
7
|
+
delay?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SlideTransition = {
|
|
11
|
+
duration: number;
|
|
12
|
+
easing?: string;
|
|
13
|
+
enter?: TransitionPhase;
|
|
14
|
+
exit?: TransitionPhase;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function resolveTransition(
|
|
18
|
+
pages: Page[],
|
|
19
|
+
index: number,
|
|
20
|
+
moduleDefault?: SlideTransition,
|
|
21
|
+
): SlideTransition | undefined {
|
|
22
|
+
return pages[index]?.transition ?? moduleDefault;
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const QUERY = '(prefers-reduced-motion: reduce)';
|
|
4
|
+
|
|
5
|
+
export function usePrefersReducedMotion(): boolean {
|
|
6
|
+
const [reduce, setReduce] = useState(() => {
|
|
7
|
+
if (typeof window === 'undefined') return false;
|
|
8
|
+
return window.matchMedia(QUERY).matches;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const mql = window.matchMedia(QUERY);
|
|
13
|
+
const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);
|
|
14
|
+
mql.addEventListener('change', onChange);
|
|
15
|
+
return () => mql.removeEventListener('change', onChange);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return reduce;
|
|
19
|
+
}
|
package/src/app/routes/slide.tsx
CHANGED
|
@@ -48,12 +48,13 @@ import { NotesDrawer } from '../components/notes-drawer';
|
|
|
48
48
|
import { PdfProgressToast } from '../components/pdf-progress-toast';
|
|
49
49
|
import { openPresenterWindow, Player } from '../components/player';
|
|
50
50
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
51
|
+
import { SlideTransitionLayer } from '../components/slide-transition-layer';
|
|
51
52
|
import { type ThumbnailActions, ThumbnailRail } from '../components/thumbnail-rail';
|
|
52
53
|
import { exportSlideAsHtml } from '../lib/export-html';
|
|
53
54
|
import { exportSlideAsPdf, isSafari } from '../lib/export-pdf';
|
|
54
55
|
import { remapNotesSessionCacheAfterReorder } from '../lib/inspector/use-notes';
|
|
55
|
-
import { SlidePageProvider } from '../lib/page-context';
|
|
56
56
|
import type { SlideModule } from '../lib/sdk';
|
|
57
|
+
import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
|
|
57
58
|
import { useSlideModule } from '../lib/use-slide-module';
|
|
58
59
|
|
|
59
60
|
const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
|
|
@@ -76,6 +77,7 @@ export function Slide() {
|
|
|
76
77
|
const { renameSlide } = useFolders();
|
|
77
78
|
const slideViewportRef = useRef<HTMLElement>(null);
|
|
78
79
|
const t = useLocale();
|
|
80
|
+
const prefersReducedMotion = usePrefersReducedMotion();
|
|
79
81
|
|
|
80
82
|
const modulePages = useMemo(() => slide?.default ?? [], [slide]);
|
|
81
83
|
const [pages, setPages] = useState<typeof modulePages>(modulePages);
|
|
@@ -237,6 +239,8 @@ export function Slide() {
|
|
|
237
239
|
goTo(index - 1);
|
|
238
240
|
} else if (e.key === 'f' || e.key === 'F') {
|
|
239
241
|
setPlayMode('fullscreen');
|
|
242
|
+
} else if (import.meta.env.DEV && (e.key === 'd' || e.key === 'D')) {
|
|
243
|
+
setDesignOpen((v) => !v);
|
|
240
244
|
}
|
|
241
245
|
};
|
|
242
246
|
window.addEventListener('keydown', onKey);
|
|
@@ -325,6 +329,7 @@ export function Slide() {
|
|
|
325
329
|
<Player
|
|
326
330
|
pages={pages}
|
|
327
331
|
design={slide.design}
|
|
332
|
+
transition={slide.transition}
|
|
328
333
|
index={index}
|
|
329
334
|
onIndexChange={goTo}
|
|
330
335
|
onExit={() => setPlayMode(null)}
|
|
@@ -335,17 +340,16 @@ export function Slide() {
|
|
|
335
340
|
);
|
|
336
341
|
}
|
|
337
342
|
|
|
338
|
-
const CurrentPage = pages[index];
|
|
339
343
|
const title = slide.meta?.title ?? slideId;
|
|
340
344
|
|
|
341
345
|
return (
|
|
342
346
|
<HistoryProvider>
|
|
343
|
-
<InspectorProvider slideId={slideId}>
|
|
347
|
+
<InspectorProvider slideId={slideId} pageIndex={index}>
|
|
344
348
|
<SelectionReporter />
|
|
345
349
|
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
|
346
350
|
{/* Editorial toolbar — three zones, hairline separators, mono-folio center */}
|
|
347
|
-
<header className="
|
|
348
|
-
<div className="flex items-center gap-1.5 md:gap-2">
|
|
351
|
+
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-hairline bg-sidebar/85 px-2 backdrop-blur-md md:px-3">
|
|
352
|
+
<div className="flex shrink-0 items-center gap-1.5 md:gap-2">
|
|
349
353
|
{showSlideBrowser && (
|
|
350
354
|
<Button asChild variant="ghost" size="icon-sm" title={t.slide.home}>
|
|
351
355
|
<Link to="/" aria-label={t.slide.backToHome}>
|
|
@@ -379,13 +383,13 @@ export function Slide() {
|
|
|
379
383
|
</div>
|
|
380
384
|
|
|
381
385
|
{/* Centered title — the rail and mobile pill carry the page count. */}
|
|
382
|
-
<div className="
|
|
383
|
-
<div className="
|
|
386
|
+
<div className="flex min-w-0 flex-1 justify-center px-2">
|
|
387
|
+
<div className="min-w-0 max-w-[34rem]">
|
|
384
388
|
<InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
|
|
385
389
|
</div>
|
|
386
390
|
</div>
|
|
387
391
|
|
|
388
|
-
<div className="flex items-center gap-1">
|
|
392
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
389
393
|
{view === 'slides' && (
|
|
390
394
|
<button
|
|
391
395
|
type="button"
|
|
@@ -585,9 +589,13 @@ export function Slide() {
|
|
|
585
589
|
canNext={index < pageCount - 1}
|
|
586
590
|
/>
|
|
587
591
|
<SlideCanvas design={slide.design}>
|
|
588
|
-
<
|
|
589
|
-
|
|
590
|
-
|
|
592
|
+
<SlideTransitionLayer
|
|
593
|
+
pages={pages}
|
|
594
|
+
index={index}
|
|
595
|
+
total={pageCount}
|
|
596
|
+
moduleTransition={slide.transition}
|
|
597
|
+
disabled={prefersReducedMotion}
|
|
598
|
+
/>
|
|
591
599
|
</SlideCanvas>
|
|
592
600
|
<ClickNavZones
|
|
593
601
|
onPrev={() => goTo(index - 1)}
|