update packages and add valign

This commit is contained in:
2026-04-05 20:00:27 +02:00
parent b062fb98e3
commit 03fb00e374
640 changed files with 109768 additions and 39311 deletions

View File

@@ -0,0 +1,69 @@
# React Wrapper Notes
This directory contains the `@revealjs/react` wrapper. Future changes must preserve the current Reveal/React synchronization model unless the behavior is intentionally redesigned and the tests are updated to match.
## Source Of Truth
- Implementation:
- `react/src/Deck.tsx`
- `react/src/Slide.tsx`
- `react/src/Fragment.tsx`
- Behavioral tests:
- `react/src/__tests__/Deck.test.tsx`
- `react/src/__tests__/Slide.test.tsx`
- `react/src/__tests__/Fragment.test.tsx`
- Public-facing behavior summary:
- `react/README.md`
## Deck Lifecycle Invariants
- `Deck` creates one `Reveal` instance on mount and destroys it on unmount.
- `Deck` must remain safe under React `StrictMode`; do not reintroduce double initialization.
- Event props are wired with `deck.on()` after initialization and cleaned up with `deck.off()` when callbacks change or the component unmounts.
## `Reveal.sync()` Policy
- `Reveal.sync()` is expensive. Call it rarely.
- `Deck` must not call `sync()` for ordinary React content updates inside existing slides.
- Example: timers, counters, text changes, or other React-only DOM updates inside a slide should not trigger a full deck sync.
- `Deck` should only call `sync()` when the rendered slide structure changes.
- Current meaning: slides added, removed, reordered, or regrouped into/out of vertical stacks.
- On first ready render, one deck-level sync is still expected.
## `Reveal.configure()` Policy
- `config` is shallow-compared.
- Recreating the `config` object with the same values must not call `configure()`.
- `configure()` should only be called after the deck is initialized and ready.
- `configure()` performs its own Reveal-side sync, so the wrapper must avoid an immediate redundant `sync()` afterward.
- The current code uses a skip flag for this. If you change that logic, preserve the existing behavior where the skip state is reset safely even if Reveal is briefly not ready during the follow-up render.
## Slide-Level Sync Policy
- `Slide` renders a `<section>` and owns slide-level attribute syncing via `deck.syncSlide(slide)`.
- `Slide` should only call `syncSlide()` when the slide's effective `data-*` attribute signature changes after mount.
- `Slide` must not call `syncSlide()` on first render; the initial deck-level sync handles first-time registration.
## Responsibility Split
- Keep responsibilities narrow:
- `Deck` handles Reveal instance lifecycle, config, plugin capture, event wiring, and structure-level sync.
- `Slide` handles slide-local attribute mapping and `syncSlide()`.
- `Stack`, `Code`, and `Fragment` should stay lightweight unless there is a strong reason otherwise.
- Avoid solving slide-local problems with deck-wide `sync()` when a narrower mechanism is possible.
## When Changing Behavior
- If you change sync/config/plugin behavior, update the relevant tests first or in the same change.
- If you change the public React API or behavior, update `react/README.md`.
- Prefer adding narrow tests for regressions:
- content-only rerenders must not trigger full deck sync
- structural slide changes must still trigger full deck sync
- slide attribute changes must trigger `syncSlide()` only
## Validation
Run these after React wrapper changes:
- `npm run react:test`
- `npm run react:build`

View File

@@ -0,0 +1,198 @@
<p align="center">
<a href="https://revealjs.com">
<img src="https://hakim-static.s3.amazonaws.com/reveal-js/logo/v1/reveal-black-text-sticker.png" alt="reveal.js" width="500">
</a>
</p>
# @revealjs/react
`@revealjs/react` is a thin React wrapper around the [Reveal.js](https://revealjs.com) presentation framework. Describe your slides as React components and let the wrapper handle the rest.
## Installation
Install the package along with its peer dependencies:
```bash
npm i @revealjs/react reveal.js react react-dom
# or
yarn add @revealjs/react reveal.js react react-dom
```
The package ships only the React bindings. You still need to import Reveal CSS, themes, and any plugins your deck uses.
## Set up a deck
Render a `Deck` with one or more `Slide` children and import the core Reveal styles:
```tsx
import { Deck, Slide } from '@revealjs/react';
import 'reveal.js/reveal.css';
import 'reveal.js/theme/black.css';
export function Presentation() {
return (
<Deck>
<Slide>
<h1>Hello</h1>
<p>My first Reveal deck in React.</p>
</Slide>
<Slide background="#111827">
<h2>Second slide</h2>
</Slide>
</Deck>
);
}
```
## Components
Alongside `Deck` and `Slide`, the package ships a few components for common slide patterns. `Fragment` reveals content one step at a time, `Code` renders a syntax-highlighted block via the highlight plugin, and `Stack` groups slides into a vertical column:
```tsx
import { Deck, Slide, Stack, Fragment, Code } from '@revealjs/react';
import RevealHighlight from 'reveal.js/plugin/highlight';
import 'reveal.js/plugin/highlight/monokai.css';
export function Presentation() {
return (
<Deck plugins={[RevealHighlight]}>
<Slide>
<h2>Step by step</h2>
<Fragment animation="fade-up" as="p">First point</Fragment>
<Fragment animation="fade-up" asChild>
<div>Second point</div>
</Fragment>
<Code language="javascript" lineNumbers>
{`console.log('Hello, world!');`}
</Code>
</Slide>
<Stack>
<Slide>Vertical 1</Slide>
<Slide>Vertical 2</Slide>
</Stack>
</Deck>
);
}
```
## Configure Reveal
Pass any Reveal configuration through the `config` prop on `Deck`. Plugins are registered separately via `plugins` and are applied once at initialization time, matching Reveal's plugin lifecycle.
```tsx
import { Deck, Slide } from '@revealjs/react';
import 'reveal.js/reveal.css';
import 'reveal.js/theme/black.css';
import 'reveal.js/plugin/highlight/monokai.css';
import RevealHighlight from 'reveal.js/plugin/highlight';
export function Presentation() {
return (
<Deck
config={{
width: 1280,
height: 720,
hash: true,
controls: true,
progress: true,
transition: 'slide',
}}
plugins={[RevealHighlight]}
>
<Slide>Configured deck</Slide>
</Deck>
);
}
```
`config` maps directly to [Reveal's configuration object](https://revealjs.com/config/). `Slide` supports convenient Reveal slide props such as `background`, `backgroundImage`, `backgroundColor`, `visibility`, `autoAnimate`, `transition`, `transitionSpeed`, `autoSlide`, `notes`, `backgroundInteractive`, and `preload`, while still passing through raw `data-*` attributes to the rendered `<section>` element.
## Subscribe to events
Use event props on `Deck` to respond to Reveal lifecycle and navigation events:
```tsx
import { Deck, Slide } from '@revealjs/react';
export function Presentation() {
return (
<Deck
onReady={(deck) => {
console.log('Reveal ready', deck);
}}
onSync={() => {
console.log('Deck synced');
}}
onSlideChange={(event) => {
console.log('Slide changed', event.indexh, event.indexv);
}}
onFragmentShown={(event) => {
console.log('Fragment shown', event.fragment);
}}
>
<Slide>Intro</Slide>
<Slide>Next</Slide>
</Deck>
);
}
```
## Access the Reveal API
Use `useReveal()` inside the deck tree to call the Reveal API from your own components:
```tsx
import { Deck, Slide, useReveal } from '@revealjs/react';
function NextButton() {
const deck = useReveal();
return <button onClick={() => deck?.next()}>Next slide</button>;
}
export function Presentation() {
return (
<Deck>
<Slide>
<h2>Controlled from React</h2>
<NextButton />
</Slide>
</Deck>
);
}
```
To access the Reveal instance outside of the component tree, pass a `deckRef` to `Deck`:
```tsx
import { useRef } from 'react';
import { Deck, Slide } from '@revealjs/react';
import type { RevealApi } from 'reveal.js';
export function Presentation() {
const deckRef = useRef<RevealApi | null>(null);
return (
<Deck deckRef={deckRef}>
<Slide>Hello</Slide>
</Deck>
);
}
```
## How it works
- `Deck` creates one Reveal instance on mount and destroys it on unmount. Initialization is asynchronous — `onReady` fires once `reveal.initialize()` resolves, after which the instance is also accessible via `useReveal()` and `deckRef`.
- `Deck` calls `reveal.sync()` when the rendered slide structure changes, such as slides being added, removed, reordered, or regrouped into stacks.
- `Slide` handles slide-level `data-*` attribute updates locally with `reveal.syncSlide()`, so ordinary React content updates inside a slide do not trigger a full deck sync.
- `config` is shallow-compared on each render so that `reveal.configure()` is only called when a value actually changes.
- `plugins` are initialization-only, matching Reveal's plugin lifecycle. The prop is captured once on first mount and ignored on subsequent renders.
- Event props are wired with `deck.on()` after initialization and cleaned up with `deck.off()`. Changing a callback between renders swaps the listener automatically.
---
<div align="center">
MIT licensed | Copyright © 2011-2026 Hakim El Hattab, https://hakim.se
</div>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@revealjs/react Demo</title>
</head>
<body>
<div id="root" style="width: 100vw; height: 100vh;"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"private": true,
"name": "revealjs-react-demo",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"reveal.js": "file:../../"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.4.0",
"typescript": "^5.2.2",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,230 @@
import { Children, useEffect, useState } from 'react';
import { Deck, Slide, Stack, Fragment, Code, useReveal } from '@revealjs/react';
import 'reveal.js/reveal.css';
import 'reveal.js/theme/black.css';
import 'reveal.js/plugin/highlight/monokai.css';
// @ts-ignore
import RevealHighlight from 'reveal.js/plugin/highlight';
const buttonStyle: React.CSSProperties = {
padding: '0.55em 0.95em',
fontSize: '0.7em',
fontWeight: 600,
lineHeight: 1.2,
color: '#ffffff',
background: 'rgba(8, 13, 24, 0.72)',
border: '1px solid rgba(255, 255, 255, 0.4)',
borderRadius: '0.35em',
cursor: 'pointer',
};
function NavigationControls() {
const deck = useReveal();
return (
<div style={{ marginTop: '1em' }}>
<button style={buttonStyle} onClick={() => deck?.prev()}>
Previous
</button>{' '}
<button style={buttonStyle} onClick={() => deck?.next()}>
Next
</button>
</div>
);
}
function Columns({ children }: { children: React.ReactNode }) {
return (
<div style={{ display: 'flex', flexDirection: 'row' }}>
{Children.map(children, (child, index) => (
<div key={index} style={{ flex: 1 }}>
{child}
</div>
))}
</div>
);
}
function SlideSyncPlayground() {
const [count, setCount] = useState(0);
const [slideColor, setSlideColor] = useState('#1b1f2a');
const randomColor = () => {
const value = Math.floor(Math.random() * 0xffffff)
.toString(16)
.padStart(6, '0');
return `#${value}`;
};
return (
<Slide background={slideColor}>
<h2>Slide-local HTML updates</h2>
<p>
This slide updates only its own React-rendered HTML, without manually calling{' '}
<code>sync</code> or <code>syncSlide</code>.
</p>
<div>
<div style={{ marginBottom: '0.75em' }}>
<button style={buttonStyle} onClick={() => setCount((c) => c + 1)}>
Increase count
</button>{' '}
<button style={buttonStyle} onClick={() => setSlideColor(randomColor())}>
Randomize background
</button>
</div>
<p>
<strong>Current count:</strong> {count}
</p>
<p>
<strong>Slide color:</strong> {slideColor}
</p>
</div>
</Slide>
);
}
function Demo() {
const [showBonus, setShowBonus] = useState(false);
const [controls, setControls] = useState(true);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'c' && !e.ctrlKey && !e.metaKey && !e.altKey) {
setControls((prev) => !prev);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
return (
<Deck
config={{
width: 1280,
height: 720,
transition: 'slide',
hash: true,
controls,
}}
plugins={[RevealHighlight]}
onReady={(deck) => console.log('Deck ready!', deck)}
onSync={() => console.log('Deck synced')}
onSlideSync={(e) => {
const slide = (e as Reveal.SlideSyncEvent).slide;
console.log('Slide synced', slide);
}}
onSlideChange={(e) => console.log('Slide changed')}
>
<Slide>
<h1>@revealjs/react</h1>
<p>React wrapper for reveal.js</p>
</Slide>
<Slide data-background="#000">
<h2>Fragments</h2>
<Columns>
<div>
<Fragment animation="fade-up">
<p>This appears first</p>
</Fragment>
<Fragment animation="fade-up">
<p>Then this</p>
</Fragment>
<Fragment animation="highlight-red" asChild>
<p>And this gets highlighted</p>
</Fragment>
</div>
<div>
<Code language="html" codeStyle={{ padding: '0.5em' }}>
{`
<Fragment animation="fade-up">
<p>This appears first</p>
</Fragment>
<Fragment animation="fade-up">
<p>Then this</p>
</Fragment>
<Fragment animation="highlight-red">
<p>And this gets highlighted</p>
</Fragment>
`}
</Code>
</div>
</Columns>
</Slide>
<Stack>
<Slide background="indigo">
<h2>Vertical Stack</h2>
<p>Press down to navigate</p>
<Code language="html" codeStyle={{ padding: '0.5em' }}>
{`
<Stack>
<Slide background="indigo">
<h2>Vertical Stack</h2>
<p>Press down to navigate</p>
</Slide>
<Slide background="indigo">
<h2>Stack Slide 2</h2>
<p>Vertical navigation works!</p>
</Slide>
</Stack>
`}
</Code>
</Slide>
<Slide background="indigo">
<h2>Stack Slide 2</h2>
<p>Vertical navigation works!</p>
</Slide>
</Stack>
<Slide>
<Columns>
<div style={{ textAlign: 'left' }}>
<h2>API Hook</h2>
<p>Components inside slides can access the reveal.js API via the useReveal() hook.</p>
<NavigationControls />
</div>
<div>
<Code language="javascript" lineNumbers="1|4">
{`
const deck = useReveal();
function nextSlide() {
deck?.next();
}
`}
</Code>
</div>
</Columns>
</Slide>
<SlideSyncPlayground />
<Slide>
<h2>Dynamic Slides</h2>
<p>Add slides at runtime sync() handles it</p>
<button style={buttonStyle} onClick={() => setShowBonus((b) => !b)}>
{showBonus ? 'Remove' : 'Add'} bonus slide
</button>
</Slide>
{showBonus && (
<Slide data-background="#2d2d8c">
<h2>Bonus Slide!</h2>
<p>Dynamically added via React state</p>
</Slide>
)}
<Slide>
<h2>The End</h2>
<Fragment animation="fade-up">
<p>Thanks for watching!</p>
</Fragment>
</Slide>
</Deck>
);
}
export default Demo;

View File

@@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import Demo from './Demo';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Demo />
</StrictMode>
);

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@revealjs/react": ["../src/index.ts"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,12 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@revealjs/react': resolve(__dirname, '../src/index.ts'),
},
},
});

367
scripts/reveal.js/react/dist/index.mjs vendored Normal file
View File

@@ -0,0 +1,367 @@
import { createContext as K, useRef as l, useState as W, useEffect as M, useLayoutEffect as P, useContext as V, Children as Y, isValidElement as H, Fragment as Q, cloneElement as X, useMemo as Z } from "react";
import { jsx as R } from "react/jsx-runtime";
import tt from "reveal.js";
const x = K(null), et = [];
function nt(e, t) {
if (e === t) return !1;
if (!e || !t) return e !== t;
const n = Object.keys(e), a = Object.keys(t);
if (n.length !== a.length) return !0;
for (const r of n)
if (!(r in t) || e[r] !== t[r])
return !0;
return !1;
}
function F(e, t) {
e && (typeof e == "function" ? e(t) : e.current = t);
}
function rt(e) {
return e.tagName === "SECTION";
}
function $(e, t, n) {
return Array.from(e.children).filter(rt).map((a) => {
let r = t.get(a);
r === void 0 && (r = n.current++, t.set(a, r));
const i = $(a, t, n);
return i.length > 0 ? [r, i] : r;
});
}
function at(e, t, n) {
return e ? JSON.stringify($(e, t, n)) : "[]";
}
function ht({
config: e,
plugins: t = et,
onReady: n,
onSync: a,
onSlideSync: r,
onSlideChange: i,
onSlideTransitionEnd: u,
onFragmentShown: h,
onFragmentHidden: C,
onOverviewShown: d,
onOverviewHidden: y,
onPaused: w,
onResumed: E,
deckRef: b,
className: N,
style: I,
children: T
}) {
const v = l(null), p = l(null), s = l(null), [f, o] = W(null), D = l(t), S = l(!1), A = l(e), L = l(null), B = l(/* @__PURE__ */ new WeakMap()), U = l(1), j = l(!1), m = l(0);
return M(() => {
if (j.current = !0, m.current += 1, s.current)
s.current.isReady() && o(s.current);
else {
const c = new tt(v.current, {
...e,
plugins: D.current
});
A.current = e, s.current = c, c.initialize().then(() => {
!j.current || s.current !== c || (o(c), n?.(c));
});
}
return () => {
j.current = !1;
const c = s.current;
if (!c) return;
const g = ++m.current;
Promise.resolve().then(() => {
if (!(j.current || m.current !== g) && s.current === c) {
try {
c.destroy();
} catch {
}
s.current === c && (s.current = null);
}
});
};
}, []), M(() => (F(b, f), () => F(b, null)), [b, f]), M(() => {
if (!f) return;
const g = [
["sync", a],
["slidesync", r],
["slidechanged", i],
["slidetransitionend", u],
["fragmentshown", h],
["fragmenthidden", C],
["overviewshown", d],
["overviewhidden", y],
["paused", w],
["resumed", E]
].filter((k) => k[1] != null);
for (const [k, _] of g)
f.on(k, _);
return () => {
for (const [k, _] of g)
f.off(k, _);
};
}, [
f,
a,
r,
i,
u,
h,
C,
d,
y,
w,
E
]), P(() => {
!f || !s.current?.isReady() || nt(A.current, e) && (S.current = !0, s.current.configure(e ?? {}), A.current = e);
}, [f, e]), P(() => {
const c = S.current;
S.current = !1;
const g = at(
p.current,
B.current,
U
);
if (c) {
L.current = g;
return;
}
s.current?.isReady() && L.current !== g && (s.current.sync(), L.current = g);
}), /* @__PURE__ */ R(x.Provider, { value: f, children: /* @__PURE__ */ R("div", { className: N ? `reveal ${N}` : "reveal", style: I, ref: v, children: /* @__PURE__ */ R("div", { className: "slides", ref: p, children: T }) }) });
}
const it = "[]", ot = {
background: "data-background",
backgroundImage: "data-background-image",
backgroundVideo: "data-background-video",
backgroundVideoLoop: "data-background-video-loop",
backgroundVideoMuted: "data-background-video-muted",
backgroundIframe: "data-background-iframe",
backgroundColor: "data-background-color",
backgroundGradient: "data-background-gradient",
backgroundSize: "data-background-size",
backgroundPosition: "data-background-position",
backgroundRepeat: "data-background-repeat",
backgroundOpacity: "data-background-opacity",
backgroundTransition: "data-background-transition",
visibility: "data-visibility",
autoAnimate: "data-auto-animate",
autoAnimateId: "data-auto-animate-id",
autoAnimateRestart: "data-auto-animate-restart",
autoAnimateUnmatched: "data-auto-animate-unmatched",
autoAnimateEasing: "data-auto-animate-easing",
autoAnimateDuration: "data-auto-animate-duration",
autoAnimateDelay: "data-auto-animate-delay",
transition: "data-transition",
transitionSpeed: "data-transition-speed",
autoSlide: "data-autoslide",
notes: "data-notes",
backgroundInteractive: "data-background-interactive",
preload: "data-preload"
};
function ut(e) {
return JSON.stringify(
Object.entries(e).filter(([t]) => t.startsWith("data-")).sort(([t], [n]) => t.localeCompare(n))
);
}
function st(e, t) {
const n = { ...e }, a = n;
for (const [r, i] of Object.entries(ot)) {
if (a[i] !== void 0) continue;
const u = t[r];
if (u !== void 0) {
if (u === !1) {
r === "autoAnimateUnmatched" && (a[i] = "false");
continue;
}
a[i] = typeof u == "boolean" ? "" : u;
}
}
return n;
}
function bt({
children: e,
background: t,
backgroundImage: n,
backgroundVideo: a,
backgroundVideoLoop: r,
backgroundVideoMuted: i,
backgroundIframe: u,
backgroundColor: h,
backgroundGradient: C,
backgroundSize: d,
backgroundPosition: y,
backgroundRepeat: w,
backgroundOpacity: E,
backgroundTransition: b,
visibility: N,
autoAnimate: I,
autoAnimateId: T,
autoAnimateRestart: v,
autoAnimateUnmatched: p,
autoAnimateEasing: s,
autoAnimateDuration: f,
autoAnimateDelay: o,
transition: D,
transitionSpeed: S,
autoSlide: A,
notes: L,
backgroundInteractive: B,
preload: U,
...j
}) {
const m = V(x), c = l(null), g = l(null), k = l(null), _ = st(j, {
background: t,
backgroundImage: n,
backgroundVideo: a,
backgroundVideoLoop: r,
backgroundVideoMuted: i,
backgroundIframe: u,
backgroundColor: h,
backgroundGradient: C,
backgroundSize: d,
backgroundPosition: y,
backgroundRepeat: w,
backgroundOpacity: E,
backgroundTransition: b,
visibility: N,
autoAnimate: I,
autoAnimateId: T,
autoAnimateRestart: v,
autoAnimateUnmatched: p,
autoAnimateEasing: s,
autoAnimateDuration: f,
autoAnimateDelay: o,
transition: D,
transitionSpeed: S,
autoSlide: A,
notes: L,
backgroundInteractive: B,
preload: U
}), O = ut(_);
return P(() => {
const z = c.current;
if (!m || !z || typeof m.syncSlide != "function") return;
if (g.current !== m) {
g.current = m, k.current = O;
return;
}
if (O === it) return;
const q = g.current === m, J = k.current === O;
q && J || (m.syncSlide(z), g.current = m, k.current = O);
}, [m, O]), /* @__PURE__ */ R("section", { ref: c, ..._, children: e });
}
function pt({ className: e, style: t, children: n }) {
return /* @__PURE__ */ R("section", { className: e, style: t, children: n });
}
function G(...e) {
return e.filter(Boolean).join(" ");
}
function ct(e, t) {
return e ? t ? {
...e,
...t
} : e : t;
}
function kt({
animation: e,
index: t,
as: n,
asChild: a,
className: r,
style: i,
children: u
}) {
const h = G("fragment", e, r);
if (a) {
let d;
try {
d = Y.only(u);
} catch {
throw new Error("Fragment with asChild expects exactly one React element child.");
}
if (!H(d) || d.type === Q)
throw new Error("Fragment with asChild expects exactly one non-Fragment React element child.");
const y = {
className: G(d.props.className, h),
style: ct(d.props.style, i)
};
return t !== void 0 && (y["data-fragment-index"] = t), X(d, y);
}
return /* @__PURE__ */ R(n ?? "span", { className: h, style: i, "data-fragment-index": t, children: u });
}
function lt(e) {
const t = e.replace(/\r\n/g, `
`).split(`
`);
for (; t.length && t[0].trim().length === 0; ) t.shift();
for (; t.length && t[t.length - 1].trim().length === 0; ) t.pop();
if (!t.length) return "";
const n = t.filter((a) => a.trim().length > 0).reduce(
(a, r) => Math.min(a, r.match(/^\s*/)?.[0].length ?? 0),
Number.POSITIVE_INFINITY
);
return t.map((a) => a.slice(n)).join(`
`);
}
function dt(e) {
const t = e.parentElement;
t && Array.from(t.children).forEach((n) => {
n !== e && n instanceof HTMLElement && n.tagName === "CODE" && n.classList.contains("fragment") && n.remove();
});
}
function yt({
children: e,
code: t,
language: n,
trim: a = !0,
lineNumbers: r,
startFrom: i,
noEscape: u,
codeClassName: h,
codeStyle: C,
codeProps: d,
className: y,
style: w,
...E
}) {
const b = V(x), N = l(null), I = l(""), T = typeof t == "string" ? t : typeof e == "string" ? e : "", v = Z(() => a ? lt(T) : T, [T, a]), p = r === !0 ? "" : r === !1 || r == null ? void 0 : String(r), s = [n, h].filter(Boolean).join(" "), f = ["code-wrapper", y].filter(Boolean).join(" ");
return P(() => {
const o = N.current;
if (!o || !b) return;
const D = b.getPlugin?.("highlight");
if (!D || typeof D.highlightBlock != "function") return;
const S = [
v,
n || "",
h || "",
p == null ? "__none__" : `lineNumbers:${p}`,
i == null ? "" : String(i),
u ? "1" : "0"
].join("::");
if (I.current === S && o.getAttribute("data-highlighted") === "yes")
return;
dt(o), o.textContent = v, o.removeAttribute("data-highlighted"), o.classList.remove("hljs"), o.classList.remove("has-highlights"), p == null ? o.removeAttribute("data-line-numbers") : o.setAttribute("data-line-numbers", p), i == null ? o.removeAttribute("data-ln-start-from") : o.setAttribute("data-ln-start-from", String(i)), u ? o.setAttribute("data-noescape", "") : o.removeAttribute("data-noescape"), D.highlightBlock(o);
const A = typeof o.closest == "function" ? o.closest("section") : null;
A && typeof b.syncFragments == "function" && b.syncFragments(A), I.current = S;
}, [b, v, n, h, p, i, u]), /* @__PURE__ */ R("pre", { className: f, style: w, ...E, children: /* @__PURE__ */ R(
"code",
{
...d,
ref: N,
className: s || void 0,
style: C,
"data-line-numbers": p,
"data-ln-start-from": i,
"data-noescape": u ? "" : void 0,
children: v
}
) });
}
function vt() {
return V(x);
}
export {
yt as Code,
ht as Deck,
kt as Fragment,
x as RevealContext,
bt as Slide,
pt as Stack,
vt as useReveal
};

3982
scripts/reveal.js/react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
{
"name": "@revealjs/react",
"version": "0.1.1",
"description": "React wrapper for reveal.js",
"license": "MIT",
"homepage": "https://revealjs.com/react",
"author": {
"name": "Hakim El Hattab",
"email": "hakim.elhattab@gmail.com",
"web": "https://hakim.se"
},
"repository": {
"type": "git",
"url": "git://github.com/hakimel/reveal.js.git"
},
"type": "module",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"demo": "npm run --prefix demo dev"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"reveal.js": ">=5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"jsdom": "^28.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"reveal.js": "file:..",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4",
"vitest": "^4.0.18"
},
"overrides": {
"minimatch": ">=10.2.3"
},
"files": [
"dist"
]
}

View File

@@ -0,0 +1,138 @@
import { useContext, useLayoutEffect, useMemo, useRef } from 'react';
import { RevealContext } from './context';
import type { CodeProps } from './types';
type HighlightPlugin = {
highlightBlock?: (block: HTMLElement) => void;
};
function normalizeCode(code: string) {
const lines = code.replace(/\r\n/g, '\n').split('\n');
while (lines.length && lines[0].trim().length === 0) lines.shift();
while (lines.length && lines[lines.length - 1].trim().length === 0) lines.pop();
if (!lines.length) return '';
const minIndent = lines
.filter((line) => line.trim().length > 0)
.reduce(
(acc, line) => Math.min(acc, line.match(/^\s*/)?.[0].length ?? 0),
Number.POSITIVE_INFINITY
);
return lines.map((line) => line.slice(minIndent)).join('\n');
}
function cleanupGeneratedFragments(block: HTMLElement) {
const pre = block.parentElement;
if (!pre) return;
// RevealHighlight creates extra <code.fragment> nodes for each highlight step (e.g. "1|3").
// Remove previously generated nodes before re-highlighting to avoid duplicate steps.
Array.from(pre.children).forEach((child) => {
if (
child !== block &&
child instanceof HTMLElement &&
child.tagName === 'CODE' &&
child.classList.contains('fragment')
) {
child.remove();
}
});
}
export function Code({
children,
code,
language,
trim = true,
lineNumbers,
startFrom,
noEscape,
codeClassName,
codeStyle,
codeProps,
className,
style,
...rest
}: CodeProps) {
const deck = useContext(RevealContext);
const codeRef = useRef<HTMLElement>(null);
const lastHighlightSignatureRef = useRef<string>('');
const rawCode = typeof code === 'string' ? code : typeof children === 'string' ? children : '';
const normalizedCode = useMemo(() => (trim ? normalizeCode(rawCode) : rawCode), [rawCode, trim]);
const lineNumbersValue =
lineNumbers === true
? ''
: lineNumbers === false || lineNumbers == null
? undefined
: String(lineNumbers);
const codeClasses = [language, codeClassName].filter(Boolean).join(' ');
const preClasses = ['code-wrapper', className].filter(Boolean).join(' ');
useLayoutEffect(() => {
const block = codeRef.current;
if (!block || !deck) return;
const plugin = deck.getPlugin?.('highlight') as HighlightPlugin | undefined;
if (!plugin || typeof plugin.highlightBlock !== 'function') return;
const highlightSignature = [
normalizedCode,
language || '',
codeClassName || '',
lineNumbersValue == null ? '__none__' : `lineNumbers:${lineNumbersValue}`,
startFrom == null ? '' : String(startFrom),
noEscape ? '1' : '0',
].join('::');
if (
lastHighlightSignatureRef.current === highlightSignature &&
block.getAttribute('data-highlighted') === 'yes'
) {
return;
}
cleanupGeneratedFragments(block);
block.textContent = normalizedCode;
block.removeAttribute('data-highlighted');
block.classList.remove('hljs');
block.classList.remove('has-highlights');
// Restore source attributes before each highlight call since RevealHighlight mutates
// data-line-numbers on the original block when it expands multi-step highlights.
if (lineNumbersValue == null) block.removeAttribute('data-line-numbers');
else block.setAttribute('data-line-numbers', lineNumbersValue);
if (startFrom == null) block.removeAttribute('data-ln-start-from');
else block.setAttribute('data-ln-start-from', String(startFrom));
if (noEscape) block.setAttribute('data-noescape', '');
else block.removeAttribute('data-noescape');
plugin.highlightBlock(block);
const slide = typeof block.closest === 'function' ? block.closest('section') : null;
if (slide && typeof deck.syncFragments === 'function') {
deck.syncFragments(slide as HTMLElement);
}
lastHighlightSignatureRef.current = highlightSignature;
}, [deck, normalizedCode, language, codeClassName, lineNumbersValue, startFrom, noEscape]);
return (
<pre className={preClasses} style={style} {...rest}>
<code
{...codeProps}
ref={codeRef}
className={codeClasses || undefined}
style={codeStyle}
data-line-numbers={lineNumbersValue}
data-ln-start-from={startFrom}
data-noescape={noEscape ? '' : undefined}
>
{normalizedCode}
</code>
</pre>
);
}

View File

@@ -0,0 +1,256 @@
import { useEffect, useLayoutEffect, useRef, useState, type Ref, type RefObject } from 'react';
import Reveal from 'reveal.js';
import type { RevealApi } from 'reveal.js';
import { RevealContext } from './context';
import type { DeckProps } from './types';
const DEFAULT_PLUGINS: NonNullable<DeckProps['plugins']> = [];
type DeckEventHandler = NonNullable<DeckProps['onSync']>;
type SlideStructureNode = number | [number, SlideStructureNode[]];
type CurrentRef<T> = { current: T };
// Shallow-compare config objects so that re-renders where the parent creates a new object
// literal with identical values do not trigger an unnecessary configure() call.
function hasShallowConfigChanges(prev: DeckProps['config'], next: DeckProps['config']) {
if (prev === next) return false;
if (!prev || !next) return prev !== next;
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return true;
for (const key of prevKeys) {
if (!(key in next)) return true;
if ((prev as Record<string, unknown>)[key] !== (next as Record<string, unknown>)[key]) {
return true;
}
}
return false;
}
function setRef<T>(ref: Ref<T | null> | undefined, value: T | null) {
if (!ref) return;
if (typeof ref === 'function') {
ref(value);
} else {
(ref as RefObject<T | null>).current = value;
}
}
function isSectionElement(element: Element): element is HTMLElement {
return element.tagName === 'SECTION';
}
function getSectionStructure(
container: Element,
slideIds: WeakMap<HTMLElement, number>,
nextSlideIdRef: CurrentRef<number>
): SlideStructureNode[] {
return Array.from(container.children)
.filter(isSectionElement)
.map((section) => {
let id = slideIds.get(section);
if (id === undefined) {
id = nextSlideIdRef.current++;
slideIds.set(section, id);
}
const childSlides = getSectionStructure(section, slideIds, nextSlideIdRef);
return childSlides.length > 0 ? [id, childSlides] : id;
});
}
function getSlidesStructureSignature(
slidesElement: HTMLElement | null,
slideIds: WeakMap<HTMLElement, number>,
nextSlideIdRef: CurrentRef<number>
) {
if (!slidesElement) return '[]';
return JSON.stringify(getSectionStructure(slidesElement, slideIds, nextSlideIdRef));
}
export function Deck({
config,
plugins = DEFAULT_PLUGINS,
onReady,
onSync,
onSlideSync,
onSlideChange,
onSlideTransitionEnd,
onFragmentShown,
onFragmentHidden,
onOverviewShown,
onOverviewHidden,
onPaused,
onResumed,
deckRef,
className,
style,
children,
}: DeckProps) {
const deckDivRef = useRef<HTMLDivElement>(null);
const slidesDivRef = useRef<HTMLDivElement>(null);
const revealRef = useRef<RevealApi | null>(null);
const [deck, setDeck] = useState<RevealApi | null>(null);
// Plugins are init-only in reveal.js; we register them once when creating the instance.
const initialPluginsRef = useRef<NonNullable<DeckProps['plugins']>>(plugins);
// configure() performs its own sync in Reveal; this flag prevents us from running an
// immediate second sync in the next layout effect pass.
const skipNextSyncRef = useRef(false);
// Track the last config reference we applied so we can skip redundant configure() calls.
const appliedConfigRef = useRef<DeckProps['config']>(config);
const lastSyncedSlidesSignatureRef = useRef<string | null>(null);
const slideIdsRef = useRef(new WeakMap<HTMLElement, number>());
const nextSlideIdRef = useRef(1);
const mountedRef = useRef(false);
const teardownRequestRef = useRef(0);
// Create the Reveal instance once on mount and destroy it on unmount.
useEffect(() => {
mountedRef.current = true;
teardownRequestRef.current += 1;
if (!revealRef.current) {
const instance = new Reveal(deckDivRef.current!, {
...config,
plugins: initialPluginsRef.current,
});
// Capture the config that was passed to the constructor so the configure
// effect can later detect whether anything actually changed.
appliedConfigRef.current = config;
revealRef.current = instance;
instance.initialize().then(() => {
if (!mountedRef.current || revealRef.current !== instance) return;
setDeck(instance);
onReady?.(instance);
});
} else if (revealRef.current.isReady()) {
// React StrictMode unmounts and remounts every effect. On the second mount
// the instance is already live, so skip construction. The isReady() guard
// ensures we only expose it once initialization has fully completed.
setDeck(revealRef.current);
}
return () => {
mountedRef.current = false;
const instance = revealRef.current;
if (!instance) return;
// Defer teardown to the next microtask. In StrictMode the component
// remounts immediately, incrementing teardownRequestRef before the
// microtask runs. The stale request number causes the callback to bail
// out, preventing the instance from being destroyed on a live component.
const teardownRequest = ++teardownRequestRef.current;
Promise.resolve().then(() => {
if (mountedRef.current || teardownRequestRef.current !== teardownRequest) return;
if (revealRef.current !== instance) return;
try {
instance.destroy();
} catch (e) {
// Ignore errors during cleanup
}
if (revealRef.current === instance) {
revealRef.current = null;
}
});
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Keep consumer refs in sync, including when the ref prop itself changes.
useEffect(() => {
setRef(deckRef, deck);
return () => setRef(deckRef, null);
}, [deckRef, deck]);
// Attach and detach Reveal event listeners from the provided callbacks.
useEffect(() => {
if (!deck) return;
const events: [string, DeckEventHandler | undefined][] = [
['sync', onSync],
['slidesync', onSlideSync],
['slidechanged', onSlideChange],
['slidetransitionend', onSlideTransitionEnd],
['fragmentshown', onFragmentShown],
['fragmenthidden', onFragmentHidden],
['overviewshown', onOverviewShown],
['overviewhidden', onOverviewHidden],
['paused', onPaused],
['resumed', onResumed],
];
const bound = events.filter((e): e is [string, DeckEventHandler] => e[1] != null);
for (const [name, handler] of bound) {
deck.on(name, handler);
}
return () => {
for (const [name, handler] of bound) {
deck.off(name, handler);
}
};
}, [
deck,
onSync,
onSlideSync,
onSlideChange,
onSlideTransitionEnd,
onFragmentShown,
onFragmentHidden,
onOverviewShown,
onOverviewHidden,
onPaused,
onResumed,
]);
// Re-apply config after init and mark that configure already performed a sync.
useLayoutEffect(() => {
if (!deck || !revealRef.current?.isReady()) return;
if (!hasShallowConfigChanges(appliedConfigRef.current, config)) return;
skipNextSyncRef.current = true;
revealRef.current.configure(config ?? {});
appliedConfigRef.current = config;
}, [deck, config]);
// Sync Reveal's internal slide bookkeeping only when the rendered slide
// structure changes. Avoid triggering sync for child changes.
useLayoutEffect(() => {
const shouldSkip = skipNextSyncRef.current;
skipNextSyncRef.current = false;
const slidesStructureSignature = getSlidesStructureSignature(
slidesDivRef.current,
slideIdsRef.current,
nextSlideIdRef
);
if (shouldSkip) {
lastSyncedSlidesSignatureRef.current = slidesStructureSignature;
return;
}
if (!revealRef.current?.isReady()) return;
if (lastSyncedSlidesSignatureRef.current === slidesStructureSignature) return;
revealRef.current.sync();
lastSyncedSlidesSignatureRef.current = slidesStructureSignature;
});
return (
<RevealContext.Provider value={deck}>
<div className={className ? `reveal ${className}` : 'reveal'} style={style} ref={deckDivRef}>
<div className="slides" ref={slidesDivRef}>
{children}
</div>
</div>
</RevealContext.Provider>
);
}

View File

@@ -0,0 +1,76 @@
import {
Children,
Fragment as ReactFragment,
cloneElement,
isValidElement,
type CSSProperties,
type ReactElement,
} from 'react';
import type { FragmentProps } from './types';
type FragmentChildProps = {
className?: string;
style?: CSSProperties;
'data-fragment-index'?: number;
};
function mergeClassNames(...classNames: Array<string | undefined>) {
return classNames.filter(Boolean).join(' ');
}
function mergeStyles(
childStyle: CSSProperties | undefined,
style: CSSProperties | undefined
) {
if (!childStyle) return style;
if (!style) return childStyle;
return {
...childStyle,
...style,
};
}
export function Fragment({
animation,
index,
as,
asChild,
className,
style,
children,
}: FragmentProps) {
const classes = mergeClassNames('fragment', animation, className);
if (asChild) {
let child: ReactElement<FragmentChildProps>;
try {
child = Children.only(children) as ReactElement<FragmentChildProps>;
} catch {
throw new Error('Fragment with asChild expects exactly one React element child.');
}
if (!isValidElement(child) || child.type === ReactFragment) {
throw new Error('Fragment with asChild expects exactly one non-Fragment React element child.');
}
const fragmentChildProps: FragmentChildProps = {
className: mergeClassNames(child.props.className, classes),
style: mergeStyles(child.props.style, style),
};
if (index !== undefined) {
fragmentChildProps['data-fragment-index'] = index;
}
return cloneElement(child, fragmentChildProps);
}
const Tag = as ?? 'span';
return (
<Tag className={classes} style={style} data-fragment-index={index}>
{children}
</Tag>
);
}

View File

@@ -0,0 +1,180 @@
import { useContext, useLayoutEffect, useRef } from 'react';
import type { RevealApi } from 'reveal.js';
import { RevealContext } from './context';
import type {
SlideAutoAnimateProps,
SlideBackgroundProps,
SlideDataAttributeValue,
SlideProps,
SlideRevealProps,
} from './types';
const EMPTY_DATA_ATTRIBUTES_SIGNATURE = '[]';
type SlideShorthandProps = SlideBackgroundProps & SlideAutoAnimateProps & SlideRevealProps;
const SLIDE_DATA_ATTRIBUTES: Record<keyof SlideShorthandProps, `data-${string}`> = {
background: 'data-background',
backgroundImage: 'data-background-image',
backgroundVideo: 'data-background-video',
backgroundVideoLoop: 'data-background-video-loop',
backgroundVideoMuted: 'data-background-video-muted',
backgroundIframe: 'data-background-iframe',
backgroundColor: 'data-background-color',
backgroundGradient: 'data-background-gradient',
backgroundSize: 'data-background-size',
backgroundPosition: 'data-background-position',
backgroundRepeat: 'data-background-repeat',
backgroundOpacity: 'data-background-opacity',
backgroundTransition: 'data-background-transition',
visibility: 'data-visibility',
autoAnimate: 'data-auto-animate',
autoAnimateId: 'data-auto-animate-id',
autoAnimateRestart: 'data-auto-animate-restart',
autoAnimateUnmatched: 'data-auto-animate-unmatched',
autoAnimateEasing: 'data-auto-animate-easing',
autoAnimateDuration: 'data-auto-animate-duration',
autoAnimateDelay: 'data-auto-animate-delay',
transition: 'data-transition',
transitionSpeed: 'data-transition-speed',
autoSlide: 'data-autoslide',
notes: 'data-notes',
backgroundInteractive: 'data-background-interactive',
preload: 'data-preload',
};
type SlideElementProps = Omit<SlideProps, 'children' | keyof SlideShorthandProps>;
function getDataAttributesSignature(attributes: SlideElementProps) {
return JSON.stringify(
Object.entries(attributes)
.filter(([key]) => key.startsWith('data-'))
.sort(([a], [b]) => a.localeCompare(b))
);
}
function getSlideAttributes(
attributes: SlideElementProps,
shorthandProps: SlideShorthandProps
): SlideElementProps {
const resolvedAttributes = { ...attributes } as SlideElementProps;
const resolvedDataAttributes = resolvedAttributes as unknown as Record<
string,
SlideDataAttributeValue
>;
for (const [propName, dataAttributeName] of Object.entries(SLIDE_DATA_ATTRIBUTES) as Array<
[keyof SlideShorthandProps, `data-${string}`]
>) {
if (resolvedDataAttributes[dataAttributeName] !== undefined) continue;
const value = shorthandProps[propName];
if (value === undefined) continue;
if (value === false) {
if (propName === 'autoAnimateUnmatched') {
resolvedDataAttributes[dataAttributeName] = 'false';
}
continue;
}
resolvedDataAttributes[dataAttributeName] = typeof value === 'boolean' ? '' : value;
}
return resolvedAttributes;
}
export function Slide({
children,
background,
backgroundImage,
backgroundVideo,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundIframe,
backgroundColor,
backgroundGradient,
backgroundSize,
backgroundPosition,
backgroundRepeat,
backgroundOpacity,
backgroundTransition,
visibility,
autoAnimate,
autoAnimateId,
autoAnimateRestart,
autoAnimateUnmatched,
autoAnimateEasing,
autoAnimateDuration,
autoAnimateDelay,
transition,
transitionSpeed,
autoSlide,
notes,
backgroundInteractive,
preload,
...rest
}: SlideProps) {
const deck = useContext(RevealContext);
const slideRef = useRef<HTMLElement>(null);
const lastSyncedDeckRef = useRef<RevealApi | null>(null);
const lastSyncedSignatureRef = useRef<string | null>(null);
const slideAttributes = getSlideAttributes(rest, {
background,
backgroundImage,
backgroundVideo,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundIframe,
backgroundColor,
backgroundGradient,
backgroundSize,
backgroundPosition,
backgroundRepeat,
backgroundOpacity,
backgroundTransition,
visibility,
autoAnimate,
autoAnimateId,
autoAnimateRestart,
autoAnimateUnmatched,
autoAnimateEasing,
autoAnimateDuration,
autoAnimateDelay,
transition,
transitionSpeed,
autoSlide,
notes,
backgroundInteractive,
preload,
});
const dataAttributesSignature = getDataAttributesSignature(slideAttributes);
useLayoutEffect(() => {
const slide = slideRef.current;
if (!deck || !slide || typeof deck.syncSlide !== 'function') return;
// The first render for a given deck instance is handled by the parent Deck.sync() pass.
// syncSlide() is only safe once Reveal is aware of this slide.
if (lastSyncedDeckRef.current !== deck) {
lastSyncedDeckRef.current = deck;
lastSyncedSignatureRef.current = dataAttributesSignature;
return;
}
if (dataAttributesSignature === EMPTY_DATA_ATTRIBUTES_SIGNATURE) return;
const sameDeck = lastSyncedDeckRef.current === deck;
const sameDataAttributes = lastSyncedSignatureRef.current === dataAttributesSignature;
if (sameDeck && sameDataAttributes) return;
deck.syncSlide(slide);
lastSyncedDeckRef.current = deck;
lastSyncedSignatureRef.current = dataAttributesSignature;
}, [deck, dataAttributesSignature]);
return (
<section ref={slideRef} {...slideAttributes}>
{children}
</section>
);
}

View File

@@ -0,0 +1,9 @@
import type { StackProps } from './types';
export function Stack({ className, style, children }: StackProps) {
return (
<section className={className} style={style}>
{children}
</section>
);
}

View File

@@ -0,0 +1,142 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Code } from '../Code';
import { RevealContext } from '../context';
describe('Code', () => {
it('renders pre/code and trims multiline template literals by default', () => {
const { container } = render(
<Code language="javascript">{`
function add(a, b) {
return a + b;
}
`}</Code>
);
const pre = container.querySelector('pre');
const code = container.querySelector('pre > code');
expect(pre).toBeInTheDocument();
expect(pre).toHaveClass('code-wrapper');
expect(code).toHaveClass('javascript');
expect(code?.textContent).toBe('function add(a, b) {\n\treturn a + b;\n}');
});
it('can disable trimming', () => {
const source = '\n\tconst value = 1;\n';
const { container } = render(<Code trim={false}>{source}</Code>);
const code = container.querySelector('pre > code');
expect(code?.textContent).toBe(source);
});
it('maps line number props to reveal data attributes', () => {
const { container } = render(
<Code language="js" lineNumbers="|2,4-6|8" startFrom={10}>
{`console.log('hello')`}
</Code>
);
const code = container.querySelector('pre > code');
expect(code).toHaveAttribute('data-line-numbers', '|2,4-6|8');
expect(code).toHaveAttribute('data-ln-start-from', '10');
});
it('invokes the Reveal highlight plugin when registered on the deck', () => {
const highlightBlock = vi.fn((block: HTMLElement) => {
block.setAttribute('data-highlighted', 'yes');
});
const deck = {
getPlugin: vi.fn().mockReturnValue({ highlightBlock }),
syncFragments: vi.fn(),
} as any;
const { container } = render(
<RevealContext.Provider value={deck}>
<section>
<Code language="javascript">{`console.log('hello')`}</Code>
</section>
</RevealContext.Provider>
);
const code = container.querySelector('pre > code');
expect(deck.getPlugin).toHaveBeenCalledWith('highlight');
expect(highlightBlock).toHaveBeenCalledWith(code);
expect(deck.syncFragments).toHaveBeenCalledWith(expect.any(HTMLElement));
});
it('rehighlights updated code without accumulating generated fragments', () => {
const highlightBlock = vi.fn((block: HTMLElement) => {
block.setAttribute('data-highlighted', 'yes');
const fragment = block.cloneNode(true) as HTMLElement;
fragment.classList.add('fragment');
block.parentElement?.appendChild(fragment);
});
const deck = {
getPlugin: vi.fn().mockReturnValue({ highlightBlock }),
syncFragments: vi.fn(),
} as any;
const { container, rerender } = render(
<RevealContext.Provider value={deck}>
<section>
<Code lineNumbers="|1|2">{`console.log('one')`}</Code>
</section>
</RevealContext.Provider>
);
expect(container.querySelectorAll('pre > code.fragment')).toHaveLength(1);
rerender(
<RevealContext.Provider value={deck}>
<section>
<Code lineNumbers="|1|2">{`console.log('two')`}</Code>
</section>
</RevealContext.Provider>
);
expect(highlightBlock).toHaveBeenCalledTimes(2);
expect(container.querySelectorAll('pre > code.fragment')).toHaveLength(1);
expect(container.querySelector('pre > code:not(.fragment)')).toHaveTextContent("console.log('two')");
});
it('restores full line-number steps before rehighlighting after plugin mutation', () => {
const seenLineNumbers: string[] = [];
const highlightBlock = vi.fn((block: HTMLElement) => {
seenLineNumbers.push(block.getAttribute('data-line-numbers') || '');
block.setAttribute('data-highlighted', 'yes');
// Mimic RevealHighlight: original block is rewritten to the first step and
// an extra fragment block is appended for following steps.
block.setAttribute('data-line-numbers', '1');
const fragment = block.cloneNode(true) as HTMLElement;
fragment.classList.add('fragment');
fragment.setAttribute('data-line-numbers', '3');
block.parentElement?.appendChild(fragment);
});
const deck = {
getPlugin: vi.fn().mockReturnValue({ highlightBlock }),
syncFragments: vi.fn(),
} as any;
const { rerender } = render(
<RevealContext.Provider value={deck}>
<section>
<Code language="javascript" lineNumbers="1|3">{`console.log('one')`}</Code>
</section>
</RevealContext.Provider>
);
rerender(
<RevealContext.Provider value={deck}>
<section>
<Code language="ts" lineNumbers="1|3">{`console.log('one')`}</Code>
</section>
</RevealContext.Provider>
);
expect(seenLineNumbers).toEqual(['1|3', '1|3']);
});
});

View File

@@ -0,0 +1,450 @@
import { render, act, cleanup } from '@testing-library/react';
import { StrictMode } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockApi = vi.hoisted(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn(),
sync: vi.fn(),
isReady: vi.fn().mockReturnValue(true),
on: vi.fn(),
off: vi.fn(),
configure: vi.fn(),
}));
const RevealConstructor = vi.hoisted(() =>
vi.fn(function () {
return mockApi;
})
);
vi.mock('reveal.js', () => ({ default: RevealConstructor }));
import { Deck, Slide, useReveal } from '../index';
beforeEach(() => {
vi.clearAllMocks();
mockApi.initialize.mockResolvedValue(undefined);
mockApi.isReady.mockReturnValue(true);
});
describe('Deck', () => {
it('renders .reveal > .slides structure', async () => {
let container: HTMLElement;
await act(async () => {
({ container } = render(
<Deck>
<Slide>Hello</Slide>
</Deck>
));
});
const reveal = container!.querySelector('.reveal');
expect(reveal).toBeInTheDocument();
const slides = reveal?.querySelector('.slides');
expect(slides).toBeInTheDocument();
const section = slides?.querySelector('section');
expect(section).toHaveTextContent('Hello');
});
it('calls new Reveal() and initialize() on mount', async () => {
await act(async () => {
render(
<Deck config={{ transition: 'slide', hash: true }}>
<Slide>Test</Slide>
</Deck>
);
});
expect(RevealConstructor).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.objectContaining({
transition: 'slide',
hash: true,
plugins: [],
})
);
expect(mockApi.initialize).toHaveBeenCalledTimes(1);
});
it('calls destroy() on unmount', async () => {
await act(async () => {
render(
<Deck>
<Slide>Test</Slide>
</Deck>
);
});
await act(async () => {
cleanup();
});
expect(mockApi.destroy).toHaveBeenCalledTimes(1);
});
it('calls sync() after render when ready', async () => {
await act(async () => {
render(
<Deck>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.sync).toHaveBeenCalled();
});
it('does not call sync() when slide content changes without structural changes', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck>
<Slide>
<p>Tick 1</p>
</Slide>
</Deck>
));
});
mockApi.sync.mockClear();
await act(async () => {
rerender(
<Deck>
<Slide>
<p>Tick 2</p>
</Slide>
</Deck>
);
});
expect(mockApi.sync).not.toHaveBeenCalled();
});
it('does not call sync() twice when configure() already syncs', async () => {
mockApi.configure.mockImplementation(() => {
mockApi.sync();
});
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck config={{ transition: 'slide' }}>
<Slide>Test</Slide>
</Deck>
));
});
mockApi.configure.mockClear();
mockApi.sync.mockClear();
await act(async () => {
rerender(
<Deck config={{ transition: 'convex' }}>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.configure).toHaveBeenCalledTimes(1);
expect(mockApi.sync).toHaveBeenCalledTimes(1);
});
it('calls sync() when keyed slides are reordered', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck>
{['first', 'second'].map((label) => (
<Slide key={label}>{label}</Slide>
))}
</Deck>
));
});
mockApi.sync.mockClear();
await act(async () => {
rerender(
<Deck>
{['second', 'first'].map((label) => (
<Slide key={label}>{label}</Slide>
))}
</Deck>
);
});
expect(mockApi.sync).toHaveBeenCalledTimes(1);
});
it('calls sync() after children change even when isReady() was false during the config-change render', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck config={{ transition: 'slide' }}>
<Slide>Slide 1</Slide>
</Deck>
));
});
// Make isReady() return true for the configure effect but false for the sync effect,
// simulating Reveal briefly being non-ready during configuration. Without the fix the
// skip flag would never be reset and the subsequent sync() call would be wrongly skipped.
mockApi.isReady.mockReturnValueOnce(true).mockReturnValueOnce(false);
await act(async () => {
rerender(
<Deck config={{ transition: 'convex' }}>
<Slide>Slide 1</Slide>
</Deck>
);
});
mockApi.isReady.mockReturnValue(true);
mockApi.sync.mockClear();
await act(async () => {
rerender(
<Deck config={{ transition: 'convex' }}>
<Slide>Slide 1</Slide>
<Slide>Slide 2</Slide>
</Deck>
);
});
expect(mockApi.sync).toHaveBeenCalledTimes(1);
});
it('does not reconfigure when config object is recreated with same values', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck config={{ transition: 'slide', hash: true }}>
<Slide>Test</Slide>
</Deck>
));
});
mockApi.configure.mockClear();
await act(async () => {
rerender(
<Deck config={{ transition: 'slide', hash: true }}>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.configure).not.toHaveBeenCalled();
});
it('fires onReady callback after initialization', async () => {
const onReady = vi.fn();
await act(async () => {
render(
<Deck onReady={onReady}>
<Slide>Test</Slide>
</Deck>
);
});
expect(onReady).toHaveBeenCalledWith(mockApi);
});
it('wires onSlideChange to the slidechanged event', async () => {
const onSlideChange = vi.fn();
await act(async () => {
render(
<Deck onSlideChange={onSlideChange}>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.on).toHaveBeenCalledWith('slidechanged', onSlideChange);
});
it('wires onSlideSync to the slidesync event', async () => {
const onSlideSync = vi.fn();
await act(async () => {
render(
<Deck onSlideSync={onSlideSync}>
<Slide>Test</Slide>
</Deck>
);
});
expect(mockApi.on).toHaveBeenCalledWith('slidesync', onSlideSync);
});
it('cleans up event listeners on unmount', async () => {
const onSlideChange = vi.fn();
await act(async () => {
render(
<Deck onSlideChange={onSlideChange}>
<Slide>Test</Slide>
</Deck>
);
});
await act(async () => {
cleanup();
});
expect(mockApi.off).toHaveBeenCalledWith('slidechanged', onSlideChange);
});
it('passes plugins to the Reveal constructor', async () => {
const fakePlugin = () => ({ id: 'fake', init: () => {} });
await act(async () => {
render(
<Deck plugins={[fakePlugin]}>
<Slide>Test</Slide>
</Deck>
);
});
expect(RevealConstructor).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.objectContaining({ plugins: [fakePlugin] })
);
});
it('registers plugins only on initialization', async () => {
const pluginA = () => ({ id: 'a', init: () => {} });
const pluginB = () => ({ id: 'b', init: () => {} });
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = render(
<Deck plugins={[pluginA]}>
<Slide>Test</Slide>
</Deck>
));
});
mockApi.configure.mockClear();
await act(async () => {
rerender(
<Deck plugins={[pluginB]}>
<Slide>Test</Slide>
</Deck>
);
});
expect(RevealConstructor).toHaveBeenCalledTimes(1);
expect(RevealConstructor).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.objectContaining({ plugins: [pluginA] })
);
expect(mockApi.configure).not.toHaveBeenCalled();
});
it('applies className and style to the .reveal div', async () => {
let container: HTMLElement;
await act(async () => {
({ container } = render(
<Deck className="custom" style={{ height: '400px' }}>
<Slide>Test</Slide>
</Deck>
));
});
const reveal = container!.querySelector('.reveal');
expect(reveal).toHaveClass('reveal', 'custom');
expect(reveal).toHaveStyle({ height: '400px' });
});
it('does not initialize twice in StrictMode', async () => {
await act(async () => {
render(
<StrictMode>
<Deck>
<Slide>Test</Slide>
</Deck>
</StrictMode>
);
});
expect(RevealConstructor).toHaveBeenCalledTimes(1);
expect(mockApi.initialize).toHaveBeenCalledTimes(1);
});
it('updates deckRef when the ref prop changes', async () => {
const refA = vi.fn();
const refB = vi.fn();
let rerender: ReturnType<typeof render>['rerender'];
let unmount: ReturnType<typeof render>['unmount'];
await act(async () => {
({ rerender, unmount } = render(
<Deck deckRef={refA}>
<Slide>Test</Slide>
</Deck>
));
});
expect(refA).toHaveBeenCalledWith(mockApi);
await act(async () => {
rerender(
<Deck deckRef={refB}>
<Slide>Test</Slide>
</Deck>
);
});
expect(refA).toHaveBeenCalledWith(null);
expect(refB).toHaveBeenCalledWith(mockApi);
await act(async () => {
unmount();
});
expect(refB).toHaveBeenCalledWith(null);
});
});
describe('useReveal', () => {
it('returns the RevealApi instance inside a Deck', async () => {
let hookResult: any = undefined;
function Inspector() {
hookResult = useReveal();
return null;
}
await act(async () => {
render(
<Deck>
<Slide>
<Inspector />
</Slide>
</Deck>
);
});
expect(hookResult).toBe(mockApi);
});
it('returns null outside of a Deck', () => {
let hookResult: any = 'not-null';
function Inspector() {
hookResult = useReveal();
return null;
}
render(<Inspector />);
expect(hookResult).toBeNull();
});
});

View File

@@ -0,0 +1,122 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Fragment } from '../Fragment';
describe('Fragment', () => {
it('renders with the "fragment" class', () => {
const { container } = render(<Fragment>Hello</Fragment>);
const el = container.querySelector('.fragment');
expect(el).toBeInTheDocument();
expect(el).toHaveTextContent('Hello');
});
it('applies the animation class', () => {
const { container } = render(<Fragment animation="fade-up">Animated</Fragment>);
const el = container.querySelector('.fragment');
expect(el).toHaveClass('fragment', 'fade-up');
});
it('sets data-fragment-index when index is provided', () => {
const { container } = render(<Fragment index={2}>Indexed</Fragment>);
const el = container.querySelector('.fragment');
expect(el).toHaveAttribute('data-fragment-index', '2');
});
it('does not set data-fragment-index when index is omitted', () => {
const { container } = render(<Fragment>No index</Fragment>);
const el = container.querySelector('.fragment');
expect(el?.getAttribute('data-fragment-index')).toBeNull();
});
it('renders as a <span> by default', () => {
const { container } = render(<Fragment>Default</Fragment>);
const el = container.querySelector('.fragment');
expect(el?.tagName).toBe('SPAN');
});
it('renders as a custom element when "as" is provided', () => {
const { container } = render(<Fragment as="div">Block</Fragment>);
const el = container.querySelector('.fragment');
expect(el?.tagName).toBe('DIV');
});
it('combines fragment, animation, and custom className', () => {
const { container } = render(
<Fragment animation="grow" className="custom">
Combined
</Fragment>
);
const el = container.querySelector('.fragment');
expect(el).toHaveClass('fragment', 'grow', 'custom');
});
it('passes style through', () => {
const { container } = render(<Fragment style={{ opacity: 0.5 }}>Styled</Fragment>);
const el = container.querySelector('.fragment');
expect(el).toHaveStyle({ opacity: '0.5' });
});
it('renders the child element directly when asChild is provided', () => {
const { container } = render(
<Fragment asChild animation="fade-up">
<li>Item</li>
</Fragment>
);
expect(container.firstElementChild?.tagName).toBe('LI');
expect(container.firstElementChild).toHaveClass('fragment', 'fade-up');
expect(container.firstElementChild).toHaveTextContent('Item');
});
it('merges child className and style when asChild is provided', () => {
const { container } = render(
<Fragment asChild animation="grow" className="outer" style={{ opacity: 0.5 }}>
<div className="inner" style={{ color: 'red' }}>
Merged
</div>
</Fragment>
);
const el = container.firstElementChild;
expect(el).toHaveClass('inner', 'fragment', 'grow', 'outer');
expect(el).toHaveStyle({ color: 'rgb(255, 0, 0)', opacity: '0.5' });
});
it('sets data-fragment-index on the child when asChild is provided', () => {
const { container } = render(
<Fragment asChild index={2}>
<div data-fragment-index={1}>Indexed</div>
</Fragment>
);
expect(container.firstElementChild).toHaveAttribute('data-fragment-index', '2');
});
it('throws when asChild receives multiple children', () => {
expect(() =>
render(
<Fragment asChild>
<span>One</span>
<span>Two</span>
</Fragment>
)
).toThrow('Fragment with asChild expects exactly one React element child.');
});
it('throws when asChild receives a React.Fragment child', () => {
expect(() =>
render(
<Fragment asChild>
<>
<span>One</span>
</>
</Fragment>
)
).toThrow('Fragment with asChild expects exactly one non-Fragment React element child.');
});
});

View File

@@ -0,0 +1,296 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Slide } from '../Slide';
import { RevealContext } from '../context';
describe('Slide', () => {
it('renders as a <section> element', () => {
const { container } = render(<Slide>Hello</Slide>);
const section = container.querySelector('section');
expect(section).toBeInTheDocument();
expect(section).toHaveTextContent('Hello');
});
it('passes data-* attributes through', () => {
const { container } = render(
<Slide data-background="#ff0000" data-transition="zoom" data-auto-animate="">
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-background', '#ff0000');
expect(section).toHaveAttribute('data-transition', 'zoom');
expect(section).toHaveAttribute('data-auto-animate', '');
});
it('maps background shorthand props to slide data attributes', () => {
const { container } = render(
<Slide
background="#111827"
backgroundImage="https://example.com/hero.png"
backgroundColor="#000000"
backgroundOpacity={0.4}
backgroundTransition="zoom"
backgroundVideoLoop
>
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-background', '#111827');
expect(section).toHaveAttribute('data-background-image', 'https://example.com/hero.png');
expect(section).toHaveAttribute('data-background-color', '#000000');
expect(section).toHaveAttribute('data-background-opacity', '0.4');
expect(section).toHaveAttribute('data-background-transition', 'zoom');
expect(section).toHaveAttribute('data-background-video-loop', '');
});
it('maps visibility and auto-animate shorthand props to slide data attributes', () => {
const { container } = render(
<Slide
visibility="hidden"
autoAnimate
autoAnimateId="hero"
autoAnimateRestart
autoAnimateUnmatched={false}
autoAnimateEasing="ease-out"
autoAnimateDuration={0.6}
autoAnimateDelay={0.1}
>
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-visibility', 'hidden');
expect(section).toHaveAttribute('data-auto-animate', '');
expect(section).toHaveAttribute('data-auto-animate-id', 'hero');
expect(section).toHaveAttribute('data-auto-animate-restart', '');
expect(section).toHaveAttribute('data-auto-animate-unmatched', 'false');
expect(section).toHaveAttribute('data-auto-animate-easing', 'ease-out');
expect(section).toHaveAttribute('data-auto-animate-duration', '0.6');
expect(section).toHaveAttribute('data-auto-animate-delay', '0.1');
});
it('maps common Reveal slide shorthand props to data attributes', () => {
const { container } = render(
<Slide
transition="zoom-in fade-out"
transitionSpeed="fast"
autoSlide={1500}
notes="Speaker notes"
backgroundInteractive
preload
>
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-transition', 'zoom-in fade-out');
expect(section).toHaveAttribute('data-transition-speed', 'fast');
expect(section).toHaveAttribute('data-autoslide', '1500');
expect(section).toHaveAttribute('data-notes', 'Speaker notes');
expect(section).toHaveAttribute('data-background-interactive', '');
expect(section).toHaveAttribute('data-preload', '');
});
it('prefers explicit data-* attributes over shorthand props', () => {
const { container } = render(
<Slide
background="#111827"
backgroundColor="#000000"
autoAnimate
visibility="hidden"
transition="zoom"
notes="Shorthand notes"
preload
data-background="#222222"
data-background-color="#333333"
data-auto-animate=""
data-visibility="uncounted"
data-transition="fade"
data-notes="Raw notes"
data-preload=""
>
Content
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('data-background', '#222222');
expect(section).toHaveAttribute('data-background-color', '#333333');
expect(section).toHaveAttribute('data-auto-animate', '');
expect(section).toHaveAttribute('data-visibility', 'uncounted');
expect(section).toHaveAttribute('data-transition', 'fade');
expect(section).toHaveAttribute('data-notes', 'Raw notes');
expect(section).toHaveAttribute('data-preload', '');
});
it('applies className and style', () => {
const { container } = render(
<Slide className="intro" style={{ fontSize: '20px' }}>
Styled
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveClass('intro');
expect(section).toHaveStyle({ fontSize: '20px' });
});
it('passes id and aria attributes', () => {
const { container } = render(
<Slide id="slide-1" aria-label="Introduction">
Accessible
</Slide>
);
const section = container.querySelector('section');
expect(section).toHaveAttribute('id', 'slide-1');
expect(section).toHaveAttribute('aria-label', 'Introduction');
});
it('renders children of any type', () => {
const { container } = render(
<Slide>
<h1>Title</h1>
<p>Paragraph</p>
</Slide>
);
const section = container.querySelector('section');
expect(section?.querySelector('h1')).toHaveTextContent('Title');
expect(section?.querySelector('p')).toHaveTextContent('Paragraph');
});
it('calls syncSlide when data-* attributes change after mount', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
const { container, rerender } = render(
<RevealContext.Provider value={deck}>
<Slide data-background-color="#111">Content</Slide>
</RevealContext.Provider>
);
const section = container.querySelector('section');
expect(section).toBeInTheDocument();
expect(deck.syncSlide).not.toHaveBeenCalled();
deck.syncSlide.mockClear();
rerender(
<RevealContext.Provider value={deck}>
<Slide data-background-color="#222">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).toHaveBeenCalledTimes(1);
expect(deck.syncSlide).toHaveBeenCalledWith(container.querySelector('section'));
});
it('calls syncSlide when shorthand background props change after mount', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
const { container, rerender } = render(
<RevealContext.Provider value={deck}>
<Slide backgroundColor="#111">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
deck.syncSlide.mockClear();
rerender(
<RevealContext.Provider value={deck}>
<Slide backgroundColor="#222">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).toHaveBeenCalledTimes(1);
expect(deck.syncSlide).toHaveBeenCalledWith(container.querySelector('section'));
});
it('calls syncSlide when shorthand Reveal slide props change after mount', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
const { container, rerender } = render(
<RevealContext.Provider value={deck}>
<Slide autoSlide={1000}>Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
deck.syncSlide.mockClear();
rerender(
<RevealContext.Provider value={deck}>
<Slide autoSlide={2000}>Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).toHaveBeenCalledTimes(1);
expect(deck.syncSlide).toHaveBeenCalledWith(container.querySelector('section'));
});
it('does not call syncSlide on first render even when data-* attributes are present', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
render(
<RevealContext.Provider value={deck}>
<Slide data-background="#111">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
});
it('does not call syncSlide for non-data prop updates', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
const { rerender } = render(
<RevealContext.Provider value={deck}>
<Slide data-background-color="#111" className="a">
Content
</Slide>
</RevealContext.Provider>
);
deck.syncSlide.mockClear();
rerender(
<RevealContext.Provider value={deck}>
<Slide data-background-color="#111" className="b">
Content
</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
});
it('does not call syncSlide on mount when no data-* attributes are present', () => {
const deck = {
syncSlide: vi.fn(),
} as any;
render(
<RevealContext.Provider value={deck}>
<Slide className="plain">Content</Slide>
</RevealContext.Provider>
);
expect(deck.syncSlide).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,47 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Stack } from '../Stack';
import { Slide } from '../Slide';
describe('Stack', () => {
it('renders as a <section> element', () => {
const { container } = render(
<Stack>
<Slide>A</Slide>
<Slide>B</Slide>
</Stack>
);
const sections = container.querySelectorAll('section');
expect(sections).toHaveLength(3); // 1 outer (Stack) + 2 inner (Slides)
});
it('creates nested section structure for vertical slides', () => {
const { container } = render(
<Stack>
<Slide>First</Slide>
<Slide>Second</Slide>
</Stack>
);
const outer = container.querySelector('section');
expect(outer).toBeInTheDocument();
const inner = outer?.querySelectorAll(':scope > section');
expect(inner).toHaveLength(2);
expect(inner?.[0]).toHaveTextContent('First');
expect(inner?.[1]).toHaveTextContent('Second');
});
it('applies className and style to the outer section', () => {
const { container } = render(
<Stack className="my-stack" style={{ padding: '10px' }}>
<Slide>Content</Slide>
</Stack>
);
const outer = container.querySelector('section');
expect(outer).toHaveClass('my-stack');
expect(outer).toHaveStyle({ padding: '10px' });
});
});

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

View File

@@ -0,0 +1,4 @@
import { createContext } from 'react';
import type { RevealApi } from 'reveal.js';
export const RevealContext = createContext<RevealApi | null>(null);

View File

@@ -0,0 +1,15 @@
import { useContext } from 'react';
import { RevealContext } from './context';
export { Deck } from './Deck';
export { Slide } from './Slide';
export { Stack } from './Stack';
export { Fragment } from './Fragment';
export { Code } from './Code';
export { RevealContext } from './context';
export type { DeckProps, SlideProps, StackProps, FragmentProps, CodeProps } from './types';
export function useReveal() {
return useContext(RevealContext);
}

View File

@@ -0,0 +1,127 @@
import type { CSSProperties, ReactNode, ElementType, Ref, ReactElement } from 'react';
import type {
FragmentAnimation,
RevealApi,
RevealConfig,
RevealPlugin,
RevealPluginFactory,
TransitionSpeed,
TransitionStyle,
} from 'reveal.js';
type DeckConfig = RevealConfig;
type DeckPlugin = RevealPlugin | RevealPluginFactory;
type RevealEventHandler = Parameters<RevealApi['on']>[1];
export type DeckProps = {
config?: Omit<DeckConfig, 'plugins'>;
/** Registered during deck initialization only. Subsequent prop updates are ignored. */
plugins?: DeckPlugin[];
onReady?: (deck: RevealApi) => void;
onSync?: RevealEventHandler;
onSlideSync?: RevealEventHandler;
onSlideChange?: RevealEventHandler;
onSlideTransitionEnd?: RevealEventHandler;
onFragmentShown?: RevealEventHandler;
onFragmentHidden?: RevealEventHandler;
onOverviewShown?: RevealEventHandler;
onOverviewHidden?: RevealEventHandler;
onPaused?: RevealEventHandler;
onResumed?: RevealEventHandler;
deckRef?: Ref<RevealApi | null>;
className?: string;
style?: CSSProperties;
children?: ReactNode;
};
export type SlideDataAttributeValue = string | number | boolean | undefined;
export type SlideDataAttributes = {
[key: `data-${string}`]: SlideDataAttributeValue;
};
export type SlideBackgroundProps = {
background?: string;
backgroundImage?: string;
backgroundVideo?: string;
backgroundVideoLoop?: boolean;
backgroundVideoMuted?: boolean;
backgroundIframe?: string;
backgroundColor?: string;
backgroundGradient?: string;
backgroundSize?: string;
backgroundPosition?: string;
backgroundRepeat?: string;
backgroundOpacity?: number | string;
backgroundTransition?: TransitionStyle;
};
export type SlideVisibility = 'hidden' | 'uncounted';
export type SlideAutoAnimateProps = {
visibility?: SlideVisibility;
autoAnimate?: boolean;
autoAnimateId?: string;
autoAnimateRestart?: boolean;
autoAnimateUnmatched?: boolean;
autoAnimateEasing?: string;
autoAnimateDuration?: number | string;
autoAnimateDelay?: number | string;
};
export type SlideRevealProps = {
transition?: string;
transitionSpeed?: TransitionSpeed;
autoSlide?: number | string;
notes?: string;
backgroundInteractive?: boolean;
preload?: boolean;
};
export type SlideProps = React.HTMLAttributes<HTMLElement> &
SlideDataAttributes &
SlideBackgroundProps &
SlideAutoAnimateProps &
SlideRevealProps & {
children?: ReactNode;
};
export type StackProps = {
className?: string;
style?: CSSProperties;
children?: ReactNode;
};
type FragmentBaseProps = {
animation?: FragmentAnimation;
className?: string;
style?: CSSProperties;
index?: number;
};
type FragmentElementProps = FragmentBaseProps & {
asChild?: false;
as?: ElementType;
children?: ReactNode;
};
type FragmentAsChildProps = FragmentBaseProps & {
asChild: true;
as?: never;
children: ReactElement;
};
export type FragmentProps = FragmentElementProps | FragmentAsChildProps;
export type CodeProps = Omit<React.HTMLAttributes<HTMLPreElement>, 'children'> & {
children?: string;
code?: string;
language?: string;
trim?: boolean;
lineNumbers?: boolean | string;
startFrom?: number;
noEscape?: boolean;
codeClassName?: string;
codeStyle?: CSSProperties;
codeProps?: Omit<React.HTMLAttributes<HTMLElement>, 'children' | 'className' | 'style'>;
};

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationDir": "dist",
"outDir": "dist",
"rootDir": "src",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,20 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [react(), dts({ include: ['src'], exclude: ['src/__tests__'] })],
build: {
outDir: resolve(__dirname, 'dist'),
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es'],
fileName: () => 'index.mjs',
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime', 'reveal.js'],
},
},
});

View File

@@ -0,0 +1,17 @@
import { resolve } from 'path';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'reveal.js': resolve(__dirname, '../js/index.ts'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/__tests__/setup.ts'],
},
});