gui/Project.js

/*
 * Copyright 2020 WICKLETS LLC
 *
 * This file is part of Wick Engine.
 *
 * Wick Engine is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Wick Engine is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Wick Engine.  If not, see <https://www.gnu.org/licenses/>.
 */

/**
 * The Project GUIElement handles the creation of the canvas and drawing the rest of the GUIElements.
 */
Wick.GUIElement.Project = class extends Wick.GUIElement {
    /**
     * Create a new GUIElement and build the canvas.
     */
    constructor (model) {
        super(model);

        this._canvas = document.createElement('canvas');
        this._ctx = this._canvas.getContext('2d');

        this._canvasContainer = document.createElement('div');
        this._canvasContainer.style.width = "100%";
        this._canvasContainer.style.height = "100%";
        this._canvasContainer.appendChild(this._canvas);

        this._drawnElements = [];

        this._mouse = {x: 0, y: 0};
        this._mouseHoverTargets = [];

        this._scrollX = 0;
        this._scrollY = 0;

        this._popupMenu = null;

        this._onProjectModified = () => {};
        this._onProjectSoftModified = () => {};

        this._attachedDocumentEvents = [];
        this._attachedCanvasEvents = [];

    }

    /**
     * Create an event on the document. Saves a reference to the event internally.
     */
    createDocumentEvent (event, callback, c) {
        document.addEventListener(event, callback, c);

        this._attachedDocumentEvents.push({
            event,
            fn: callback,
        });
    }

    /**
     * Create an event on the canvas. Saves a reference to the event internally.
     */
    createCanvasEvent (event, callback, c) {
        this._canvas.addEventListener(event, callback, c);

        this._attachedCanvasEvents.push({
            event,
            fn: callback,
        });
    }


    /**
     * Removes all events from the document and canvas.
     */
    removeAllEventListeners () {
        this._attachedDocumentEvents.forEach((evt) => {
            document.removeEventListener(evt.event, evt.fn);
        }); 

        this._attachedCanvasEvents.forEach((evt) => {
            this._canvas.removeEventListener(evt.event, evt.fn);
        });
    }

    /**
     * The div containing the GUI canvas
     */
    get canvasContainer () {
        return this._canvasContainer;
    }

    set canvasContainer (canvasContainer) {
        this._canvasContainer = canvasContainer;

        if(this._canvas !== this._canvasContainer.children[0]) {
            this._canvasContainer.innerHTML = '';
            this._canvasContainer.appendChild(this._canvas);
        }

        if(!this._mouseEventsAttached) {
            // Mouse events
            // (Only call these with non-touch devices)
            this.createDocumentEvent('mousemove', e => {
                if(e.touches) return;
                this._onMouseMove(e);
            }, false);

            this.createDocumentEvent('mouseup', e => {
                if(e.touches) return;
                this._onMouseUp(e);
            }, false);

            this.createCanvasEvent('mousedown', e => {
                if(e.touches) return;
                this._timeline_onMouseDown(e);
            }, false);

            // Auto-close popup menu if there is a click off-canvas
            this.createDocumentEvent('mousedown', e => {
                if(e.touches) return;
                if(e.target !== this._canvas) {
                    this.closePopupMenu();
                    this.draw();
                }
            }, false);

            // Scroll events
            $(this._canvas).on('mousewheel', this._onMouseWheel.bind(this));

            // Touch events
            this.createCanvasEvent('touchstart', e => {
                e.buttons = 0;
                e.clientX = e.touches[0].clientX;
                e.clientY = e.touches[0].clientY;
                this._touchStartX = e.clientX;
                this._touchStartY = e.clientY;
                e.movementX = e.touches[0].movementX;
                e.movementY = e.touches[0].movementY;
                this._onMouseMove(e);
                this._timeline_onMouseDown(e);
            }, false);

            this.createDocumentEvent('touchmove', e => {
                e.buttons = 1;
                e.clientX = e.touches[0].clientX;
                e.clientY = e.touches[0].clientY;
                e.movementX = e.clientX - this._touchStartX;
                e.movementY = e.clientY - this._touchStartY;
                this._touchStartX = e.clientX;
                this._touchStartY = e.clientY;
                this._onMouseMove(e);
            }, false);

            this.createDocumentEvent('touchend', e => {
                this._onMouseUp(e);
            }, false);

            this._mouseEventsAttached = true;
        }
    }

    /**
     * Resize the canvas so that it fits inside the canvas container, call this when the size of the canvas container changes.
     */
    resize () {
        if(!this._canvasContainer || !this._canvas) return;

        var containerWidth = this.canvasContainer.offsetWidth;
        var containerHeight = this.canvasContainer.offsetHeight;

        // Round off canvas size to avoid blurryness.
        containerWidth = Math.floor(containerWidth) - 2;
        containerHeight = Math.floor(containerHeight) - 1;

        if(this._canvas.width !== containerWidth) {
            this._canvas.width = containerWidth;
        }
        if(this._canvas.height !== containerHeight) {
            this._canvas.height = containerHeight;
        }
    };

    /**
     * Draw this GUIElement and update the mouse state
     */
    draw () {
        var ctx = this.ctx;

        // Make sure canvas is the correct size
        this.resize();

        // Reset drawn objects list
        this._drawnElements = [];

        // Draw the entire GUI
        ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        this.model.activeTimeline.guiElement.draw();

        // Draw current popup menu
        if(this._popupMenu) {
            this._popupMenu.draw();
        }

        // Draw tooltips
        this._mouseHoverTargets.forEach(target => {
            if(target.tooltip) {
                target.tooltip.draw(target.localTranslation.x, target.localTranslation.y);
            }
        });
    }

    /**
     * Give a function to call when the timeline modifies the project.
     * @param {function} fn - the function to call
     */
    onProjectModified (fn) {
        this._onProjectModified = fn;
    }

    /**
     * Give a function to call when the timeline "soft modifies" the project (moving the playhead, etc).
     * @param {function} fn - the function to call
     */
    onProjectSoftModified (fn) {
        this._onProjectSoftModified = fn;
    }

    /**
     * Add a GUIElement to the list of objects that were drawn in the last draw call.
     * @param {Wick.GUIElement} elem - the GUIElement to add
     */
    markElementAsDrawn (elem) {
        this._drawnElements.push(elem);
    }

    /**
     * The amount the timeline is scrolled horizontally.
     * @type {number}
     */
    get scrollX () {
        return this._scrollX;
    }

    set scrollX (scrollX) {
        if(scrollX < 0) scrollX = 0;
        if(scrollX > this.horizontalScrollSpace) scrollX = this.horizontalScrollSpace;
        this._scrollX = scrollX;
    }

    /**
     * The amount the timeline is scrolled vertically.
     * @type {number}
     */
    get scrollY () {
        return this._scrollY;
    }

    set scrollY (scrollY) {
        if(scrollY < 0) scrollY = 0;
        if(scrollY > this.verticalScrollSpace) scrollY = this.verticalScrollSpace;
        this._scrollY = scrollY;
    }

    /**
     * The amount of distance the timeline can be scrolled horizontally. Depends on the number of frames.
     * @type {number}
     */
    get horizontalScrollSpace () {
        return (this.model.activeTimeline.length * this.gridCellWidth * 3) + 500;
    }

    /**
     * The amount of distance the timeline can be scrolled vertically. Depends on the number of layers.
     * @type {number}
     */
    get verticalScrollSpace () {
        return this.model.activeTimeline.layers.length * this.gridCellHeight + this.gridCellHeight * 2;
    }

    /**
     * Open a popup menu
     * @param {Wick.GUIElement.PopupMenu} popupMenu - the PopupMenu to open
     */
    openPopupMenu (popupMenu) {
        this._popupMenu = popupMenu;
        this.draw();
    }

    /**
     * Close the current popup menu
     */
    closePopupMenu () {
        this._popupMenu = null;
        this.draw();
    }

    /**
     * String representation of the current frame size, can be "small", "normal", or "large".
     * @type {string}
     */
    get frameSizeMode () {
        if(Wick.GUIElement.GRID_DEFAULT_CELL_WIDTH === Wick.GUIElement.GRID_SMALL_CELL_WIDTH) {
            return 'small';
        } else if(Wick.GUIElement.GRID_DEFAULT_CELL_WIDTH === Wick.GUIElement.GRID_NORMAL_CELL_WIDTH) {
            return 'normal'
        } else if(Wick.GUIElement.GRID_DEFAULT_CELL_WIDTH === Wick.GUIElement.GRID_LARGE_CELL_WIDTH) {
            return 'large';
        }
    }

    /**
     * Drop an asset onto the timeline.
     * @param {string} uuid - The UUID of the desired asset.
     * @param {number} x - The x location of the image after creation in relation to the window.
     * @param {number} y - The y location of the image after creation in relation to the window.
     * @param {boolean} drop - If true, will drop the asset with the uuid onto the hovered frame, modifying the frame.
     */
    dragAssetAtPosition (uuid, x, y, drop) {
        this._onMouseMove({clientX: x, clientY: y, buttons: 0});
        var target = this._getTopMouseTarget();
        if(!target || !(target.model instanceof Wick.Frame)) {
            return;
        }

        var frame = target.model;
        var asset = target.project.model.getAssetByUUID(uuid);
        var oldSound = frame.sound;
        frame.sound = asset;

        if(drop) {
            this.projectWasModified();
        } else {
            this.draw();
            if(oldSound) {
                frame.sound = oldSound;
            } else {
                frame.removeSound();
            }
        }
    }

    /**
     * Auto scrolls the timeline if the playhead is considered off-screen.
     * This is built specifically for moving the playead with hotkeys.
     */
    checkForPlayheadAutoscroll () {
        var scrollWidth = this.canvas.width;
        scrollWidth -= Wick.GUIElement.LAYERS_CONTAINER_WIDTH;
        scrollWidth -= Wick.GUIElement.SCROLLBAR_SIZE;
        scrollWidth -= this.gridCellWidth;

        var scrollMin = this.scrollX;
        var scrollMax = this.scrollX + scrollWidth;

        var playheadPosition = this.model.activeTimeline.playheadPosition;
        var playheadX = (playheadPosition - 1) * this.gridCellWidth;

        if(playheadX < scrollMin) {
            this.scrollX = playheadX;
            this.draw();
        } if (playheadX > scrollMax) {
            this.scrollX = playheadX - scrollWidth;
            this.draw();
        }
    }

    _onMouseMove (e) {
        // Update mouse position
        var rect = this._canvas.getBoundingClientRect();
        this._mouse = {
            x: e.clientX - rect.left,
            y: e.clientY - rect.top
        };

        // Optimization: Only update if the mouse is on the canvas (unless something is being dragged)
        var mouseOffCanvas = (
            this._mouse.x < 0 ||
            this._mouse.y < 0 ||
            this._mouse.x > this.canvas.width ||
            this._mouse.y > this.canvas.height);
        if(e.buttons === 0 && !this.canvasClicked && mouseOffCanvas) {
            if(this._mouseHoverTargets.length > 0) {
                this._mouseHoverTargets = [];
                this.draw();
            }
            return;
        }

        // Update mouse targets
        if(e.buttons === 0) {
            // Mouse moved - find new hover targets
            this._mouseHoverTargets = this._drawnElements.filter(elem => {
                return elem.model.project && elem.mouseInBounds(this._mouse);
            });

            // Update cursor
            var top = this._getTopMouseTarget();
            if(top) {
                this.canvas.style.cursor = top.cursor
            } else {
                this.canvas.style.cursor = 'default';
            }
        } else {
            // Mouse is dragging - fire drag events if needed
            if(!this.canvasClicked) {
                // Don't drag if the click didn't originate from the canvas.
            } else if (!this._mouseHasMoved(this._clickXY, {x:e.clientX, y:e.clientY}, 5)) {
                // Don't start dragging things until the mouse has moved a little bit.
            } else {
                this._onMouseDrag(e);
            }
        }

        this.draw();
    }

    _timeline_onMouseDown (e) {
        this.closePopupMenu();
        this.canvasClicked = true;
        this._clickXY = {x: e.clientX, y: e.clientY};

        if(this._mouseHoverTargets.length === 0) {
            // Clicked nothing - clear the selection
            this.model.selection.clear();
        } else {
            // Clicked something - run that element's onMouseDown
            this._lastClickedElem = this._getTopMouseTarget();
            this._lastClickedElem.onMouseDown(e);
        }

        this.draw();
    }

    _onMouseUp (e) {
        // Call mouse event functions on the elements interacted with
        var target = this._getTopMouseTarget();
        if(this.canvasClicked && this._isDragging) {
            target && target.onMouseUp(e);
        } else if (this.canvasClicked && this._lastClickedElem === target) {
            target && target.onMouseUp(e);
        }

        this.canvasClicked = false;
        this._isDragging = false;

        this.draw();

        // Call mousemove so that the next mouse targets can be found without having to move the mouse again
        this._onMouseMove(e);

        clearInterval(this.autoscrollInterval);
        this.autoscrollInterval = null;
    }

    _onMouseDrag (e) {
        this._isDragging = true;

        // Call event functons on the elements interacted with
        var target = this._getTopMouseTarget();
        if(target) {
            this.canvas.style.cursor = 'grabbing';
            target.onMouseDrag(e);
            this._doAutoScroll(target);
        }
    }

    /**
     * Refers to mousewheel events on the timeline.
     * @param {*} e 
     */
    _onMouseWheel (e) {
        e.preventDefault();
        if (!this.model.isPublished) {
            var dx = e.deltaX * e.deltaFactor * 0.5;
            var dy = e.deltaY * e.deltaFactor * 0.5;
            this.scrollX += dx;
            this.scrollY -= dy;
            this.draw();
        }
    }

    _getTopMouseTarget () {
        var l = this._mouseHoverTargets.length-1;
        return this._mouseHoverTargets[l];
    }

    _doAutoScroll (target) {
        if(this.autoscrollInterval) return;

        this.autoscrollInterval = setInterval(() => {
            var left = Wick.GUIElement.LAYERS_CONTAINER_WIDTH;
            var right = this.canvas.width - Wick.GUIElement.SCROLLBAR_SIZE;
            var top = Wick.GUIElement.NUMBER_LINE_HEIGHT + Wick.GUIElement.BREADCRUMBS_HEIGHT;
            var bottom = this.canvas.height - Wick.GUIElement.SCROLLBAR_SIZE;

            var distFromLeft = this._mouse.x - left;
            var distFromRight = this._mouse.x - right;
            var distFromTop = this._mouse.y - top;
            var distFromBottom = this._mouse.y - bottom;

            if(target.canAutoScrollX) {
                if(this._mouse.x > right) {
                    this.scrollX += distFromRight * Wick.GUIElement.AUTO_SCROLL_SPEED;
                }
                if(this._mouse.x < left) {
                    this.scrollX += distFromLeft * Wick.GUIElement.AUTO_SCROLL_SPEED;
                }
            }
            if(target.canAutoScrollY) {
                if(this._mouse.y > bottom) {
                    this.scrollY += distFromBottom * Wick.GUIElement.AUTO_SCROLL_SPEED;
                }
                if(this._mouse.y < top) {
                    this.scrollY += distFromTop * Wick.GUIElement.AUTO_SCROLL_SPEED;
                }
            }

            this.draw();
        }, 16);
    }

    _mouseHasMoved (origMouse, currMouse, amount) {
        var d = {
            x: Math.abs(origMouse.x - currMouse.x),
            y: Math.abs(origMouse.y - currMouse.y),
        };
        return d.x > amount || d.y > amount;
    }
}