@open-slide/core 1.9.0 → 1.10.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.
@@ -1,4 +1,4 @@
1
- import { Locale } from "./types-AalTbxMj.js";
1
+ import { Locale } from "./types-D_q_ylIe.js";
2
2
 
3
3
  //#region src/config.d.ts
4
4
  type OpenSlideBuildConfig = {
@@ -329,7 +329,9 @@ const en = {
329
329
  toastDeleted: "Deleted page {n}",
330
330
  toastDuplicateFailed: "Could not duplicate page",
331
331
  toastDeleteFailed: "Could not delete page",
332
- resizeRail: "Resize thumbnail rail"
332
+ resizeRail: "Resize thumbnail rail",
333
+ transitionIndicator: "Has slide transition",
334
+ stepsIndicator: "Has step-by-step reveals"
333
335
  },
334
336
  pdfToast: {
335
337
  title: "Exporting PDF",
@@ -719,7 +721,9 @@ const ja = {
719
721
  toastDeleted: "ページ {n} を削除しました",
720
722
  toastDuplicateFailed: "ページを複製できませんでした",
721
723
  toastDeleteFailed: "ページを削除できませんでした",
722
- resizeRail: "サムネイル幅を調整"
724
+ resizeRail: "サムネイル幅を調整",
725
+ transitionIndicator: "スライドトランジションあり",
726
+ stepsIndicator: "ステップ表示あり"
723
727
  },
724
728
  pdfToast: {
725
729
  title: "PDF を書き出し中",
@@ -1109,7 +1113,9 @@ const zhCN = {
1109
1113
  toastDeleted: "已删除第 {n} 页",
1110
1114
  toastDuplicateFailed: "无法复制页面",
1111
1115
  toastDeleteFailed: "无法删除页面",
1112
- resizeRail: "调整缩略图栏宽度"
1116
+ resizeRail: "调整缩略图栏宽度",
1117
+ transitionIndicator: "有换页转场",
1118
+ stepsIndicator: "有逐步揭示"
1113
1119
  },
1114
1120
  pdfToast: {
1115
1121
  title: "导出 PDF",
@@ -1499,7 +1505,9 @@ const zhTW = {
1499
1505
  toastDeleted: "已刪除第 {n} 頁",
1500
1506
  toastDuplicateFailed: "無法複製頁面",
1501
1507
  toastDeleteFailed: "無法刪除頁面",
1502
- resizeRail: "調整縮圖欄寬度"
1508
+ resizeRail: "調整縮圖欄寬度",
1509
+ transitionIndicator: "有換頁轉場",
1510
+ stepsIndicator: "有逐步揭示"
1503
1511
  },
1504
1512
  pdfToast: {
1505
1513
  title: "匯出 PDF",
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { Locale, Plural } from "./types-AalTbxMj.js";
2
- import { OpenSlideConfig } from "./config-D_5nlXFU.js";
3
- import { CSSProperties, ComponentType, HTMLAttributes } from "react";
4
- import * as react_jsx_runtime1 from "react/jsx-runtime";
1
+ import { Locale, Plural } from "./types-D_q_ylIe.js";
2
+ import { OpenSlideConfig } from "./config-mwmC1XI1.js";
3
+ import { CSSProperties, ComponentType, HTMLAttributes, PropsWithChildren } from "react";
4
+ import * as react_jsx_runtime0 from "react/jsx-runtime";
5
+ import * as react_jsx_runtime3 from "react/jsx-runtime";
6
+ import * as react_jsx_runtime4 from "react/jsx-runtime";
5
7
 
6
8
  //#region src/app/components/image-placeholder.d.ts
7
9
  type ImagePlaceholderProps = {
@@ -18,7 +20,7 @@ declare function ImagePlaceholder({
18
20
  style,
19
21
  className,
20
22
  ...rest
21
- }: ImagePlaceholderProps): react_jsx_runtime1.JSX.Element;
23
+ }: ImagePlaceholderProps): react_jsx_runtime0.JSX.Element;
22
24
 
23
25
  //#endregion
24
26
  //#region src/app/lib/design.d.ts
@@ -89,4 +91,22 @@ declare const CANVAS_WIDTH = 1920;
89
91
  declare const CANVAS_HEIGHT = 1080;
90
92
 
91
93
  //#endregion
92
- export { CANVAS_HEIGHT, CANVAS_WIDTH, DesignFonts, DesignPalette, DesignSystem, DesignTypeScale, ImagePlaceholder, ImagePlaceholderProps, Locale, OpenSlideConfig, Page, Plural, SlideMeta, SlideModule, SlideTransition, TransitionPhase, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
94
+ //#region src/app/lib/step-context.d.ts
95
+ type StepsProps = PropsWithChildren;
96
+ declare function Steps({
97
+ children
98
+ }: StepsProps): react_jsx_runtime3.JSX.Element;
99
+ type StepProps = PropsWithChildren<{
100
+ duration?: number;
101
+ }>;
102
+ type InternalStepProps = StepProps & {
103
+ _revealed?: boolean;
104
+ };
105
+ declare function Step({
106
+ children,
107
+ duration,
108
+ _revealed
109
+ }: InternalStepProps): react_jsx_runtime4.JSX.Element;
110
+
111
+ //#endregion
112
+ export { CANVAS_HEIGHT, CANVAS_WIDTH, DesignFonts, DesignPalette, DesignSystem, DesignTypeScale, ImagePlaceholder, ImagePlaceholderProps, Locale, OpenSlideConfig, Page, Plural, SlideMeta, SlideModule, SlideTransition, Step, StepProps, Steps, StepsProps, TransitionPhase, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { en, ja, zhCN, zhTW } from "./format-CYOb2cAQ.js";
1
+ import { en, ja, zhCN, zhTW } from "./format-BvBmqbNW.js";
2
2
  import { cssVarsToString, defaultDesign, designToCssVars } from "./design-cpzS8aud.js";
3
- import { createContext, useContext, useRef, useState, useSyncExternalStore } from "react";
3
+ import { Children, cloneElement, createContext, isValidElement, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { toast } from "sonner";
5
5
  import config from "virtual:open-slide/config";
6
- import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
7
 
8
8
  //#region src/app/lib/assets.ts
9
9
  async function listAssets(slideId) {
@@ -333,10 +333,10 @@ function PlaceholderIcon() {
333
333
 
334
334
  //#endregion
335
335
  //#region src/app/lib/page-context.tsx
336
- const GLOBAL_KEY = "__open_slide_page_context__";
337
- const g = globalThis;
338
- if (!g[GLOBAL_KEY]) g[GLOBAL_KEY] = createContext(null);
339
- const SlidePageContext = g[GLOBAL_KEY];
336
+ const GLOBAL_KEY$1 = "__open_slide_page_context__";
337
+ const g$1 = globalThis;
338
+ if (!g$1[GLOBAL_KEY$1]) g$1[GLOBAL_KEY$1] = createContext(null);
339
+ const SlidePageContext = g$1[GLOBAL_KEY$1];
340
340
  function useSlidePageNumber() {
341
341
  const ctx = useContext(SlidePageContext);
342
342
  if (!ctx) throw new Error("useSlidePageNumber must be called from a slide page rendered by @open-slide/core");
@@ -352,4 +352,80 @@ const CANVAS_WIDTH = 1920;
352
352
  const CANVAS_HEIGHT = 1080;
353
353
 
354
354
  //#endregion
355
- export { CANVAS_HEIGHT, CANVAS_WIDTH, ImagePlaceholder, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
355
+ //#region src/app/lib/use-prefers-reduced-motion.ts
356
+ const QUERY = "(prefers-reduced-motion: reduce)";
357
+ function usePrefersReducedMotion() {
358
+ const [reduce, setReduce] = useState(() => {
359
+ if (typeof window === "undefined") return false;
360
+ return window.matchMedia(QUERY).matches;
361
+ });
362
+ useEffect(() => {
363
+ const mql = window.matchMedia(QUERY);
364
+ const onChange = (e) => setReduce(e.matches);
365
+ mql.addEventListener("change", onChange);
366
+ return () => mql.removeEventListener("change", onChange);
367
+ }, []);
368
+ return reduce;
369
+ }
370
+
371
+ //#endregion
372
+ //#region src/app/lib/step-context.tsx
373
+ const GLOBAL_KEY = "__open_slide_step_host_context__";
374
+ const g = globalThis;
375
+ if (!g[GLOBAL_KEY]) g[GLOBAL_KEY] = createContext(null);
376
+ const StepHostContext = g[GLOBAL_KEY];
377
+ function Steps({ children }) {
378
+ const host = useContext(StepHostContext);
379
+ const flat = Children.toArray(children);
380
+ const stepCount = flat.filter((c) => isValidElement(c) && c.type === Step).length;
381
+ const initial = host?.entryDirection === "forward" ? 0 : stepCount;
382
+ const revealedRef = useRef(initial);
383
+ const [revealed, setRevealed] = useState(initial);
384
+ useEffect(() => {
385
+ if (!host) return;
386
+ const ctrl = {
387
+ advance: () => {
388
+ if (revealedRef.current >= stepCount) return false;
389
+ revealedRef.current += 1;
390
+ setRevealed(revealedRef.current);
391
+ return true;
392
+ },
393
+ retreat: () => {
394
+ if (revealedRef.current <= 0) return false;
395
+ revealedRef.current -= 1;
396
+ setRevealed(revealedRef.current);
397
+ return true;
398
+ }
399
+ };
400
+ return host.register(ctrl);
401
+ }, [host, stepCount]);
402
+ const effectiveRevealed = host ? revealed : stepCount;
403
+ let stepIdx = 0;
404
+ return /* @__PURE__ */ jsx(Fragment, { children: flat.map((child, key) => {
405
+ if (isValidElement(child) && child.type === Step) {
406
+ const idx = stepIdx++;
407
+ return cloneElement(child, {
408
+ key: child.key ?? key,
409
+ _revealed: idx < effectiveRevealed
410
+ });
411
+ }
412
+ return child;
413
+ }) });
414
+ }
415
+ function Step({ children, duration = 180, _revealed }) {
416
+ const reduceMotion = usePrefersReducedMotion();
417
+ const revealed = _revealed ?? true;
418
+ const ms = reduceMotion ? 0 : duration;
419
+ return /* @__PURE__ */ jsx("div", {
420
+ "data-osd-step": revealed ? "revealed" : "pending",
421
+ style: {
422
+ opacity: revealed ? 1 : 0,
423
+ visibility: revealed ? "visible" : "hidden",
424
+ transition: `opacity ${ms}ms cubic-bezier(0, 0, 0.2, 1)`
425
+ },
426
+ children
427
+ });
428
+ }
429
+
430
+ //#endregion
431
+ export { CANVAS_HEIGHT, CANVAS_WIDTH, ImagePlaceholder, Step, Steps, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
@@ -1,4 +1,4 @@
1
- import { Locale, Plural } from "../types-AalTbxMj.js";
1
+ import { Locale, Plural } from "../types-D_q_ylIe.js";
2
2
 
3
3
  //#region src/locale/en.d.ts
4
4
  declare const en: Locale;
@@ -1,3 +1,3 @@
1
- import { en, format, ja, plural, zhCN, zhTW } from "../format-CYOb2cAQ.js";
1
+ import { en, format, ja, plural, zhCN, zhTW } from "../format-BvBmqbNW.js";
2
2
 
3
3
  export { en, format, ja, plural, zhCN, zhTW };
@@ -357,6 +357,8 @@ type Locale = {
357
357
  toastDuplicateFailed: string;
358
358
  toastDeleteFailed: string;
359
359
  resizeRail: string;
360
+ transitionIndicator: string;
361
+ stepsIndicator: string;
360
362
  };
361
363
  pdfToast: {
362
364
  title: string;
@@ -1,5 +1,5 @@
1
- import "../types-AalTbxMj.js";
2
- import { OpenSlideConfig } from "../config-D_5nlXFU.js";
1
+ import "../types-D_q_ylIe.js";
2
+ import { OpenSlideConfig } from "../config-mwmC1XI1.js";
3
3
  import { InlineConfig } from "vite";
4
4
 
5
5
  //#region src/vite/config.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.9.0",
3
+ "version": "1.10.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": {
@@ -308,6 +308,45 @@ const Footer = () => {
308
308
 
309
309
  `current` is 1-indexed (matches what readers see) and `total` is the slide's page count. The hook works in every render context (main viewer, thumbnails, overview grid, present mode, presenter window, HTML/PDF export) — the same `<Footer />` JSX is correct everywhere. Call the hook inside a component that's used **per page**; don't try to call it at module top level.
310
310
 
311
+ ## Stepped reveals (`<Steps>` / `<Step>`)
312
+
313
+ Reveal a page one beat at a time instead of showing everything at once. Wrap the deferred parts in `<Step>`, wrap the group in `<Steps>`. Each `→` reveals the next `<Step>`; `→` after the last one advances to the next page. `←` peels the last reveal back. Use it to stage attention — show framing first, then the consequence, then the turn — so the audience reads at the speaker's pace, not ahead.
314
+
315
+ `slides/build-on-reveal/` is the canonical worked example; study it before authoring a stepped page.
316
+
317
+ ```tsx
318
+ import { Step, Steps } from '@open-slide/core';
319
+
320
+ <Steps>
321
+ <Step><div style={BULLET_ROW}>An audience reads faster than a presenter speaks.</div></Step>
322
+ <Step><div style={BULLET_ROW}>Showing every bullet at once invites pre-reading.</div></Step>
323
+ <Step><div style={BULLET_ROW}>Revealing in time stages attention.</div></Step>
324
+ </Steps>
325
+ ```
326
+
327
+ ### Rules
328
+
329
+ - **`<Step>` must be a *direct* child of `<Steps>`.** A `<Step>` nested deeper (or used without a `<Steps>` parent) renders fully revealed and defers nothing.
330
+ - **Non-`Step` children render immediately.** Put a headline or intro paragraph *inside* `<Steps>` as a plain element and it shows from the start; only the `<Step>` blocks wait. This is the "headline always, body in turn" pattern:
331
+ ```tsx
332
+ <Steps>
333
+ <h2>Not everything has to wait.</h2>{/* visible immediately */}
334
+ <Step><p>First, set the stage…</p></Step>
335
+ <Step><p>Then, layer the consequence…</p></Step>
336
+ </Steps>
337
+ ```
338
+ - **Multiple `<Steps>` blocks on one page compose in document order.** The first block reveals all its steps before the second begins; `←` unwinds in reverse. Use this for two columns that build left-then-right, each column owning its own `<Steps>`:
339
+ ```tsx
340
+ <div style={COL}><Steps><Step>…</Step><Step>…</Step></Steps></div>{/* finishes first */}
341
+ <div style={COL}><Steps><Step>…</Step><Step>…</Step></Steps></div>{/* then this */}
342
+ ```
343
+ - **Entry direction decides the starting state — same content, two rhythms.** Entering forward (`→` from the previous page) starts empty and builds up. Jumping in via the overview grid, or arriving backward from a later page, shows the page **fully composed** with every step already revealed. Design the page to read well both ways: a thumbnail or overview jump should look complete, not blank.
344
+ - **`<Step>` fades in over `duration` ms (default 180).** Pass `<Step duration={...}>` to adjust. `prefers-reduced-motion: reduce` collapses it to an instant cut automatically — don't write a fallback.
345
+
346
+ ### When to reach for it
347
+
348
+ Use stepped reveals when the *order* of ideas is the point — a list whose payoff is the last item, a build-up to a conclusion, a before/after. Don't wrap every page's content in `<Step>` reflexively: a page the audience should take in at a glance (a hero title, a single quote, a diagram) is stronger shown whole. Reveals are timing, not decoration — same restraint as transitions.
349
+
311
350
  ## Page transitions
312
351
 
313
352
  The framework can run an enter/exit animation between every slide change. There's **no default** — pages snap unless you declare a `SlideTransition`. Snap-swap is a perfectly tasteful default; only opt in when motion adds something.
@@ -541,6 +580,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
541
580
  - [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit `<Component />` instances, not via `array.map` over a data list.
542
581
  - [ ] All imported assets exist on disk — slide-local under `slides/<id>/assets/`, or global under `assets/` (imported via `@assets/...`).
543
582
  - [ ] Every `<ImagePlaceholder>` corresponds to a real image the user must supply — not decorative filler. If it could be replaced by typography or layout, it should be.
583
+ - [ ] If a page uses `<Steps>`/`<Step>`, every `<Step>` is a direct child of a `<Steps>`, and the page still reads as complete when jumped to via the overview grid (entering forward builds up; jumping in shows it fully revealed).
544
584
  - [ ] If a `SlideTransition` is declared, every page sits in one family — same duration band (140–280 ms), same easing pair, same out-then-in stagger, magnitude under 12 px / 3%. No six-different-vocabularies decks. When in doubt, omit transitions entirely.
545
585
  - [ ] Nothing outside `slides/<id>/` was edited.
546
586
 
@@ -561,3 +601,5 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
561
601
  - ❌ Sprinkling `<ImagePlaceholder>` across pages "for visual interest". Placeholders are for content the user owns; they're not stock-photo slots.
562
602
  - ❌ Using a placeholder for an icon or decorative shape — those are typography/SVG problems, not asset problems.
563
603
  - ❌ Rendering visually repeated elements with `array.map(...)` over a data array. Define a component and instantiate it explicitly per item (`<Card />`, `<Card />`, `<Card />`) so the inspector can edit each independently — a shared `map` body mutates every instance at once.
604
+ - ❌ Wrapping every page's content in `<Step>` reflexively. Stepped reveals are for content whose *order* is the point; a glance-and-get-it page (hero title, single quote, diagram) is stronger shown whole.
605
+ - ❌ A `<Step>` that isn't a direct child of `<Steps>` (nested deeper, or with no `<Steps>` parent). It renders fully revealed and defers nothing — the reveal silently does nothing.
@@ -4,6 +4,7 @@ import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
4
4
  import { cn } from '@/lib/utils';
5
5
  import type { DesignSystem } from '../lib/design';
6
6
  import type { Page } from '../lib/sdk';
7
+ import type { EntryDirection, StepController } from '../lib/step-context';
7
8
  import type { SlideTransition } from '../lib/transition';
8
9
  import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
9
10
  import { PresentBlackoutOverlay } from './present/blackout-overlay';
@@ -84,12 +85,28 @@ export function Player({
84
85
  const canPrev = index > 0;
85
86
  const canNext = index < pages.length - 1;
86
87
 
88
+ const stepControllerRef = useRef<StepController | null>(null);
89
+ const [entryDirection, setEntryDirection] = useState<EntryDirection>('jump');
90
+
91
+ // Every navigation funnels through here so entryDirection is settled
92
+ // synchronously, before the incoming page's <Steps> reads it on mount.
93
+ const handleIndexChange = useCallback(
94
+ (next: number) => {
95
+ const delta = next - index;
96
+ setEntryDirection(delta === 1 ? 'forward' : delta === -1 ? 'backward' : 'jump');
97
+ onIndexChange(next);
98
+ },
99
+ [index, onIndexChange],
100
+ );
101
+
87
102
  const goPrev = useCallback(() => {
88
- if (index > 0) onIndexChange(index - 1);
89
- }, [index, onIndexChange]);
103
+ if (stepControllerRef.current?.retreat()) return;
104
+ if (index > 0) handleIndexChange(index - 1);
105
+ }, [index, handleIndexChange]);
90
106
  const goNext = useCallback(() => {
91
- if (index < pages.length - 1) onIndexChange(index + 1);
92
- }, [index, pages.length, onIndexChange]);
107
+ if (stepControllerRef.current?.advance()) return;
108
+ if (index < pages.length - 1) handleIndexChange(index + 1);
109
+ }, [index, pages.length, handleIndexChange]);
93
110
 
94
111
  const overlayActive = controls && (overviewOpen || helpOpen);
95
112
 
@@ -158,14 +175,14 @@ export function Player({
158
175
  if (msg.type === 'next') goNext();
159
176
  else if (msg.type === 'prev') goPrev();
160
177
  else if (msg.type === 'goto') {
161
- onIndexChange(Math.max(0, Math.min(pages.length - 1, msg.index)));
178
+ handleIndexChange(Math.max(0, Math.min(pages.length - 1, msg.index)));
162
179
  } else if (msg.type === 'toggle-blackout') {
163
180
  setBlackout((cur) => (cur === msg.mode ? null : msg.mode));
164
181
  } else if (msg.type === 'request-state') {
165
182
  send({ type: 'state', state: presenterStateRef.current });
166
183
  }
167
184
  },
168
- [goNext, goPrev, onIndexChange, pages.length],
185
+ [goNext, goPrev, handleIndexChange, pages.length],
169
186
  );
170
187
 
171
188
  const channel = usePresenterChannel(slideId ?? '__none__', (msg) => {
@@ -231,12 +248,12 @@ export function Player({
231
248
  }
232
249
  if (e.key === 'Home') {
233
250
  setKeyboardDriven(true);
234
- onIndexChange(0);
251
+ handleIndexChange(0);
235
252
  return;
236
253
  }
237
254
  if (e.key === 'End') {
238
255
  setKeyboardDriven(true);
239
- onIndexChange(pages.length - 1);
256
+ handleIndexChange(pages.length - 1);
240
257
  return;
241
258
  }
242
259
 
@@ -277,7 +294,7 @@ export function Player({
277
294
  onExit,
278
295
  goNext,
279
296
  goPrev,
280
- onIndexChange,
297
+ handleIndexChange,
281
298
  pages.length,
282
299
  slideId,
283
300
  ]);
@@ -315,6 +332,8 @@ export function Player({
315
332
  total={pages.length}
316
333
  moduleTransition={transition}
317
334
  disabled={prefersReducedMotion}
335
+ stepControllerRef={stepControllerRef}
336
+ entryDirection={entryDirection}
318
337
  />
319
338
  </SlideCanvas>
320
339
 
@@ -322,7 +341,7 @@ export function Player({
322
341
  <div data-osd-chrome style={{ display: 'contents' }}>
323
342
  <PresentProgressBar index={index} total={pages.length} visible={chromeVisible} />
324
343
  <PresentBlackoutOverlay mode={blackout} />
325
- <PresentJumpInput pageCount={pages.length} onJump={onIndexChange} />
344
+ <PresentJumpInput pageCount={pages.length} onJump={handleIndexChange} />
326
345
  <PresentLaserPointer enabled={laser} />
327
346
  <PresentControlBar
328
347
  tooltipContainer={rootEl}
@@ -350,7 +369,7 @@ export function Player({
350
369
  open={overviewOpen}
351
370
  current={index}
352
371
  onClose={() => setOverviewOpen(false)}
353
- onSelect={onIndexChange}
372
+ onSelect={handleIndexChange}
354
373
  />
355
374
  <PresentHelpOverlay open={helpOpen} onOpenChange={setHelpOpen} container={rootEl} />
356
375
  </div>
@@ -1,6 +1,7 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { type MutableRefObject, useEffect, useRef, useState } from 'react';
2
2
  import { SlidePageProvider } from '../lib/page-context';
3
3
  import type { Page } from '../lib/sdk';
4
+ import { type EntryDirection, type StepController, StepHost } from '../lib/step-context';
4
5
  import { resolveTransition, type SlideTransition, type TransitionPhase } from '../lib/transition';
5
6
 
6
7
  type Props = {
@@ -9,6 +10,8 @@ type Props = {
9
10
  total: number;
10
11
  moduleTransition?: SlideTransition;
11
12
  disabled?: boolean;
13
+ stepControllerRef?: MutableRefObject<StepController | null>;
14
+ entryDirection?: EntryDirection;
12
15
  };
13
16
 
14
17
  type Direction = 'forward' | 'backward';
@@ -30,7 +33,15 @@ function runPhase(
30
33
  });
31
34
  }
32
35
 
33
- export function SlideTransitionLayer({ pages, index, total, moduleTransition, disabled }: Props) {
36
+ export function SlideTransitionLayer({
37
+ pages,
38
+ index,
39
+ total,
40
+ moduleTransition,
41
+ disabled,
42
+ stepControllerRef,
43
+ entryDirection = 'jump',
44
+ }: Props) {
34
45
  const [current, setCurrent] = useState(index);
35
46
  const [outgoing, setOutgoing] = useState<number | null>(null);
36
47
  const [direction, setDirection] = useState<Direction>('forward');
@@ -129,6 +140,15 @@ export function SlideTransitionLayer({ pages, index, total, moduleTransition, di
129
140
  const CurrentPage = pages[current];
130
141
  const OutgoingPage = outgoing !== null ? pages[outgoing] : null;
131
142
 
143
+ // Outgoing layer mirrors the direction we just navigated so its <Steps>
144
+ // re-mounts in the state the audience just saw: forward nav → outgoing was
145
+ // fully revealed; backward nav → outgoing was at zero reveals.
146
+ const outgoingEntryDirection: EntryDirection =
147
+ entryDirection === 'backward' ? 'forward' : 'backward';
148
+
149
+ const noopControllerRef = useRef<StepController | null>(null);
150
+ const activeControllerRef = stepControllerRef ?? noopControllerRef;
151
+
132
152
  return (
133
153
  <div
134
154
  ref={wrapperRef}
@@ -138,14 +158,26 @@ export function SlideTransitionLayer({ pages, index, total, moduleTransition, di
138
158
  {OutgoingPage && outgoing !== null ? (
139
159
  <div ref={outgoingLayerRef} className="absolute inset-0">
140
160
  <SlidePageProvider index={outgoing} total={total}>
141
- <OutgoingPage />
161
+ <StepHost
162
+ isActivePage={false}
163
+ entryDirection={outgoingEntryDirection}
164
+ controllerRef={activeControllerRef}
165
+ >
166
+ <OutgoingPage />
167
+ </StepHost>
142
168
  </SlidePageProvider>
143
169
  </div>
144
170
  ) : null}
145
171
  {CurrentPage ? (
146
172
  <div ref={incomingLayerRef} className="absolute inset-0">
147
173
  <SlidePageProvider index={current} total={total}>
148
- <CurrentPage />
174
+ <StepHost
175
+ isActivePage
176
+ entryDirection={entryDirection}
177
+ controllerRef={activeControllerRef}
178
+ >
179
+ <CurrentPage />
180
+ </StepHost>
149
181
  </SlidePageProvider>
150
182
  </div>
151
183
  ) : null}
@@ -15,8 +15,8 @@ import {
15
15
  verticalListSortingStrategy,
16
16
  } from '@dnd-kit/sortable';
17
17
  import { CSS } from '@dnd-kit/utilities';
18
- import { Copy, Trash2 } from 'lucide-react';
19
- import { Fragment, useEffect, useRef } from 'react';
18
+ import { Copy, ListOrdered, type LucideIcon, Sparkles, Trash2 } from 'lucide-react';
19
+ import { Fragment, useEffect, useRef, useState } from 'react';
20
20
  import {
21
21
  ContextMenu,
22
22
  ContextMenuContent,
@@ -25,12 +25,14 @@ import {
25
25
  ContextMenuTrigger,
26
26
  } from '@/components/ui/context-menu';
27
27
  import { ScrollArea } from '@/components/ui/scroll-area';
28
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
28
29
  import { format, useLocale } from '@/lib/use-locale';
29
30
  import { cn } from '@/lib/utils';
30
31
  import type { DesignSystem } from '../lib/design';
31
32
  import { SlidePageProvider } from '../lib/page-context';
32
33
  import type { Page } from '../lib/sdk';
33
34
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
35
+ import type { SlideTransition } from '../lib/transition';
34
36
  import { SlideCanvas } from './slide-canvas';
35
37
 
36
38
  type Orientation = 'vertical' | 'horizontal';
@@ -50,6 +52,8 @@ type Props = {
50
52
  orientation?: Orientation;
51
53
  /** Vertical-only: total rail width in px. Thumbnails scale to fit. */
52
54
  width?: number;
55
+ /** Deck-level transition default; used to flag pages that inherit a transition. */
56
+ moduleTransition?: SlideTransition;
53
57
  };
54
58
 
55
59
  const DEFAULT_VERTICAL_THUMB_WIDTH = 184;
@@ -66,6 +70,7 @@ export function ThumbnailRail({
66
70
  actions,
67
71
  orientation = 'vertical',
68
72
  width,
73
+ moduleTransition,
69
74
  }: Props) {
70
75
  const activeRef = useRef<HTMLButtonElement | null>(null);
71
76
  const t = useLocale();
@@ -165,6 +170,7 @@ export function ThumbnailRail({
165
170
  scale={scale}
166
171
  thumbWidth={thumbWidth}
167
172
  height={height}
173
+ moduleTransition={moduleTransition}
168
174
  />
169
175
  );
170
176
 
@@ -218,15 +224,21 @@ export function ThumbnailRail({
218
224
  );
219
225
 
220
226
  if (!onReorder) {
221
- return <ScrollArea className="h-full border-r border-hairline bg-sidebar">{list}</ScrollArea>;
227
+ return (
228
+ <TooltipProvider delayDuration={200}>
229
+ <ScrollArea className="h-full border-r border-hairline bg-sidebar">{list}</ScrollArea>
230
+ </TooltipProvider>
231
+ );
222
232
  }
223
233
 
224
234
  return (
225
- <ScrollArea className="h-full border-r border-hairline bg-sidebar">
226
- <SortableRail pages={pages} onReorder={onReorder} onSelect={onSelect}>
227
- {list}
228
- </SortableRail>
229
- </ScrollArea>
235
+ <TooltipProvider delayDuration={200}>
236
+ <ScrollArea className="h-full border-r border-hairline bg-sidebar">
237
+ <SortableRail pages={pages} onReorder={onReorder} onSelect={onSelect}>
238
+ {list}
239
+ </SortableRail>
240
+ </ScrollArea>
241
+ </TooltipProvider>
230
242
  );
231
243
  }
232
244
 
@@ -247,6 +259,7 @@ function ThumbContents({
247
259
  scale,
248
260
  thumbWidth,
249
261
  height,
262
+ moduleTransition,
250
263
  }: {
251
264
  index: number;
252
265
  total: number;
@@ -256,18 +269,45 @@ function ThumbContents({
256
269
  scale: number;
257
270
  thumbWidth: number;
258
271
  height: number;
272
+ moduleTransition?: SlideTransition;
259
273
  }) {
274
+ const t = useLocale();
275
+ const boxRef = useRef<HTMLDivElement | null>(null);
276
+ const [hasSteps, setHasSteps] = useState(false);
277
+
278
+ // Steps live in JSX and can't be introspected statically — detect them from
279
+ // the already-rendered thumbnail DOM, where each Step emits `data-osd-step`.
280
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-detect when the page at this slot changes (reorder/edit reuses the index)
281
+ useEffect(() => {
282
+ setHasSteps(boxRef.current?.querySelector('[data-osd-step]') != null);
283
+ }, [PageComp]);
284
+
285
+ const hasTransition = Boolean(PageComp.transition ?? moduleTransition);
286
+
260
287
  return (
261
288
  <>
262
- <span
263
- className={cn(
264
- 'mt-1.5 w-7 shrink-0 text-right font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
265
- active ? 'text-brand' : 'text-muted-foreground/70',
289
+ <div className="mt-1.5 flex w-7 shrink-0 flex-col items-end gap-1">
290
+ <span
291
+ className={cn(
292
+ 'font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
293
+ active ? 'text-brand' : 'text-muted-foreground/70',
294
+ )}
295
+ >
296
+ {(index + 1).toString().padStart(2, '0')}
297
+ </span>
298
+ {(hasTransition || hasSteps) && (
299
+ <div className="flex flex-col items-end gap-0.5">
300
+ {hasTransition && (
301
+ <ThumbIndicator icon={Sparkles} label={t.thumbnailRail.transitionIndicator} />
302
+ )}
303
+ {hasSteps && (
304
+ <ThumbIndicator icon={ListOrdered} label={t.thumbnailRail.stepsIndicator} />
305
+ )}
306
+ </div>
266
307
  )}
267
- >
268
- {(index + 1).toString().padStart(2, '0')}
269
- </span>
308
+ </div>
270
309
  <div
310
+ ref={boxRef}
271
311
  className={cn(
272
312
  'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-[border-color,box-shadow]',
273
313
  active
@@ -292,6 +332,28 @@ function ThumbContents({
292
332
  );
293
333
  }
294
334
 
335
+ function ThumbIndicator({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
336
+ return (
337
+ <Tooltip>
338
+ <TooltipTrigger asChild>
339
+ <span
340
+ role="img"
341
+ aria-label={label}
342
+ className={cn(
343
+ 'flex size-3.5 items-center justify-center text-muted-foreground/55',
344
+ 'motion-safe:transition-colors group-hover/thumb:text-muted-foreground/80',
345
+ )}
346
+ >
347
+ <Icon className="size-3" strokeWidth={2} />
348
+ </span>
349
+ </TooltipTrigger>
350
+ <TooltipContent side="right" sideOffset={6}>
351
+ {label}
352
+ </TooltipContent>
353
+ </Tooltip>
354
+ );
355
+ }
356
+
295
357
  function ThumbContextMenu({
296
358
  index,
297
359
  actions,
@@ -0,0 +1,169 @@
1
+ import {
2
+ Children,
3
+ type Context,
4
+ cloneElement,
5
+ createContext,
6
+ isValidElement,
7
+ type MutableRefObject,
8
+ type PropsWithChildren,
9
+ type ReactElement,
10
+ useContext,
11
+ useEffect,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'react';
17
+ import { usePrefersReducedMotion } from './use-prefers-reduced-motion';
18
+
19
+ export type EntryDirection = 'forward' | 'backward' | 'jump';
20
+
21
+ export type StepController = {
22
+ advance: () => boolean;
23
+ retreat: () => boolean;
24
+ };
25
+
26
+ type StepHostContextValue = {
27
+ register: (ctrl: StepController) => () => void;
28
+ entryDirection: EntryDirection;
29
+ };
30
+
31
+ const GLOBAL_KEY = '__open_slide_step_host_context__';
32
+ type GlobalWithCtx = typeof globalThis & {
33
+ [GLOBAL_KEY]?: Context<StepHostContextValue | null>;
34
+ };
35
+ const g = globalThis as GlobalWithCtx;
36
+ if (!g[GLOBAL_KEY]) {
37
+ g[GLOBAL_KEY] = createContext<StepHostContextValue | null>(null);
38
+ }
39
+ const StepHostContext = g[GLOBAL_KEY];
40
+
41
+ type StepHostProps = PropsWithChildren<{
42
+ isActivePage: boolean;
43
+ entryDirection: EntryDirection;
44
+ controllerRef: MutableRefObject<StepController | null>;
45
+ }>;
46
+
47
+ export function StepHost({ isActivePage, entryDirection, controllerRef, children }: StepHostProps) {
48
+ const controllersRef = useRef<StepController[]>([]);
49
+
50
+ const composite = useMemo<StepController>(
51
+ () => ({
52
+ advance: () => {
53
+ for (const c of controllersRef.current) {
54
+ if (c.advance()) return true;
55
+ }
56
+ return false;
57
+ },
58
+ retreat: () => {
59
+ for (let i = controllersRef.current.length - 1; i >= 0; i--) {
60
+ if (controllersRef.current[i].retreat()) return true;
61
+ }
62
+ return false;
63
+ },
64
+ }),
65
+ [],
66
+ );
67
+
68
+ // useLayoutEffect cleanup-then-mount ordering keeps the registry slot
69
+ // continuous across page swaps — the outgoing host clears its composite
70
+ // before the next active host installs its own, with no gap and no overlap.
71
+ useLayoutEffect(() => {
72
+ if (!isActivePage) return;
73
+ controllerRef.current = composite;
74
+ return () => {
75
+ if (controllerRef.current === composite) controllerRef.current = null;
76
+ };
77
+ }, [isActivePage, composite, controllerRef]);
78
+
79
+ const value = useMemo<StepHostContextValue>(
80
+ () => ({
81
+ register: (ctrl) => {
82
+ if (!isActivePage) return () => {};
83
+ controllersRef.current.push(ctrl);
84
+ return () => {
85
+ const i = controllersRef.current.indexOf(ctrl);
86
+ if (i !== -1) controllersRef.current.splice(i, 1);
87
+ };
88
+ },
89
+ entryDirection,
90
+ }),
91
+ [isActivePage, entryDirection],
92
+ );
93
+
94
+ return <StepHostContext.Provider value={value}>{children}</StepHostContext.Provider>;
95
+ }
96
+
97
+ export type StepsProps = PropsWithChildren;
98
+
99
+ export function Steps({ children }: StepsProps) {
100
+ const host = useContext(StepHostContext);
101
+ const flat = Children.toArray(children);
102
+ const stepCount = flat.filter((c) => isValidElement(c) && c.type === Step).length;
103
+
104
+ const initial = host?.entryDirection === 'forward' ? 0 : stepCount;
105
+ const revealedRef = useRef(initial);
106
+ const [revealed, setRevealed] = useState(initial);
107
+
108
+ useEffect(() => {
109
+ if (!host) return;
110
+ const ctrl: StepController = {
111
+ advance: () => {
112
+ if (revealedRef.current >= stepCount) return false;
113
+ revealedRef.current += 1;
114
+ setRevealed(revealedRef.current);
115
+ return true;
116
+ },
117
+ retreat: () => {
118
+ if (revealedRef.current <= 0) return false;
119
+ revealedRef.current -= 1;
120
+ setRevealed(revealedRef.current);
121
+ return true;
122
+ },
123
+ };
124
+ return host.register(ctrl);
125
+ }, [host, stepCount]);
126
+
127
+ const effectiveRevealed = host ? revealed : stepCount;
128
+
129
+ let stepIdx = 0;
130
+ return (
131
+ <>
132
+ {flat.map((child, key) => {
133
+ if (isValidElement(child) && child.type === Step) {
134
+ const idx = stepIdx++;
135
+ return cloneElement(child as ReactElement<{ _revealed?: boolean }>, {
136
+ key: child.key ?? key,
137
+ _revealed: idx < effectiveRevealed,
138
+ });
139
+ }
140
+ return child;
141
+ })}
142
+ </>
143
+ );
144
+ }
145
+
146
+ export type StepProps = PropsWithChildren<{
147
+ duration?: number;
148
+ }>;
149
+
150
+ type InternalStepProps = StepProps & { _revealed?: boolean };
151
+
152
+ export function Step({ children, duration = 180, _revealed }: InternalStepProps) {
153
+ const reduceMotion = usePrefersReducedMotion();
154
+ const revealed = _revealed ?? true;
155
+ const ms = reduceMotion ? 0 : duration;
156
+
157
+ return (
158
+ <div
159
+ data-osd-step={revealed ? 'revealed' : 'pending'}
160
+ style={{
161
+ opacity: revealed ? 1 : 0,
162
+ visibility: revealed ? 'visible' : 'hidden',
163
+ transition: `opacity ${ms}ms cubic-bezier(0, 0, 0.2, 1)`,
164
+ }}
165
+ >
166
+ {children}
167
+ </div>
168
+ );
169
+ }
@@ -642,6 +642,7 @@ export function Slide() {
642
642
  onSelect={goTo}
643
643
  onReorder={import.meta.env.DEV ? reorderPage : undefined}
644
644
  actions={thumbnailActions}
645
+ moduleTransition={slide.transition}
645
646
  />
646
647
  <main
647
648
  ref={slideViewportRef}
@@ -724,6 +725,7 @@ function ResizableRail(props: {
724
725
  onSelect: (i: number) => void;
725
726
  onReorder?: (from: number, to: number) => void;
726
727
  actions?: ThumbnailActions;
728
+ moduleTransition?: SlideModule['transition'];
727
729
  }) {
728
730
  const t = useLocale();
729
731
  const [width, setWidth] = useState<number>(readStoredRailWidth);
package/src/locale/en.ts CHANGED
@@ -343,6 +343,8 @@ export const en: Locale = {
343
343
  toastDuplicateFailed: 'Could not duplicate page',
344
344
  toastDeleteFailed: 'Could not delete page',
345
345
  resizeRail: 'Resize thumbnail rail',
346
+ transitionIndicator: 'Has slide transition',
347
+ stepsIndicator: 'Has step-by-step reveals',
346
348
  },
347
349
 
348
350
  pdfToast: {
package/src/locale/ja.ts CHANGED
@@ -348,6 +348,8 @@ export const ja: Locale = {
348
348
  toastDuplicateFailed: 'ページを複製できませんでした',
349
349
  toastDeleteFailed: 'ページを削除できませんでした',
350
350
  resizeRail: 'サムネイル幅を調整',
351
+ transitionIndicator: 'スライドトランジションあり',
352
+ stepsIndicator: 'ステップ表示あり',
351
353
  },
352
354
 
353
355
  pdfToast: {
@@ -364,6 +364,8 @@ export type Locale = {
364
364
  toastDuplicateFailed: string;
365
365
  toastDeleteFailed: string;
366
366
  resizeRail: string;
367
+ transitionIndicator: string;
368
+ stepsIndicator: string;
367
369
  };
368
370
 
369
371
  pdfToast: {
@@ -341,6 +341,8 @@ export const zhCN: Locale = {
341
341
  toastDuplicateFailed: '无法复制页面',
342
342
  toastDeleteFailed: '无法删除页面',
343
343
  resizeRail: '调整缩略图栏宽度',
344
+ transitionIndicator: '有换页转场',
345
+ stepsIndicator: '有逐步揭示',
344
346
  },
345
347
 
346
348
  pdfToast: {
@@ -341,6 +341,8 @@ export const zhTW: Locale = {
341
341
  toastDuplicateFailed: '無法複製頁面',
342
342
  toastDeleteFailed: '無法刪除頁面',
343
343
  resizeRail: '調整縮圖欄寬度',
344
+ transitionIndicator: '有換頁轉場',
345
+ stepsIndicator: '有逐步揭示',
344
346
  },
345
347
 
346
348
  pdfToast: {