@open-slide/core 1.9.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 +12 -0
- package/dist/{build-ZM7IfDO-.js → build-CtmQSpg-.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-D_5nlXFU.d.ts → config-14qk4fP8.d.ts} +2 -1
- package/dist/{config-BAZeaz2P.js → config-Bk2i4eJ1.js} +6 -4
- package/dist/{dev-BQkNTG_t.js → dev-DplvRqZx.js} +1 -1
- package/dist/{format-CYOb2cAQ.js → format-BvBmqbNW.js} +12 -4
- package/dist/index.d.ts +24 -4
- package/dist/index.js +100 -8
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +1 -1
- package/dist/{preview-D8hUtbRA.js → preview-p4gcc8ip.js} +1 -1
- package/dist/{types-AalTbxMj.d.ts → types-D_q_ylIe.d.ts} +2 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +42 -0
- package/src/app/app.tsx +1 -1
- package/src/app/components/player.tsx +50 -14
- package/src/app/components/present/use-presenter-channel.ts +2 -0
- package/src/app/components/slide-canvas.tsx +12 -6
- package/src/app/components/slide-transition-layer.tsx +44 -4
- package/src/app/components/thumbnail-rail.tsx +77 -15
- package/src/app/lib/export-pdf.ts +67 -3
- package/src/app/lib/step-context.tsx +261 -0
- package/src/app/routes/presenter.tsx +32 -7
- package/src/app/routes/slide.tsx +26 -4
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +2 -0
- package/src/locale/ja.ts +2 -0
- package/src/locale/types.ts +2 -0
- package/src/locale/zh-cn.ts +2 -0
- package/src/locale/zh-tw.ts +2 -0
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:
|
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-
|
|
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-
|
|
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-
|
|
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) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Locale } from "./types-
|
|
1
|
+
import { Locale } from "./types-D_q_ylIe.js";
|
|
2
2
|
|
|
3
3
|
//#region src/config.d.ts
|
|
4
4
|
type OpenSlideBuildConfig = {
|
|
@@ -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 ?
|
|
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 ?
|
|
3937
|
-
|
|
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,
|
|
@@ -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-
|
|
2
|
-
import { OpenSlideConfig } from "./config-
|
|
3
|
-
import { CSSProperties, ComponentType, HTMLAttributes } from "react";
|
|
1
|
+
import { Locale, Plural } from "./types-D_q_ylIe.js";
|
|
2
|
+
import { OpenSlideConfig } from "./config-14qk4fP8.js";
|
|
3
|
+
import { CSSProperties, ComponentType, HTMLAttributes, PropsWithChildren } from "react";
|
|
4
4
|
import * as react_jsx_runtime1 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 = {
|
|
@@ -89,4 +91,22 @@ declare const CANVAS_WIDTH = 1920;
|
|
|
89
91
|
declare const CANVAS_HEIGHT = 1080;
|
|
90
92
|
|
|
91
93
|
//#endregion
|
|
92
|
-
|
|
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-
|
|
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, 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
|
-
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,96 @@ const CANVAS_WIDTH = 1920;
|
|
|
352
352
|
const CANVAS_HEIGHT = 1080;
|
|
353
353
|
|
|
354
354
|
//#endregion
|
|
355
|
-
|
|
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?.controlled ? 0 : host?.entryDirection === "forward" ? 0 : stepCount;
|
|
382
|
+
const revealedRef = useRef(initial);
|
|
383
|
+
const [revealed, setRevealed] = useState(initial);
|
|
384
|
+
const idRef = useRef({});
|
|
385
|
+
const applyRevealed = useCallback((n) => {
|
|
386
|
+
revealedRef.current = n;
|
|
387
|
+
setRevealed(n);
|
|
388
|
+
}, []);
|
|
389
|
+
useLayoutEffect(() => {
|
|
390
|
+
if (!host) return;
|
|
391
|
+
const id = idRef.current;
|
|
392
|
+
const ctrl = {
|
|
393
|
+
advance: () => {
|
|
394
|
+
if (revealedRef.current >= stepCount) return false;
|
|
395
|
+
applyRevealed(revealedRef.current + 1);
|
|
396
|
+
host.reportRevealed(id, revealedRef.current);
|
|
397
|
+
return true;
|
|
398
|
+
},
|
|
399
|
+
retreat: () => {
|
|
400
|
+
if (revealedRef.current <= 0) return false;
|
|
401
|
+
applyRevealed(revealedRef.current - 1);
|
|
402
|
+
host.reportRevealed(id, revealedRef.current);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
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
|
+
]);
|
|
418
|
+
const effectiveRevealed = host ? revealed : stepCount;
|
|
419
|
+
let stepIdx = 0;
|
|
420
|
+
return /* @__PURE__ */ jsx(Fragment, { children: flat.map((child, key) => {
|
|
421
|
+
if (isValidElement(child) && child.type === Step) {
|
|
422
|
+
const idx = stepIdx++;
|
|
423
|
+
return cloneElement(child, {
|
|
424
|
+
key: child.key ?? key,
|
|
425
|
+
_revealed: idx < effectiveRevealed
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return child;
|
|
429
|
+
}) });
|
|
430
|
+
}
|
|
431
|
+
function Step({ children, duration = 180, _revealed }) {
|
|
432
|
+
const reduceMotion = usePrefersReducedMotion();
|
|
433
|
+
const revealed = _revealed ?? true;
|
|
434
|
+
const ms = reduceMotion ? 0 : duration;
|
|
435
|
+
return /* @__PURE__ */ jsx("div", {
|
|
436
|
+
"data-osd-step": revealed ? "revealed" : "pending",
|
|
437
|
+
style: {
|
|
438
|
+
opacity: revealed ? 1 : 0,
|
|
439
|
+
visibility: revealed ? "visible" : "hidden",
|
|
440
|
+
transition: `opacity ${ms}ms cubic-bezier(0, 0, 0.2, 1)`
|
|
441
|
+
},
|
|
442
|
+
children
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
//#endregion
|
|
447
|
+
export { CANVAS_HEIGHT, CANVAS_WIDTH, ImagePlaceholder, Step, Steps, cssVarsToString, defaultDesign, designToCssVars, useSlidePageNumber };
|
package/dist/locale/index.d.ts
CHANGED
package/dist/locale/index.js
CHANGED
package/dist/vite/index.d.ts
CHANGED
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -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.
|
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,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, StepAggregate, 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,37 @@ 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
|
+
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
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
// Every navigation funnels through here so entryDirection is settled
|
|
101
|
+
// synchronously, before the incoming page's <Steps> reads it on mount.
|
|
102
|
+
const handleIndexChange = useCallback(
|
|
103
|
+
(next: number) => {
|
|
104
|
+
const delta = next - index;
|
|
105
|
+
setEntryDirection(delta === 1 ? 'forward' : delta === -1 ? 'backward' : 'jump');
|
|
106
|
+
onIndexChange(next);
|
|
107
|
+
},
|
|
108
|
+
[index, onIndexChange],
|
|
109
|
+
);
|
|
110
|
+
|
|
87
111
|
const goPrev = useCallback(() => {
|
|
88
|
-
if (
|
|
89
|
-
|
|
112
|
+
if (stepControllerRef.current?.retreat()) return;
|
|
113
|
+
if (index > 0) handleIndexChange(index - 1);
|
|
114
|
+
}, [index, handleIndexChange]);
|
|
90
115
|
const goNext = useCallback(() => {
|
|
91
|
-
if (
|
|
92
|
-
|
|
116
|
+
if (stepControllerRef.current?.advance()) return;
|
|
117
|
+
if (index < pages.length - 1) handleIndexChange(index + 1);
|
|
118
|
+
}, [index, pages.length, handleIndexChange]);
|
|
93
119
|
|
|
94
120
|
const overlayActive = controls && (overviewOpen || helpOpen);
|
|
95
121
|
|
|
@@ -147,8 +173,15 @@ export function Player({
|
|
|
147
173
|
// and answers `request-state` pings so newly opened presenter windows
|
|
148
174
|
// hydrate immediately.
|
|
149
175
|
const presenterState = useMemo<PresenterState>(
|
|
150
|
-
() => ({
|
|
151
|
-
|
|
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],
|
|
152
185
|
);
|
|
153
186
|
const presenterStateRef = useRef(presenterState);
|
|
154
187
|
presenterStateRef.current = presenterState;
|
|
@@ -158,14 +191,14 @@ export function Player({
|
|
|
158
191
|
if (msg.type === 'next') goNext();
|
|
159
192
|
else if (msg.type === 'prev') goPrev();
|
|
160
193
|
else if (msg.type === 'goto') {
|
|
161
|
-
|
|
194
|
+
handleIndexChange(Math.max(0, Math.min(pages.length - 1, msg.index)));
|
|
162
195
|
} else if (msg.type === 'toggle-blackout') {
|
|
163
196
|
setBlackout((cur) => (cur === msg.mode ? null : msg.mode));
|
|
164
197
|
} else if (msg.type === 'request-state') {
|
|
165
198
|
send({ type: 'state', state: presenterStateRef.current });
|
|
166
199
|
}
|
|
167
200
|
},
|
|
168
|
-
[goNext, goPrev,
|
|
201
|
+
[goNext, goPrev, handleIndexChange, pages.length],
|
|
169
202
|
);
|
|
170
203
|
|
|
171
204
|
const channel = usePresenterChannel(slideId ?? '__none__', (msg) => {
|
|
@@ -231,12 +264,12 @@ export function Player({
|
|
|
231
264
|
}
|
|
232
265
|
if (e.key === 'Home') {
|
|
233
266
|
setKeyboardDriven(true);
|
|
234
|
-
|
|
267
|
+
handleIndexChange(0);
|
|
235
268
|
return;
|
|
236
269
|
}
|
|
237
270
|
if (e.key === 'End') {
|
|
238
271
|
setKeyboardDriven(true);
|
|
239
|
-
|
|
272
|
+
handleIndexChange(pages.length - 1);
|
|
240
273
|
return;
|
|
241
274
|
}
|
|
242
275
|
|
|
@@ -277,7 +310,7 @@ export function Player({
|
|
|
277
310
|
onExit,
|
|
278
311
|
goNext,
|
|
279
312
|
goPrev,
|
|
280
|
-
|
|
313
|
+
handleIndexChange,
|
|
281
314
|
pages.length,
|
|
282
315
|
slideId,
|
|
283
316
|
]);
|
|
@@ -315,6 +348,9 @@ export function Player({
|
|
|
315
348
|
total={pages.length}
|
|
316
349
|
moduleTransition={transition}
|
|
317
350
|
disabled={prefersReducedMotion}
|
|
351
|
+
stepControllerRef={stepControllerRef}
|
|
352
|
+
entryDirection={entryDirection}
|
|
353
|
+
onStepAggregateChange={handleAggregateChange}
|
|
318
354
|
/>
|
|
319
355
|
</SlideCanvas>
|
|
320
356
|
|
|
@@ -322,7 +358,7 @@ export function Player({
|
|
|
322
358
|
<div data-osd-chrome style={{ display: 'contents' }}>
|
|
323
359
|
<PresentProgressBar index={index} total={pages.length} visible={chromeVisible} />
|
|
324
360
|
<PresentBlackoutOverlay mode={blackout} />
|
|
325
|
-
<PresentJumpInput pageCount={pages.length} onJump={
|
|
361
|
+
<PresentJumpInput pageCount={pages.length} onJump={handleIndexChange} />
|
|
326
362
|
<PresentLaserPointer enabled={laser} />
|
|
327
363
|
<PresentControlBar
|
|
328
364
|
tooltipContainer={rootEl}
|
|
@@ -350,7 +386,7 @@ export function Player({
|
|
|
350
386
|
open={overviewOpen}
|
|
351
387
|
current={index}
|
|
352
388
|
onClose={() => setOverviewOpen(false)}
|
|
353
|
-
onSelect={
|
|
389
|
+
onSelect={handleIndexChange}
|
|
354
390
|
/>
|
|
355
391
|
<PresentHelpOverlay open={helpOpen} onOpenChange={setHelpOpen} container={rootEl} />
|
|
356
392
|
</div>
|
|
@@ -361,6 +397,6 @@ export function Player({
|
|
|
361
397
|
|
|
362
398
|
export function openPresenterWindow(slideId: string) {
|
|
363
399
|
if (typeof window === 'undefined') return;
|
|
364
|
-
const url =
|
|
400
|
+
const url = `${import.meta.env.BASE_URL}s/${encodeURIComponent(slideId)}/presenter`;
|
|
365
401
|
window.open(url, `open-slide-presenter-${slideId}`, 'popup,width=1280,height=800');
|
|
366
402
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CSSProperties, type ReactNode,
|
|
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(
|
|
27
|
+
const [fitScale, setFitScale] = useState<number | null>(null);
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
30
|
if (scale !== undefined) return;
|
|
31
31
|
const el = containerRef.current;
|
|
32
32
|
if (!el) return;
|
|
33
|
-
const
|
|
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
|
|
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',
|