@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.
@@ -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
  }
@@ -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
+ }
@@ -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="relative flex h-12 shrink-0 items-center justify-between border-b border-hairline bg-sidebar/85 px-2 backdrop-blur-md md:px-3">
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="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-center px-2">
383
- <div className="pointer-events-auto min-w-0 max-w-[min(34rem,calc(100vw-22rem))]">
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
- <SlidePageProvider index={index} total={pageCount}>
589
- <CurrentPage />
590
- </SlidePageProvider>
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)}