update packages and add valign
This commit is contained in:
69
scripts/reveal.js/react/AGENTS.md
Normal file
69
scripts/reveal.js/react/AGENTS.md
Normal 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`
|
||||
198
scripts/reveal.js/react/README.md
Normal file
198
scripts/reveal.js/react/README.md
Normal 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>
|
||||
12
scripts/reveal.js/react/demo/index.html
Normal file
12
scripts/reveal.js/react/demo/index.html
Normal 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>
|
||||
1790
scripts/reveal.js/react/demo/package-lock.json
generated
Normal file
1790
scripts/reveal.js/react/demo/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
scripts/reveal.js/react/demo/package.json
Normal file
16
scripts/reveal.js/react/demo/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
230
scripts/reveal.js/react/demo/src/Demo.tsx
Normal file
230
scripts/reveal.js/react/demo/src/Demo.tsx
Normal 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;
|
||||
9
scripts/reveal.js/react/demo/src/main.tsx
Normal file
9
scripts/reveal.js/react/demo/src/main.tsx
Normal 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>
|
||||
);
|
||||
16
scripts/reveal.js/react/demo/tsconfig.json
Normal file
16
scripts/reveal.js/react/demo/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
scripts/reveal.js/react/demo/vite.config.ts
Normal file
12
scripts/reveal.js/react/demo/vite.config.ts
Normal 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
367
scripts/reveal.js/react/dist/index.mjs
vendored
Normal 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
3982
scripts/reveal.js/react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
scripts/reveal.js/react/package.json
Normal file
57
scripts/reveal.js/react/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
138
scripts/reveal.js/react/src/Code.tsx
Normal file
138
scripts/reveal.js/react/src/Code.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
scripts/reveal.js/react/src/Deck.tsx
Normal file
256
scripts/reveal.js/react/src/Deck.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
scripts/reveal.js/react/src/Fragment.tsx
Normal file
76
scripts/reveal.js/react/src/Fragment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
scripts/reveal.js/react/src/Slide.tsx
Normal file
180
scripts/reveal.js/react/src/Slide.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
scripts/reveal.js/react/src/Stack.tsx
Normal file
9
scripts/reveal.js/react/src/Stack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
scripts/reveal.js/react/src/__tests__/Code.test.tsx
Normal file
142
scripts/reveal.js/react/src/__tests__/Code.test.tsx
Normal 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']);
|
||||
});
|
||||
});
|
||||
450
scripts/reveal.js/react/src/__tests__/Deck.test.tsx
Normal file
450
scripts/reveal.js/react/src/__tests__/Deck.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
122
scripts/reveal.js/react/src/__tests__/Fragment.test.tsx
Normal file
122
scripts/reveal.js/react/src/__tests__/Fragment.test.tsx
Normal 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.');
|
||||
});
|
||||
});
|
||||
296
scripts/reveal.js/react/src/__tests__/Slide.test.tsx
Normal file
296
scripts/reveal.js/react/src/__tests__/Slide.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
47
scripts/reveal.js/react/src/__tests__/Stack.test.tsx
Normal file
47
scripts/reveal.js/react/src/__tests__/Stack.test.tsx
Normal 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' });
|
||||
});
|
||||
});
|
||||
1
scripts/reveal.js/react/src/__tests__/setup.ts
Normal file
1
scripts/reveal.js/react/src/__tests__/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
4
scripts/reveal.js/react/src/context.ts
Normal file
4
scripts/reveal.js/react/src/context.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
import type { RevealApi } from 'reveal.js';
|
||||
|
||||
export const RevealContext = createContext<RevealApi | null>(null);
|
||||
15
scripts/reveal.js/react/src/index.ts
Normal file
15
scripts/reveal.js/react/src/index.ts
Normal 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);
|
||||
}
|
||||
127
scripts/reveal.js/react/src/types.ts
Normal file
127
scripts/reveal.js/react/src/types.ts
Normal 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'>;
|
||||
};
|
||||
17
scripts/reveal.js/react/tsconfig.json
Normal file
17
scripts/reveal.js/react/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
scripts/reveal.js/react/vite.config.ts
Normal file
20
scripts/reveal.js/react/vite.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
17
scripts/reveal.js/react/vitest.config.ts
Normal file
17
scripts/reveal.js/react/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user