Source: view/Timeline.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 {h} from "inferno-hyperscript";
import {VirtualDOMView} from "./VirtualDOMView";
import Jed from "jed";

/** Timeline pane of the presentation editor.
 *
 * The Timeline view shows a table where columns represent frames
 * and rows represent layers.
 * The user can choose to manipulate only a subset of the layers of the document.
 * The other layers are grouped into a "Default" row.
 *
 * The Timeline view allows the user to:
 *  - choose editable layers from the document
 *  - show/hide layers
 *  - create/delete/reorder frames
 *  - add/remove layers and frames to/from the selection
 *
 * @extends module:view/VirtualDOMView.VirtualDOMView
 * @todo Add documentation
 */
export class Timeline extends VirtualDOMView {

    /** Initialize a new timeline view.
     *
     * @param {HTMLElement} container - The HTML element that will contain this preview area.
     * @param {module:model/Presentation.Presentation} presentation - The current Sozi presentation.
     * @param {module:model/Selection.Selection} selection - The object that manages the frame and layer selection.
     * @param {module:Controller.Controller} controller - The controller that manages the current editor.
     */
    constructor(container, presentation, selection, controller) {
        super(container, controller);

        /** The current Sozi presentation.
         *
         * @type {module:model/Presentation.Presentation} */
        this.presentation = presentation;

        /** The object that manages the frame and layer selection.
         *
         * @type {module:model/Selection.Selection} */
        this.selection = selection;
    }

    /** Toggle the visibility of a layer in the preview area.
     *
     * @param {number} layerIndex - The index of the layer to change.
     * @param {MouseEvent} evt - The DOM event that triggered this action.
     *
     * @listens click
     *
     * @see {@linkcode module:Controller.Controller#updateLayerVisibility}
     */
    toggleLayerVisibility(layerIndex, evt) {
        this.controller.updateLayerVisibility(this.controller.getLayersAtIndex(layerIndex));
        evt.stopPropagation();
    }

    /** Update the list of selected frames.
     *
     * This method is called when the user clicks a frame heading in the timeline.
     *
     * @param {number} frameIndex - The index of the frame to add or remove.
     * @param {MouseEvent} evt - The DOM event that triggered this action.
     *
     * @listens click
     *
     * @see {@linkcode module:Controller.Controller#updateFrameSelection}
     */
    updateFrameSelection(frameIndex, evt) {
        this.controller.updateFrameSelection(evt.ctrlKey, evt.shiftKey, frameIndex);
        evt.stopPropagation();
    }

    /** Update the list of selected layers.
     *
     * This method is called when the user clicks a layer heading in the timeline.
     *
     * @param {number} layerIndex - The index of the layer to add or remove.
     * @param {MouseEvent} evt - The DOM event that triggered this action.
     *
     * @listens click
     *
     * @see {@linkcode module:Controller.Controller#updateLayerSelection}
     */
    updateLayerSelection(layerIndex, evt) {
        this.controller.updateLayerSelection(evt.ctrlKey, evt.shiftKey, this.controller.getLayersAtIndex(layerIndex));
        evt.stopPropagation();
    }

    /** Update the list of selected frames and layers.
     *
     * This method is called when the user clicks a cell inside the timeline table.
     *
     * @param {number} layerIndex - The index of the layer to add or remove.
     * @param {number} frameIndex - The index of the frame to add or remove.
     * @param {MouseEvent} evt - The DOM event that triggered this action.
     *
     * @listens click
     *
     * @see {@linkcode module:Controller.Controller#updateLayerSelection}
     */
    updateLayerAndFrameSelection(layerIndex, frameIndex, evt) {
        this.controller.updateLayerAndFrameSelection(evt.ctrlKey, evt.shiftKey, this.controller.getLayersAtIndex(layerIndex), frameIndex);
        evt.stopPropagation();
    }

    /** @inheritdoc */
    repaint() {
        super.repaint();

        const topLeft = this.container.querySelector(".timeline-top-left");
        const topRight = this.container.querySelector(".timeline-top-right");
        const bottomLeft = this.container.querySelector(".timeline-bottom-left");
        const bottomRight = this.container.querySelector(".timeline-bottom-right");

        const topLeftTable = topLeft.querySelector("table");
        const topRightTable = topRight.querySelector("table");
        const bottomLeftTable = bottomLeft.querySelector("table");

        const leftWidth = Math.max(topLeftTable.clientWidth, bottomLeftTable.clientWidth);
        const rightWidth = this.container.clientWidth - leftWidth;
        const topHeight = Math.max(topLeftTable.clientHeight, topRightTable.clientHeight);
        const bottomHeight = this.container.clientHeight - topHeight;

        // Fit the width of the left tables,
        // allocate remaining width to the right tables
        topLeft.style.width = bottomLeft.style.width =
        topLeftTable.style.width = bottomLeftTable.style.width = leftWidth + "px";
        topRight.style.width = bottomRight.style.width = rightWidth + "px";

        // Fit the height of the top tables,
        // allocate remaining width to the bottom tables
        topLeft.style.height = topRight.style.height = topHeight + "px";
        bottomLeft.style.height = bottomRight.style.height = bottomHeight + "px";

        // Corresponding rows in left and right tables must have the same height
        const leftRows  = document.querySelectorAll(".timeline-top-left tr,  .timeline-bottom-left tr");
        const rightRows = document.querySelectorAll(".timeline-top-right tr, .timeline-bottom-right tr");
        leftRows.forEach((leftRow, rowIndex) => {
            const rightRow = rightRows[rowIndex];
            const maxHeight = Math.max(leftRow.clientHeight, rightRow.clientHeight);
            leftRow.style.height = rightRow.style.height = maxHeight + "px";
        });
    }

    /** @inheritdoc */
    render() {
        const controller = this.controller;
        const _ = controller.gettext;

        let even = true;
        function updateEven(frame, layer) {
            if (frame.index === 0) {
                even = true;
            }
            else if (!frame.layerProperties[layer.index].link) {
                even = !even;
            }
            return even;
        }

        function isLinked(frame, layer) {
            return frame.index > 0 && frame.layerProperties[layer.index].link;
        }

        function hasNoReferenceElement(frame, layer) {
            return !isLinked(frame, layer) && !frame.layerProperties[layer.index].referenceElement;
        }

        const defaultLayersAreVisible = controller.defaultLayers.some(layer => layer.isVisible);

        return h("div", [
            h("div.timeline-top-left", [
                h("table.timeline", [
                    h("tr", [
                        h("th", [
                            h("button", {
                                title: _("Delete the selected frames"),
                                disabled: this.selection.selectedFrames.length ? undefined : "disabled",
                                onclick() { controller.deleteFrames(); }
                            }, h("i.fas.fa-trash"))
                        ]),
                        h("th", [
                            h("button", {
                                title: _("Create a new frame"),
                                onclick() { controller.addFrame(); }
                            }, h("i.fas.fa-plus"))
                        ]),
                    ]),
                    h("tr", [
                        h("th", {colspan: 2},
                            h("select", {
                                onchange: evt => {
                                    const value = evt.target.value;
                                    evt.target.value = "__sozi_add__";
                                    if (value === "__sozi_add_all__") {
                                        controller.addAllLayers();
                                    }
                                    else {
                                        controller.addLayer(value);
                                    }
                                }
                            }, [
                                h("option", {value: "__sozi_add__", selected: "selected"}, _("Add layer")),
                                h("option", {value: "__sozi_add_all__"}, _("Add all layers")),
                                this.presentation.layers.slice().reverse()
                                    .filter(layer => !layer.auto && controller.defaultLayers.indexOf(layer) >= 0)
                                    .map(layer => h("option", {value: layer.index}, layer.label))
                            ])
                        )
                    ])
                ])
            ]),
            h("div.timeline-bottom-left", [
                h("table.timeline", [
                    controller.hasDefaultLayer ? h("tr", [
                        h("th.layer-icons", [
                            defaultLayersAreVisible ?
                                h("i.visibility.far.fa-eye", {
                                    title: _("This layer is visible. Click to hide it."),
                                    onclick: evt => this.toggleLayerVisibility(-1, evt)
                                }) :
                                h("i.visibility.far.fa-eye-slash", {
                                    title: _("This layer is hidden. Click to show it."),
                                    onclick: evt => this.toggleLayerVisibility(-1, evt)
                                }),
                            h("i.remove.fas.fa-times", {style: {visibility: "hidden"}})
                        ]),
                        h("th", {
                            className: "layer-label" + (controller.defaultLayersAreSelected ? " selected" : ""),
                            onclick: evt => this.updateLayerSelection(-1, evt)
                        }, _("Default"))
                    ]) : null,
                    this.presentation.layers.slice().reverse()
                        .filter(layer => controller.editableLayers.indexOf(layer) >= 0)
                        .map(layer => h("tr", [
                                h("th.layer-icons", [
                                    layer.isVisible ?
                                        h("i.visibility.far.fa-eye", {
                                            title: _("This layer is visible. Click to hide it."),
                                            onclick: evt => this.toggleLayerVisibility(layer.index, evt)
                                        }) :
                                        h("i.visibility.far.fa-eye-slash", {
                                            title: _("This layer is hidden. Click to show it."),
                                            onclick: evt => this.toggleLayerVisibility(layer.index, evt)
                                        }),
                                    h("i.remove.fas.fa-times", {
                                        title: _("Remove this layer"),
                                        onclick: () => controller.removeLayer(layer.index)
                                    })
                                ]),
                                h("th", {
                                    className: "layer-label" + (this.selection.selectedLayers.indexOf(layer) >= 0 ? " selected" : ""),
                                    onclick: evt => this.updateLayerSelection(layer.index, evt)
                                }, layer.label)
                            ])
                        ),
                    h("tr", {style: {visibility: "collapse"}}, [
                        h("th.layer-icons"),
                        h("th.layer-label", _("Default"))
                    ])
                ])
            ]),
            h("div.timeline-top-right", [
                h("table.timeline", [
                    h("tr", this.presentation.frames.map((frame, frameIndex)  => h("th", {
                            className: "frame-index" +
                                (this.selection.selectedFrames.indexOf(frame) >= 0 ? " selected" : "") +
                                (frame === this.selection.currentFrame ? " current" : ""),
                            onclick: evt => this.updateFrameSelection(frameIndex, evt)
                        }, [
                            h("i.insert-before.fas.fa-arrow-circle-down", {
                                title: Jed.sprintf(_("Insert selection before frame %d"), frameIndex + 1),
                                onclick(evt) {
                                    controller.moveFrames(frameIndex);
                                    evt.stopPropagation();
                                }
                            }),
                            h("i.insert-after.fas.fa-arrow-circle-down", {
                                title: Jed.sprintf(_("Insert selection after frame %d"), frameIndex + 1),
                                onclick(evt) {
                                    controller.moveFrames(frameIndex + 1);
                                    evt.stopPropagation();
                                }
                            }),
                            (frameIndex + 1).toString()
                        ])
                    )),
                    h("tr",
                      this.presentation.frames.map((frame, frameIndex) => h("th", {
                                title: frame.title,
                                className: "frame-title" +
                                    (this.selection.selectedFrames.indexOf(frame) >= 0 ? " selected" : "") +
                                    (frame === this.selection.currentFrame ? " current" : ""),
                                onclick: evt => this.updateFrameSelection(frameIndex, evt)
                            }, frame.title)
                        )
                     )
                ])
            ]),
            h("div.timeline-bottom-right", {
                onscroll: evt => {
                    this.container.querySelector(".timeline-top-right").scrollLeft = evt.target.scrollLeft;
                    this.container.querySelector(".timeline-bottom-left").scrollTop = evt.target.scrollTop;
                }
            }, h("table.timeline", [
                    controller.hasDefaultLayer ? h("tr",
                        this.presentation.frames.map((frame, frameIndex) => h("td", {
                            className:
                                (controller.defaultLayersAreSelected && this.selection.selectedFrames.indexOf(frame) >= 0 ? "selected" : "") +
                                (frame === this.selection.currentFrame ? " current" : "") +
                                (isLinked(frame, controller.defaultLayers[0]) ? " link" : "") +
                                (updateEven(frame, controller.defaultLayers[0]) ? " even" : " odd"),
                            onclick: evt => this.updateLayerAndFrameSelection(-1, frameIndex, evt)
                        }, hasNoReferenceElement(frame, controller.defaultLayers[0]) ? h("i.fas.fa-exclamation-triangle", {title: _("You should add graphic elements in the current area to help Sozi keep track of this layer's position.")}) : null))
                    ) : null,
                    this.presentation.layers.slice().reverse()
                        .filter(layer => controller.editableLayers.indexOf(layer) >= 0)
                        .map(layer => h("tr",
                            this.presentation.frames.map((frame, frameIndex) => h("td", {
                                className:
                                    (this.selection.selectedLayers.indexOf(layer) >= 0 && this.selection.selectedFrames.indexOf(frame) >= 0 ? "selected" : "") +
                                    (frame === this.selection.currentFrame ? " current" : "") +
                                    (isLinked(frame, layer) ? " link" : "") +
                                    (updateEven(frame, layer) ? " even" : " odd"),
                                onclick: evt => this.updateLayerAndFrameSelection(layer.index, frameIndex, evt)
                            }, hasNoReferenceElement(frame, layer) ? h("i.fas.fa-exclamation-triangle", {title: _("You should add graphic elements in the current area to help Sozi keep track of this layer's position.")}) : null)
                        ))),
                    h("tr.collapse",
                        this.presentation.frames.map(frame => h("td", frame.title))
                    )
                ])
            )
        ]);
    }
}