You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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