blob: ecbf4ade9779d9941608d2bd5db1b84754c27ee6 [file] [log] [blame]
Marc Kupietz09b75752023-10-07 09:32:19 +02001import speakerViewHTML from './speaker-view.html'
2
3import { marked } from 'marked';
4
5/**
6 * Handles opening of and synchronization with the reveal.js
7 * notes window.
8 *
9 * Handshake process:
10 * 1. This window posts 'connect' to notes window
11 * - Includes URL of presentation to show
12 * 2. Notes window responds with 'connected' when it is available
13 * 3. This window proceeds to send the current presentation state
14 * to the notes window
15 */
16const Plugin = () => {
17
18 let connectInterval;
19 let speakerWindow = null;
20 let deck;
21
22 /**
23 * Opens a new speaker view window.
24 */
25 function openSpeakerWindow() {
26
27 // If a window is already open, focus it
28 if( speakerWindow && !speakerWindow.closed ) {
29 speakerWindow.focus();
30 }
31 else {
32 speakerWindow = window.open( 'about:blank', 'reveal.js - Notes', 'width=1100,height=700' );
33 speakerWindow.marked = marked;
34 speakerWindow.document.write( speakerViewHTML );
35
36 if( !speakerWindow ) {
37 alert( 'Speaker view popup failed to open. Please make sure popups are allowed and reopen the speaker view.' );
38 return;
39 }
40
41 connect();
42 }
43
44 }
45
46 /**
47 * Reconnect with an existing speaker view window.
48 */
49 function reconnectSpeakerWindow( reconnectWindow ) {
50
51 if( speakerWindow && !speakerWindow.closed ) {
52 speakerWindow.focus();
53 }
54 else {
55 speakerWindow = reconnectWindow;
56 window.addEventListener( 'message', onPostMessage );
57 onConnected();
58 }
59
60 }
61
62 /**
63 * Connect to the notes window through a postmessage handshake.
64 * Using postmessage enables us to work in situations where the
65 * origins differ, such as a presentation being opened from the
66 * file system.
67 */
68 function connect() {
69
70 const presentationURL = deck.getConfig().url;
71
72 const url = typeof presentationURL === 'string' ? presentationURL :
73 window.location.protocol + '//' + window.location.host + window.location.pathname + window.location.search;
74
75 // Keep trying to connect until we get a 'connected' message back
76 connectInterval = setInterval( function() {
77 speakerWindow.postMessage( JSON.stringify( {
78 namespace: 'reveal-notes',
79 type: 'connect',
80 state: deck.getState(),
81 url
82 } ), '*' );
83 }, 500 );
84
85 window.addEventListener( 'message', onPostMessage );
86
87 }
88
89 /**
90 * Calls the specified Reveal.js method with the provided argument
91 * and then pushes the result to the notes frame.
92 */
93 function callRevealApi( methodName, methodArguments, callId ) {
94
95 let result = deck[methodName].apply( deck, methodArguments );
96 speakerWindow.postMessage( JSON.stringify( {
97 namespace: 'reveal-notes',
98 type: 'return',
99 result,
100 callId
101 } ), '*' );
102
103 }
104
105 /**
106 * Posts the current slide data to the notes window.
107 */
108 function post( event ) {
109
110 let slideElement = deck.getCurrentSlide(),
111 notesElements = slideElement.querySelectorAll( 'aside.notes' ),
112 fragmentElement = slideElement.querySelector( '.current-fragment' );
113
114 let messageData = {
115 namespace: 'reveal-notes',
116 type: 'state',
117 notes: '',
118 markdown: false,
119 whitespace: 'normal',
120 state: deck.getState()
121 };
122
123 // Look for notes defined in a slide attribute
124 if( slideElement.hasAttribute( 'data-notes' ) ) {
125 messageData.notes = slideElement.getAttribute( 'data-notes' );
126 messageData.whitespace = 'pre-wrap';
127 }
128
129 // Look for notes defined in a fragment
130 if( fragmentElement ) {
131 let fragmentNotes = fragmentElement.querySelector( 'aside.notes' );
132 if( fragmentNotes ) {
133 messageData.notes = fragmentNotes.innerHTML;
134 messageData.markdown = typeof fragmentNotes.getAttribute( 'data-markdown' ) === 'string';
135
136 // Ignore other slide notes
137 notesElements = null;
138 }
139 else if( fragmentElement.hasAttribute( 'data-notes' ) ) {
140 messageData.notes = fragmentElement.getAttribute( 'data-notes' );
141 messageData.whitespace = 'pre-wrap';
142
143 // In case there are slide notes
144 notesElements = null;
145 }
146 }
147
148 // Look for notes defined in an aside element
149 if( notesElements ) {
150 messageData.notes = Array.from(notesElements).map( notesElement => notesElement.innerHTML ).join( '\n' );
151 messageData.markdown = notesElements[0] && typeof notesElements[0].getAttribute( 'data-markdown' ) === 'string';
152 }
153
154 speakerWindow.postMessage( JSON.stringify( messageData ), '*' );
155
156 }
157
158 /**
159 * Check if the given event is from the same origin as the
160 * current window.
161 */
162 function isSameOriginEvent( event ) {
163
164 try {
165 return window.location.origin === event.source.location.origin;
166 }
167 catch ( error ) {
168 return false;
169 }
170
171 }
172
173 function onPostMessage( event ) {
174
175 // Only allow same-origin messages
176 // (added 12/5/22 as a XSS safeguard)
177 if( isSameOriginEvent( event ) ) {
178
179 let data = JSON.parse( event.data );
180 if( data && data.namespace === 'reveal-notes' && data.type === 'connected' ) {
181 clearInterval( connectInterval );
182 onConnected();
183 }
184 else if( data && data.namespace === 'reveal-notes' && data.type === 'call' ) {
185 callRevealApi( data.methodName, data.arguments, data.callId );
186 }
187
188 }
189
190 }
191
192 /**
193 * Called once we have established a connection to the notes
194 * window.
195 */
196 function onConnected() {
197
198 // Monitor events that trigger a change in state
199 deck.on( 'slidechanged', post );
200 deck.on( 'fragmentshown', post );
201 deck.on( 'fragmenthidden', post );
202 deck.on( 'overviewhidden', post );
203 deck.on( 'overviewshown', post );
204 deck.on( 'paused', post );
205 deck.on( 'resumed', post );
206
207 // Post the initial state
208 post();
209
210 }
211
212 return {
213 id: 'notes',
214
215 init: function( reveal ) {
216
217 deck = reveal;
218
219 if( !/receiver/i.test( window.location.search ) ) {
220
221 // If the there's a 'notes' query set, open directly
222 if( window.location.search.match( /(\?|\&)notes/gi ) !== null ) {
223 openSpeakerWindow();
224 }
225 else {
226 // Keep listening for speaker view hearbeats. If we receive a
227 // heartbeat from an orphaned window, reconnect it. This ensures
228 // that we remain connected to the notes even if the presentation
229 // is reloaded.
230 window.addEventListener( 'message', event => {
231
232 if( !speakerWindow && typeof event.data === 'string' ) {
233 let data;
234
235 try {
236 data = JSON.parse( event.data );
237 }
238 catch( error ) {}
239
240 if( data && data.namespace === 'reveal-notes' && data.type === 'heartbeat' ) {
241 reconnectSpeakerWindow( event.source );
242 }
243 }
244 });
245 }
246
247 // Open the notes when the 's' key is hit
248 deck.addKeyBinding({keyCode: 83, key: 'S', description: 'Speaker notes view'}, function() {
249 openSpeakerWindow();
250 } );
251
252 }
253
254 },
255
256 open: openSpeakerWindow
257 };
258
259};
260
261export default Plugin;