Source: player/Viewport.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 {Camera} from "./Camera";
import {EventEmitter} from "events";

/** The mouse button for the drag action.
 *
 * 0 is the left button.
 *
 * @readonly
 * @default
 * @type {number} */
const DRAG_BUTTON = 0;

/** The minimum distance to detect a drag action, in pixels.
 *
 * @readonly
 * @default
 * @type {number} */
const DRAG_THRESHOLD_PX = 5;

/** The zoom step factor for user zoom action (keyboard and mouse wheel).
 *
 * @readonly
 * @default
 * @type {number} */
const SCALE_FACTOR = 1.05;

/** The rotation step angle for user rotate action (keyboard and mouse wheel), in degrees.
 *
 * @readonly
 * @default
 * @type {number} */
const ROTATE_STEP = 5;

/** The delay after the last mouse wheel event to consider that the wheel action is terminated, in milliseconds.
 *
 * @readonly
 * @default
 * @type {number} */
const WHEEL_TIMEOUT_MS = 200;

/** The thickness of the rectangle where the mouse can resize the clipping rectangle.
 *
 * @readonly
 * @default
 * @type {number} */
const CLIP_BORDER = 3;

/** Signals a mouse click in a viewport.
 *
 * @event module:player/Viewport.click
 */

/** Signals a mouse button press in a viewport.
 *
 * @event module:player/Viewport.mouseDown
 */

/** Signals the possible start of a drag gesture in a viewport.
 *
 * @event module:player/Viewport.dragStart
 */

/** Signals the end of a drag gesture in a viewport.
 *
 * @event module:player/Viewport.dragEnd
 */

/** Signals a user-activated change in the camera states of a viewport.
 *
 * @event module:player/Viewport.userChangeState
 */

/** Viewing area for Sozi presentation.
 *
 * @extends EventEmitter
 */
export class Viewport extends EventEmitter {

    /** Initialize a new viewport for the given presentation.
     *
     * @param {module:model/Presentation.Presentation} presentation - The presentations to display.
     * @param {boolean} editMode - Is the presentation opened in an editor?
     */
    constructor(presentation, editMode) {
        super();

        /** The presentations to display.
         *
         * @type {module:model/Presentation.Presentation} */
        this.presentation = presentation;

        /** Is the presentation opened in an editor?
         *
         * @type {boolean} */
        this.editMode = !!editMode;

        /** The cameras that operate in this viewport.
         *
         * @default
         * @type {module:player/Camera.Camera[]} */
        this.cameras = [];

        /** The current X coordinate of the mous during a drag action.
         *
         * @default
         * @type {number} */
        this.mouseDragX = 0;

        /** The current Y coordinate of the mous during a drag action.
         *
         * @default
         * @type {number} */
        this.mouseDragY = 0;

        /** The effect of dragging in this viewport.
         *
         * Acceptable values are: `"scale"`, `"translate"`, `"rotate"`, `"clip"`.
         *
         * @default
         * @type {string} */
        this.dragMode = "translate";

        /** A description of the current clipping modification.
         *
         * Supported operations are:
         * - `"select"`: draw a new clipping rectangle.
         * - `"move"`: move the current clipping rectangle.
         * - `"n"`, `"s"`, `"e"`, `"w"`: move the north, south, east, or west border of the current clipping rectangle.
         * - `"ne"`, `"nw"`, `"se"`, `"sw"`: move the north-east, north-west, south-east, or south-west corner of the current clipping rectangle.
         *
         * @default
         * @type {object}
         * @property {module:player/Camera.Camera[]} cameras - The cameras affected by the clipping operation.
         * @property {string} operation - The type of modification in progress.
         */
        this.clipMode = {cameras: [], operation: "select"};

        /** Should the viewport reveal the hidden SVG elements?
         *
         * @default
         * @type {boolean} */
        this.showHiddenElements = false;

        /** A timeout ID to detect the end of a mouse wheel gesture.
         *
         * @default
         * @type {?number} */
        this.wheelTimeout = null;

        /** The mouse drag event handler.
         *
         * This function is registered as an event listener after
         * a mouse-down event.
         *
         * @param {MouseEvent} evt - A DOM event.
         * @returns {void}
         * @listens mousemove
         */
        this.dragHandler = evt => this.onDrag(evt);

        /** The mouse drag end event handler.
         *
         * This function is registered as an event listener after
         * a mouse-down event.
         *
         * @param {MouseEvent} evt - A DOM event.
         * @returns {void}
         * @listens mouseup
         */
        this.dragEndHandler = evt => this.onDragEnd(evt);
    }

    /** Create a unique SVG element ID.
     *
     * The result is an identifier with the given prefix followed
     * by a random integer.
     * This method always creates an ID that is not taken in the current
     * SVG document.
     *
     * @param {string} prefix - A string to use as a prefix in the resulting ID.
     * @returns {string} - A new unique ID.
     */
    makeUniqueId(prefix) {
        let suffix = Math.floor(1000 * (1 + 9 * Math.random()));
        let id;
        do {
            id = prefix + suffix;
            suffix ++;
        } while(this.svgRoot.getElementById(id));
        return id;
    }

    /** Complete the initialization of this viewport when the document has been loaded.
     *
     * This method registers event handlers and creates a camera for each layer.
     */
    onLoad() {
        this.svgRoot.addEventListener("mousedown", evt => this.onMouseDown(evt), false);
        this.svgRoot.addEventListener("mousemove", evt => this.onMouseMove(evt), false);
        this.svgRoot.addEventListener("contextmenu", evt => this.onContextMenu(evt), false);

        const wheelEvent =
            "onwheel" in document.createElement("div") ? "wheel" :  // Modern browsers support "wheel"
            document.onmousewheel !== undefined ? "mousewheel" :    // Webkit and IE support at least "mousewheel"
            "DOMMouseScroll";                                       // Firefox < 17
        this.svgRoot.addEventListener(wheelEvent, evt => this.onWheel(evt), false);

        this.cameras = this.presentation.layers.map(layer => new Camera(this, layer));
    }

    /** Is this viewport ready to for a repaint operation?
     *
     * @readonly
     * @type {boolean}
     */
    get ready() {
        return !!(this.presentation.document && this.presentation.document.root);
    }

    /** The SVG root element.
     *
     * @readonly
     * @type {SVGSVGElement}
     */
    get svgRoot() {
        return this.presentation.document.root;
    }

    /** Get a layer of the document.
     *
     * @param {string} nodeId - The ID of an SVG group.
     * @returns {module:model/Presentation.Layer} - A layer representation.
     */
    getLayer(nodeId) {
        return this.layers.filter(layer => layer.nodeId === nodeId)[0];
    }

    /** Process a right-click in this viewport.
     *
     * This method forwards the `contextmenu` event as a
     * viewport {@linkcode module:player/Viewport.click|click} event.
     *
     * @param {MouseEvent} evt - A DOM event.
     *
     * @listens contextmenu
     * @fires module:player/Viewport.click
     */
    onContextMenu(evt) {
        evt.stopPropagation();
        evt.preventDefault();
        this.emit("click", 2, evt);
    }

    /** Process a mouse move event in this viewport.
     *
     * This method changes the mouse cursor shape depending on the current mode.
     *
     * @param {MouseEvent} evt - A DOM event.
     *
     * @listens mousemove
     */
    onMouseMove(evt) {
        if (this.dragMode === "clip") {
            switch (this.getClipMode(evt).operation) {
                case "select":
                    this.svgRoot.style.cursor = "crosshair";
                    break;
                case "n":
                case "s":
                    this.svgRoot.style.cursor = "ns-resize";
                    break;
                case "w":
                case "e":
                    this.svgRoot.style.cursor = "ew-resize";
                    break;
                case "nw":
                case "se":
                    this.svgRoot.style.cursor = "nwse-resize";
                    break;
                case "ne":
                case "sw":
                    this.svgRoot.style.cursor = "nesw-resize";
                    break;
                case "move":
                    this.svgRoot.style.cursor = "move";
                    break;
                default:
                    this.svgRoot.style.cursor = "default";
            }
        }
        else {
            this.svgRoot.style.cursor = "default";
        }
    }

    /** Process a mouse down event in this viewport.
     *
     * If the mouse button pressed is the left button,
     * this method will setup event listeners for detecting a drag action.
     *
     * @param {MouseEvent} evt - A DOM event.
     *
     * @listens mousedown
     * @fires module:player/Viewport.mouseDown
     */
    onMouseDown(evt) {
        evt.stopPropagation();
        evt.preventDefault();

        if (evt.button === DRAG_BUTTON) {
            this.mouseDragged = false;
            this.mouseDragChangedState = false;
            this.mouseDragX = this.mouseDragStartX = evt.clientX;
            this.mouseDragY = this.mouseDragStartY = evt.clientY;

            document.documentElement.addEventListener("mousemove", this.dragHandler, false);
            document.documentElement.addEventListener("mouseup", this.dragEndHandler, false);

            if (this.dragMode === "clip") {
                this.clipMode = this.getClipMode(evt);
            }
        }

        this.emit("mouseDown", evt.button);
    }

    /** Detect the current clip mode depending on the current mouse location.
     *
     * This method will compare the current mouse coordinates with the borders
     * of the clipping rectangle of each camera, and decide which clipping
     * operation is in progress on which cameras.
     *
     * @param {MouseEvent} evt - A DOM event containing the current mouse coordinates.
     * @returns {object} - A list of cameras and a clipping operation.
     *
     * @see {@linkcode module:player/Viewport.Viewport#clipMode|clipMode}
     */
    getClipMode(evt) {
        const x = evt.clientX - this.x;
        const y = evt.clientY - this.y;

        const camerasByOperation = {
            nw: [],
            sw: [],
            ne: [],
            se: [],
            w: [],
            e: [],
            n: [],
            s: [],
            move: []
        };

        const selectedCameras = this.cameras.filter(camera => camera.selected);

        for (let camera of selectedCameras) {
            const rect = camera.clipRect;
            if (x >= rect.x - CLIP_BORDER && x <= rect.x + rect.width  + CLIP_BORDER &&
                y >= rect.y - CLIP_BORDER && y <= rect.y + rect.height + CLIP_BORDER) {
                const w = x <= rect.x + CLIP_BORDER;
                const e = x >= rect.x + rect.width - CLIP_BORDER - 1;
                const n = y <= rect.y + CLIP_BORDER;
                const s = y >= rect.y + rect.height - CLIP_BORDER - 1;
                const operation =
                    w || e || n || s ?
                        (n ? "n" : s ? "s" : "") +
                        (w ? "w" : e ? "e" : "") :
                        "move";
                camerasByOperation[operation].push(camera);
            }
        }

        for (let operation in camerasByOperation) {
            if (camerasByOperation[operation].length) {
                return {
                    cameras: camerasByOperation[operation],
                    operation: operation
                };
            }
        }

        return {
            cameras: selectedCameras,
            operation: "select"
        };
    }

    /** Process a mouse drag event.
     *
     * This method is called when a mouse move event happens after a mouse down event.
     *
     * @param {MouseEvent} evt - A DOM event.
     *
     * @listens mousemove
     * @fires module:player/Viewport.dragStart
     */
    onDrag(evt) {
        evt.stopPropagation();

        const xFromCenter = evt.clientX - this.x - this.width / 2;
        const yFromCenter = evt.clientY - this.y - this.height / 2;
        let angle = 180 * Math.atan2(yFromCenter, xFromCenter) / Math.PI;
        let translateX = evt.clientX;
        let translateY = evt.clientY;
        const zoom = Math.sqrt(xFromCenter * xFromCenter + yFromCenter * yFromCenter);
        const deltaX = evt.clientX - this.mouseDragX;
        const deltaY = evt.clientY - this.mouseDragY;

        // The drag action is confirmed when one of the mouse coordinates
        // has moved past the threshold
        if (!this.mouseDragged && (Math.abs(deltaX) > DRAG_THRESHOLD_PX ||
                                   Math.abs(deltaY) > DRAG_THRESHOLD_PX)) {
            this.mouseDragged = true;

            this.rotateStart = this.rotatePrev = angle;
            this.translateStartX = this.translateXPrev = translateX;
            this.translateStartY = this.translateYPrev = translateY;
            this.zoomPrev = zoom;

            this.emit("dragStart");
        }

        if (this.mouseDragged) {
            let mode = this.dragMode;
            if (mode == "translate") {
                if (evt.altKey) {
                    mode = "scale";
                }
                else if (evt.shiftKey) {
                    mode = "rotate";
                }
            }

            switch (mode) {
                case "scale":
                    if (this.editMode || this.presentation.enableMouseZoom) {
                        if (this.zoomPrev !== 0) {
                            this.zoom(zoom / this.zoomPrev, this.width / 2, this.height / 2);
                            this.mouseDragChangedState = true;
                        }
                        this.zoomPrev = zoom;
                    }
                    break;

                case "rotate":
                    if (this.editMode || this.presentation.enableMouseRotation) {
                        if (evt.ctrlKey) {
                            angle = 10 * Math.round((angle - this.rotateStart) / 10) + this.rotateStart;
                        }
                        this.rotate(this.rotatePrev - angle);
                        this.mouseDragChangedState = true;
                        this.rotatePrev = angle;
                    }
                    break;

                case "clip":
                    switch (this.clipMode.operation) {
                        case "select":
                            this.clip(this.mouseDragStartX - this.x, this.mouseDragStartY - this.y,
                                      this.mouseDragX      - this.x, this.mouseDragY      - this.y);
                            break;
                        case "move":
                            this.clipRel(deltaX, deltaY, deltaX, deltaY);
                            break;
                        case "w":
                            this.clipRel(deltaX, 0, 0, 0);
                            break;
                        case "e":
                            this.clipRel(0, 0, deltaX, 0);
                            break;
                        case "n":
                            this.clipRel(0, deltaY, 0, 0);
                            break;
                        case "s":
                            this.clipRel(0, 0, 0, deltaY);
                            break;
                        case "nw":
                            this.clipRel(deltaX, deltaY, 0, 0);
                            break;
                        case "ne":
                            this.clipRel(0, deltaY, deltaX, 0);
                            break;
                        case "sw":
                            this.clipRel(deltaX, 0, 0, deltaY);
                            break;
                        case "se":
                            this.clipRel(0, 0, deltaX, deltaY);
                            break;
                    }
                    this.mouseDragChangedState = true;
                    break;

                default: // case "translate":
                    if (this.editMode || this.presentation.enableMouseTranslation) {
                        if (evt.ctrlKey) {
                            if (Math.abs(translateX - this.translateStartX) >= Math.abs(translateY - this.translateStartY)) {
                                translateY = this.translateStartY;
                            }
                            else {
                                translateX = this.translateStartX;
                            }
                        }
                        this.translate(translateX - this.translateXPrev, translateY - this.translateYPrev);
                        this.mouseDragChangedState = true;
                        this.translateXPrev = translateX;
                        this.translateYPrev = translateY;
                    }
            }
            this.mouseDragX = evt.clientX;
            this.mouseDragY = evt.clientY;
        }
    }

    /** Process a drag end event.
     *
     * This method is called when a mouse up event happens after a mouse down event.
     * If the mouse has been moved past the drag threshold, this method
     * will fire a `dragEnd` event. Otherwise, it will fire a `click` event.
     *
     * @param {MouseEvent} evt - A DOM event
     *
     * @listens mouseup
     * @fires module:player/Viewport.userChangeState
     * @fires module:player/Viewport.dragEnd
     * @fires module:player/Viewport.click
     */
    onDragEnd(evt) {
        evt.stopPropagation();
        evt.preventDefault();

        if (evt.button === DRAG_BUTTON) {
            if (this.mouseDragged) {
                this.emit("dragEnd");
                if (this.mouseDragChangedState) {
                    this.emit("userChangeState");
                }
            }
            else {
                this.emit("click", evt.button, evt);
            }

            document.documentElement.removeEventListener("mousemove", this.dragHandler, false);
            document.documentElement.removeEventListener("mouseup", this.dragEndHandler, false);
        }
        else {
            this.emit("click", evt.button, evt);
        }
    }

    /** Process a mouse wheel event in this viewport.
     *
     * The effect of the mouse wheel depends on the state of the Shift key:
     *    - released: zoom in and out,
     *    - pressed: rotate clockwise or counter-clockwise
     *
     * @param {WheelEvent} evt - A DOM event.
     *
     * @fires module:player/Viewport.userChangeState
     */
    onWheel(evt) {
        if (this.wheelTimeout !== null) {
            window.clearTimeout(this.wheelTimeout);
        }

        evt.stopPropagation();
        evt.preventDefault();

        let delta = 0;
        if (evt.wheelDelta) {   // "mousewheel" event
            delta = evt.wheelDelta;
        }
        else if (evt.detail) {  // "DOMMouseScroll" event
            delta = -evt.detail;
        }
        else {                  // "wheel" event
            delta = -evt.deltaY;
        }

        let changed = false;

        if (delta !== 0) {
            if (evt.shiftKey) {
                // TODO rotate around mouse cursor
                if (this.editMode || this.presentation.enableMouseRotation) {
                    this.rotate(delta > 0 ? ROTATE_STEP : -ROTATE_STEP);
                    changed = true;
                }
            }
            else {
                if (this.editMode || this.presentation.enableMouseZoom) {
                    this.zoom(delta > 0 ? SCALE_FACTOR : 1/SCALE_FACTOR, evt.clientX - this.x, evt.clientY - this.y);
                    changed = true;
                }
            }
        }

        if (changed) {
            this.wheelTimeout = window.setTimeout(() => {
                this.wheelTimeout = null;
                this.emit("userChangeState");
            }, WHEEL_TIMEOUT_MS);
        }
    }

    /** The X coordinate of the current viewport in the current browser window.
     *
     * If the SVG is a standalone document, the returned value is 0.
     *
     * @readonly
     * @type {number}
     */
    get x() {
        return this.svgRoot.getScreenCTM().e;
    }

    /** The Y coordinate of the current viewport in the current browser window.
     *
     * If the SVG is a standalone document, the returned value is 0.
     *
     * @readonly
     * @type {number}
     */
    get y() {
        return this.svgRoot.getScreenCTM().f;
    }

    /** The width of the current viewport.
     *
     * If the SVG is inlined in an HTML document, the returned width
     * includes the padding width of the container.
     *
     * If the SVG is a standalone document, the returned width is the
     * window's inner width.
     *
     * @readonly
     * @type {number}
     */
    get width() {
        return this.svgRoot === document.documentElement ?
            window.innerWidth :
            this.svgRoot.parentNode.clientWidth;
    }

    /** The height of the current viewport.
     *
     * If the SVG is inlined in an HTML document, the returned height
     * includes the padding height of the container.
     *
     * If the SVG is a standalone document, the returned height is the
     * window's inner height.
     *
     * @readonly
     * @type {number}
     */
    get height() {
        return this.svgRoot === document.documentElement ?
            window.innerHeight :
            this.svgRoot.parentNode.clientHeight;
    }

    /** Repaint the current viewport.
     *
     * This method updates:
     * - the dimensions of the SVG document,
     * - all cameras,
     * - the visibility of all hidden elements.
     */
    repaint() {
        this.svgRoot.setAttribute("width", this.width);
        this.svgRoot.setAttribute("height", this.height);

        this.update();

        for (let id of this.presentation.elementsToHide) {
            const elt = document.getElementById(id);
            if (elt) {
                elt.style.visibility = this.showHiddenElements ? "visible" : "hidden";
            }
        }
    }

    /** Update all cameras in the current viewport.
     *
     * @see {@linkcode module:player/Camera.Camera#update}
     */
    update() {
        for (let camera of this.cameras) {
            camera.update();
        }
    }

    /** Set the states of the cameras of the current viewport.
     *
     * @param {module:model/CameraState.CameraState[]} states - The camera states to copy.
     */
    setAtStates(states) {
        states.forEach((state, index) => {
            this.cameras[index].copy(state);
        });
    }

    /** Apply an additional translation to the SVG document based on onscreen coordinates.
     *
     * This method delegates to the cameras of the currently selected layers.
     *
     * @param {number} deltaX - The horizontal displacement, in pixels.
     * @param {number} deltaY - The vertical displacement, in pixels.
     */
    translate(deltaX, deltaY) {
        for (let camera of this.cameras) {
            if (camera.selected) {
                camera.translate(deltaX, deltaY);
            }
        }
    }

    /** Zooms the content of this viewport with the given factor.
     *
     * The zoom is centered around (`x`, `y`).
     *
     * @param {number} factor - The scaling factor, above 1 to zoom in, below 1 to zoom out.
     * @param {number} x - The X coordinate of the transformation center (this point will not move during the operation).
     * @param {number} y - The Y coordinate of the transformation center (this point will not move during the operation).
     *
     * @see {@linkcode module:player/Camera.Camera#zoom}
     */
    zoom(factor, x, y) {
        for (let camera of this.cameras) {
            if (camera.selected) {
                camera.zoom(factor, x, y);
            }
        }
    }

    /** Rotate the content of this viewport with the given angle.
     *
     * The rotation is centered around the center of the display area.
     *
     * @param {number} angle - The rotation angle, in degrees.
     *
     * @see {@linkcode module:player/Camera.Camera#rotate}
     */
    rotate(angle) {
        for (let camera of this.cameras) {
            if (camera.selected) {
                camera.rotate(angle);
            }
        }
    }

    /** Clip the content of this viewport.
     *
     * @param {number} x0 - The X coordinate of the first corner of the clipping rectangle.
     * @param {number} y0 - The Y coordinate of the first corner of the clipping rectangle.
     * @param {number} x1 - The X coordinate of the opposite corner of the clipping rectangle.
     * @param {number} y1 - The Y coordinate of the opposite corner of the clipping rectangle.
     *
     * @see {@linkcode module:player/Camera.Camera#clip}
     */
    clip(x0, y0, x1, y1) {
        for (let camera of this.clipMode.cameras) {
            camera.clip(x0, y0, x1, y1);
        }
    }

    /** Update the clipping rectangles for all selected cameras.
     *
     * @param {number} w - The X offset with respect to the west border.
     * @param {number} n - The Y offset with respect to the north border.
     * @param {number} e - The X offset with respect to the east border.
     * @param {number} s - The Y offset with respect to the south border.
     *
     * @see {@linkcode module:player/Camera.Camera#clip}
     */
    clipRel(w, n, e, s) {
        for (let camera of this.clipMode.cameras) {
            const rect = camera.clipRect;
            if (w <= rect.width + e - 1 && n <= rect.height + s - 1) {
                camera.clip(rect.x + w,
                            rect.y + n,
                            rect.x + rect.width + e - 1,
                            rect.y + rect.height + s - 1);
            }
        }
    }
}