@open-slide/core 1.10.0 → 1.11.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/README.md CHANGED
@@ -41,6 +41,18 @@ const openSlideConfig: OpenSlideConfig = {
41
41
  export default openSlideConfig;
42
42
  ```
43
43
 
44
+ ### Hosting under a subpath
45
+
46
+ Set `base` to deploy the built site under a sub-directory (intranet folders, GitHub Pages project sites, reverse proxies). Use a leading and trailing slash:
47
+
48
+ ```ts
49
+ const openSlideConfig: OpenSlideConfig = {
50
+ base: '/my-slides/',
51
+ };
52
+ ```
53
+
54
+ The value is passed straight to Vite's `base` and to React Router's `basename`, so client-side navigation matches the deployed path.
55
+
44
56
  ## Authoring slides
45
57
 
46
58
  Slides live under `slides/<kebab-case-id>/index.tsx` and default-export an array of `Page` components:
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-BAZeaz2P.js";
2
+ import { createViteConfig } from "./config-Bk2i4eJ1.js";
3
3
  import path from "node:path";
4
4
  import { build as build$1, mergeConfig } from "vite";
5
5
 
package/dist/cli/bin.js CHANGED
@@ -57,15 +57,15 @@ async function run(argv) {
57
57
  program.name("open-slide").description("Author slides — we handle the Vite/React stack.").version(version, "-v, --version", "print version").helpOption("-h, --help", "show help").showHelpAfterError(chalk.dim("(run `open-slide --help` for usage)"));
58
58
  program.command("dev").description("Start the dev server").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").option("--no-skills-check", "skip the built-in skills drift check").action(async (flags) => {
59
59
  if (flags.skillsCheck !== false) await runSkillsDriftCheck(resolveBuiltinSkillsDir());
60
- const { dev } = await import("../dev-BQkNTG_t.js");
60
+ const { dev } = await import("../dev-DplvRqZx.js");
61
61
  await dev(flags);
62
62
  });
63
63
  program.command("build").description("Build a static site").option("--out-dir <dir>", "output directory (defaults to `dist`)").action(async (flags) => {
64
- const { build } = await import("../build-ZM7IfDO-.js");
64
+ const { build } = await import("../build-CtmQSpg-.js");
65
65
  await build(flags);
66
66
  });
67
67
  program.command("preview").description("Preview the production build").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
68
- const { preview } = await import("../preview-D8hUtbRA.js");
68
+ const { preview } = await import("../preview-p4gcc8ip.js");
69
69
  await preview(flags);
70
70
  });
71
71
  program.command("sync:skills").description("Sync built-in skills from @open-slide/core into this workspace").option("--dry-run", "show what would change without writing").action(async (flags) => {
@@ -7,6 +7,7 @@ type OpenSlideBuildConfig = {
7
7
  allowHtmlDownload?: boolean;
8
8
  };
9
9
  type OpenSlideConfig = {
10
+ base?: string;
10
11
  slidesDir?: string;
11
12
  themesDir?: string;
12
13
  assetsDir?: string;
@@ -3681,7 +3681,7 @@ function parseCreatedAtMs(iso) {
3681
3681
  async function generateSlidesModule(files, slidesRoot, isDev) {
3682
3682
  const entries = await Promise.all(files.map(async (abs) => {
3683
3683
  const id = toId(abs, slidesRoot);
3684
- const importPath = isDev ? `/@fs/${abs.replace(/^\/+/, "")}` : abs;
3684
+ const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
3685
3685
  const meta = await readSlideMeta(abs);
3686
3686
  return {
3687
3687
  id,
@@ -3713,7 +3713,7 @@ if (import.meta.hot) {
3713
3713
  }
3714
3714
  ` : "";
3715
3715
  const cases = entries.map((e) => {
3716
- const importExpr = isDev ? `import(/* @vite-ignore */ ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
3716
+ const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
3717
3717
  return ` case ${JSON.stringify(e.id)}: return ${importExpr};`;
3718
3718
  }).join("\n");
3719
3719
  return `// virtual:open-slide/slides — generated
@@ -3933,8 +3933,9 @@ function generateThemesModule(themes, isDev) {
3933
3933
  const cases = themes.flatMap((t$5) => {
3934
3934
  const abs = t$5.demoAbs;
3935
3935
  if (!abs) return [];
3936
- const importPath = isDev ? `/@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
3937
- return [` case ${JSON.stringify(t$5.id)}: return import(${JSON.stringify(importPath)});`];
3936
+ const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
3937
+ const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(importPath)})` : `import(${JSON.stringify(importPath)})`;
3938
+ return [` case ${JSON.stringify(t$5.id)}: return ${importExpr};`];
3938
3939
  }).join("\n");
3939
3940
  return `// virtual:open-slide/themes — generated
3940
3941
  export const themes = ${JSON.stringify(meta)};
@@ -4029,6 +4030,7 @@ async function createViteConfig(opts) {
4029
4030
  const themesAbs = path.resolve(userCwd, themesDir);
4030
4031
  const assetsAbs = path.resolve(userCwd, assetsDir);
4031
4032
  return {
4033
+ base: config.base ?? "/",
4032
4034
  root: APP_ROOT,
4033
4035
  configFile: false,
4034
4036
  envDir: userCwd,
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-BAZeaz2P.js";
2
+ import { createViteConfig } from "./config-Bk2i4eJ1.js";
3
3
  import { createServer, mergeConfig } from "vite";
4
4
 
5
5
  //#region src/cli/dev.ts
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Locale, Plural } from "./types-D_q_ylIe.js";
2
- import { OpenSlideConfig } from "./config-mwmC1XI1.js";
2
+ import { OpenSlideConfig } from "./config-14qk4fP8.js";
3
3
  import { CSSProperties, ComponentType, HTMLAttributes, PropsWithChildren } from "react";
4
- import * as react_jsx_runtime0 from "react/jsx-runtime";
4
+ import * as react_jsx_runtime1 from "react/jsx-runtime";
5
5
  import * as react_jsx_runtime3 from "react/jsx-runtime";
6
6
  import * as react_jsx_runtime4 from "react/jsx-runtime";
7
7
 
@@ -20,7 +20,7 @@ declare function ImagePlaceholder({
20
20
  style,
21
21
  className,
22
22
  ...rest
23
- }: ImagePlaceholderProps): react_jsx_runtime0.JSX.Element;
23
+ }: ImagePlaceholderProps): react_jsx_runtime1.JSX.Element;
24
24
 
25
25
  //#endregion
26
26
  //#region src/app/lib/design.d.ts
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { en, ja, zhCN, zhTW } from "./format-BvBmqbNW.js";
2
2
  import { cssVarsToString, defaultDesign, designToCssVars } from "./design-cpzS8aud.js";
3
- import { Children, cloneElement, createContext, isValidElement, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { toast } from "sonner";
5
5
  import config from "virtual:open-slide/config";
6
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -378,27 +378,43 @@ function Steps({ children }) {
378
378
  const host = useContext(StepHostContext);
379
379
  const flat = Children.toArray(children);
380
380
  const stepCount = flat.filter((c) => isValidElement(c) && c.type === Step).length;
381
- const initial = host?.entryDirection === "forward" ? 0 : stepCount;
381
+ const initial = host?.controlled ? 0 : host?.entryDirection === "forward" ? 0 : stepCount;
382
382
  const revealedRef = useRef(initial);
383
383
  const [revealed, setRevealed] = useState(initial);
384
- useEffect(() => {
384
+ const idRef = useRef({});
385
+ const applyRevealed = useCallback((n) => {
386
+ revealedRef.current = n;
387
+ setRevealed(n);
388
+ }, []);
389
+ useLayoutEffect(() => {
385
390
  if (!host) return;
391
+ const id = idRef.current;
386
392
  const ctrl = {
387
393
  advance: () => {
388
394
  if (revealedRef.current >= stepCount) return false;
389
- revealedRef.current += 1;
390
- setRevealed(revealedRef.current);
395
+ applyRevealed(revealedRef.current + 1);
396
+ host.reportRevealed(id, revealedRef.current);
391
397
  return true;
392
398
  },
393
399
  retreat: () => {
394
400
  if (revealedRef.current <= 0) return false;
395
- revealedRef.current -= 1;
396
- setRevealed(revealedRef.current);
401
+ applyRevealed(revealedRef.current - 1);
402
+ host.reportRevealed(id, revealedRef.current);
397
403
  return true;
398
404
  }
399
405
  };
400
- return host.register(ctrl);
401
- }, [host, stepCount]);
406
+ return host.register({
407
+ id,
408
+ stepCount,
409
+ initialRevealed: revealedRef.current,
410
+ controller: ctrl,
411
+ setRevealed: applyRevealed
412
+ });
413
+ }, [
414
+ host,
415
+ stepCount,
416
+ applyRevealed
417
+ ]);
402
418
  const effectiveRevealed = host ? revealed : stepCount;
403
419
  let stepIdx = 0;
404
420
  return /* @__PURE__ */ jsx(Fragment, { children: flat.map((child, key) => {
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-BAZeaz2P.js";
2
+ import { createViteConfig } from "./config-Bk2i4eJ1.js";
3
3
  import { mergeConfig, preview as preview$1 } from "vite";
4
4
 
5
5
  //#region src/cli/preview.ts
@@ -1,5 +1,5 @@
1
1
  import "../types-D_q_ylIe.js";
2
- import { OpenSlideConfig } from "../config-mwmC1XI1.js";
2
+ import { OpenSlideConfig } from "../config-14qk4fP8.js";
3
3
  import { InlineConfig } from "vite";
4
4
 
5
5
  //#region src/vite/config.d.ts
@@ -1,4 +1,4 @@
1
1
  import "../design-cpzS8aud.js";
2
- import { createViteConfig } from "../config-BAZeaz2P.js";
2
+ import { createViteConfig } from "../config-Bk2i4eJ1.js";
3
3
 
4
4
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.10.0",
3
+ "version": "1.11.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": {
package/src/app/app.tsx CHANGED
@@ -11,7 +11,7 @@ import { ThemeDetailPage, ThemesGalleryPage } from './routes/themes';
11
11
 
12
12
  export function App() {
13
13
  return (
14
- <BrowserRouter>
14
+ <BrowserRouter basename={import.meta.env.BASE_URL}>
15
15
  <Routes>
16
16
  {config.build.showSlideBrowser ? (
17
17
  <Route element={<HomeShell />}>
@@ -4,7 +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
+ import type { EntryDirection, StepAggregate, StepController } from '../lib/step-context';
8
8
  import type { SlideTransition } from '../lib/transition';
9
9
  import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
10
10
  import { PresentBlackoutOverlay } from './present/blackout-overlay';
@@ -87,6 +87,15 @@ export function Player({
87
87
 
88
88
  const stepControllerRef = useRef<StepController | null>(null);
89
89
  const [entryDirection, setEntryDirection] = useState<EntryDirection>('jump');
90
+ const [stepAggregate, setStepAggregate] = useState<StepAggregate>({
91
+ revealed: 0,
92
+ stepCount: 0,
93
+ });
94
+ const handleAggregateChange = useCallback((a: StepAggregate) => {
95
+ setStepAggregate((cur) =>
96
+ cur.revealed === a.revealed && cur.stepCount === a.stepCount ? cur : a,
97
+ );
98
+ }, []);
90
99
 
91
100
  // Every navigation funnels through here so entryDirection is settled
92
101
  // synchronously, before the incoming page's <Steps> reads it on mount.
@@ -164,8 +173,15 @@ export function Player({
164
173
  // and answers `request-state` pings so newly opened presenter windows
165
174
  // hydrate immediately.
166
175
  const presenterState = useMemo<PresenterState>(
167
- () => ({ index, pageCount: pages.length, blackout, startedAt }),
168
- [index, pages.length, blackout, startedAt],
176
+ () => ({
177
+ index,
178
+ pageCount: pages.length,
179
+ blackout,
180
+ startedAt,
181
+ stepIndex: stepAggregate.revealed,
182
+ stepCount: stepAggregate.stepCount,
183
+ }),
184
+ [index, pages.length, blackout, startedAt, stepAggregate],
169
185
  );
170
186
  const presenterStateRef = useRef(presenterState);
171
187
  presenterStateRef.current = presenterState;
@@ -334,6 +350,7 @@ export function Player({
334
350
  disabled={prefersReducedMotion}
335
351
  stepControllerRef={stepControllerRef}
336
352
  entryDirection={entryDirection}
353
+ onStepAggregateChange={handleAggregateChange}
337
354
  />
338
355
  </SlideCanvas>
339
356
 
@@ -380,6 +397,6 @@ export function Player({
380
397
 
381
398
  export function openPresenterWindow(slideId: string) {
382
399
  if (typeof window === 'undefined') return;
383
- const url = `/s/${encodeURIComponent(slideId)}/presenter`;
400
+ const url = `${import.meta.env.BASE_URL}s/${encodeURIComponent(slideId)}/presenter`;
384
401
  window.open(url, `open-slide-presenter-${slideId}`, 'popup,width=1280,height=800');
385
402
  }
@@ -5,6 +5,8 @@ export type PresenterState = {
5
5
  pageCount: number;
6
6
  blackout: 'black' | 'white' | null;
7
7
  startedAt: number; // epoch ms when present mode began
8
+ stepIndex: number;
9
+ stepCount: number;
8
10
  };
9
11
 
10
12
  export type PresenterCommand =
@@ -1,4 +1,4 @@
1
- import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react';
1
+ import { type CSSProperties, type ReactNode, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { cn } from '@/lib/utils';
3
3
  import { type DesignSystem, designToCssVars } from '../lib/design';
4
4
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
@@ -24,22 +24,27 @@ export function SlideCanvas({
24
24
  design,
25
25
  }: Props) {
26
26
  const containerRef = useRef<HTMLDivElement>(null);
27
- const [fitScale, setFitScale] = useState(1);
27
+ const [fitScale, setFitScale] = useState<number | null>(null);
28
28
 
29
- useEffect(() => {
29
+ useLayoutEffect(() => {
30
30
  if (scale !== undefined) return;
31
31
  const el = containerRef.current;
32
32
  if (!el) return;
33
- const ro = new ResizeObserver(() => {
33
+ const measure = () => {
34
34
  const { width, height } = el.getBoundingClientRect();
35
35
  if (width === 0 || height === 0) return;
36
36
  setFitScale(Math.min(width / CANVAS_WIDTH, height / CANVAS_HEIGHT));
37
- });
37
+ };
38
+ // Measure synchronously before paint so the fitted scale is applied on the
39
+ // first visible frame — otherwise the canvas flashes at full size.
40
+ measure();
41
+ const ro = new ResizeObserver(measure);
38
42
  ro.observe(el);
39
43
  return () => ro.disconnect();
40
44
  }, [scale]);
41
45
 
42
- const s = scale ?? fitScale;
46
+ const measured = scale ?? fitScale;
47
+ const s = measured ?? 1;
43
48
  const scaledW = CANVAS_WIDTH * s;
44
49
  const scaledH = CANVAS_HEIGHT * s;
45
50
 
@@ -55,6 +60,7 @@ export function SlideCanvas({
55
60
  style={{
56
61
  width: scaledW,
57
62
  height: scaledH,
63
+ visibility: measured === null ? 'hidden' : undefined,
58
64
  ...(center
59
65
  ? {
60
66
  position: 'absolute',
@@ -1,7 +1,12 @@
1
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
+ import {
5
+ type EntryDirection,
6
+ type StepAggregate,
7
+ type StepController,
8
+ StepHost,
9
+ } from '../lib/step-context';
5
10
  import { resolveTransition, type SlideTransition, type TransitionPhase } from '../lib/transition';
6
11
 
7
12
  type Props = {
@@ -12,6 +17,7 @@ type Props = {
12
17
  disabled?: boolean;
13
18
  stepControllerRef?: MutableRefObject<StepController | null>;
14
19
  entryDirection?: EntryDirection;
20
+ onStepAggregateChange?: (aggregate: StepAggregate) => void;
15
21
  };
16
22
 
17
23
  type Direction = 'forward' | 'backward';
@@ -41,6 +47,7 @@ export function SlideTransitionLayer({
41
47
  disabled,
42
48
  stepControllerRef,
43
49
  entryDirection = 'jump',
50
+ onStepAggregateChange,
44
51
  }: Props) {
45
52
  const [current, setCurrent] = useState(index);
46
53
  const [outgoing, setOutgoing] = useState<number | null>(null);
@@ -175,6 +182,7 @@ export function SlideTransitionLayer({
175
182
  isActivePage
176
183
  entryDirection={entryDirection}
177
184
  controllerRef={activeControllerRef}
185
+ onAggregateChange={onStepAggregateChange}
178
186
  >
179
187
  <CurrentPage />
180
188
  </StepHost>
@@ -195,11 +195,75 @@ export async function exportSlideAsPdf(
195
195
  function neutralizeGradientBackgrounds(root: HTMLElement): void {
196
196
  const elements = root.querySelectorAll<HTMLElement>('*');
197
197
  for (const el of elements) {
198
- const bg = getComputedStyle(el).backgroundImage;
199
- if (bg?.includes('gradient(')) {
200
- el.style.backgroundImage = 'none';
198
+ const styles = getComputedStyle(el);
199
+ const bg = styles.backgroundImage;
200
+ if (!bg?.includes('gradient(')) continue;
201
+
202
+ const result = removeGradientBackgroundLayers(bg);
203
+ const size = styles.backgroundSize;
204
+ const position = styles.backgroundPosition;
205
+ const repeat = styles.backgroundRepeat;
206
+
207
+ el.style.backgroundImage = result.backgroundImage;
208
+ if (result.keptIndices.length === 0 || result.keptIndices.length === result.layerCount)
209
+ continue;
210
+
211
+ el.style.backgroundSize = reindexBackgroundLayerValues(size, result.keptIndices);
212
+ el.style.backgroundPosition = reindexBackgroundLayerValues(position, result.keptIndices);
213
+ el.style.backgroundRepeat = reindexBackgroundLayerValues(repeat, result.keptIndices);
214
+ }
215
+ }
216
+
217
+ function removeGradientBackgroundLayers(backgroundImage: string): {
218
+ backgroundImage: string;
219
+ keptIndices: number[];
220
+ layerCount: number;
221
+ } {
222
+ const layers = splitBackgroundImageLayers(backgroundImage);
223
+ const keptLayers: string[] = [];
224
+ const keptIndices: number[] = [];
225
+
226
+ for (let i = 0; i < layers.length; i++) {
227
+ const layer = layers[i];
228
+ if (!layer) continue;
229
+ const value = layer.trim();
230
+ if (value.startsWith('url(') && !value.includes('gradient(')) {
231
+ keptLayers.push(value);
232
+ keptIndices.push(i);
201
233
  }
202
234
  }
235
+
236
+ return {
237
+ backgroundImage: keptLayers.length > 0 ? keptLayers.join(', ') : 'none',
238
+ keptIndices,
239
+ layerCount: layers.length,
240
+ };
241
+ }
242
+
243
+ function reindexBackgroundLayerValues(value: string, keptIndices: number[]): string {
244
+ const layers = splitBackgroundImageLayers(value);
245
+ if (layers.length === 0) return value;
246
+
247
+ return keptIndices.map((index) => layers[index % layers.length]).join(', ');
248
+ }
249
+
250
+ function splitBackgroundImageLayers(backgroundImage: string): string[] {
251
+ const layers: string[] = [];
252
+ let depth = 0;
253
+ let layerStart = 0;
254
+
255
+ for (let i = 0; i < backgroundImage.length; i++) {
256
+ const char = backgroundImage[i];
257
+ if (char === '(') depth++;
258
+ if (char === ')') depth = Math.max(0, depth - 1);
259
+ if (char === ',' && depth === 0) {
260
+ layers.push(backgroundImage.slice(layerStart, i).trim());
261
+ layerStart = i + 1;
262
+ }
263
+ }
264
+
265
+ layers.push(backgroundImage.slice(layerStart).trim());
266
+ return layers;
203
267
  }
204
268
 
205
269
  function sleep(ms: number): Promise<void> {
@@ -7,8 +7,8 @@ import {
7
7
  type MutableRefObject,
8
8
  type PropsWithChildren,
9
9
  type ReactElement,
10
+ useCallback,
10
11
  useContext,
11
- useEffect,
12
12
  useLayoutEffect,
13
13
  useMemo,
14
14
  useRef,
@@ -23,9 +23,24 @@ export type StepController = {
23
23
  retreat: () => boolean;
24
24
  };
25
25
 
26
+ export type StepAggregate = {
27
+ revealed: number;
28
+ stepCount: number;
29
+ };
30
+
31
+ type Registration = {
32
+ id: object;
33
+ stepCount: number;
34
+ initialRevealed: number;
35
+ controller: StepController;
36
+ setRevealed: (n: number) => void;
37
+ };
38
+
26
39
  type StepHostContextValue = {
27
- register: (ctrl: StepController) => () => void;
40
+ register: (reg: Registration) => () => void;
41
+ reportRevealed: (id: object, revealed: number) => void;
28
42
  entryDirection: EntryDirection;
43
+ controlled: boolean;
29
44
  };
30
45
 
31
46
  const GLOBAL_KEY = '__open_slide_step_host_context__';
@@ -42,22 +57,39 @@ type StepHostProps = PropsWithChildren<{
42
57
  isActivePage: boolean;
43
58
  entryDirection: EntryDirection;
44
59
  controllerRef: MutableRefObject<StepController | null>;
60
+ // When set, the host distributes this count across <Steps> children in
61
+ // mount order — first fills to its stepCount, next takes the remainder.
62
+ controlledRevealed?: number;
63
+ onAggregateChange?: (aggregate: StepAggregate) => void;
45
64
  }>;
46
65
 
47
- export function StepHost({ isActivePage, entryDirection, controllerRef, children }: StepHostProps) {
48
- const controllersRef = useRef<StepController[]>([]);
66
+ export function StepHost({
67
+ isActivePage,
68
+ entryDirection,
69
+ controllerRef,
70
+ controlledRevealed,
71
+ onAggregateChange,
72
+ children,
73
+ }: StepHostProps) {
74
+ type Tracked = Registration & { revealed: number };
75
+ const registrationsRef = useRef<Tracked[]>([]);
76
+
77
+ const onAggregateChangeRef = useRef(onAggregateChange);
78
+ onAggregateChangeRef.current = onAggregateChange;
79
+ const controlledRevealedRef = useRef(controlledRevealed);
80
+ controlledRevealedRef.current = controlledRevealed;
49
81
 
50
82
  const composite = useMemo<StepController>(
51
83
  () => ({
52
84
  advance: () => {
53
- for (const c of controllersRef.current) {
54
- if (c.advance()) return true;
85
+ for (const r of registrationsRef.current) {
86
+ if (r.controller.advance()) return true;
55
87
  }
56
88
  return false;
57
89
  },
58
90
  retreat: () => {
59
- for (let i = controllersRef.current.length - 1; i >= 0; i--) {
60
- if (controllersRef.current[i].retreat()) return true;
91
+ for (let i = registrationsRef.current.length - 1; i >= 0; i--) {
92
+ if (registrationsRef.current[i].controller.retreat()) return true;
61
93
  }
62
94
  return false;
63
95
  },
@@ -76,19 +108,63 @@ export function StepHost({ isActivePage, entryDirection, controllerRef, children
76
108
  };
77
109
  }, [isActivePage, composite, controllerRef]);
78
110
 
111
+ const notifyAggregate = useCallback(() => {
112
+ const cb = onAggregateChangeRef.current;
113
+ if (!cb) return;
114
+ let revealed = 0;
115
+ let stepCount = 0;
116
+ for (const r of registrationsRef.current) {
117
+ revealed += r.revealed;
118
+ stepCount += r.stepCount;
119
+ }
120
+ cb({ revealed, stepCount });
121
+ }, []);
122
+
123
+ const distributeControlled = useCallback(() => {
124
+ const target = controlledRevealedRef.current;
125
+ if (target == null) return;
126
+ let remaining = target;
127
+ for (const r of registrationsRef.current) {
128
+ const share = Math.max(0, Math.min(r.stepCount, remaining));
129
+ remaining -= share;
130
+ if (r.revealed !== share) {
131
+ r.revealed = share;
132
+ r.setRevealed(share);
133
+ }
134
+ }
135
+ }, []);
136
+
137
+ useLayoutEffect(() => {
138
+ if (controlledRevealed == null) return;
139
+ distributeControlled();
140
+ notifyAggregate();
141
+ }, [controlledRevealed, distributeControlled, notifyAggregate]);
142
+
79
143
  const value = useMemo<StepHostContextValue>(
80
144
  () => ({
81
- register: (ctrl) => {
82
- if (!isActivePage) return () => {};
83
- controllersRef.current.push(ctrl);
145
+ register: (reg) => {
146
+ const tracked: Tracked = { ...reg, revealed: reg.initialRevealed };
147
+ registrationsRef.current.push(tracked);
148
+ if (controlledRevealedRef.current != null) {
149
+ distributeControlled();
150
+ }
151
+ notifyAggregate();
84
152
  return () => {
85
- const i = controllersRef.current.indexOf(ctrl);
86
- if (i !== -1) controllersRef.current.splice(i, 1);
153
+ const i = registrationsRef.current.indexOf(tracked);
154
+ if (i !== -1) registrationsRef.current.splice(i, 1);
155
+ notifyAggregate();
87
156
  };
88
157
  },
158
+ reportRevealed: (id, revealed) => {
159
+ const r = registrationsRef.current.find((x) => x.id === id);
160
+ if (!r) return;
161
+ r.revealed = revealed;
162
+ notifyAggregate();
163
+ },
89
164
  entryDirection,
165
+ controlled: controlledRevealed != null,
90
166
  }),
91
- [isActivePage, entryDirection],
167
+ [entryDirection, controlledRevealed, distributeControlled, notifyAggregate],
92
168
  );
93
169
 
94
170
  return <StepHostContext.Provider value={value}>{children}</StepHostContext.Provider>;
@@ -101,28 +177,44 @@ export function Steps({ children }: StepsProps) {
101
177
  const flat = Children.toArray(children);
102
178
  const stepCount = flat.filter((c) => isValidElement(c) && c.type === Step).length;
103
179
 
104
- const initial = host?.entryDirection === 'forward' ? 0 : stepCount;
180
+ // Controlled mode waits for the host to assign a slice in the registration
181
+ // layout-effect; otherwise the entry direction picks the initial reveal.
182
+ const initial = host?.controlled ? 0 : host?.entryDirection === 'forward' ? 0 : stepCount;
105
183
  const revealedRef = useRef(initial);
106
184
  const [revealed, setRevealed] = useState(initial);
107
185
 
108
- useEffect(() => {
186
+ const idRef = useRef<object>({});
187
+
188
+ const applyRevealed = useCallback((n: number) => {
189
+ revealedRef.current = n;
190
+ setRevealed(n);
191
+ }, []);
192
+
193
+ useLayoutEffect(() => {
109
194
  if (!host) return;
195
+ const id = idRef.current;
110
196
  const ctrl: StepController = {
111
197
  advance: () => {
112
198
  if (revealedRef.current >= stepCount) return false;
113
- revealedRef.current += 1;
114
- setRevealed(revealedRef.current);
199
+ applyRevealed(revealedRef.current + 1);
200
+ host.reportRevealed(id, revealedRef.current);
115
201
  return true;
116
202
  },
117
203
  retreat: () => {
118
204
  if (revealedRef.current <= 0) return false;
119
- revealedRef.current -= 1;
120
- setRevealed(revealedRef.current);
205
+ applyRevealed(revealedRef.current - 1);
206
+ host.reportRevealed(id, revealedRef.current);
121
207
  return true;
122
208
  },
123
209
  };
124
- return host.register(ctrl);
125
- }, [host, stepCount]);
210
+ return host.register({
211
+ id,
212
+ stepCount,
213
+ initialRevealed: revealedRef.current,
214
+ controller: ctrl,
215
+ setRevealed: applyRevealed,
216
+ });
217
+ }, [host, stepCount, applyRevealed]);
126
218
 
127
219
  const effectiveRevealed = host ? revealed : stepCount;
128
220
 
@@ -1,5 +1,5 @@
1
1
  import { ChevronLeft, ChevronRight, RotateCcw, Square, Sun } from 'lucide-react';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { useParams } from 'react-router-dom';
4
4
  import { Button } from '@/components/ui/button';
5
5
  import { format, useLocale } from '@/lib/use-locale';
@@ -11,6 +11,7 @@ import {
11
11
  import { SlideCanvas } from '../components/slide-canvas';
12
12
  import { SlidePageProvider } from '../lib/page-context';
13
13
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
14
+ import { type StepController, StepHost } from '../lib/step-context';
14
15
  import { useSlideModule } from '../lib/use-slide-module';
15
16
 
16
17
  export function Presenter() {
@@ -114,14 +115,20 @@ export function Presenter() {
114
115
  const pages = slide.default;
115
116
  const total = pages.length;
116
117
  const index = Math.max(0, Math.min(total - 1, state?.index ?? 0));
117
- const nextIndex = Math.min(total - 1, index + 1);
118
- const hasNext = index < total - 1;
119
118
  const note = slide.notes?.[index];
120
119
  const blackout = state?.blackout ?? null;
121
120
  const startedAt = state?.startedAt ?? localStart;
121
+ const stepIndex = Math.max(0, state?.stepIndex ?? 0);
122
+ const stepCount = Math.max(0, state?.stepCount ?? 0);
123
+
124
+ const stepsRemaining = stepIndex < stepCount;
125
+ const hasNextSlide = index < total - 1;
126
+ const hasNext = stepsRemaining || hasNextSlide;
127
+ const nextPageIndex = stepsRemaining ? index : Math.min(total - 1, index + 1);
128
+ const nextRevealed = stepsRemaining ? stepIndex + 1 : 0;
122
129
 
123
130
  const CurrentPage = pages[index];
124
- const NextPage = hasNext ? pages[nextIndex] : null;
131
+ const NextPage = hasNext ? pages[nextPageIndex] : null;
125
132
 
126
133
  return (
127
134
  <div className="dark flex h-dvh w-screen flex-col overflow-hidden bg-background text-foreground">
@@ -140,7 +147,9 @@ export function Presenter() {
140
147
  <div className="relative min-h-0 flex-1 overflow-hidden rounded-[8px] bg-black ring-1 ring-border">
141
148
  <SlideCanvas flat design={slide.design}>
142
149
  <SlidePageProvider index={index} total={total}>
143
- <CurrentPage />
150
+ <PreviewStepHost revealed={stepIndex}>
151
+ <CurrentPage />
152
+ </PreviewStepHost>
144
153
  </SlidePageProvider>
145
154
  </SlideCanvas>
146
155
  {blackout && (
@@ -167,8 +176,10 @@ export function Presenter() {
167
176
  >
168
177
  {NextPage ? (
169
178
  <SlideCanvas flat freezeMotion design={slide.design}>
170
- <SlidePageProvider index={nextIndex} total={total}>
171
- <NextPage />
179
+ <SlidePageProvider index={nextPageIndex} total={total}>
180
+ <PreviewStepHost revealed={nextRevealed}>
181
+ <NextPage />
182
+ </PreviewStepHost>
172
183
  </SlidePageProvider>
173
184
  </SlideCanvas>
174
185
  ) : (
@@ -350,6 +361,20 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
350
361
  return <span className="eyebrow">{children}</span>;
351
362
  }
352
363
 
364
+ function PreviewStepHost({ revealed, children }: { revealed: number; children: ReactNode }) {
365
+ const noopControllerRef = useRef<StepController | null>(null);
366
+ return (
367
+ <StepHost
368
+ isActivePage={false}
369
+ entryDirection="jump"
370
+ controllerRef={noopControllerRef}
371
+ controlledRevealed={revealed}
372
+ >
373
+ {children}
374
+ </StepHost>
375
+ );
376
+ }
377
+
353
378
  function Clock() {
354
379
  const [now, setNow] = useState(() => new Date());
355
380
  const t = useLocale();
@@ -36,6 +36,7 @@ import {
36
36
  DropdownMenuContent,
37
37
  DropdownMenuItem,
38
38
  DropdownMenuSeparator,
39
+ DropdownMenuShortcut,
39
40
  DropdownMenuTrigger,
40
41
  } from '@/components/ui/dropdown-menu';
41
42
  import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -236,21 +237,37 @@ export function Slide() {
236
237
  if (playMode) return;
237
238
  const onKey = (e: KeyboardEvent) => {
238
239
  if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
239
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown') {
240
+ if (
241
+ e.key === 'ArrowRight' ||
242
+ e.key === 'ArrowDown' ||
243
+ e.key === ' ' ||
244
+ e.key === 'PageDown'
245
+ ) {
240
246
  e.preventDefault();
241
247
  goTo(index + 1);
242
- } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
248
+ return;
249
+ }
250
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
243
251
  e.preventDefault();
244
252
  goTo(index - 1);
245
- } else if (e.key === 'f' || e.key === 'F') {
253
+ return;
254
+ }
255
+ // Letter shortcuts only fire bare so browser combos (Cmd/Ctrl-P, ⌘F…) stay intact.
256
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
257
+ if (e.key === 'f' || e.key === 'F') {
246
258
  setPlayMode('fullscreen');
259
+ } else if (e.key === 'Enter') {
260
+ setPlayMode('window');
261
+ } else if (e.key === 'p' || e.key === 'P') {
262
+ if (slideId) openPresenterWindow(slideId);
263
+ setPlayMode('window');
247
264
  } else if (import.meta.env.DEV && (e.key === 'd' || e.key === 'D')) {
248
265
  setDesignOpen((v) => !v);
249
266
  }
250
267
  };
251
268
  window.addEventListener('keydown', onKey);
252
269
  return () => window.removeEventListener('keydown', onKey);
253
- }, [index, goTo, playMode]);
270
+ }, [index, goTo, playMode, slideId]);
254
271
 
255
272
  if (error) {
256
273
  return (
@@ -606,10 +623,12 @@ export function Slide() {
606
623
  <DropdownMenuItem onSelect={() => setPlayMode('window')}>
607
624
  <Play />
608
625
  {t.slide.presentInWindow}
626
+ <DropdownMenuShortcut>↵</DropdownMenuShortcut>
609
627
  </DropdownMenuItem>
610
628
  <DropdownMenuItem onSelect={() => setPlayMode('fullscreen')}>
611
629
  <Maximize />
612
630
  {t.slide.presentFullscreen}
631
+ <DropdownMenuShortcut>F</DropdownMenuShortcut>
613
632
  </DropdownMenuItem>
614
633
  <DropdownMenuItem
615
634
  onSelect={() => {
@@ -619,6 +638,7 @@ export function Slide() {
619
638
  >
620
639
  <MonitorSpeaker />
621
640
  {t.slide.presentPresenter}
641
+ <DropdownMenuShortcut>P</DropdownMenuShortcut>
622
642
  </DropdownMenuItem>
623
643
  </DropdownMenuContent>
624
644
  </DropdownMenu>
@@ -10,6 +10,7 @@ declare module 'virtual:open-slide/config' {
10
10
  import type { Locale } from '../locale/types';
11
11
 
12
12
  const config: {
13
+ base?: string;
13
14
  slidesDir?: string;
14
15
  port?: number;
15
16
  locale?: Locale;