loading...
Cover image for JSON::Presenter - a JavaScript presentation engine

JSON::Presenter - a JavaScript presentation engine

gtanyware profile image Graham Trott Updated on ・8 min read

In the first half of this article I described the format of a browser presentation language based on JSON. Here is a JavaScript engine with which to run presentations coded in that language.

The JSON::Presenter engine runs in 2 modes:

  • Stand-alone mode, where the engine takes over the page and runs the presentation by itself, and
  • Embedded mode, where the engine is called from some other part of the page to run a script provided by the page code.

In stand-alone mode the HTML looks like this:

<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>

  <body>

    <div id="jp-container" style="text-align:center;width:100%">
      <b>JSON::Presenter</b><br>
      Click/Tap or key Space/RightArrow to start in manual mode<br>
      Click/Tap or key Space/RightArrow to advance<br>
      Key Enter to start in auto mode<br>
      Click/Tap to exit auto mode
</div>
  <pre id="jp-script" style="display:none">https://cdn.jsdelivr.net/gh/easycoder/json-presenter/demo.json</pre>
    <script src="https://cdn.jsdelivr.net/gh/easycoder/json-presenter/jsonPresenter.js?v=1.0.0"></script>

  </body>
</html>

The <body> contains only 3 items:

  1. The container in which the presentation will run, with a special ID required by the presentation engine. Here I preload it with a helpful message.
  2. A preformatted block containing the URL of the presentation script (usually somewhere on your own server).
  3. The presentation engine JavaScript, from the GitHub CDN.

If the script URL is missing the engine will go into embedded mode and wait to be handed a script from somewhere else in the page code.

The step sequencer

The presentation engine is a simple step sequencer. This is the main step runner function:

    // Process a single step
    const doStep = () => {
        if (stepno < script.steps.length) {
            step = script.steps[stepno++];
            while (!step.action) {
                if (step.speed) {
                    speed = step.speed;
                }
                else throw Error(`Unknown syntax: '${JSON.stringify(step, 0, 2)}'`);
                step = script.steps[stepno++];
            }
            if (step.comment) {
                console.log(`Step ${stepno}: ${step.comment}`);
            } else {
                console.log(`Step ${stepno}: ${step.action}`);
            }
            switch (step.action) {
                case `set content`:
                    doSetContent();
                    break;
                case `show`:
                    doShowHide(true);
                    break;
                case `hide`:
                    doShowHide(false);
                    break;
                case `pause`:
                    doPause();
                    break;
                case `hold`:
                    doHold();
                    break;
                case `fade up`:
                    doFade(true);
                    break;
                case `fade down`:
                    doFade(false);
                    break;
                case `crossfade`:
                    doCrossfade();
                    break;
                case`transition`:
                    doTransition();
                    break;
                default:
                    throw Error(`Unknown action: '${step.action}'`);
            }
        }
        else {
            console.log(`Step ${stepno}: Finished`);  
        }
    };

The step types handled are in the switch list, and others will no doubt be added to this list as development continues. Each step type has its own handler function, making it easy to extend the language.

The full listing is available at the JSON::Presenter repository so I will just reproduce here a few of the step handlers. The first one is to create a text block:

    // Create a text block.
    const createTextBlock = (block) => {
        const container = block.container;
        if (block.element) {
            container.removeChild(block.element);
        }
        const w = container.getBoundingClientRect().width / 1000;
        const h = container.getBoundingClientRect().height / 1000;
        const properties = block.properties;
        const element = document.createElement(`div`);
        block.element = element;
        element.style[`position`] = `absolute`;
        element.style[`opacity`] = `0.0`;
        element.style[`left`] = properties.blockLeft * w;
        element.style[`top`] = properties.blockTop * h;
        element.style[`width`] = `${properties.blockWidth * w}px`;
        element.style[`height`] = `${properties.blockHeight * h}px`;
        element.style[`background`] = properties.blockBackground;
        element.style[`border`] = properties.blockBorder;
        container.appendChild(element);
        const marginLeft = properties.textMarginLeft * w;
        const marginTop = properties.textMarginTop * h;
        const inner = document.createElement(`div`);
        inner.style[`position`] = `absolute`;
        inner.style[`left`] = marginLeft;
        inner.style[`top`] = marginTop;
        inner.style[`width`] = `calc(100% - ${marginLeft}px - ${marginLeft}px)`;
        element.appendChild(inner);
        element.inner = inner;
        const text = document.createElement(`div`);
        text.style[`font-family`] = properties.fontFamily;
        text.style[`font-size`] = `${properties.fontSize * h}px`;
        text.style[`font-weight`] = properties.fontWeight;
        text.style[`font-style`] = properties.fontStyle;
        text.style[`color`] = properties.fontColor;
        text.style[`text-align`] = properties.textAlign;
        inner.appendChild(text);
        inner.text = text;
    };

A text block is a nested set of 3 divs. The outer one (element) has a position, size, border and background. The middle one (inner) has margins and the innermost one (text) contains the text itself so it has font attributes.

Each of the attributes is either as given in the specification of the block or as computed from the value given in relation to the width or height of the presentation container.

Image blocks are simpler, with just a single div. The image itself is set as the background property.

The simplest of the step handlers shows or hides blocks:

    // Show or hide a block
    const doShowHide = (showHide) => {
        if (Array.isArray(step.blocks)) {
            for (const block of step.blocks)
            {
                script.blocks[block].element.style[`opacity`] = showHide ? `1.0` : `0.0`;
            }
        } else {
            script.blocks[step.blocks].element.style[`opacity`] = showHide ? `1.0` : `0.0`;
        }
    };

This uses opacity rather than visibility. All blocks are potentially visible; it's just the value of opacity that determines if they can actually be seen. As you can see from the code, the handler checks if a single block or an array of blocks has been given and acts accordingly.

The next simplest is fade up and down:

    // Fade up or down
    const doFade = (upDown) => {
        const animSteps = Math.round(step.duration * 25);
        let animStep = 0;
        const interval = setInterval(() => {
            if (animStep < animSteps) {
                const ratio =  0.5 - Math.cos(Math.PI * animStep / animSteps) / 2;
                if (Array.isArray(step.blocks)) {
                    let blocks = step.blocks.length;
                    for (const block of step.blocks)
                    {
                        const element = script.blocks[block].element;
                        element.style[`opacity`] = upDown ? ratio : 1.0 - ratio;
                    }
                } else {
                    const block = script.blocks[step.blocks];
                    if (!block.element) {
                        clearInterval(interval);
                        throw Error(`I can't fade up a block with no content`);
                    }
                    block.element.style[`opacity`] = upDown ? ratio : 1.0 - ratio;
                }
                animStep++;
            } else {
                clearInterval(interval);
                if (!step.continue) {
                    JSON_Presenter(container, script, stepno);
                }
            }
        }, speed === `normal` ? 40 : 0);
        if (continueFlag) {
            doStep();
        }
    };

There's a mathematical expression involving a cosine; this does roughly what swing does in JQuery, making the animation start gently, accelerate then decelerate towards the end.

If the current step has the continue property the next step will start immediately; if it is not present the fade will complete before the next step is started. This allows you to run multiple effects at the same time, such as a crossfade where one block fades down and another fades up. There is also a separate in-place crossfade action that leaves the end result in the original block, which is more useful if you are just substituting content.

Initialization

To initialize the system before running the first step we have this function:

    // Initialization
    const init = () => {
        container.innerHTML = ``;
        document.removeEventListener(`click`, init);
        document.onkeydown = null;
        if (script.title) {
            document.title = script.title;
        }
        const height = Math.round(parseFloat(container.offsetWidth) * script.aspectH / script.aspectW);
        container.style.height = `${Math.round(height)}px`;
        container.style.position = `relative`;
        container.style.overflow = `hidden`;
        container.style.cursor = 'none';
        container.style[`background-size`] = `cover`;
        for (const property of containerStyles) {
            if (typeof script.container[property] !== 'undefined') {
                container.style[property] = script.container[property];
            }
        }
        initBlocks(container, script.blocks, script.defaults);
        preloadImages(script.content);
        stepno = 0;
        doStep();
    }

The code sets up the container, computing its height from the aspectW and aspectH properties of the script and setting up a predefined set of CSS properties if they have been provided in the script. Next it calls functions to initialize all the blocks, setting all their properties to default values, and to preload the images so the presentation doesn't stutter. Finally it calls the step runner.

Startup

So far everything has been functions, none of which have yet been called. This will happen when the user clicks, taps or presses a key while the system is displaying the startup screen. Here is the code that runs in response to that event.

    // Wait for a click/tap or a keypress to start
    document.addEventListener(`click`, init);
    document.onkeydown = function (event) {
        if (event.code === `Enter`) {
            mode = `auto`;
        }
        init();
        return true;
    };

Stand-alone mode

For stand-alone mode the presentation engine also has the following code that waits for the page to complete loading:

window.onload = () => {
    const createCORSRequest = (url) => {
        let xhr = new XMLHttpRequest();
        if (`withCredentials` in xhr) {

            // Check if the XMLHttpRequest object has a "withCredentials" property.
            // "withCredentials" only exists on XMLHTTPRequest2 objects.
            xhr.open(`GET`, url, true);

        } else if (typeof XDomainRequest != `undefined`) {

            // Otherwise, check if XDomainRequest.
            // XDomainRequest only exists in IE, and is IE's way of making CORS requests.
            xhr = new XDomainRequest();
            xhr.open(`GET`, url);

        } else {

            // Otherwise, CORS is not supported by the browser.
            xhr = null;

        }
        return xhr;
    };  

    const scriptElement = document.getElementById(`jp-script`);
    if (scriptElement) {
        const request = createCORSRequest(`${scriptElement.innerText}?v=${Math.floor(Date.now())}`);
        if (!request) {
            throw Error(`Unable to access the JSON script`);
        }

        request.onload = () => {
            if (200 <= request.status && request.status < 400) {
                const script = JSON.parse(request.responseText);
                JSON_Presenter(document.getElementById(`jp-container`), script);
        } else {
                throw Error(`Unable to access the JSON script`);
            }
        };

        request.onerror = () => {
            throw Error(`Unable to access the JSON script`);
        };

        request.send();
    }
};

This waits for the page to load then gets the script URL, fetches the script using AJAX and runs it. The part that refers to Date(now) is a cache-buster that ensures you always get the latest version of the script.

If the jp-script tag is missing the run does not start, the assumption being that JSON_Presenter() will be called from elsewhere in the page code.

Where to go from here

As they say, prediction is difficult; especially of the future. When considering where this project might lead, a number of possibilities come to mind:

  • More step handlers. One could be for text that 'writes itself' along a line, simulating user input; another to embed video. I also have a plan to add a special interactive block (containing plug-in code written in JavaScript or even in my own high-level DSL, EasyCoder), so a presentation could deliver a tutorial that incorporates user interactivity.
  • A plug-in mechanism that allows step handlers to be written by anyone, delivered as JavaScript files that can simply be added to the page script.
  • The code I show here is just the runtime engine. At this stage, the only tool available for creating presentations is a text editor. Although writing scripts is not particularly hard it would be nice to have a simple 2-pane IDE, with a script editor in one pane and the result displayed in the other in real-time as the script is being edited.
  • Moving on from there, a full IDE with a built-in component library. In PowerPoint we create presentations by selecting, dragging and resizing blocks. There's no need for JSON::Presenter to emulate PowerPoint; the features are different and given those differences it may be that PowerPoint is not the 'best' way of doing things on the Web.

Most of these are so far only at the "blue sky" stage. As an Open Source project it is open to anyone to contribute. At just over 600 lines of code JSON::Presenter is still a very small project and easy to pick up. Anyone interested in collaborating is welcome to contact the author.

Note on June 23, 2020: This project is now obsolete and is maintained only as an archive. Development has transferred to I Wanna Show You (IWSY), which includes full documentation. The repository URL is https://github.com/easycoder/easycoder.github.io/tree/master/iwsy.

Photo by Paul Zoetemeijer on Unsplash

Posted on by:

gtanyware profile

Graham Trott

@gtanyware

Software Engineering relic with a keen interest in making programming more accessible to ordinary people.

Discussion

pic
Editor guide