Source: Controller.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 {Storage} from "./Storage";
import {Frame, LayerProperties} from "./model/Presentation";
import {CameraState} from "./model/CameraState";
import {EventEmitter} from "events";
import * as i18n from "./i18n";
import * as exporter from "./exporter/exporter";

/** The maximum size of the undo stack.
 *
 * @readonly
 * @default
 * @type {number}
 */
const UNDO_STACK_LIMIT = 100;

/** Signals that the presentation data has changed.
 *
 * @event module:Controller.presentationChange */

/** Signals that the editor state data has changed.
 *
 * @event module:Controller.editorStateChange */

/** Signals that the editor UI needs to be repainted.
 *
 * @event module:Controller.repaint */

/** Signals that the current editor window has received the focus.
 *
 * @event module:Controller.focus */

/** Signals that the current editor window has lost the focus.
 *
 * @event module:Controller.blur */

/** Sozi editor UI controller.
 *
 * A Controller instance manages the user interface of the editor and updates
 * the presentation data in reaction to user actions.
 *
 * @extends EventEmitter
 */
export class Controller extends EventEmitter {

    /** Initialize a new Sozi editor UI controller.
     *
     * @param {module:model/Preferences.Preferences} preferences - The object that holds the user settings of the editor.
     * @param {module:model/Presentation.Presentation} presentation - The Sozi presentation opened in the editor.
     * @param {module:model/Selection.Selection} selection - The object that represents the selection in the timeline.
     * @param {module:player/Viewport.Viewport} viewport - The object that displays a preview of the presentation.
     * @param {module:player/Player.Player} player - The object that animates the presentation.
     */
    constructor(preferences, presentation, selection, viewport, player) {
        super();

        /** The function that returns translated text in the current language.
         *
         * @param {string} s - The text to translate.
         * @returns {string} The translated text.
         */
        this.gettext = s => s;

        /** The object that holds the user settings of the editor.
         *
         * @type {module:model/Preferences.Preferences} */
        this.preferences = preferences;

        /** The Sozi presentation opened in the editor.
         *
         * @type {module:model/Presentation.Presentation} */
        this.presentation = presentation;

        /** The object that represents the selection in the timeline.
         *
         * @type {module:model/Selection.Selection} */
        this.selection = selection;

        /** The object that displays a preview of the presentation.
         *
         * @type {module:player/Viewport.Viewport} */
        this.viewport = viewport;

        /** The object that animates the presentation.
         *
         * @type {module:player/Player.Player} */
        this.player = player;

        /** The object that manages the file I/O.
         *
         * @type {module:Storage.Storage} */
        this.storage = new Storage(this, presentation, selection);

        /** The layers that have been added to the timeline.
         *
         * @type {module:model/Presentation.Layer[]} */
        this.editableLayers = [];

        /** The layers that fall in the "default" row of the timeline.
         *
         * @type {module:model/Presentation.Layer[]} */
        this.defaultLayers = [];

        /** The stack of operations that can be undone.
         *
         * @type {Array} */
        this.undoStack = [];

        /** The stack of operations that can be redone.
         *
         * @type {Array} */
        this.redoStack = [];

        /** The timeout ID of the current notification.
         *
         * @type {?number} */
        this.notificationTimeout = null;

        /** True if the current window has the focus.
         *
         * @type {boolean} */
        this.hasFocus = false;

        /** True if an export operation is in progress.
         *
         * @default
         * @type {boolean}
         */
        this.exporting = false;

        window.addEventListener("focus", () => {
            this.hasFocus = true;
            this.emit("focus");
        });

        window.addEventListener("blur", () => {
            this.hasFocus = false;
            this.emit("blur");
        });

        this.on("repaint", () => this.onRepaint());
        player.on("frameChange", () => this.onFrameChange());
    }

    /** Finalize the initialization of the application.
     *
     * Load and apply the user preferences.
     * Activate the storage instance.
     */
    activate() {
        this.preferences.load();
        this.applyPreferences();
        this.storage.activate();
    }

    /** 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 {
            editableLayers: this.editableLayers.map(layer => layer.groupId)
        };
    }

    /** Copy the properties of the given object into this instance.
     *
     * This method will build the list of editable layers managed by this controller,
     * from a list of group IDs provided by the given object.
     *
     * @param {object} storable - A plain object with the properties to copy.
     */
    fromStorable(storable) {
        this.editableLayers = [];

        if ("editableLayers" in storable) {
            for (let groupId of storable.editableLayers) {
                const layer = this.presentation.getLayerWithId(groupId);
                if (layer && this.editableLayers.indexOf(layer) < 0) {
                    this.editableLayers.push(layer);
                }
            }
        }
    }

    /** Show a notification.
     *
     * The presentation editor does not use the operating system's notification
     * system.
     * This method will display a notification inside the application.
     *
     * A notification will be hidden after a given time.
     * Consecutive notifications are concatenated if they happen in a short period of time.
     *
     * @param {string} severity - The severity of the event to signal (`"error"` or `"info"`).
     * @param {string} body - An HTML string to show in the notification area.
     */
    showNotification(severity, body) {
        const _ = this.gettext;
        const msg = document.getElementById("message");
        if (this.notificationTimeout === null) {
            msg.querySelector(".body").innerHTML = "";
        }
        else {
            clearTimeout(this.notificationTimeout);
        }

        msg.querySelector(".title").innerHTML =
            severity === "error" ? _('<i class="fas fa-exclamation-triangle"></i> Error')
                                 : _('<i class="fas fa-info-circle"></i> Information');

        msg.querySelector(".body").innerHTML  += `<div>${body}</div>`;
        msg.classList.add("visible", severity);
        this.notificationTimeout = setTimeout(() => this.hideNotification(), 5000);
    }

    /** Hide all notifications. */
    hideNotification() {
        const msg = document.getElementById("message");
        msg.classList.remove("visible", "info", "error");
        clearTimeout(this.notificationTimeout);
        this.notificationTimeout = null;
    }

    /** Show a notification with an information message.
     *
     * @param {string} body - The message to display.
     * @param {boolean} force - Ignore the user preferences for notifications.
     */
    info(body, force=false) {
        if (this.preferences.enableNotifications || force) {
            this.showNotification("info", body);
        }
    }

    /** Show a notification with an error message.
     *
     * @param {string} body - The message to display.
     */
    error(body) {
        this.showNotification("error", body);
    }

    /** Update the visible frame on repaint.
     *
     * This method is called each time this controller emits the "repaint" event.
     * If the {@link module:model/Selection.Selection#currentFrame|currently selected frame} is different
     * from the {@link module:player/Player.Player#currentFrame|currently visible frame},
     * it will move to the selected frame.
     *
     * @listens module:Controller.repaint
     */
    onRepaint() {
        if (this.selection.currentFrame && (this.selection.currentFrame !== this.player.currentFrame || this.player.animator.running)) {
            if (this.preferences.animateTransitions) {
                this.player.moveToFrame(this.selection.currentFrame);
            }
            else {
                this.player.jumpToFrame(this.selection.currentFrame);
            }
        }
    }

    /** On frame change, recompute the reference element.
     *
     * @listens module:player/Player.frameChange
     */
    onFrameChange() {
        let changed = false;
        for (let layer of this.presentation.layers) {
            const layerProperties = this.selection.currentFrame.layerProperties[layer.index];
            if (layer.isVisible) {
                // Update the reference SVG element if applicable.
                const {element} = this.viewport.cameras[layer.index].getCandidateReferenceElement();
                if (element && element !== layerProperties.referenceElement) {
                    layerProperties.referenceElementId = element.getAttribute("id");
                    changed = true;
                }
            }
        }
        if (changed) {
            this.emit("repaint");
        }
    }

    /** Finalize the loading of a presentation.
     *
     * This method is called by a {@linkcode module:Storage.Storage|Storage} instance when the SVG and JSON files
     * of a presentation have been loaded.
     * It sets a default {@link module:Controller.Controller#selection|selection} if needed,
     * and shows the {@link module:model/Selection.Selection#currentFrame|current frame}.
     */
    onLoad() {
        // If no frame is selected, select the first frame.
        if (!this.selection.selectedFrames.length && this.presentation.frames.length) {
            this.selection.addFrame(this.presentation.frames[0]);
        }

        // If no layer is selected, select all layers.
        if (!this.selection.selectedLayers.length) {
            this.selection.selectedLayers = this.presentation.layers.slice();
        }

        // Show the currently selected frame, if applicable.
        if (this.selection.currentFrame) {
            this.player.jumpToFrame(this.selection.currentFrame);
        }

        // Mark the cameras as selected for the selected frames.
        this.updateCameraSelection();

        // Collect all layers that are not in the editable set
        // into the "default" layer set.
        this.defaultLayers = [];
        for (let layer of this.presentation.layers) {
            if (this.editableLayers.indexOf(layer) < 0) {
                this.defaultLayers.push(layer);
            }
        }
    }

    /** Process an SVG document change event.
     *
     * Depending on the user preferences, this method will reload the
     * presentation, or prompt the user.
     *
     * @param {any} fileDescriptor - The file that changed recently.
     */
    onFileChange(fileDescriptor) {
        const _ = this.gettext;
        const doReload = () => {
            this.info(_("Document was changed. Reloading."));
            this.reload();
        };

        if (this.storage.backend.sameFile(fileDescriptor, this.storage.svgFileDescriptor)) {
            switch (this.getPreference("reloadMode")) {
                case "auto": doReload(); break;
                case "onfocus":
                    if (this.hasFocus) {
                        doReload();
                    }
                    else {
                        this.once("focus", doReload);
                    }
                    break;
                default: this.info(_("Document was changed."));
            }
        }
    }

    /** Save the presentation.
     *
     * This method delegates the operation to its {@linkcode module:Storage.Storage|Storage} instance and triggers
     * a repaint so that the UI shows the correct "saved" status.
     *
     * @fires module:Controller.repaint
     *
     * @see {@linkcode module:Storage.Storage#save}
     */
    async save() {
        await this.storage.save();
        this.emit("repaint");
    }

    /** Reload the presentation.
     *
     * This method delegates the operation to its {@linkcode module:Storage.Storage|Storage} instance.
     */
    reload() {
        this.storage.reload();
    }

    /** Add a custom stylesheet or script to the current presentation.
     *
     * This action supports undo and redo.
     *
     * @param {string} path - The path of the file to add.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    addCustomFile(path) {
        this.perform(
            function onDo() {
                this.presentation.customFiles.push(this.storage.toRelativePath(path));
            },
            function onUndo() {
                this.presentation.customFiles.pop();
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Remove a custom stylesheet or script from the current presentation.
     *
     * This action supports undo and redo.
     *
     * @param {number} index - The index of the entry to remove in the custom file list.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    removeCustomFile(index) {
        const fileName = this.presentation.customFiles[index];
        this.perform(
            function onDo() {
                this.presentation.customFiles.splice(index, 1);
            },
            function onUndo() {
                this.presentation.customFiles.splice(index, 0, fileName);
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Get the list of custom stylesheets and scripts added to the current presentation.
     *
     * @returns {string[]} - An array of file paths.
     */
    getCustomFiles() {
        return this.presentation.customFiles;
    }

    /** Add a new frame to the presentation.
     *
     * A new frame is added to the presentation after the
     * {@link module:model/Selection.Selection#currentFrame|currently selected frame}.
     * If no frame is selected, the new frame is added at the
     * end of the presentation.
     *
     * This action supports undo and redo.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    addFrame() {
        let frame, frameIndex;

        if (this.selection.currentFrame) {
            // If a frame is selected, insert the new frame after.
            frame = new Frame(this.selection.currentFrame);
            frameIndex = this.selection.currentFrame.index + 1;
        }
        else {
            // If no frame is selected, copy the state of the current viewport
            // and add the new frame at the end of the presentation.
            frame = new Frame(this.presentation);
            frame.setAtStates(this.viewport.cameras);
            frameIndex = this.presentation.frames.length;
        }

        // Set the 'link' flag to all layers in the new frame.
        if (frameIndex > 0) {
            for (let layer of frame.layerProperties) {
                layer.link = true;
            }
        }

        this.perform(
            function onDo() {
                this.presentation.frames.splice(frameIndex, 0, frame);
                this.presentation.updateLinkedLayers();
                this.selection.selectedFrames = [frame];
            },
            function onUndo() {
                this.presentation.frames.splice(frameIndex, 1);
                this.presentation.updateLinkedLayers();
            },
            true,
            ["presentationChange", "editorStateChange", "repaint"]
        );
    }

    /** Delete the selected frames from the presentation.
     *
     * This action supports undo and redo.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    deleteFrames() {
        // Sort the selected frames by presentation order.
        const framesByIndex = this.selection.selectedFrames.map(f => [f, f.index]).sort((a, b) => a[1] - b[1]);

        this.perform(
            function onDo() {
                // Remove the selected frames and clear the selection.
                // We don't use the saved index here because the actual index
                // will change as frames are deleted.
                for (let [frame, index] of framesByIndex) {
                    this.presentation.frames.splice(frame.index, 1);
                }
                this.selection.selectedFrames = [];
                this.presentation.updateLinkedLayers();
            },
            function onUndo() {
                // Restore the deleted frames to their original locations.
                for (let [frame, index] of framesByIndex) {
                    this.presentation.frames.splice(index, 0, frame);
                }
                this.presentation.updateLinkedLayers();
            },
            true,
            ["presentationChange", "editorStateChange", "repaint"]
        );
    }

    /** Move the selected frames to the given location.
     *
     * This action supports undo and redo.
     *
     * @param {number} toFrameIndex - The new index of the first frame in the selection.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    moveFrames(toFrameIndex) {
        // Sort the selected frames by presentation order.
        const framesByIndex = this.selection.selectedFrames.slice().sort((a, b) => a.index - b.index);
        const frameIndices = framesByIndex.map(frame => frame.index);

        // Compute the new target frame index after the selection has been removed.
        for (let frame of framesByIndex) {
            if (frame.index < toFrameIndex) {
                toFrameIndex --;
            }
        }

        // Keep a copy of the current frame list for the Undo operation.
        const savedFrames = this.presentation.frames.slice();

        // Create a new frame list by removing the selected frames
        // and inserting them at the target frame index.
        const reorderedFrames = this.presentation.frames.filter(frame => !this.selection.hasFrames([frame]));
        Array.prototype.splice.apply(reorderedFrames, [toFrameIndex, 0].concat(framesByIndex));

        // Identify the frames and layers that must be unlinked after the move operation.
        // If a linked frame is moved after a frame to which it was not previously linked,
        // then it will be unlinked.
        const unlink = reorderedFrames.flatMap((frame, frameIndex) =>
            frame.layerProperties.filter((layer, layerIndex) =>
                layer.link && (frameIndex === 0 || !frame.isLinkedTo(reorderedFrames[frameIndex - 1], layerIndex))
            )
        );

        this.perform(
            function onDo() {
                this.presentation.frames = reorderedFrames;
                for (let layer of unlink) {
                    layer.link = false;
                }
                this.presentation.updateLinkedLayers();
            },
            function onUndo() {
                for (let layer of unlink) {
                    layer.link = true;
                }
                this.presentation.frames = savedFrames;
                this.presentation.updateLinkedLayers();
            },
            false,
            ["presentationChange", "editorStateChange", "repaint"]
        );
    }

    /** Select the cameras corresponding to the selected layers.
     *
     * For each selected layer, this method sets the {@linkcode module:player/Camera.Camera#selected|selected} property of
     * the corresponding camera in the current viewport.
     */
    updateCameraSelection() {
        for (let camera of this.viewport.cameras) {
            camera.selected = this.selection.hasLayers([camera.layer]);
        }
    }

    /** Add a layer to the timeline.
     *
     * This method takes a layers from the "default" layer set and moves it
     * to the "editable" layer set.
     *
     * This operation modifies the editor state and does not affect the actual presentation.
     * This action does not support undo and redo.
     *
     * @param {number} layerIndex - The index of the layer to add.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    addLayer(layerIndex) {
        const layer = this.presentation.layers[layerIndex];
        if (this.editableLayers.indexOf(layer) < 0) {
            this.editableLayers.push(layer);
        }

        const layerIndexInDefaults = this.defaultLayers.indexOf(layer);
        if (layerIndexInDefaults >= 0) {
            this.defaultLayers.splice(layerIndexInDefaults, 1);
        }

        this.addLayerToSelection(layer);

        // Force a repaint even if the controller
        // did not modify the selection
        this.emit("editorStateChange");
        this.emit("repaint");
    }

    /** Add all layers to the timeline.
     *
     * This method takes all layers from the "default" layer set and moves them
     * to the "editable" layer set.
     *
     * This operation modifies the editor state and does not affect the actual presentation.
     * This action does not support undo and redo.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    addAllLayers() {
        for (let layer of this.defaultLayers.slice()) {
            if (layer.auto) {
                continue;
            }

            this.editableLayers.push(layer);

            const layerIndexInDefaults = this.defaultLayers.indexOf(layer);
            this.defaultLayers.splice(layerIndexInDefaults, 1);

            this.addLayerToSelection(layer);
        }

        // Force a repaint even if the controller
        // did not modify the selection
        this.emit("editorStateChange");
        this.emit("repaint");
    }

    /** Remove a layer from the timeline.
     *
     * This method takes a layers from the "editable" layer list and moves it
     * to the "default" layer set.
     *
     * This operation modifies the editor state and does not affect the actual presentation.
     * This action does not support undo and redo.
     *
     * @param {number} layerIndex - The index of the layer to remove.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    removeLayer(layerIndex) {
        const layer = this.presentation.layers[layerIndex];

        const layerIndexInEditable = this.editableLayers.indexOf(layer);
        this.editableLayers.splice(layerIndexInEditable, 1);

        if (this.defaultLayersAreSelected) {
            this.addLayerToSelection(layer);
        }
        else if (this.selection.selectedLayers.length > 1) {
            this.removeLayerFromSelection(layer);
        }
        else {
            this.selectLayers(this.defaultLayers);
        }

        this.defaultLayers.push(layer);

        // Force a repaint even if the controller
        // did not modify the selection
        this.emit("editorStateChange");
        this.emit("repaint");
    }

    /** Get the layers at the given index.
     *
     * This method will return an array containing either a single layer,
     * or the layers in the "default" set.
     *
     * @param {number} layerIndex - The index of the layer to get, or a negative number for the "default" layers.
     * @returns {module:model/Presentation.Layer[]} The layer(s) at the given index.
     */
    getLayersAtIndex(layerIndex) {
        return layerIndex >= 0 ?
            [this.presentation.layers[layerIndex]] :
            this.defaultLayers;
    }

    /** `true` if all layers in the "default" set are selected.
     *
     * @readonly
     * @type {boolean}
     */
    get defaultLayersAreSelected() {
        return this.defaultLayers.every(layer => this.selection.selectedLayers.indexOf(layer) >= 0);
    }

    /** `true` if there is at least one layer in the "default" set.
     *
     * @readonly
     * @type {boolean}
     */
    get hasDefaultLayer() {
        return this.defaultLayers.length > 1 ||
               this.defaultLayers.length > 0 && this.defaultLayers[0].svgNodes.length;
    }

    /** Select the given layers.
     *
     * This methods adds the given layers to the selection and removes
     * the other previously selected layers.
     *
     * @param {module:model/Presentation.Layer[]} layers - The layers to select.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    selectLayers(layers) {
        this.selection.selectedLayers = layers.slice();
        this.updateCameraSelection();
        this.emit("editorStateChange");
        this.emit("repaint");
    }

    /** Add a layer to the selection.
     *
     * This method adds the given layer to the selection if it is not
     * already present.
     *
     * @param {module:model/Presentation.Layer} layer - The layer to select.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    addLayerToSelection(layer) {
        if (!this.selection.hasLayers([layer])) {
            this.selection.addLayer(layer);
            this.updateCameraSelection();
            this.emit("editorStateChange");
            this.emit("repaint");
        }
    }

    /** Remove a layer from the selection.
     *
     * This method removes the given layer to the selection if it is present.
     *
     * @param {module:model/Presentation.Layer} layer - The layer to deselect.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    removeLayerFromSelection(layer) {
        if (this.selection.hasLayers([layer])) {
            this.selection.removeLayer(layer);
            this.updateCameraSelection();
            this.emit("editorStateChange");
            this.emit("repaint");
        }
    }

    /** Select a single frame.
     *
     * This method adds the frame at the given index to the selection,
     * and removes the other previously selected frames.
     *
     * @param {number} index - The index of the frame to select. A negative number counts backwards from the end.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    selectFrame(index) {
        if (index < 0) {
            index = this.presentation.frames.length + index;
        }
        this.updateLayerAndFrameSelection(false, false, this.selection.selectedLayers, index);
    }

    /** Select all frames.
     *
     * This methods adds all the frames of the presentation to the selection.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    selectAllFrames() {
        this.selection.selectedFrames = this.presentation.frames.slice();
        this.updateCameraSelection();
        this.emit("editorStateChange");
        this.emit("repaint");
    }

    /** Select a specific frame at a relative location from the {@link module:model/Selection.Selection#currentFrame|current frame}.
     *
     * The absolute index of the frame to select is the sum of the {@link module:model/Selection.Selection#currentFrame|current frame}
     * index and the given relative index.
     *
     * @param {number} relativeIndex - The relative location of the frame to select with respect to the current frame.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    selectRelativeFrame(relativeIndex) {
        if (this.selection.currentFrame) {
            const lastIndex = this.presentation.frames.length - 1;
            let targetIndex = this.selection.currentFrame.index + relativeIndex;
            targetIndex = targetIndex < 0 ? 0 : (targetIndex > lastIndex ? lastIndex : targetIndex);
            this.updateLayerAndFrameSelection(false, false, this.selection.selectedLayers, targetIndex);
        }
    }

    /** Update the selection with the given frame.
     *
     * This method adds or removes frames to/from the selection depending on the
     * selection mode specified by the parameters `single` and `sequence`.
     * If `single` and `sequence` are false, select only the given frame and all layers.
     *
     * @param {boolean} single - If true, add or remove one frame to/from the selection.
     * @param {boolean} sequence - If true, add or remove consecutive frames, starting from the {@link module:model/Selection.Selection#currentFrame|current frame} up/down to the given index.
     * @param {number} frameIndex - The index of a frame in the presentation.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    updateFrameSelection(single, sequence, frameIndex) {
        const frame = this.presentation.frames[frameIndex];
        if (single) {
            this.selection.toggleFrameSelection(frame);
        }
        else if (sequence) {
            if (!this.selection.selectedFrames.length) {
                this.selection.addFrame(frame);
            }
            else {
                const startIndex = this.selection.currentFrame.index;
                const inc = startIndex <= frameIndex ? 1 : -1;
                for (let i = startIndex + inc; startIndex <= frameIndex ? i <= frameIndex : i >= frameIndex; i += inc) {
                    this.selection.toggleFrameSelection(this.presentation.frames[i]);
                }
            }
        }
        else {
            this.selection.selectedLayers = this.presentation.layers.slice();
            this.selection.selectedFrames = [frame];
            this.updateCameraSelection();
        }

        this.emit("editorStateChange");
        this.emit("repaint");
    }

    /** Update the selection with the given layer.
     *
     * This method adds or removes layers to/from the selection depending on the
     * selection mode specified by the parameters `single` and `sequence`.
     * If `single` and `sequence` are false, select only the given layers and all frames.
     *
     * @todo Sequence mode is not supported yet.
     *
     * @param {boolean} single - If true, add or remove the given layers to/from the selection.
     * @param {boolean} sequence - If true, add or remove consecutive layers, starting from the current layer up/down to the given layers.
     * @param {module:model/Presentation.Layer[]} layers - The layers to select or deselect.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    updateLayerSelection(single, sequence, layers) {
        if (single) {
            for (let layer of layers) {
                this.selection.toggleLayerSelection(layer);
            }
        }
        else if (sequence) {
            // TODO toggle from last selected layer to current
        }
        else {
            this.selection.selectedLayers = layers.slice();
            this.selection.selectedFrames = this.presentation.frames.slice();
        }

        this.updateCameraSelection();

        this.emit("editorStateChange");
        this.emit("repaint");
    }

    /** Update the selection with the given layers and a given frame.
     *
     * This method adds or removes frames and layers to/from the selection depending
     * on the selection mode specified by the parameters `single` and `sequence`.
     * If `single` and `sequence` are false, select only the given frame and layers.
     *
     * @todo Sequence mode is not supported for layers.
     *
     * @param {boolean} single - If true, add or remove the given layers and frame to/from the selection.
     * @param {boolean} sequence - If true, add or remove consevutive frames and layers starting from the {@link module:model/Selection.Selection#currentFrame|current frame} and layer.
     * @param {module:model/Presentation.Layer[]} layers - The layers to select or deselect.
     * @param {number} frameIndex - The index of the frame in the presentation
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    updateLayerAndFrameSelection(single, sequence, layers, frameIndex) {
        const frame = this.presentation.frames[frameIndex];
        if (single) {
            if (this.selection.hasLayers(layers) && this.selection.hasFrames([frame])) {
                for (let layer of layers) {
                    this.selection.removeLayer(layer);
                }
                this.selection.removeFrame(frame);
            }
            else {
                for (let layer of layers) {
                    this.selection.addLayer(layer);
                }
                this.selection.addFrame(frame);
            }
        }
        else if (sequence) {
            if (!this.selection.selectedFrames.length) {
                this.selection.addFrame(frame);
            }
            else {
                const startIndex = this.selection.currentFrame.index;
                const inc = startIndex <= frameIndex ? 1 : -1;
                for (let i = startIndex + inc; startIndex <= frameIndex ? i <= frameIndex : i >= frameIndex; i += inc) {
                    this.selection.toggleFrameSelection(this.presentation.frames[i]);
                }
            }
            // TODO toggle from last selected layer to current
        }
        else {
            this.selection.selectedLayers = layers.slice();
            this.selection.selectedFrames = [frame];
        }

        this.updateCameraSelection();

        this.emit("editorStateChange");
        this.emit("repaint");
    }

    /** Toggle the visibility of the given layers.
     *
     * If the layers becomes visible, they are added to the selection,
     * otherwise, they are removed from the selection.
     *
     * @param {module:model/Presentation.Layer[]} layers - The layers to show or hide.
     *
     * @fires module:Controller.editorStateChange
     * @fires module:Controller.repaint
     */
    updateLayerVisibility(layers) {
        for (let layer of layers) {
            layer.isVisible = !layer.isVisible;
            if (layer.isVisible) {
                this.selection.addLayer(layer);
            }
            else {
                this.selection.removeLayer(layer);
            }
        }

        this.emit("editorStateChange");
        this.emit("repaint");
    }

    /** Reset the selected layers to their initial state.
     *
     * This method puts all selected layers, in all selected frames,
     * in the same state as if the SVG was just opened.
     * Users can perform this operation to recover from a sequence of transformations
     * that resulted in an undesired layer state.
     *
     * This action supports undo and redo.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    resetLayer() {
        const selectedFrames = this.selection.selectedFrames.slice();
        const selectedLayers = this.selection.selectedLayers.slice();

        const savedValues = selectedFrames.map(
            frame => selectedLayers.map(
                layer => ({
                    link: frame.layerProperties[layer.index].link
                })
            )
        );

        const savedCameraStates = selectedFrames.map(
            frame => selectedLayers.map(
                layer => new CameraState(frame.cameraStates[layer.index])
            )
        );

        this.perform(
            function onDo() {
                for (let frame of selectedFrames) {
                    for (let layer of selectedLayers) {
                        frame.cameraStates[layer.index].copy(this.presentation.initialCameraState);
                        frame.layerProperties[layer.index].link = false;
                    }

                    this.presentation.updateLinkedLayers();
                }
            },
            function onUndo() {
                selectedFrames.forEach((frame, frameIndex) => {
                    selectedLayers.forEach((layer, layerIndex) => {
                        frame.cameraStates[layer.index].copy(savedCameraStates[frameIndex][layerIndex]);
                        frame.layerProperties[layer.index].link = savedValues[frameIndex][layerIndex].link;
                    });

                    this.presentation.updateLinkedLayers();
                });
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Copy the properties of a given layer into the selected layers.
     *
     * This method copies the layer properties and geometrical transformations
     * for the given layer in the selected frames, into the selected layers
     * in the same frames.
     *
     * This action supports undo and redo.
     *
     * @param {number} groupId - The ID of the SVG group for the layer to copy.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    copyLayer(groupId) {
        const selectedFrames = this.selection.selectedFrames.slice();
        const selectedLayers = this.selection.selectedLayers.slice();
        const savedValues = selectedFrames.map(
            frame => selectedLayers.map(
                layer => new LayerProperties(frame.layerProperties[layer.index])
            )
        );

        const savedCameraStates = selectedFrames.map(
            frame => selectedLayers.map(
                layer => new CameraState(frame.cameraStates[layer.index])
            )
        );

        // Find the layer to copy.
        // If the given group id corresponds to the "default" layer set,
        // choose the first layer that contains an SVG element.
        // Otherwise, choose the layer with the given id.
        const layerToCopy = groupId == "__default__" ?
            this.defaultLayers.filter(layer => layer.svgNodes.length > 0)[0] :
            this.presentation.getLayerWithId(groupId);
        if (!layerToCopy || !layerToCopy.svgNodes.length) {
            return;
        }

        this.perform(
            function onDo() {
                for (let frame of selectedFrames) {
                    for (let layer of selectedLayers) {
                        if (layer != layerToCopy) {
                            frame.layerProperties[layer.index].copy(frame.layerProperties[layerToCopy.index]);
                            frame.cameraStates[layer.index].copy(frame.cameraStates[layerToCopy.index]);
                            if (frame.index === 0 || !this.selection.hasFrames([this.presentation.frames[frame.index - 1]])) {
                                frame.layerProperties[layer.index].link = false;
                            }
                        }
                    }
                }
                this.presentation.updateLinkedLayers();
            },
            function onUndo() {
                selectedFrames.forEach((frame, frameIndex) => {
                    selectedLayers.forEach((layer, layerIndex) => {
                        frame.layerProperties[layer.index].copy(savedValues[frameIndex][layerIndex]);
                        frame.cameraStates[layer.index].copy(savedCameraStates[frameIndex][layerIndex]);
                    });
                });
                this.presentation.updateLinkedLayers();
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** `true` if the "Fit Element" function is available.
     *
     * This property is `true` when the outline element of each selected layer belongs to this layer.
     *
     * @readonly
     * @type {boolean}
     */
    get canFitElement() {
        return this.selection.selectedFrames.length === 1 &&
               this.selection.selectedLayers.length >= 1 &&
               this.selection.selectedLayers.every(layer => {
                   const id = this.selection.currentFrame.layerProperties[layer.index].outlineElementId;
                   const elt = this.presentation.document.root.getElementById(id);
                   return elt && this.selection.selectedLayers.some(l => l.contains(elt));
               });
    }

    /** Find and assign an outline element to the current frame in the selected layers.
     *
     * @see {@linkcode module:player/Camera.Camera#getCandidateReferenceElement}
     * @see {@linkcode module:Controller.Controller#setOutlineElement}
     */
    autoselectOutlineElement() {
        const currentFrame = this.selection.currentFrame;
        if (!currentFrame) {
            return;
        }

        let outlineElt = null, outlineScore = null;

        for (let layer of this.selection.selectedLayers) {
            const {element, score} = this.viewport.cameras[layer.index].getCandidateReferenceElement();
            if (element && (outlineScore === null || score < outlineScore)) {
                outlineElt   = element;
                outlineScore = score;
            }
        }

        if (outlineElt) {
            this.setOutlineElement(outlineElt);
        }
    }

    /** Use the element with the user-provided ID as an outline element.
     *
     * @see {@linkcode module:Controller.Controller#setOutlineElement}
     */
    fitElement() {
        const outlineElementId = this.getLayerProperty("outlineElementId");
        const outlineElt = this.presentation.document.root.getElementById(outlineElementId);
        if (outlineElt) {
            this.setOutlineElement(outlineElt);
        }
    }

    /** Get a property of the current presentation.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign getters
     * to HTML fields that represent presentation properties.
     *
     * @param {string} property - The name of the property to get.
     * @returns {any} - The value of the property.
     */
    getPresentationProperty(property) {
        return this.presentation[property];
    }

    /** Set a property of the current presentation.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign setters
     * to HTML fields that represent presentation properties.
     *
     * This action supports undo and redo.
     *
     * @param {string} property - The name of the property to set.
     * @param {any} newValue - The new value of the property.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    setPresentationProperty(property, newValue) {
        const pres = this.presentation;
        const savedValue = pres[property];

        this.perform(
            function onDo() {
                pres[property] = newValue;
            },
            function onUndo() {
                pres[property] = savedValue;
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Get a property of the selected frames.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign getters
     * to HTML fields that represent frame properties.
     *
     * @param {string} property - The name of the property to get.
     * @returns {Array} The values of the property in the selected frames.
     */
    getFrameProperty(property) {
        const values = [];

        for (let frame of this.selection.selectedFrames) {
            const current = frame[property];
            if (values.indexOf(current) < 0) {
                values.push(current);
            }
        }

        return values;
    }

    /** Set a property of the selected frames.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign setters
     * to HTML fields that represent frame properties.
     *
     * This action supports undo and redo.
     *
     * @param {string} property - The name of the property to set.
     * @param {any} newValue - The new value of the property.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    setFrameProperty(property, newValue) {
        const savedValues = this.selection.selectedFrames.map(frame => [frame, frame[property]]);

        this.perform(
            function onDo() {
                for (let [frame, value] of savedValues) {
                    frame[property] = newValue;
                }
            },
            function onUndo() {
                for (let [frame, value] of savedValues) {
                    frame[property] = value;
                }
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Get a property of the selected layers in the selected frames.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign getters
     * to HTML fields that represent layer properties.
     *
     * @param {string} property - The name of the property to get.
     * @returns {Array} The values of the property in the selected layers.
     */
    getLayerProperty(property) {
        const values = [];

        for (let frame of this.selection.selectedFrames) {
            for (let layer of this.selection.selectedLayers) {
                const current = frame.layerProperties[layer.index][property];
                if (values.indexOf(current) < 0) {
                    values.push(current);
                }
            }
        }

        return values;
    }

    /** Set a property of the selected layers in the selected frames.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign setters
     * to HTML fields that represent layer properties.
     *
     * This action supports undo and redo.
     *
     * @param {string} property - The name of the property to set.
     * @param {any} newValue - The new value of the property.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    setLayerProperty(property, newValue) {
        if (property === "outlineElementId") {
            const outlineElt = this.presentation.document.root.getElementById(newValue);
            if (outlineElt) {
                this.setOutlineElement(outlineElt);
            }
            return;
        }

        const selectedFrames = this.selection.selectedFrames.slice();
        const selectedLayers = this.selection.selectedLayers.slice();
        const savedValues = selectedFrames.map(
            frame => selectedLayers.map(
                layer => frame.layerProperties[layer.index][property]
            )
        );

        const link = property === "link" && newValue;

        const savedCameraStates = selectedFrames.map(
            frame => selectedLayers.map(
                layer => new CameraState(frame.cameraStates[layer.index])
            )
        );

        this.perform(
            function onDo() {
                for (let frame of selectedFrames) {
                    for (let layer of selectedLayers) {
                        frame.layerProperties[layer.index][property] = newValue;
                    }
                }

                this.presentation.updateLinkedLayers();
            },
            function onUndo() {
                selectedFrames.forEach((frame, frameIndex) => {
                    selectedLayers.forEach((layer, layerIndex) => {
                        frame.layerProperties[layer.index][property] = savedValues[frameIndex][layerIndex];
                        if (link) {
                            frame.cameraStates[layer.index].copy(savedCameraStates[frameIndex][layerIndex]);
                        }
                    });
                });

                this.presentation.updateLinkedLayers();
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Get a property of the selected cameras in the selected frames.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign getters
     * to HTML fields that represent camera properties.
     *
     * @param {string} property - The name of the property to get.
     * @returns {Array} The values of the property in the selected cameras.
     */
    getCameraProperty(property) {
        const values = [];

        for (let frame of this.selection.selectedFrames) {
            for (let layer of this.selection.selectedLayers) {
                const current = frame.cameraStates[layer.index][property];
                if (values.indexOf(current) < 0) {
                    values.push(current);
                }
            }
        }

        return values;
    }

    /** Set a property of the selected cameras in the selected frames.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign setters
     * to HTML fields that represent camera properties.
     *
     * This action supports undo and redo.
     *
     * @param {string} property - The name of the property to set.
     * @param {any} newValue - The new value of the property.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    setCameraProperty(property, newValue) {
        const selectedFrames = this.selection.selectedFrames.slice();
        const selectedLayers = this.selection.selectedLayers.slice();

        const savedValues = selectedFrames.map(
            frame => selectedLayers.map(
                layer => ({
                    prop: frame.cameraStates[layer.index][property],
                    link: frame.layerProperties[layer.index].link
                })
            )
        );

        this.perform(
            function onDo() {
                for (let frame of selectedFrames) {
                    for (let layer of selectedLayers) {
                        frame.cameraStates[layer.index][property] = newValue;
                        frame.layerProperties[layer.index].link = false;
                    }
                }

                this.presentation.updateLinkedLayers();
            },
            function onUndo() {
                selectedFrames.forEach((frame, frameIndex) => {
                    selectedLayers.forEach((layer, layerIndex) => {
                        frame.cameraStates[layer.index][property] = savedValues[frameIndex][layerIndex].prop;
                        frame.layerProperties[layer.index].link = savedValues[frameIndex][layerIndex].link;
                    });
                });

                this.presentation.updateLinkedLayers();
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Update the cameras in the current selection based on the current viewport state.
     *
     * This method is called after a user action that modifies the cameras in the
     * viewport. It copies the camera states of the viewport to the camera states
     * of the selected layers of the presentation.
     *
     * This action supports undo and redo.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    updateCameraStates() {
        const currentFrame = this.selection.currentFrame;
        if (!currentFrame) {
            return;
        }

        const savedFrame    = new Frame(currentFrame);
        const modifiedFrame = new Frame(currentFrame);

        for (let layer of this.selection.selectedLayers) {
            const camera = this.viewport.cameras[layer.index];

            // Update the camera states of the current frame
            modifiedFrame.cameraStates[layer.index].copy(camera);

            // We will update the layer properties corresponding to the
            // current camera in the modified frame
            const layerProperties = modifiedFrame.layerProperties[layer.index];

            // Mark the modified layers as unlinked in the current frame
            layerProperties.link = false;

            // Update the reference SVG element if applicable.
            const {element} = camera.getCandidateReferenceElement();
            if (element) {
                layerProperties.referenceElementId = element.getAttribute("id");
            }
        }

        this.perform(
            function onDo() {
                currentFrame.copy(modifiedFrame, true);
                this.presentation.updateLinkedLayers();
            },
            function onUndo() {
                currentFrame.copy(savedFrame, true);
                this.presentation.updateLinkedLayers();
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Set the given element as the outline element of the selected layers in the current frame.
     *
     * This action supports undo and redo.
     *
     * @param {SVGElement} outlineElement - The element to use as an outline of the selected layers.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    setOutlineElement(outlineElement) {
        const currentFrame = this.selection.currentFrame;
        if (!currentFrame) {
            return;
        }

        const savedFrame    = new Frame(currentFrame);
        const modifiedFrame = new Frame(currentFrame);

        // Find the layer that contains the given element
        let outlineLayerGroup = outlineElement;
        while (outlineLayerGroup.parentNode.parentNode.parentNode !== this.presentation.document.root) {
            outlineLayerGroup = outlineLayerGroup.parentNode;
        }
        const outlineLayer = this.presentation.getLayerWithId(outlineLayerGroup.id);

        // Compute the offset in that layer.
        const outlineState = currentFrame.cameraStates[outlineLayer.index];
        const outlineAngle = outlineState.angle;
        const offset       = outlineState.offsetFromElement(outlineElement);

        // Compute the scaling factor to apply.
        const outlineCam   = this.viewport.cameras[outlineLayer.index];
        const outlineScale = outlineCam.scale;
        outlineCam.applyOffset(offset);
        const scalingFactor = outlineCam.scale / outlineScale;

        for (let layer of this.selection.selectedLayers) {
            const cameraState = modifiedFrame.cameraStates[layer.index];

            if (layer === outlineLayer) {
                cameraState.applyOffset(offset);
            }
            else {
                const camera   = this.viewport.cameras[layer.index];
                const angleRad = (cameraState.angle - outlineAngle) * Math.PI / 180;
                const si       = Math.sin(angleRad) * outlineScale / camera.scale;
                const co       = Math.cos(angleRad) * outlineScale / camera.scale;

                cameraState.applyOffset({
                    deltaX:       offset.deltaX * co - offset.deltaY * si,
                    deltaY:       offset.deltaX * si + offset.deltaY * co,
                    widthFactor:  scalingFactor,
                    heightFactor: scalingFactor,
                    deltaAngle:   offset.deltaAngle
                });
            }

            cameraState.resetClipping();

            const layerProperties            = modifiedFrame.layerProperties[layer.index];
            layerProperties.link             = false;
            layerProperties.outlineElementId = outlineElement.getAttribute("id");
        }

        this.perform(
            function onDo() {
                currentFrame.copy(modifiedFrame, true);
                this.presentation.updateLinkedLayers();
            },
            function onUndo() {
                currentFrame.copy(savedFrame, true);
                this.presentation.updateLinkedLayers();
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Set the width component (numerator) of the current aspect ratio of the preview area.
     *
     * This action supports undo and redo.
     *
     * @param {number} width - The desired width.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    setAspectWidth(width) {
        const widthPrev = this.presentation.aspectWidth;
        this.perform(
            function onDo() {
                this.presentation.aspectWidth = width;
            },
            function onUndo() {
                this.presentation.aspectWidth = widthPrev;
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Set the height component (denominator) of the current aspect ratio of the preview area.
     *
     * This action supports undo and redo.
     *
     * @param {number} height - The desired height.
     *
     * @fires module:Controller.presentationChange
     * @fires module:Controller.repaint
     */
    setAspectHeight(height) {
        const heightPrev = this.presentation.aspectHeight;
        this.perform(
            function onDo() {
                this.presentation.aspectHeight = height;
            },
            function onUndo() {
                this.presentation.aspectHeight = heightPrev;
            },
            false,
            ["presentationChange", "repaint"]
        );
    }

    /** Set the effect of dragging in the preview area.
     *
     * @param {string} dragMode - The new drag mode.
     *
     * @fires module:Controller.repaint
     *
     * @see {@linkcode module:player/Viewport.Viewport#dragMode}
     */
    setDragMode(dragMode) {
        this.viewport.dragMode = dragMode;
        this.emit("repaint");
    }

    /** Get a property of the preferences object.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign getters
     * to HTML fields that represent preferences.
     *
     * @param {string} key - The name of the property to get.
     * @returns {any} - The value of the property in the preferences.
     */
    getPreference(key) {
        return this.preferences[key];
    }

    /** Set a property of the preferences object.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign setters
     * to HTML fields that represent preferences.
     *
     * This operation cannot be undone.
     *
     * @param {string} key - The name of the property to set.
     * @param {any} newValue - The new value of the property.
     *
     * @fires module:Controller.repaint
     */
    setPreference(key, newValue) {
        this.preferences[key] = newValue;
        this.applyPreferences({[key]: true});
    }

    /** Get the keyboard shortcut for a given action.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign getters
     * to HTML fields that represent keyboard shortcut preferences.
     *
     * @param {string} action - A supported keyboard action name.
     * @returns {string} - A shortcut definition.
     */
    getShortcut(action) {
        return this.preferences.keys[action];
    }

    /** Set a keyboard shortcut for a given action.
     *
     * This method is used in the {@linkcode module:view/Properties.Properties|Properties} view to assign setters
     * to HTML fields that represent keyboard shortcut preferences.
     *
     * This operation cannot be undone.
     *
     * @param {string} action - A supported keyboard action name.
     * @param {any} newValue - A shortcut definition.
     */
    setShortcut(action, newValue) {
        // Find occurrences of modifier keys in the given value.
        let ctrl  = /\bCtrl\s*\+/i.test(newValue);
        let alt   = /\bAlt\s*\+/i.test(newValue);
        let shift = /\bShift\s*\+/i.test(newValue);

        // Remove all occurrences of modifier keys and spaces in the given value.
        newValue = newValue.replace(/\bCtrl\s*\+\s*/gi, "");
        newValue = newValue.replace(/\bAlt\s*\+\s*/gi, "");
        newValue = newValue.replace(/\bShift\s*\+\s*/gi, "");
        newValue = newValue.replace(/\s/g, "").toUpperCase();
        if (newValue.length === 0) {
            return;
        }

        // Rewrite the shortcut in standard order.
        if (shift) {
            newValue = "Shift+" + newValue;
        }
        if (alt) {
            newValue = "Alt+" + newValue;
        }
        if (ctrl) {
            newValue = "Ctrl+" + newValue;
        }
        this.preferences.keys[action] = newValue;
    }

    /** Update the user interface after modifying the preferences.
     *
     * @param {string|object} [changed="all"] - A dictionay that maps changed property names to a boolean `true`.
     *
     * @fires module:Controller.repaint
     */
    applyPreferences(changed="all") {
        if ((changed === "all" || changed.fontSize) && this.preferences.fontSize > 0) {
            document.body.style.fontSize = this.preferences.fontSize + "pt";
        }

        if (changed === "all" || changed.language) {
            const locale = i18n.init(this.preferences.language);
            this.gettext = s => locale.gettext(s);
        }

        this.emit("repaint");
    }

    /** Toggle the "exporting" state */
    toggleExportState() {
        this.exporting = !this.exporting;
        this.emit("repaint");
    }

    /** Export the current presentation to PDF. */
    async exportToPDF() {
        const _ = this.gettext;
        this.toggleExportState();
        await this.save();
        try {
            await exporter.exportToPDF(this.presentation, this.storage.htmlFileDescriptor);
            this.info(_("Presentation was exported to PDF."));
        }
        catch (e) {
            this.error(_("Failed to write PDF file."));
            console.log(e);
        }
        this.toggleExportState();
    }

    /** Export the current presentation to PPTX. */
    async exportToPPTX() {
        const _ = this.gettext;
        this.toggleExportState();
        await this.save();
        try {
            await exporter.exportToPPTX(this.presentation, this.storage.htmlFileDescriptor);
            this.info(_("Presentation was exported to PPTX."));
        }
        catch (e) {
            this.error(_("Failed to write PPTX file."));
            console.log(e);
        }
        this.toggleExportState();
    }

    /** Export the current presentation to video. */
    async exportToVideo() {
        const _ = this.gettext;
        this.toggleExportState();
        await this.save();
        try {
            await exporter.exportToVideo(this.presentation, this.storage.htmlFileDescriptor);
            this.info(_("Presentation was exported to video."));
        }
        catch (e) {
            this.error(_("Failed to write video file."));
            console.log(e);
        }
        this.toggleExportState();
    }

    /** Perform an operation with undo/redo support.
     *
     * This method call `onDo`, adds an operation record to the
     * {@link module:Controller.Controller#undoStack|undo stack}, and clears the
     * {@link module:Controller.Controller#undoStack|redo stack}.
     *
     * @param {function()} onDo - The function that performs the operation.
     * @param {function()} onUndo - The function that undoes the operation.
     * @param {boolean} updateSelection - If `true`, restore the selection when undoing.
     * @param {string[]} events - Emit the given events when performing of undoing the operation.
     */
    perform(onDo, onUndo, updateSelection, events) {
        const action = {onDo, onUndo, updateSelection, events};
        if (updateSelection) {
            action.selectedFrames = this.selection.selectedFrames.slice();
            action.selectedLayers = this.selection.selectedLayers.slice();
        }
        this.undoStack.push(action);
        while (this.undoStack.length > UNDO_STACK_LIMIT) {
          this.undoStack.shift();
        }
        this.redoStack = [];
        onDo.call(this);
        for (let evt of events) {
            this.emit(evt);
        }
    }

    /** Undo an operation.
     *
     * This method pops and executes an operation from the {@link module:Controller.Controller#undoStack|undo stack}.
     * It updates the selection and emits events as specified in the corresponding
     * call to {@linkcode module:Controller.Controller#peform|perform}.
     * The operation record is pushed to the {@link module:Controller.Controller#redoStack|redo stack}
     */
    undo() {
        if (!this.undoStack.length) {
            return;
        }
        const action = this.undoStack.pop();
        this.redoStack.push(action);
        action.onUndo.call(this);
        if (action.updateSelection) {
            this.selection.selectedFrames = action.selectedFrames.slice();
            this.selection.selectedLayers = action.selectedLayers.slice();
        }
        for (let evt of action.events) {
            this.emit(evt);
        }
    }

    /** Redo an operation.
     *
     * This method pops and executes an operation from the {@link module:Controller.Controller#undoStack|redo stack}.
     * It updates the selection and emits events as specified in the corresponding
     * call to {@linkcode module:Controller.Controller#peform|perform}.
     * The operation record is pushed to the {@link module:Controller.Controller#redoStack|undo stack}
     */
    redo() {
        if (!this.redoStack.length) {
            return;
        }
        const action = this.redoStack.pop();
        this.undoStack.push(action);
        action.onDo.call(this);
        for (let evt of action.events) {
            this.emit(evt);
        }
    }
}