hakimel/reveal.js

View on GitHub
plugin/notes/plugin.js

Summary

Maintainability
D
1 day
Test Coverage
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 && notesElements.length ) {
            // Ignore notes inside of fragments since those are shown
            // individually when stepping through fragments
            notesElements = Array.from( notesElements ).filter( notesElement => notesElement.closest( '.fragment' ) === null );

            messageData.notes = 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 ) ) {

            try {
                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 );
                }
          } catch (e) {}

        }

    }

    /**
     * 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;