@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 +12 -0
- package/dist/{build-ZM7IfDO-.js → build-CtmQSpg-.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-mwmC1XI1.d.ts → config-14qk4fP8.d.ts} +1 -0
- package/dist/{config-BAZeaz2P.js → config-Bk2i4eJ1.js} +6 -4
- package/dist/{dev-BQkNTG_t.js → dev-DplvRqZx.js} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +25 -9
- package/dist/{preview-D8hUtbRA.js → preview-p4gcc8ip.js} +1 -1
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/src/app/app.tsx +1 -1
- package/src/app/components/player.tsx +21 -4
- 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 +9 -1
- package/src/app/lib/export-pdf.ts +67 -3
- package/src/app/lib/step-context.tsx +114 -22
- package/src/app/routes/presenter.tsx +32 -7
- package/src/app/routes/slide.tsx +24 -4
- package/src/app/virtual.d.ts +1 -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) => {
|
|
@@ -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,
|
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-
|
|
2
|
+
import { OpenSlideConfig } from "./config-14qk4fP8.js";
|
|
3
3
|
import { CSSProperties, ComponentType, HTMLAttributes, PropsWithChildren } from "react";
|
|
4
|
-
import * as
|
|
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):
|
|
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
|
-
|
|
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
|
|
390
|
-
|
|
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
|
|
396
|
-
|
|
401
|
+
applyRevealed(revealedRef.current - 1);
|
|
402
|
+
host.reportRevealed(id, revealedRef.current);
|
|
397
403
|
return true;
|
|
398
404
|
}
|
|
399
405
|
};
|
|
400
|
-
return host.register(
|
|
401
|
-
|
|
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) => {
|
package/dist/vite/index.d.ts
CHANGED
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
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
|
-
() => ({
|
|
168
|
-
|
|
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 =
|
|
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
|
}
|
|
@@ -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',
|
|
@@ -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 {
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
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: (
|
|
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({
|
|
48
|
-
|
|
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
|
|
54
|
-
if (
|
|
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 =
|
|
60
|
-
if (
|
|
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: (
|
|
82
|
-
|
|
83
|
-
|
|
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 =
|
|
86
|
-
if (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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
|
|
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
|
|
120
|
-
|
|
205
|
+
applyRevealed(revealedRef.current - 1);
|
|
206
|
+
host.reportRevealed(id, revealedRef.current);
|
|
121
207
|
return true;
|
|
122
208
|
},
|
|
123
209
|
};
|
|
124
|
-
return host.register(
|
|
125
|
-
|
|
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[
|
|
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
|
-
<
|
|
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={
|
|
171
|
-
<
|
|
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();
|
package/src/app/routes/slide.tsx
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
|
|
243
251
|
e.preventDefault();
|
|
244
252
|
goTo(index - 1);
|
|
245
|
-
|
|
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>
|