edgedocker-component/edgedocker-component.js

/**
 *  EdgeDocker
 *  Provides Developer-Console-like behaviour in browsers
 * 
 *  Copyright 2020, 2021 Rolf Bagge, Janus B. Kristensen, CAVI,
 *  Center for Advanced Visualization and Interacion, Aarhus University
 *    
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0

 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
**/

/**
 * @typedef {object} EdgeDocker~config
 * @property {Element} [parent=html] - The parent to attach the docker too.
 * @property {EdgeDocker.MODE} [mode=RIGHT] - The edge to attach too.
 */

/**
 * EdgeDocker can dock its container along the edges of the browser, just like Dev console does.
 */
class EdgeDocker {
    /**
     * Create a new EdgeDocker
     * @param {EdgeDocker~config} options
     */
    constructor(options) {
        let self = this;

        this.parent = WebstrateComponents.Tools.fromConfig(options, "parent", document.querySelector("html"));

        // Add the resizer and content areas
        this.resizeHandle = document.createElement("transient");
        this.resizeHandle.classList.add("docking-area-resizer");
        this.parent.appendChild(this.resizeHandle);
        this.outerComponentArea = document.createElement("component-area");
        this.outerComponentArea.classList.add("docking-area-component");
        this.outerComponentArea.setAttribute("transient-element",true);
        this.parent.appendChild(this.outerComponentArea);
        this.visualizerHandle = document.createElement("transient");
        this.visualizerHandle.classList.add("docking-area-visualizer");
        this.parent.appendChild(this.visualizerHandle);
        
        // Create shadowDOM for content in component area and simulate a fake toplevel HTML element on it
        if (options.shadowRoot){
            this.componentShadow = this.outerComponentArea.attachShadow({mode:"open"});//this.outerComponentArea;
            this.innerComponentArea = document.createElement("component-area-content");
            this.componentShadow.appendChild(this.innerComponentArea);
            this.componentShadow.matches = (query)=>{
                if (query==="html") return true;
            }
            
            // For components that do not supply their own stylesheets we can pass thru the main page's
            if (options.shadowCompatibility){
                this.shadowCompatibilityNode = document.createElement("compatibility-pasthru");
                this.shadowCompatibilityNode.style.display="none";
                this.componentShadow.appendChild(this.shadowCompatibilityNode);
                
                let updateCompatibilityPasthru = function updateCompatibilityPasthru(){
                    self.shadowCompatibilityNode.innerHTML="";
                    document.querySelectorAll("head link, head style, head wpm-package svg").forEach((style)=>{
                        self.shadowCompatibilityNode.appendChild(style.cloneNode(true));
                    });                
                }
                let observer = new MutationObserver(updateCompatibilityPasthru);
                observer.observe(document.head, {
                    childList: true, 
                    subtree: true
                });                
                updateCompatibilityPasthru();
                
            }
        } else {
            this.innerComponentArea = this.outerComponentArea;
        }
        
        // Do not let events bubble through the component area to the document below
        let stopEvent = (evt)=>{
            evt.stopPropagation();
        };
        ["click"].forEach((event)=>{
            this.outerComponentArea.addEventListener(event, stopEvent);
        });

        this.dragging = false;

        // init resize
        this.setupResizeHandle(this.resizeHandle);
        this.setMode(WebstrateComponents.Tools.fromConfig(options, "mode", EdgeDocker.MODE.RIGHT));

        this.positionAnimationFrame = null;
        this.boundsAnimationFrame = null;

        let oldStyleWidth = null;
        let oldStyleHeight = null;

        //Crude observer to check for docker resizeing
        let observer = new MutationObserver(()=>{
            let doEvent = false;


            if(self.boundsAnimationFrame != null) {
                cancelAnimationFrame(this.boundsAnimationFrame);
                self.boundsAnimationFrame = null;
            }

            self.boundsAnimationFrame = requestAnimationFrame(()=>{
                if(this.outerComponentArea.style.width != oldStyleWidth) {
                    doEvent = true;
                    oldStyleWidth = parseInt(this.outerComponentArea.style.width);
                }

                if(this.outerComponentArea.style.height != oldStyleHeight) {
                    doEvent = true;
                    oldStyleHeight = parseInt(this.outerComponentArea.style.height);
                }

                if(doEvent) {
                    self.setBounds(parseInt(this.outerComponentArea.style.left), parseInt(this.outerComponentArea.style.top), oldStyleWidth, oldStyleHeight);
                    window.dispatchEvent(new Event('resize'));
                }
            });
        });

        observer.observe(this.outerComponentArea, {
            attributes: true,
            attributeFilter: ["style"]
        });
    }

    setupResizeHandle() {
        let self = this;
        
        this.resizeAnimFrame = null;
        
        let preventEvents = ["move"];
        if(navigator.userAgent.toLowerCase().indexOf('firefox') > -1){
            preventEvents.push("down");
        }
        
        this.deltaWidth = 0;
        this.deltaHeight = 0;

        new CaviTouch(this.resizeHandle, {
            minDragDistance: 0
        });

        this.resizeHandle.addEventListener("caviDrag", (evt)=>{
            switch(self.currentMode) {
                case EdgeDocker.MODE.MAXIMIZED:
                    break;
                case EdgeDocker.MODE.BOTTOM:
                    self.deltaHeight -= evt.detail.caviEvent.deltaPosition.y;
                    break;
                case EdgeDocker.MODE.LEFT:
                    self.deltaWidth += evt.detail.caviEvent.deltaPosition.x;
                    break;
                case EdgeDocker.MODE.RIGHT:
                    self.deltaWidth -= evt.detail.caviEvent.deltaPosition.x;
                    break;
                case EdgeDocker.MODE.FLOAT:
                    self.deltaWidth += evt.detail.caviEvent.deltaPosition.x;
                    self.deltaHeight += evt.detail.caviEvent.deltaPosition.y;
                    break;
            }
            
            if(self.resizeAnimFrame === null) {
                cancelAnimationFrame(self.resizeAnimFrame);
            }
            self.resizeAnimFrame = requestAnimationFrame(()=>{
                self.resizeAnimFrame = null;
                let width = this.outerComponentArea.offsetWidth + self.deltaWidth;
                let height = this.outerComponentArea.offsetHeight + self.deltaHeight;
                
                self.deltaWidth = 0;
                self.deltaHeight = 0;

                this.setBounds(parseInt(this.outerComponentArea.style.left), parseInt(this.outerComponentArea.style.top), width, height);
                /*
                this.outerComponentArea.style.width = width+"px";
                this.outerComponentArea.style.height = height+"px";
                */
            });
        });
    }
    
    setupDragHandle(element) {
        let self = this;

        new CaviTouch(element, {
            preventDefaultEvents: []
        });

        let currentSelectedMode = null;
        let oldSelectedMode = null;

        let currentX = 0;
        let currentY = 0;
        
        element.addEventListener("caviDoubleTap", ()=>{
            if(self.currentMode !== EdgeDocker.MODE.MAXIMIZED){
                self.setMode(EdgeDocker.MODE.MAXIMIZED);
            } else {
                self.setMode(EdgeDocker.MODE.FLOAT);
            }
        });

        element.addEventListener("caviDragStart", (evt)=>{
            self.dragging = true;

            oldSelectedMode = self.currentMode;

            if(self.currentMode !== EdgeDocker.MODE.FLOAT) {
                self.setMode(EdgeDocker.MODE.FLOAT);

                let bounds = element.getBoundingClientRect();

                self.setPosition(evt.detail.caviEvent.position.x-bounds.width / 2.0, evt.detail.caviEvent.position.y-bounds.height / 2.0, true);
            }

            self.parent.setAttribute("transient-data-docking-area-component-drag", "dragging");

            let bounds = this.outerComponentArea.getBoundingClientRect();
            currentX = bounds.x;
            currentY = bounds.y;
        });

        element.addEventListener(("caviDrag"), (evt)=>{
            let percentWidth = self.parent.offsetWidth * 0.1;
            let percentHeight = self.parent.offsetHeight * 0.1;

            currentX += evt.detail.caviEvent.deltaPosition.x;
            currentY += evt.detail.caviEvent.deltaPosition.y;

            if(evt.detail.caviEvent.position.x < percentWidth) {
                self.parent.setAttribute("transient-data-docking-area-component-drag", "dragging left");
                currentSelectedMode = EdgeDocker.MODE.LEFT;
            } else if(self.parent.offsetWidth - evt.detail.caviEvent.position.x < percentWidth) {
                self.parent.setAttribute("transient-data-docking-area-component-drag", "dragging right");
                currentSelectedMode = EdgeDocker.MODE.RIGHT;
            } else if(evt.detail.caviEvent.position.y - self.parent.scrollTop < percentHeight) {
                self.parent.setAttribute("transient-data-docking-area-component-drag", "dragging maximized");
                currentSelectedMode = EdgeDocker.MODE.MAXIMIZED;
            } else if(self.parent.offsetHeight - (evt.detail.caviEvent.position.y - self.parent.scrollTop) < percentHeight) {
                self.parent.setAttribute("transient-data-docking-area-component-drag", "dragging bottom");
                currentSelectedMode = EdgeDocker.MODE.BOTTOM;
            } else {
                self.parent.setAttribute("transient-data-docking-area-component-drag", "dragging float");
                currentSelectedMode = EdgeDocker.MODE.FLOAT;
            }

            self.setPosition(currentX, currentY);
        });

        element.addEventListener("caviDragStop", (evt)=>{

            self.setMode(currentSelectedMode, false);

            if(oldSelectedMode !== currentSelectedMode) {
                self.saveMode();
            }

            if(currentSelectedMode === EdgeDocker.MODE.FLOAT) {
                self.saveBounds(currentSelectedMode, true);
            }

            self.parent.setAttribute("transient-data-docking-area-component-drag", "");

            if(currentSelectedMode === EdgeDocker.MODE.FLOAT) {
                self.setPosition(currentX, currentY, true);
            }

            setTimeout(()=>{
                self.dragging = false;
            });
        });
        
        element.style.cursor = "move";
    }

    /**
     * Sets the position of this EdgeDocker, Ignored if not in FLOAT mode
     * @param {Number} x
     * @param {Number} y
     */
    setPosition(x, y, immediately=false) {
        if(this.currentMode === EdgeDocker.MODE.FLOAT) {
            let self = this;

            if(this.positionAnimationFrame != null) {
                cancelAnimationFrame(this.positionAnimationFrame);
                this.positionAnimationFrame = null;
            }

            if(immediately) {
                this.outerComponentArea.style.left = x + "px";
                this.outerComponentArea.style.top = y + "px";
                self.visualizerHandle.style.left = x + "px";
                self.visualizerHandle.style.top = y + "px";
            } else {
                this.positionAnimationFrame = requestAnimationFrame(()=>{
                    if(self.currentMode === EdgeDocker.MODE.FLOAT) {
                        this.outerComponentArea.style.left = x + "px";
                        this.outerComponentArea.style.top = y + "px";
                        self.visualizerHandle.style.left = x + "px";
                        self.visualizerHandle.style.top = y + "px";
                    }
                });
            }
        }
    }

    /**
     * Sets the bounds of this EdgeDocker
     * @param {Number} x
     * @param {Number} y
     * @param {Number} width
     * @param {Number} height
     * @param {boolean} [doSave=true] - Determines if the bounds should be saved or not
     */
    setBounds(x, y, width, height, doSave = true) {
        this.setPosition(x, y, true);

        let maxWidth = this.parent.offsetWidth * 0.8;
        let maxHeight = this.parent.offsetHeight * 0.8;

        width = Math.min(width, maxWidth);
        height = Math.min(height, maxHeight);

        switch(this.currentMode) {
            case EdgeDocker.MODE.LEFT:
            case EdgeDocker.MODE.RIGHT:
                //Only Set Width
                this.outerComponentArea.style.width = width + "px";
                break;

            case EdgeDocker.MODE.BOTTOM:
                //Only Set height
                this.outerComponentArea.style.height = height + "px";
                break;

            case EdgeDocker.MODE.FLOAT:
                //Set Width and Height
                this.outerComponentArea.style.width = width + "px";
                this.outerComponentArea.style.height = height + "px";
                break;
        }

        if(doSave) {
            this.saveBounds(this.currentMode);
        }
    }

    /**
     * Sets the current dock mode of this EdgeDocker
     * @param {EdgeDocker.MODE} mode
     * @param {boolean} [doLoad=true] - Determines if this setMode should load bounds from storage or not.
     */
    setMode(mode, doLoad = true){
        let oldWidth = parseInt(this.outerComponentArea.style.height);
        let oldHeight = parseInt(this.outerComponentArea.style.width);
        let oldMode = this.currentMode;

        this.parent.setAttribute("transient-data-docking-area-component-mode", mode);
        this.currentMode = mode;

        if(this.currentMode === EdgeDocker.MODE.MAXIMIZED || this.currentMode === EdgeDocker.MODE.EMBEDDED) {
            this.outerComponentArea.style.width = "";
            this.outerComponentArea.style.height = "";
        } else if(this.currentMode === EdgeDocker.MODE.LEFT || this.currentMode === EdgeDocker.MODE.RIGHT) {
            this.outerComponentArea.style.height = "";
            this.outerComponentArea.style.width = (this.parent.offsetWidth*0.5)+"px"; //Overidden if any saved mode exists
        } else if(this.currentMode === EdgeDocker.MODE.BOTTOM) {
            this.outerComponentArea.style.width = "";
            this.outerComponentArea.style.height = (this.parent.offsetHeight*0.33)+"px"; //Overidden if any saved mode exists
        } else if(this.currentMode === EdgeDocker.MODE.FLOAT && oldMode !== EdgeDocker.MODE.FLOAT) {
            this.outerComponentArea.style.width = "";
            this.outerComponentArea.style.height = "";
        }
        
        if (this.currentMode !== EdgeDocker.MODE.FLOAT){
            this.outerComponentArea.style.top = null;
            this.outerComponentArea.style.left = null;
        }

        if(doLoad) {
            this.loadBounds(this.currentMode);
        }

        window.dispatchEvent(new Event('resize'));
    }
    
    dockInto(parentElement){
        if (parentElement){
            // Move into the element if one is given
            parentElement.appendChild(this.outerComponentArea);
        } else {
            // Default to being shown before the visualizer
            this.visualizerHandle.parentElement.insertBefore(this.outerComponentArea, this.visualizerHandle);
        }
    }

    loadBounds(mode) {
        let key = location.pathname + "_" + mode;

        let bounds = localStorage.getItem(key);

        if(bounds != null) {
            try {
                bounds = JSON.parse(bounds);
                this.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
            } catch(e) {
                console.error(e);
            }
        }
    }

    saveBounds(mode, forceSave = false) {
        if(this.dragging && !forceSave) {
            return;
        }

        let key = location.pathname + "_" + mode;

        let bounds = this.outerComponentArea.getBoundingClientRect();

        localStorage.setItem(key, JSON.stringify(bounds));
    }

    /**
     * Save the current mode
     */
    saveMode() {
        let key = location.pathname + "_Mode";
        localStorage.setItem(key, this.currentMode);
    }

    /**
     * Load the saved mode, or use fallback if no saved mode
     * @param {EdgeDocker.MODE} fallback - The mode to use if none is saved.
     */
    loadMode(fallback) {
        let key = location.pathname + "_Mode";
        let mode = localStorage.getItem(key);
        if(mode != null) {
            this.setMode(mode);
        } else {
            this.setMode(fallback);
        }
    }

    /**
     * Fetch the component area where DOM elements can be added to this EdgeDocker
     * @returns {HTMLElement}
     */
    getComponentArea(){
        return this.innerComponentArea;
    }
}

/**
 * The supported modes of EdgeDocker
 * @readonly
 * @enum
 */
EdgeDocker.MODE = {
    MAXIMIZED: "maximized",
    MINIMIZED: "minimized",
    LEFT: "left edge",
    RIGHT: "right edge",
    BOTTOM: "bottom edge",
    FLOAT: "floating",
    EMBEDDED: "embedded"
};

window.EdgeDocker = EdgeDocker;