blob: 62649c2fda3fd75d1cff914dfd683126628d3c02 [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
Marc Kupietz9c036a42024-05-14 13:17:25 +0200149 if( notesElements && notesElements.length ) {
150 // Ignore notes inside of fragments since those are shown
151 // individually when stepping through fragments
152 notesElements = Array.from( notesElements ).filter( notesElement => notesElement.closest( '.fragment' ) === null );
153
154 messageData.notes = notesElements.map( notesElement => notesElement.innerHTML ).join( '\n' );
Marc Kupietz09b75752023-10-07 09:32:19 +0200155 messageData.markdown = notesElements[0] && typeof notesElements[0].getAttribute( 'data-markdown' ) === 'string';
156 }
157
158 speakerWindow.postMessage( JSON.stringify( messageData ), '*' );
159
160 }
161
162 /**
163 * Check if the given event is from the same origin as the
164 * current window.
165 */
166 function isSameOriginEvent( event ) {
167
168 try {
169 return window.location.origin === event.source.location.origin;
170 }
171 catch ( error ) {
172 return false;
173 }
174
175 }
176
177 function onPostMessage( event ) {
178
179 // Only allow same-origin messages
180 // (added 12/5/22 as a XSS safeguard)
181 if( isSameOriginEvent( event ) ) {
182
Marc Kupietz9c036a42024-05-14 13:17:25 +0200183 try {
184 let data = JSON.parse( event.data );
185 if( data && data.namespace === 'reveal-notes' && data.type === 'connected' ) {
186 clearInterval( connectInterval );
187 onConnected();
188 }
189 else if( data && data.namespace === 'reveal-notes' && data.type === 'call' ) {
190 callRevealApi( data.methodName, data.arguments, data.callId );
191 }
192 } catch (e) {}
Marc Kupietz09b75752023-10-07 09:32:19 +0200193
194 }
195
196 }
197
198 /**
199 * Called once we have established a connection to the notes
200 * window.
201 */
202 function onConnected() {
203
204 // Monitor events that trigger a change in state
205 deck.on( 'slidechanged', post );
206 deck.on( 'fragmentshown', post );
207 deck.on( 'fragmenthidden', post );
208 deck.on( 'overviewhidden', post );
209 deck.on( 'overviewshown', post );
210 deck.on( 'paused', post );
211 deck.on( 'resumed', post );
212
213 // Post the initial state
214 post();
215
216 }
217
218 return {
219 id: 'notes',
220
221 init: function( reveal ) {
222
223 deck = reveal;
224
225 if( !/receiver/i.test( window.location.search ) ) {
226
227 // If the there's a 'notes' query set, open directly
228 if( window.location.search.match( /(\?|\&)notes/gi ) !== null ) {
229 openSpeakerWindow();
230 }
231 else {
232 // Keep listening for speaker view hearbeats. If we receive a
233 // heartbeat from an orphaned window, reconnect it. This ensures
234 // that we remain connected to the notes even if the presentation
235 // is reloaded.
236 window.addEventListener( 'message', event => {
237
238 if( !speakerWindow && typeof event.data === 'string' ) {
239 let data;
240
241 try {
242 data = JSON.parse( event.data );
243 }
244 catch( error ) {}
245
246 if( data && data.namespace === 'reveal-notes' && data.type === 'heartbeat' ) {
247 reconnectSpeakerWindow( event.source );
248 }
249 }
250 });
251 }
252
253 // Open the notes when the 's' key is hit
254 deck.addKeyBinding({keyCode: 83, key: 'S', description: 'Speaker notes view'}, function() {
255 openSpeakerWindow();
256 } );
257
258 }
259
260 },
261
262 open: openSpeakerWindow
263 };
264
265};
266
267export default Plugin;