plugin.js (6936B)
1 import speakerViewHTML from './speaker-view.html' 2 3 import { 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 */ 16 const 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 && 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' ); 155 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 183 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) {} 193 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 267 export default Plugin;