import speakerViewHTML from "./speaker-view.html"; import { marked } from "marked"; /** * Handles opening of and synchronization with the reveal.js * notes window. * * Handshake process: * 1. This window posts 'connect' to notes window * - Includes URL of presentation to show * 2. Notes window responds with 'connected' when it is available * 3. This window proceeds to send the current presentation state * to the notes window */ const Plugin = () => { let connectInterval; let speakerWindow = null; let deck; /** * Opens a new speaker view window. */ function openSpeakerWindow() { // If a window is already open, focus it if (speakerWindow && !speakerWindow.closed) { speakerWindow.focus(); } else { speakerWindow = window.open( "about:blank", "reveal.js - Notes", "width=1100,height=700" ); speakerWindow.marked = marked; speakerWindow.document.write(speakerViewHTML); if (!speakerWindow) { alert( "Speaker view popup failed to open. Please make sure popups are allowed and reopen the speaker view." ); return; } connect(); } } /** * Reconnect with an existing speaker view window. */ function reconnectSpeakerWindow(reconnectWindow) { if (speakerWindow && !speakerWindow.closed) { speakerWindow.focus(); } else { speakerWindow = reconnectWindow; window.addEventListener("message", onPostMessage); onConnected(); } } /** * Connect to the notes window through a postmessage handshake. * Using postmessage enables us to work in situations where the * origins differ, such as a presentation being opened from the * file system. */ function connect() { const presentationURL = deck.getConfig().url; const url = typeof presentationURL === "string" ? presentationURL : window.location.protocol + "//" + window.location.host + window.location.pathname + window.location.search; // Keep trying to connect until we get a 'connected' message back connectInterval = setInterval(function () { speakerWindow.postMessage( JSON.stringify({ namespace: "reveal-notes", type: "connect", state: deck.getState(), url, }), "*" ); }, 500); window.addEventListener("message", onPostMessage); } /** * Calls the specified Reveal.js method with the provided argument * and then pushes the result to the notes frame. */ function callRevealApi(methodName, methodArguments, callId) { let result = deck[methodName].apply(deck, methodArguments); speakerWindow.postMessage( JSON.stringify({ namespace: "reveal-notes", type: "return", result, callId, }), "*" ); } /** * Posts the current slide data to the notes window. */ function post(event) { let slideElement = deck.getCurrentSlide(), notesElements = slideElement.querySelectorAll("aside.notes"), fragmentElement = slideElement.querySelector(".current-fragment"); let messageData = { namespace: "reveal-notes", type: "state", notes: "", markdown: false, whitespace: "normal", state: deck.getState(), }; // Look for notes defined in a slide attribute if (slideElement.hasAttribute("data-notes")) { messageData.notes = slideElement.getAttribute("data-notes"); messageData.whitespace = "pre-wrap"; } // Look for notes defined in a fragment if (fragmentElement) { let fragmentNotes = fragmentElement.querySelector("aside.notes"); if (fragmentNotes) { messageData.notes = fragmentNotes.innerHTML; messageData.markdown = typeof fragmentNotes.getAttribute("data-markdown") === "string"; // Ignore other slide notes notesElements = null; } else if (fragmentElement.hasAttribute("data-notes")) { messageData.notes = fragmentElement.getAttribute("data-notes"); messageData.whitespace = "pre-wrap"; // In case there are slide notes notesElements = null; } } // Look for notes defined in an aside element if (notesElements) { messageData.notes = Array.from(notesElements) .map((notesElement) => notesElement.innerHTML) .join("\n"); messageData.markdown = notesElements[0] && typeof notesElements[0].getAttribute("data-markdown") === "string"; } speakerWindow.postMessage(JSON.stringify(messageData), "*"); } /** * Check if the given event is from the same origin as the * current window. */ function isSameOriginEvent(event) { try { return window.location.origin === event.source.location.origin; } catch (error) { return false; } } function onPostMessage(event) { // Only allow same-origin messages // (added 12/5/22 as a XSS safeguard) if (isSameOriginEvent(event)) { let data = JSON.parse(event.data); if ( data && data.namespace === "reveal-notes" && data.type === "connected" ) { clearInterval(connectInterval); onConnected(); } else if ( data && data.namespace === "reveal-notes" && data.type === "call" ) { callRevealApi(data.methodName, data.arguments, data.callId); } } } /** * Called once we have established a connection to the notes * window. */ function onConnected() { // Monitor events that trigger a change in state deck.on("slidechanged", post); deck.on("fragmentshown", post); deck.on("fragmenthidden", post); deck.on("overviewhidden", post); deck.on("overviewshown", post); deck.on("paused", post); deck.on("resumed", post); // Post the initial state post(); } return { id: "notes", init: function (reveal) { deck = reveal; if (!/receiver/i.test(window.location.search)) { // If the there's a 'notes' query set, open directly if (window.location.search.match(/(\?|\&)notes/gi) !== null) { openSpeakerWindow(); } else { // Keep listening for speaker view hearbeats. If we receive a // heartbeat from an orphaned window, reconnect it. This ensures // that we remain connected to the notes even if the presentation // is reloaded. window.addEventListener("message", (event) => { if (!speakerWindow && typeof event.data === "string") { let data; try { data = JSON.parse(event.data); } catch (error) {} if ( data && data.namespace === "reveal-notes" && data.type === "heartbeat" ) { reconnectSpeakerWindow(event.source); } } }); } // Open the notes when the 's' key is hit deck.addKeyBinding( { keyCode: 83, key: "S", description: "Speaker notes view" }, function () { openSpeakerWindow(); } ); } }, open: openSpeakerWindow, }; }; export default Plugin;