| Marc Kupietz | 09b7575 | 2023-10-07 09:32:19 +0200 | [diff] [blame^] | 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 ) { | 
|  | 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 |  | 
|  | 261 | export default Plugin; |