UNPKG

nodecg

Version:

Dynamic broadcast graphics rendered in a browser

341 lines (333 loc) 12.9 kB
import "@polymer/paper-spinner/paper-spinner.js"; // These get elided unless we do this hacky stuff to force typescript and webpack to keep them. import * as keep1 from "./ncg-dashboard-panel"; keep1; import * as Polymer from "@polymer/polymer"; import { timeOut } from "@polymer/polymer/lib/utils/async.js"; import { Debouncer } from "@polymer/polymer/lib/utils/debounce.js"; import { afterNextRender } from "@polymer/polymer/lib/utils/render-status.js"; import Draggabilly from "draggabilly"; import Packery from "packery"; class NcgWorkspace extends Polymer.PolymerElement { static get template() { return Polymer.html ` <style include="nodecg-theme"> :host { display: block; width: 100%; height: 100%; pointer-events: none; box-sizing: border-box; } #panels { transform: translateY(40px); opacity: 0; transition: opacity 500ms ease, transform 600ms ease-out; } :host(:not([fullbleed])) { padding: 32px; } :host(:not([fullbleed])) #panels { padding-bottom: 32px; } #panels { width: 100%; height: 100%; } #loadingSpinner { position: fixed; top: 50%; left: 50%; transform: translate(-50%); width: 68px; height: 68px; --paper-spinner-stroke-width: 5px; --paper-spinner-layer-1-color: #645BA6; --paper-spinner-layer-2-color: #A50074; --paper-spinner-layer-3-color: #5BA664; --paper-spinner-layer-4-color: #C9513E; } iframe { display: block; width: 100%; height: 100%; } </style> <div id="panels"> <template is="dom-repeat" items="[[panels]]" as="panel"> <ncg-dashboard-panel id="[[panel.bundleName]]_[[panel.name]]" display-title="[[panel.title]]" bundle="[[panel.bundleName]]" panel="[[panel.name]]" header-color="[[panel.headerColor]]" width="[[panel.width]]" on-transitioning-changed="_handlePanelCollapseTransition" fullbleed="[[panel.fullbleed]]"> <iframe src="/bundles/[[panel.bundleName]]/dashboard/[[panel.file]]" frameborder="0" scrolling\$="[[_calcIframeScrolling(panel.fullbleed)]]" id="[[panel.bundleName]]_[[panel.name]]_iframe" on-iframe-resized="shiftPackery" fullbleed\$="[[panel.fullbleed]]" loading="lazy"> </iframe> </ncg-dashboard-panel> </template> </div> <paper-spinner id="loadingSpinner" active=""></paper-spinner> `; } static get is() { return "ncg-workspace"; } static get properties() { return { workspace: Object, panels: { type: Array, computed: "_computePanels(workspace)", }, fullbleed: { type: Boolean, computed: "_computeFullbleed(workspace)", reflectToAttribute: true, value: false, }, usePackery: { type: Boolean, computed: "_computeUsePackery(fullbleed)", }, route: { type: Object, observer: "_routeChanged", }, PANEL_SORT_ORDER_STORAGE_KEY: { type: String, computed: "_computePanelSortOrderStorageKey(workspace)", }, }; } ready() { super.ready(); // 2018-08-26: This is a quick fix for workspaces never initializing on Firefox. // For some reason FF never fires the `load` event, or it's firing it before we start listening. // This is less than ideal for us, because this quick fix doesn't _actually_ hide the loads, // so it looks quite ugly. // This might need to move to listening for the load events of each individual iframe instead? if (document.readyState === "complete") { this._init(); } else { window.addEventListener("load", () => { this._init(); }); } } _init() { if (this["_initialized"]) { throw new Error("attempted multiple init"); } this["_initialized"] = true; this.$["loadingSpinner"].active = false; this.applyPackery(); setTimeout(() => { this.addEventListener("tap", this.shiftPackery); this.$["panels"].style.opacity = 1; this.$["panels"].style.transform = "translateY(0)"; this.$["panels"].style.pointerEvents = "auto"; }, 750); } connectedCallback() { super.connectedCallback(); afterNextRender(this, () => { if (this["usePackery"]) { // Init Packery this.initPackery(); this.startObservingPanelMutations(); } }); } startObservingPanelMutations() { // Packery causes attributes changes, so before applying it // we disconnect then reconnect our observer to avoid infinite loops try { if (this["_panelMutationObserver"]) { return; } // Create a MutationObserver which will watch for changes to the DOM and re-apply masonry this["_panelMutationObserver"] = new MutationObserver(() => { this["_handleMutationDebounce"] = Debouncer.debounce(this["_handleMutationDebounce"], timeOut.after(150), this._debouncedMutationHandler.bind(this)); }); // Define what element should be observed by the observer // and what types of mutations trigger the callback this["_panelMutationObserver"].observe(this.$["panels"], { subtree: true, attributes: true, childList: true, characterData: true, attributeOldValue: false, characterDataOldValue: false, }); } catch (_) { console.warn("MutationObserver not supported, dashboard panels may be less responsive to DOM changes"); } } initPackery() { const packery = new Packery(this.$["panels"], { itemSelector: "ncg-dashboard-panel", columnWidth: 128, gutter: 16, isInitLayout: false, containerStyle: { position: "relative" }, }); this["_packery"] = packery; // Initial sort const sortOrder = []; // global variable for saving order, used later const rawStoredSortOrder = localStorage.getItem(this["PANEL_SORT_ORDER_STORAGE_KEY"]); if (rawStoredSortOrder) { const storedSortOrder = JSON.parse(rawStoredSortOrder); // Create a hash of items const itemsByFullName = {}; const allPanels = []; let panelName; let bundleName; let i; let len; for (i = 0, len = packery.items.length; i < len; i++) { const item = packery.items[i]; panelName = item.element.getAttribute("panel"); bundleName = item.element.getAttribute("bundle"); const fullName = `${bundleName}.${panelName}`; allPanels[i] = fullName; itemsByFullName[fullName] = item; } // Merge the saved panel array with our currently loaded panels, remove dupes const allPanelsOrdered = arrayUnique(storedSortOrder.concat(allPanels)); // Remove panels that no longer exist const removededOld = allPanelsOrdered.filter((val) => allPanels.includes(val)); // Overwrite packery item order len = removededOld.length; for (i = 0; i < len; i++) { panelName = removededOld[i]; packery.items[i] = itemsByFullName[panelName]; } } // Manually trigger initial layout packery.layout(); this["_packeryInitialized"] = true; const panelsList = this.$["panels"].querySelectorAll("ncg-dashboard-panel"); panelsList.forEach((itemElem) => { // Make element draggable with Draggabilly const draggie = new Draggabilly(itemElem); // Manually set the handle because we can't just use a CSS selector due to ShadowDOM draggie.handles = [itemElem.$["dragHandle"]]; // Bind Draggabilly events to Packery packery.bindDraggabillyEvents(draggie); }); // Currently, there is no built-in option to force dragged elements to gravitate to their // nearest neighbour in a specific direction. This will reset their locations 100ms after a drag // causing them to gravitate. packery.on("dragItemPositioned", () => { setTimeout(() => { packery.layout(); }, 100); }); const orderItems = () => { const itemElems = packery.getItemElements(); // Reset / empty order array sortOrder.length = 0; for (let i = 0; i < itemElems.length; i++) { sortOrder[i] = `${itemElems[i].getAttribute("bundle")}.${itemElems[i].getAttribute("panel")}`; } // Save ordering localStorage.setItem(this["PANEL_SORT_ORDER_STORAGE_KEY"], JSON.stringify(sortOrder)); }; packery.on("layoutComplete", orderItems); packery.on("dragItemPositioned", orderItems); } applyPackery() { this["_applyPackeryDebounce"] = Debouncer.debounce(this["_applyPackeryDebounce"], timeOut.after(10), () => { if (this["_packeryInitialized"]) { this["_packery"].layout(); } }); } shiftPackery() { if (!this["usePackery"]) { return; } // Called when anything in #panels receives a click or tap event. // There's probably a lot of room to optimize this and not call // this routine for every single click, // but this was just the easiest way to ensure that mutations // caused by clicks are caught, because those mutations might // have changed the vertical size of the panel. this["_shiftPackeryDebounce"] = Debouncer.debounce(this["_shiftPackeryDebounce"], timeOut.after(100), () => { if (this["_packeryInitialized"]) { // See http://packery.metafizzy.co/methods.html#shiftlayout for more details this["_packery"].shiftLayout(); } }); } _fixPackery() { this.applyPackery(); } _computePanels(workspace) { const workspaceName = workspace.route === "" ? "default" : workspace.name; const { bundles } = window.__renderData__; const panels = []; bundles.forEach((bundle) => { bundle.dashboard.panels.forEach((panel) => { if (panel.dialog) { return; } if (panel.fullbleed) { if (workspaceName === `__nodecg_fullbleed__${bundle.name}_${panel.name}`) { return panels.push(panel); } return; } if (panel.workspace === workspaceName) { panels.push(panel); } return undefined; }); }); return panels; } _computeFullbleed(workspace) { return workspace.fullbleed; } _computeUsePackery(fullbleed) { return !fullbleed; } _routeChanged(route) { if (this["usePackery"]) { // This is a hack to fix packery when the viewport size is changed // when the workspace is not visible. if (route.path === this.parentNode.route) { this["_fixPackeryDebounce"] = Debouncer.debounce(this["_fixPackeryDebounce"], timeOut.after(10), this._fixPackery.bind(this)); } } } _computePanelSortOrderStorageKey(workspace) { return ((workspace.route === "" ? "default" : workspace.name) + "_workspace_panel_sort_order"); } _handlePanelCollapseTransition(e) { // Whenever a panel finishes transitioning, shiftPackery. if (e.detail.value === false) { this.shiftPackery(); } } _debouncedMutationHandler() { this["_panelMutationObserver"].disconnect(); this.shiftPackery(); this.startObservingPanelMutations(); } _calcIframeScrolling(fullbleed) { return fullbleed ? "yes" : "no"; } } customElements.define("ncg-workspace", NcgWorkspace); function arrayUnique(array) { const a = array.concat(); for (let i = 0; i < a.length; ++i) { for (let j = i + 1; j < a.length; ++j) { if (a[i] === a[j]) { a.splice(j--, 1); } } } return a; } //# sourceMappingURL=ncg-workspace.js.map