返回 reveal.js
deck.tsx
根目录 / react / src / components / deck.tsx
1 import { useEffect, useLayoutEffect, useRef, useState, type Ref, type RefObject } from 'react';
2 import Reveal from 'reveal.js';
3 import type { RevealApi } from 'reveal.js';
4 import { RevealContext } from '../reveal-context';
5 import type { DeckProps } from '../types';
6
7 const DEFAULT_PLUGINS: NonNullable<DeckProps['plugins']> = [];
8 type DeckEventHandler = NonNullable<DeckProps['onSync']>;
9 type SlideStructureNode = number | [number, SlideStructureNode[]];
10 type CurrentRef<T> = { current: T };
11
12 // Shallow-compare config objects so that re-renders where the parent creates a new object
13 // literal with identical values do not trigger an unnecessary configure() call.
14 function hasShallowConfigChanges(prev: DeckProps['config'], next: DeckProps['config']) {
15 if (prev === next) return false;
16 if (!prev || !next) return prev !== next;
17
18 const prevKeys = Object.keys(prev);
19 const nextKeys = Object.keys(next);
20
21 if (prevKeys.length !== nextKeys.length) return true;
22
23 for (const key of prevKeys) {
24 if (!(key in next)) return true;
25 if ((prev as Record<string, unknown>)[key] !== (next as Record<string, unknown>)[key]) {
26 return true;
27 }
28 }
29
30 return false;
31 }
32
33 function setRef<T>(ref: Ref<T | null> | undefined, value: T | null) {
34 if (!ref) return;
35 if (typeof ref === 'function') {
36 ref(value);
37 } else {
38 (ref as RefObject<T | null>).current = value;
39 }
40 }
41
42 function isSectionElement(element: Element): element is HTMLElement {
43 return element.tagName === 'SECTION';
44 }
45
46 function getSectionStructure(
47 container: Element,
48 slideIds: WeakMap<HTMLElement, number>,
49 nextSlideIdRef: CurrentRef<number>
50 ): SlideStructureNode[] {
51 return Array.from(container.children)
52 .filter(isSectionElement)
53 .map((section) => {
54 let id = slideIds.get(section);
55 if (id === undefined) {
56 id = nextSlideIdRef.current++;
57 slideIds.set(section, id);
58 }
59
60 const childSlides = getSectionStructure(section, slideIds, nextSlideIdRef);
61 return childSlides.length > 0 ? [id, childSlides] : id;
62 });
63 }
64
65 function getSlidesStructureSignature(
66 slidesElement: HTMLElement | null,
67 slideIds: WeakMap<HTMLElement, number>,
68 nextSlideIdRef: CurrentRef<number>
69 ) {
70 if (!slidesElement) return '[]';
71 return JSON.stringify(getSectionStructure(slidesElement, slideIds, nextSlideIdRef));
72 }
73
74 export function Deck({
75 config,
76 plugins = DEFAULT_PLUGINS,
77 onReady,
78 onSync,
79 onSlideSync,
80 onSlideChange,
81 onSlideTransitionEnd,
82 onFragmentShown,
83 onFragmentHidden,
84 onOverviewShown,
85 onOverviewHidden,
86 onPaused,
87 onResumed,
88 deckRef,
89 className,
90 style,
91 children,
92 }: DeckProps) {
93 const deckDivRef = useRef<HTMLDivElement>(null);
94 const slidesDivRef = useRef<HTMLDivElement>(null);
95 const revealRef = useRef<RevealApi | null>(null);
96 const [deck, setDeck] = useState<RevealApi | null>(null);
97
98 // Plugins are init-only in reveal.js; we register them once when creating the instance.
99 const initialPluginsRef = useRef<NonNullable<DeckProps['plugins']>>(plugins);
100
101 // configure() performs its own sync in Reveal; this flag prevents us from running an
102 // immediate second sync in the next layout effect pass.
103 const skipNextSyncRef = useRef(false);
104
105 // Track the last config reference we applied so we can skip redundant configure() calls.
106 const appliedConfigRef = useRef<DeckProps['config']>(config);
107 const lastSyncedSlidesSignatureRef = useRef<string | null>(null);
108 const slideIdsRef = useRef(new WeakMap<HTMLElement, number>());
109 const nextSlideIdRef = useRef(1);
110 const mountedRef = useRef(false);
111 const teardownRequestRef = useRef(0);
112
113 // Create the Reveal instance once on mount and destroy it on unmount.
114 useEffect(() => {
115 mountedRef.current = true;
116 teardownRequestRef.current += 1;
117
118 if (!revealRef.current) {
119 const instance = new Reveal(deckDivRef.current!, {
120 ...config,
121 plugins: initialPluginsRef.current,
122 });
123 // Capture the config that was passed to the constructor so the configure
124 // effect can later detect whether anything actually changed.
125 appliedConfigRef.current = config;
126 revealRef.current = instance;
127
128 instance.initialize().then(() => {
129 if (!mountedRef.current || revealRef.current !== instance) return;
130 setDeck(instance);
131 onReady?.(instance);
132 });
133 } else if (revealRef.current.isReady()) {
134 // React StrictMode unmounts and remounts every effect. On the second mount
135 // the instance is already live, so skip construction. The isReady() guard
136 // ensures we only expose it once initialization has fully completed.
137 setDeck(revealRef.current);
138 }
139
140 return () => {
141 mountedRef.current = false;
142 const instance = revealRef.current;
143 if (!instance) return;
144
145 // Defer teardown to the next microtask. In StrictMode the component
146 // remounts immediately, incrementing teardownRequestRef before the
147 // microtask runs. The stale request number causes the callback to bail
148 // out, preventing the instance from being destroyed on a live component.
149 const teardownRequest = ++teardownRequestRef.current;
150 Promise.resolve().then(() => {
151 if (mountedRef.current || teardownRequestRef.current !== teardownRequest) return;
152 if (revealRef.current !== instance) return;
153
154 try {
155 instance.destroy();
156 } catch (e) {
157 // Ignore errors during cleanup
158 }
159
160 if (revealRef.current === instance) {
161 revealRef.current = null;
162 }
163 });
164 };
165 }, []); // eslint-disable-line react-hooks/exhaustive-deps
166
167 // Keep consumer refs in sync, including when the ref prop itself changes.
168 useEffect(() => {
169 setRef(deckRef, deck);
170 return () => setRef(deckRef, null);
171 }, [deckRef, deck]);
172
173 // Attach and detach Reveal event listeners from the provided callbacks.
174 useEffect(() => {
175 if (!deck) return;
176
177 const events: [string, DeckEventHandler | undefined][] = [
178 ['sync', onSync],
179 ['slidesync', onSlideSync],
180 ['slidechanged', onSlideChange],
181 ['slidetransitionend', onSlideTransitionEnd],
182 ['fragmentshown', onFragmentShown],
183 ['fragmenthidden', onFragmentHidden],
184 ['overviewshown', onOverviewShown],
185 ['overviewhidden', onOverviewHidden],
186 ['paused', onPaused],
187 ['resumed', onResumed],
188 ];
189
190 const bound = events.filter((e): e is [string, DeckEventHandler] => e[1] != null);
191 for (const [name, handler] of bound) {
192 deck.on(name, handler);
193 }
194
195 return () => {
196 for (const [name, handler] of bound) {
197 deck.off(name, handler);
198 }
199 };
200 }, [
201 deck,
202 onSync,
203 onSlideSync,
204 onSlideChange,
205 onSlideTransitionEnd,
206 onFragmentShown,
207 onFragmentHidden,
208 onOverviewShown,
209 onOverviewHidden,
210 onPaused,
211 onResumed,
212 ]);
213
214 // Re-apply config after init and mark that configure already performed a sync.
215 useLayoutEffect(() => {
216 if (!deck || !revealRef.current?.isReady()) return;
217 if (!hasShallowConfigChanges(appliedConfigRef.current, config)) return;
218
219 skipNextSyncRef.current = true;
220 revealRef.current.configure(config ?? {});
221 appliedConfigRef.current = config;
222 }, [deck, config]);
223
224 // Sync Reveal's internal slide bookkeeping only when the rendered slide
225 // structure changes. Avoid triggering sync for child changes.
226 useLayoutEffect(() => {
227 const shouldSkip = skipNextSyncRef.current;
228 skipNextSyncRef.current = false;
229 const slidesStructureSignature = getSlidesStructureSignature(
230 slidesDivRef.current,
231 slideIdsRef.current,
232 nextSlideIdRef
233 );
234
235 if (shouldSkip) {
236 lastSyncedSlidesSignatureRef.current = slidesStructureSignature;
237 return;
238 }
239
240 if (!revealRef.current?.isReady()) return;
241 if (lastSyncedSlidesSignatureRef.current === slidesStructureSignature) return;
242
243 revealRef.current.sync();
244 lastSyncedSlidesSignatureRef.current = slidesStructureSignature;
245 });
246
247 return (
248 <RevealContext.Provider value={deck}>
249 <div className={className ? `reveal ${className}` : 'reveal'} style={style} ref={deckDivRef}>
250 <div className="slides" ref={slidesDivRef}>
251 {children}
252 </div>
253 </div>
254 </RevealContext.Provider>
255 );
256 }
257
257 lines Plain Text