Source: model/Presentation.js

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/** @module */

import {EventEmitter} from "events";
import {CameraState} from "./CameraState";
import {hasReliableBoundaries} from "../player/Camera";

/** Copy a property from an object to another.
 *
 * If the source object has a property with the given name,
 * this property is copied to the target object.
 *
 * @param {object} dest - The destination object.
 * @param {object} src - The source object.
 * @param {string} prop - The name of the property to copy.
 */
function copyIfSet(dest, src, prop) {
    if (src.hasOwnProperty(prop)) {
        dest[prop] = src[prop];
    }
}

/** Signals that a new SVG document has been attached to a presentation.
 *
 * @event module:model/Presentation.svgChange */

/** Layer properties for a frame in a Sozi presentation.
 *
 * In a given frame, one instance of this class is created for each layer.
 * An instance of `LayerProperties` provides information about the properties
 * of a frame in a given layer.
 *
 * @todo Find a better name for this class.
 */
export class LayerProperties {

    /** Initialize a new layer properties object.
     *
     * If the argument is another instance of `LayerProperties`, this constructor will create a copy of
     * of that instance.
     * If the argument is a {@linkcode module:model/Presentation.Frame|Frame} instance,
     * an object with default properties will be created.
     *
     * @param {(LayerProperties|Frame)} obj - An instance to copy, or a frame.
     */
    constructor(obj) {
        if (obj instanceof LayerProperties) {
            this.copy(obj);
        }
        else {
            /** The frame that owns the current object.
             *
             * @type {module:model/Presentation.Frame} */
            this.frame = obj;

            /** Does the current frame copy the geometry of the previous frame in the current layer?
             *
             * @default
             * @type {boolean} */
            this.link = false;

            /** The SVG ID of the reference element for the current frame in the current layer.
             *
             * @default
             * @type {string} */
            this.referenceElementId = "";

            /** The SVG ID of the outline element for the current frame in the current layer.
             *
             * @default
             * @type {string} */
            this.outlineElementId = "";

            /** The name of the timing function for the transition to the current frame in the current layer.
             *
             * @default
             * @type {string} */
            this.transitionTimingFunction = "linear";

            /** The relative zoom factor for the transition to the current frame in the current layer.
             *
             * @default
             * @type {number} */
            this.transitionRelativeZoom = 0;

            /** The SVG ID of a path to follow during the transition to the current frame in the current layer.
             *
             * @default
             * @type {string} */
            this.transitionPathId = "";
        }
    }

    /** Copy another layer properties into the current instance.
     *
     * @param {module:model/Presentation.LayerProperties} other - The object to copy.
     */
    copy(other) {
        this.frame                    = other.frame;
        this.link                     = other.link;
        this.referenceElementId       = other.referenceElementId;
        this.outlineElementId         = other.outlineElementId;
        this.transitionTimingFunction = other.transitionTimingFunction;
        this.transitionRelativeZoom   = other.transitionRelativeZoom;
        this.transitionPathId         = other.transitionPathId;
    }

    /** Convert this instance to a plain object that can be stored as JSON.
     *
     * The result contains all the properties needed by the editor to restore
     * the state of this instance.
     *
     * @returns {object} - A plain object with the properties needed by the editor.
     */
    toStorable() {
        return {
            link                    : this.link,
            referenceElementId      : this.referenceElementId,
            outlineElementId        : this.outlineElementId,
            transitionTimingFunction: this.transitionTimingFunction,
            transitionRelativeZoom  : this.transitionRelativeZoom,
            transitionPathId        : this.transitionPathId
        };
    }

    /** Convert this instance to a plain object that can be stored as JSON.
     *
     * The result contains only the properties needed by the Sozi player to
     * show and animate the presentation.
     *
     * @returns {object} - A plain object with the properties needed by the player.
     */
    toMinimalStorable() {
        return {
            transitionTimingFunction: this.transitionTimingFunction,
            transitionRelativeZoom  : this.transitionRelativeZoom,
            transitionPathId        : this.transitionPathId
        };
    }

    /** Copy the properties of the given object into this instance.
     *
     * @param {object} storable - A plain object with the properties to copy.
     */
    fromStorable(storable) {
        copyIfSet(this, storable, "link");
        copyIfSet(this, storable, "referenceElementId");
        copyIfSet(this, storable, "outlineElementId");
        copyIfSet(this, storable, "transitionTimingFunction");
        copyIfSet(this, storable, "transitionRelativeZoom");
        copyIfSet(this, storable, "transitionPathId");
    }

    /** The index of the current layer.
     *
     * @readonly
     * @type {number} */
    get index() {
        return this.frame.layerProperties.indexOf(this);
    }

    /** The reference SVG element of the current frame in the current layer.
     *
     * @readonly
     * @type {?SVGElement} */
    get referenceElement() {
        const elt = this.frame.presentation.document.root.getElementById(this.referenceElementId);
        return elt && hasReliableBoundaries(elt) ? elt : null;
    }

    /** The SVG element used to outline the current frame in the current layer.
     *
     * @readonly
     * @type {?SVGElement} */
    get outlineElement() {
        return this.frame.presentation.document.root.getElementById(this.outlineElementId);
    }

    /** The SVG path to follow in transitions to the current frame in the current layer.
     *
     * @readonly
     * @type {?SVGElement} */
    get transitionPath() {
        return this.frame.presentation.document.root.getElementById(this.transitionPathId);
    }

    /** Will the outline element be hidden when playing the presentation?
     *
     * @type {boolean}
     */
    get outlineElementHide() {
        return this.frame.presentation.elementsToHide.indexOf(this.outlineElementId) >= 0;
    }

    set outlineElementHide(hide) {
        if (this.outlineElement === this.frame.presentation.document.root) {
            return;
        }
        const hidden = this.outlineElementHide;
        if (hide && !hidden) {
            this.frame.presentation.elementsToHide.push(this.outlineElementId);
        }
        else if (!hide && hidden) {
            const index = this.frame.presentation.elementsToHide.indexOf(this.outlineElementId);
            this.frame.presentation.elementsToHide.splice(index, 1);
        }
        if (this.outlineElement) {
            this.outlineElement.style.visibility = hide ? "hidden" : "visible";
        }
    }

    /** Will the transition path be hidden when playing the presentation?
     *
     * @type {boolean}
     */
    get transitionPathHide() {
        return this.frame.presentation.elementsToHide.indexOf(this.transitionPathId) >= 0;
    }

    set transitionPathHide(hide) {
        const hidden = this.transitionPathHide;
        if (hide && !hidden) {
            this.frame.presentation.elementsToHide.push(this.transitionPathId);
        }
        else if (!hide && hidden) {
            const index = this.frame.presentation.elementsToHide.indexOf(this.transitionPathId);
            this.frame.presentation.elementsToHide.splice(index, 1);
        }
        if (this.transitionPath) {
            this.transitionPath.style.visibility = hide ? "hidden" : "visible";
        }
    }
}

/** A frame in a Sozi presentation. */
export class Frame {

    /** Initialize a new frame.
     *
     * If the argument is another frame, this constructor will create a copy of
     * of that frame.
     * If the argument is a {@linkcode module:model/Presentation.Presentation|Presentation} instance,
     * an object with default properties will be created.
     *
     * @param {(Frame|Presentation)} obj - A frame to copy, or a presentation.
     */
    constructor(obj) {
        if (obj instanceof Frame) {
            this.copy(obj);
        }
        else {
            /** The presentation that contains this frame.
             *
             * @type {module:model/Presentation.Presentation} */
            this.presentation = obj;

            /** A unique identifier for this frame.
             *
             * @type {string} */
            this.frameId = obj.makeFrameId();

            /** The layer-specific properties of this frame.
             *
             * @type {module:model/Presentation.LayerProperties[]} */
            this.layerProperties = obj.layers.map(lp => new LayerProperties(this));

            /** The camera states of this frame for each layer.
             *
             * @type {module:model/Presentation.CameraState[]} */
            this.cameraStates = obj.layers.map(cs => new CameraState(obj.document.root));

            /** The title of this frame.
             *
             * @default
             * @type {string} */
            this.title = "New frame";

            /** The nesting level of the title of this frame in the frame list.
             *
             * @default
             * @type {number} */
            this.titleLevel = 0;

            /** The presenter's notes for this frame.
             *
             * @default
             * @type {string} */
            this.notes = "";

            /** The duration of this frame, in milliseconds.
             *
             * @default
             * @type {number} */
            this.timeoutMs = 0;

            /** Will the player move to the next frame automatically when the duration of this frame has elapsed?
             *
             * @default
             * @type {boolean} */
            this.timeoutEnable = false;

            /** The duration of the transition to this frame, in milliseconds.
             *
             * @default
             * @type {number} */
            this.transitionDurationMs = 1000;

            /** Will the player show the title of this frame in the table of contents?
             *
             * @default
             * @type {boolean} */
            this.showInFrameList = true;

            /** Will the player show the number of this frame?
             *
             * @default
             * @type {boolean} */
            this.showFrameNumber = true;
        }
    }

    /** Copy the properties of another frame into the current instance.
     *
     * This method will also construct copies of the layer properties and
     * camera states of the original frame.
     * The default behavior is to make a new unique ID to the current frame.
     *
     * @param {module:model/Presentation.Frame} other - The frame to copy.
     * @param {boolean} preserveId - If `true`, keep the original ID of the current frame.
     */
    copy(other, preserveId=false) {
        this.presentation = other.presentation;
        if (!preserveId) {
            this.frameId = other.presentation.makeFrameId();
        }
        this.title                = other.title;
        this.titleLevel           = other.titleLevel;
        this.notes                = other.notes;
        this.timeoutMs            = other.timeoutMs;
        this.timeoutEnable        = other.timeoutEnable;
        this.transitionDurationMs = other.transitionDurationMs;
        this.showInFrameList      = other.showInFrameList;
        this.showFrameNumber      = other.showFrameNumber;
        this.layerProperties      = other.layerProperties.map(lp => new LayerProperties(lp));
        this.cameraStates         = other.cameraStates.map(cs => new CameraState(cs));
    }

    /** Convert this instance to a plain object that can be stored as JSON.
     *
     * The result contains all the properties needed by the editor to restore
     * the state of this instance.
     *
     * @returns {object} - A plain object with the properties needed by the editor.
     */
    toStorable() {
        const layerProperties = {};
        const cameraStates = {};
        const cameraOffsets = {};

        this.presentation.layers.forEach((layer, index) => {
            const lp = this.layerProperties[index];
            const cs = this.cameraStates[index];
            const re = lp.referenceElement;

            const key = layer.groupId;
            layerProperties[key] = lp.toStorable();
            cameraStates[key] = cs.toStorable();
            if (re) {
                cameraOffsets[key] = this.cameraStates[index].offsetFromElement(re);
            }
        });

        return {
            frameId             : this.frameId,
            title               : this.title,
            titleLevel          : this.titleLevel,
            notes               : this.notes,
            timeoutMs           : this.timeoutMs,
            timeoutEnable       : this.timeoutEnable,
            transitionDurationMs: this.transitionDurationMs,
            showInFrameList     : this.showInFrameList,
            showFrameNumber     : this.showFrameNumber,
            layerProperties,
            cameraStates,
            cameraOffsets
        };
    }

    /** Convert this instance to a plain object that can be stored as JSON.
     *
     * The result contains only the properties needed by the Sozi player to
     * show and animate the presentation.
     *
     * @returns {object} - A plain object with the properties needed by the player.
     */
    toMinimalStorable() {
        const layerProperties = {};
        const cameraStates = {};

        this.presentation.layers.forEach((layer, index) => {
            const lp = this.layerProperties[index];
            const cs = this.cameraStates[index];

            const key = layer.groupId;
            layerProperties[key] = lp.toMinimalStorable();
            cameraStates[key] = cs.toMinimalStorable();
        });

        return {
            frameId: this.frameId,
            title: this.title,
            titleLevel: this.titleLevel,
            notes: this.notes,
            timeoutMs: this.timeoutMs,
            timeoutEnable: this.timeoutEnable,
            transitionDurationMs: this.transitionDurationMs,
            showInFrameList: this.showInFrameList,
            showFrameNumber: this.showFrameNumber,
            layerProperties,
            cameraStates
        };
    }

    /** Copy the properties of the given object into this instance.
     *
     * @param {object} storable - A plain object with the properties to copy.
     */
    fromStorable(storable) {
        copyIfSet(this, storable, "frameId");
        copyIfSet(this, storable, "title");
        copyIfSet(this, storable, "titleLevel");
        copyIfSet(this, storable, "notes");
        copyIfSet(this, storable, "timeoutMs");
        copyIfSet(this, storable, "timeoutEnable");
        copyIfSet(this, storable, "transitionDurationMs");
        copyIfSet(this, storable, "showInFrameList");
        copyIfSet(this, storable, "showFrameNumber");

        // TODO if storable.layerProperties has keys not in layers, create fake layers marked as "deleted"
        this.presentation.layers.forEach((layer, index) => {
            // If the current layer has been added to the SVG after the frame
            // was created, copy the properties of the "auto" layer.
            const key = layer.groupId in storable.layerProperties ? layer.groupId : "__sozi_auto__";
            if (key in storable.layerProperties) {
                const lp = this.layerProperties[index];
                lp.fromStorable(storable.layerProperties[key]);

                const cs = this.cameraStates[index];
                cs.fromStorable(storable.cameraStates[key]);

                const re = lp.referenceElement;
                if (re) {
                    const ofs = storable.cameraOffsets[key] || {};
                    cs.setAtElement(re, ofs.deltaX, ofs.deltaY,
                                    ofs.widthFactor, ofs.heightFactor,
                                    ofs.deltaAngle);
                    // TODO compare current camera state with stored camera state.
                    // If different, mark the current layer as "dirty".
                }
            }
        });
    }

    /** The index of this frame in the presentation.
     *
     * @readonly
     * @type {number}
     */
    get index() {
        return this.presentation.frames.indexOf(this);
    }

    /** Copy the given camera states into the current frame.
     *
     * @param {module:model/CameraState.CameraState[]} states - The states to copy.
     */
    setAtStates(states) {
        states.forEach((state, index) => {
            this.cameraStates[index].copy(state);
        });
    }

    /** Check whether the current frame is linked to the given frame in the layer at the given index.
     *
     * Considering two frames A and B where A comes before B in the presentation order,
     * A and B are linked if all frames in the sequence that starts after A and finishes at B
     * have their `link` attribute `true` in their layer properties at the given index.
     *
     * @param {Frame} frame - Another frame to check against the current frame.
     * @param {number} layerIndex - The index of a layer.
     * @returns {boolean} `true` if this frame is linked to the given other frame.
     */
    isLinkedTo(frame, layerIndex) {
        const [first, second] = this.index < frame.index ? [this, frame] : [frame, this];
        return second.layerProperties[layerIndex].link &&
               (second.index === first.index + 1 ||
                second.index > first.index &&
                this.presentation.frames[second.index - 1].isLinkedTo(first, layerIndex));
    }
}

/** A layer in an SVG document.
 *
 * The SVG standard does not define a notion of layer.
 * The implementation of layers depends on the software that was used to create
 * the SVG document.
 *
 * In Sozi, a layer is an SVG group that is a direct child of the SVG root element.
 * When Sozi opens an SVG document, elements that do not belong to a layer are
 * grouped automatically into *automatic* layers.
 */
export class Layer {
    /** Initialize a new layer.
     *
     * @param {module:model/Presentation.Presentation} presentation - The current Sozi presentation.
     * @param {string} label - The display name of this layer.
     * @param {boolean} auto - Was the layer created by Sozi to collect isolated elements?
     */
    constructor(presentation, label, auto) {
        /** The current presentation.
         *
         * @type {module:model/Presentation.Presentation} */
        this.presentation = presentation;

        /** The display name of this layer.
         *
         * @type {string} */
        this.label = label;

        /** Was the layer created by Sozi to collect isolated elements?
         *
         * @type {boolean} */
        this.auto = auto;

        /** The SVG element(s) that constitute this layer.
         *
         * If `auto` is `false`, this array will contain a single SVG group element.
         * If `auto` is `true`, this array can contain several groups that are
         * managed as a single layer in Sozi.
         *
         * @type {SVGElement[]} */
        this.svgNodes = [];
    }

    /** The identifier of the SVG group for this layer.
     *
     * If `auto` is `true`, the value of this property is `"__sozi_auto__"`.
     *
     * @type {string}
     */
    get groupId() {
        return this.auto ? "__sozi_auto__" : this.svgNodes[0].getAttribute("id");
    }

    /** The index of this layer.
     *
     * @type {number} */
    get index() {
        return this.presentation.layers.indexOf(this);
    }

    /** Is this layer visible?
     *
     * This property corresponds to the CSS `display` property of the SVG group
     * for this layer.
     *
     * @type {boolean}
     */
    get isVisible() {
        return this.svgNodes.some(node => window.getComputedStyle(node).display !== "none");
    }

    set isVisible(visible) {
        for (let node of this.svgNodes) {
            node.style.display = visible ? "initial" : "none";
        }
    }

    /** Does this layer contain the given SVG element?
     *
     * @param {SVGElement} svgElement - An element to check.
     * @returns {boolean} `true` if the given element is a child of the current layer.
     */
    contains(svgElement) {
        return this.svgNodes.some(node => node.contains(svgElement));
    }
}

/** Constant: the SVG namespace
 *
 * @type {string} */
const SVG_NS = "http://www.w3.org/2000/svg";

/** Type for SVG documents.
 *
 * @external SVGDocument
 */

/** Sozi presentation.
 *
 * @extends EventEmitter
 */
export class Presentation extends EventEmitter {

    /** Initialize a Sozi document object. */
    constructor() {
        super();

        /** The SVG document attached to this presentation.
         *
         * Set it with {@linkcode module:model/Presentation.Presentation#setSVGDocument}.
         *
         * @default
         * @type {SVGDocument} */
        this.document = null;

        /** The sequence of frames in this presentation.
         *
         * @default
         * @type {module:model/Presentation.Frame[]} */
        this.frames = [];

        /** A representation of the layers of the SVG document.
         *
         * @default
         * @type {module:model/Presentation.Layer[]} */
        this.layers = [];

        /** The list of SVG elements to hide when playing the presentation.
         *
         * @default
         * @type {SVGElement[]} */
        this.elementsToHide = [];

        /** The custom CSS and JavaScript files to add to the generated HTML presentation.
         *
         * @default
         * @type {string[]} */
        this.customFiles = [];

        /** The width of the aspect ratio used in the editor for this presentation.
         *
         * @default
         * @type {number} */
        this.aspectWidth = 4;

        /** The height of the aspect ratio used in the editor for this presentation.
         *
         * @default
         * @type {number} */
        this.aspectHeight = 3;

        /** When playing the presentation, are the keyboard shortcuts for zoom-in and zoom-out enabled?
         *
         * @default
         * @type {boolean} */
        this.enableKeyboardZoom = true;

        /** When playing the presentation, are the keyboard shortcuts for rotation enabled?
         *
         * @default
         * @type {boolean} */
        this.enableKeyboardRotation = true;

        /** When playing the presentation, are the keyboard shortcuts for navigation enabled?
         *
         * @default
         * @type {boolean} */
        this.enableKeyboardNavigation = true;

        /** When playing the presentation, is the mouse gesture for translation enabled?
         *
         * @default
         * @type {boolean} */
        this.enableMouseTranslation = true;

        /** When playing the presentation, are the mouse gestures for zoom-in and zoom-out enabled?
         *
         * @default
         * @type {boolean} */
        this.enableMouseZoom = true;

        /** When playing the presentation, are the mouse gestures for rotation enabled?
         *
         * @default
         * @type {boolean} */
        this.enableMouseRotation = true;

        /** When playing the presentation, are the mouse gestures for navigation enabled?
         *
         * @default
         * @type {boolean} */
        this.enableMouseNavigation = true;

        /** When playing the presentation, does the URL change automatically on frame change?
         *
         * @default
         * @type {boolean} */
        this.updateURLOnFrameChange = true;

        /** The last export document type.
         *
         * @default
         * @type {string} */
        this.exportType = "pdf";

        /** The page size for PDF export.
         *
         * @default
         * @type {string} */
        this.exportToPDFPageSize = "A4";

        /** The page orientation for PDF export.
         *
         * @default
         * @type {string} */
        this.exportToPDFPageOrientation = "landscape";

        /** The list of frame numbers to include in the PDF export.
         *
         * @default
         * @type {string} */
        this.exportToPDFInclude = "";

        /** The list of frame numbers to exclude in the PDF export.
         *
         * @default
         * @type {string} */
        this.exportToPDFExclude = "";

        /** The slide size for PPTX export.
         *
         * @default
         * @type {string} */
        this.exportToPPTXSlideSize = "screen4x3";

        /** The list of frame numbers to include in the PPTX export.
         *
         * @default
         * @type {string} */
        this.exportToPPTXInclude = "";

        /** The list of frame numbers to exclude in the PPTX export.
         *
         * @default
         * @type {string} */
        this.exportToPPTXExclude = "";

        /** The file extension of the exported video.
         *
         * @default
         * @type {string} */
        this.exportToVideoFormat = "webm";

        /** The width of the exported video, in pixels.
         *
         * @default
         * @type {number} */
        this.exportToVideoWidth = 1280;

        /** The height of the exported video, in pixels.
         *
         * @default
         * @type {number} */
        this.exportToVideoHeight = 720;

        /** The number of images per second in the exported video.
         *
         * @default
         * @type {number} */
        this.exportToVideoFrameRate = 50;

        /** The number of bits per second in the exported video.
         *
         * @default
         * @type {number} */
        this.exportToVideoBitRate = 2000000;
    }

    /** Set the SVG document for this presentation.
     *
     * This method populates the {@linkcode module:model/Presentation.Presentation#layers} property of this instance.
     *
     * @param {SVGDocument} svgDocument - The SVG document to use.
     *
     * @fires module:model/Presentation.svgChange
     */
    setSVGDocument(svgDocument) {
        this.document = svgDocument;

        // Create an empty wrapper layer for elements that do not belong to a valid layer
        const autoLayer = new Layer(this, "auto", true);

        this.layers = [];
        for (let svgNode of this.document.root.childNodes) {
            if (svgNode instanceof SVGGElement) {
                const nodeId = svgNode.getAttribute("id");
                if (nodeId === null) {
                    autoLayer.svgNodes.push(svgNode);
                }
                else {
                    // Add the current node as a new layer.
                    const layer = new Layer(this,
                        this.document.handler.getLabel(svgNode) || ("#" + nodeId),
                        false);
                    layer.svgNodes.push(svgNode);
                    this.layers.push(layer);
                }
            }
        }

        this.layers.push(autoLayer);

        this.emit("svgChange");
    }

    /** Sets the initial state of all cameras to fit the bounding box of the SVG content. */
    setInitialCameraState() {
        /** The initial camera state.
         *
         * This property is initialized after the document has been loaded and displayed.
         *
         * @type {module:model/CameraState.CameraState} */
        this.initialCameraState = new CameraState(this.document.root);
    }

    /** Convert this instance to a plain object that can be stored as JSON.
     *
     * The result contains all the properties needed by the editor to restore
     * the state of this instance.
     *
     * @returns {object} - A plain object with the properties needed by the editor.
     */
    toStorable() {
        return {
            aspectWidth               : this.aspectWidth,
            aspectHeight              : this.aspectHeight,
            enableKeyboardZoom        : this.enableKeyboardZoom,
            enableKeyboardRotation    : this.enableKeyboardRotation,
            enableKeyboardNavigation  : this.enableKeyboardNavigation,
            enableMouseTranslation    : this.enableMouseTranslation,
            enableMouseZoom           : this.enableMouseZoom,
            enableMouseRotation       : this.enableMouseRotation,
            enableMouseNavigation     : this.enableMouseNavigation,
            updateURLOnFrameChange    : this.updateURLOnFrameChange,
            exportType                : this.exportType,
            exportToPDFPageSize       : this.exportToPDFPageSize,
            exportToPDFPageOrientation: this.exportToPDFPageOrientation,
            exportToPDFInclude        : this.exportToPDFInclude,
            exportToPDFExclude        : this.exportToPDFExclude,
            exportToPPTXSlideSize     : this.exportToPPTXSlideSize,
            exportToPPTXInclude       : this.exportToPPTXInclude,
            exportToPPTXExclude       : this.exportToPPTXExclude,
            exportToVideoFormat       : this.exportToVideoFormat,
            exportToVideoWidth        : this.exportToVideoWidth,
            exportToVideoHeight       : this.exportToVideoHeight,
            exportToVideoFrameRate    : this.exportToVideoFrameRate,
            exportToVideoBitRate      : this.exportToVideoBitRate,
            frames                    : this.frames.map(frame => frame.toStorable()),
            elementsToHide            : this.elementsToHide.slice(),
            customFiles               : this.customFiles.slice(),
        };
    }

    /** Convert this instance to a plain object that can be stored as JSON.
     *
     * The result contains only the properties needed by the Sozi player to
     * show and animate the presentation.
     *
     * @returns {object} - A plain object with the properties needed by the player.
     */
    toMinimalStorable() {
        return {
            enableKeyboardZoom      : this.enableKeyboardZoom,
            enableKeyboardRotation  : this.enableKeyboardRotation,
            enableKeyboardNavigation: this.enableKeyboardNavigation,
            enableMouseTranslation  : this.enableMouseTranslation,
            enableMouseZoom         : this.enableMouseZoom,
            enableMouseRotation     : this.enableMouseRotation,
            enableMouseNavigation   : this.enableMouseNavigation,
            updateURLOnFrameChange  : this.updateURLOnFrameChange,
            frames                  : this.frames.map(frame => frame.toMinimalStorable()),
            elementsToHide          : this.elementsToHide.slice()
        };
    }

    /** Copy the properties of the given object into this instance.
     *
     * @param {object} storable - A plain object with the properties to copy.
     */
    fromStorable(storable) {
        copyIfSet(this, storable, "aspectWidth");
        copyIfSet(this, storable, "aspectHeight");
        copyIfSet(this, storable, "enableKeyboardZoom");
        copyIfSet(this, storable, "enableKeyboardRotation");
        copyIfSet(this, storable, "enableKeyboardNavigation");
        copyIfSet(this, storable, "enableMouseTranslation");
        copyIfSet(this, storable, "enableMouseZoom");
        copyIfSet(this, storable, "enableMouseRotation");
        copyIfSet(this, storable, "enableMouseNavigation");
        copyIfSet(this, storable, "updateURLOnFrameChange");
        copyIfSet(this, storable, "exportType");
        copyIfSet(this, storable, "exportToPDFPageSize");
        copyIfSet(this, storable, "exportToPDFPageOrientation");
        copyIfSet(this, storable, "exportToPDFInclude");
        copyIfSet(this, storable, "exportToPDFExclude");
        copyIfSet(this, storable, "exportToPPTXSlideSize");
        copyIfSet(this, storable, "exportToPPTXInclude");
        copyIfSet(this, storable, "exportToPPTXExclude");
        copyIfSet(this, storable, "exportToVideoFormat");
        copyIfSet(this, storable, "exportToVideoWidth");
        copyIfSet(this, storable, "exportToVideoHeight");
        copyIfSet(this, storable, "exportToVideoFrameRate");
        copyIfSet(this, storable, "exportToVideoBitRate");

        this.frames = storable.frames.map(f => {
            const res = new Frame(this);
            res.fromStorable(f);
            return res;
        });

        if (storable.elementsToHide) {
            this.elementsToHide = storable.elementsToHide.slice();
        }

        if (storable.customFiles) {
            this.customFiles = storable.customFiles.slice();
        }
    }

    /** The title of this presentation.
     *
     * This property is extracted from the `<title>` element of the SVG document.
     * Its default value is `"Untitled"`.
     *
     * @readonly
     * @type {string} */
    get title() {
        const svgTitles = this.document.root.getElementsByTagNameNS(SVG_NS, "title");
        return svgTitles.length ? svgTitles[0].firstChild.wholeText.trim() : "Untitled";
    }

    /** Create a new unique identifier for a frame in this presentation.
     *
     * @returns {string} - A new ID
     */
    makeFrameId() {
        const prefix = "frame";
        let suffix = Math.floor(1000 * (1 + 9 * Math.random()));
        let frameId;
        do {
            frameId = prefix + suffix;
            suffix ++;
        } while (this.frames.some(frame => frame.frameId === frameId));
        return frameId;
    }

    /** Get the frame with a given ID in the current presentation.
     *
     * @param {string} frameId - The ID of the frame to find.
     * @returns {?module:model/Presentation.Frame} - The frame with that ID.
     */
    getFrameWithId(frameId) {
        for (let frame of this.frames) {
            if (frame.frameId === frameId) {
                return frame;
            }
        }
        return null;
    }

    /** Get the layer with a given ID in the current presentation.
     *
     * @param {string} groupId - The ID of an SVG group that represents a layer.
     * @returns {?module:model/Presentation.Layer} - The layer that maps to a group with that ID.
     */
    getLayerWithId(groupId) {
        for (let layer of this.layers) {
            if (layer.groupId === groupId) {
                return layer;
            }
        }
        return null;
    }

    /** Update the camera states and layer properties of all linked layers in all frames.
     *
     * This method must be called to propagate the changes in some frames to
     * the frames that are linked to them.
     */
    updateLinkedLayers() {
        if (!this.frames.length) {
            return;
        }

        const firstCameraStates      = this.frames[0].cameraStates;
        const defaultCameraState     = firstCameraStates[firstCameraStates.length - 1];

        const firstLayerProperties   = this.frames[0].layerProperties;
        const defaultLayerProperties = firstLayerProperties[firstLayerProperties.length - 1];

        this.layers.forEach((layer, layerIndex) => {
            let cameraState     = defaultCameraState;
            let layerProperties = defaultLayerProperties;

            for (let frame of this.frames) {
                if (frame.layerProperties[layerIndex].link) {
                    frame.cameraStates[layerIndex].copy(cameraState);
                    frame.layerProperties[layerIndex].referenceElementId = layerProperties.referenceElementId;
                    frame.layerProperties[layerIndex].outlineElementId   = layerProperties.outlineElementId;
                }
                else {
                    cameraState     = frame.cameraStates[layerIndex];
                    layerProperties = frame.layerProperties[layerIndex];
                }
            }
        });
    }

    /** Get the custom files with the given extension.
     *
     * @param {string} ext - The file extension.
     * @returns {string[]} - The custom files that have that extension.
     */
    getCustomFiles(ext) {
        return this.customFiles.filter(path => path.endsWith(ext));
    }
}