diff --git a/ui/src/editor-core/layer-manager.js b/ui/src/editor-core/layer-manager.js new file mode 100644 index 0000000000..cf66cbcf6b --- /dev/null +++ b/ui/src/editor-core/layer-manager.js @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2023 Xibo Signage Ltd + * + * Xibo - Digital Signage - https://xibosignage.com + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ + +const managerTemplate = require('../templates/layer-manager.hbs'); + +/** + * Layer Manager + * @param {object} parent - Parent object + * @param {object} container - Container to append the manager to + * @param {object} viewerContainer - Viewer container to interact with + */ +const LayerManager = function(parent, container, viewerContainer) { + this.parent = parent; + this.DOMObject = container; + this.viewerContainer = viewerContainer; + + this.layerStructure = []; + + this.firstRender = true; + this.wasDragged = false; + + // Show/Hide ( false by default ) + this.visible = false; +}; + + +/** + * Create structure + */ +LayerManager.prototype.createStructure = function() { + const self = this; + + // Reset structure + self.layerStructure = []; + + const addToLayerStructure = function(layer, object, auxArray = null) { + const arrayToAdd = (auxArray != null) ? + auxArray : self.layerStructure; + + if (typeof arrayToAdd[layer] === 'undefined') { + arrayToAdd[layer] = []; + } + + arrayToAdd[layer].push(object); + }; + + // Get canvas + const canvasObject = {}; + + if (!$.isEmptyObject(self.parent.layout.canvas)) { + const canvas = self.parent.layout.canvas; + + // Add properties to canvas object + canvasObject.layer = canvas.zIndex; + canvasObject.type = 'canvas'; + canvasObject.name = 'Canvas'; + canvasObject.duration = canvas.duration; + canvasObject.subLayers = []; + + // Get elements + if ((canvas.widgets)) { + Object.values(canvas.widgets).forEach((widget) => { + const elements = Object.values(widget.elements); + elements.forEach((element) => { + addToLayerStructure(element.layer, { + type: 'element', + name: element.id, + duration: widget.duration, // Element has parent widget duration + id: element.elementId, + hasGroup: (element.groupId != undefined), + groupId: layerManagerTrans.inGroup + .replace('%groupId%', element.groupId), + selected: element.selected, + }, + canvasObject.subLayers, + ); + }); + }); + } + + // Add canvas to structure + addToLayerStructure(canvas.zIndex, { + type: 'canvas', + name: 'Canvas', + duration: canvas.duration, + layers: canvasObject.subLayers, + }); + } + + // Get static widgets and playlists + Object.values(self.parent.layout.regions).forEach((region) => { + if (region.subType === 'playlist') { + addToLayerStructure(region.zIndex, { + type: 'playlist', + name: region.name, + duration: region.duration, + id: region.id, + selected: region.selected, + }); + } else { + Object.values(region.widgets).forEach((widget) => { + addToLayerStructure(region.zIndex, { + type: 'staticWidget', + name: widget.widgetName, + duration: region.duration, + subType: widget.subType, + id: widget.id, + selected: widget.selected, + }); + }); + } + }); +}; + +/** + * Set visibility + * @param {boolean=} force Force visible to on/off + */ +LayerManager.prototype.setVisible = function(force) { + // Change manager flag + this.visible = (force != undefined) ? force : !this.visible; + + // Set button status + lD.viewer.DOMObject.siblings('#layerManagerBtn') + .toggleClass('active', this.visible); + + // Render manager (and reset position) + this.render(true); + + // Save editor preferences + lD.savePrefs(); +}; + +/** + * Render Manager + */ +/** + * Render Manager + * @param {boolean=} reset Reset to default state + */ +LayerManager.prototype.render = function(reset) { + const self = this; + + // Only render if it's visible + if (this.visible != false) { + // Create layers data structure + this.createStructure(); + + // Compile layout template with data + const html = managerTemplate({ + trans: layerManagerTrans, + layerStructure: this.layerStructure, + }); + + // Append layout html to the main div + this.DOMObject.html(html); + + // Make the layer div draggable + this.DOMObject.draggable({ + handle: '.layer-manager-header', + cursor: 'dragging', + drag: function() { + self.wasDragged = true; + }, + }); + + // Select items + this.DOMObject.find('.layer-manager-layer-item.selectable') + .off().on('click', function(ev) { + const elementId = $(ev.currentTarget).data('item-id'); + const $viewerObject = self.viewerContainer.find('#' + elementId); + + if ($viewerObject.length) { + // Select in editor + lD.selectObject({ + target: $viewerObject, + forceSelect: true, + }); + + // If it's a static widget, we need to give the class to its region + const $auxTarget = ($viewerObject.hasClass('designer-widget')) ? + $viewerObject.parents('.designer-region') : + $viewerObject; + + // Select in viewer + lD.viewer.selectElement($auxTarget); + + // Mark object with selected from manager class + $auxTarget.addClass('selected-from-layer-manager'); + } + }); + + // Handle close button + this.DOMObject.find('.close-layer-manager') + .off().on('click', function(ev) { + self.setVisible(false); + }); + + // Show + this.DOMObject.show(); + } else { + // Empty container + this.DOMObject.empty(); + + // Hide container + this.DOMObject.hide(); + } + + // If it's a reset or first run, show next to the button + if (reset || this.firstRender || !self.wasDragged) { + this.wasDragged = false; + + self.DOMObject.css('top', 'auto'); + self.DOMObject.css('left', 6); + + // Button height plus offset from bottom and top of the button: 6*2 + const viewerOffsetBottom = + lD.viewer.DOMObject.siblings('#layerManagerBtn').outerHeight() + (6 * 2); + + // Bottom is calculated by using the element height + // and the offset from the bottom of the viewer + self.DOMObject.css( + 'bottom', + self.DOMObject.outerHeight() + viewerOffsetBottom, + ); + } + + this.firstRender = false; +}; + +module.exports = LayerManager; diff --git a/ui/src/editor-core/properties-panel.js b/ui/src/editor-core/properties-panel.js index 4ce90ff230..0bfea536e5 100644 --- a/ui/src/editor-core/properties-panel.js +++ b/ui/src/editor-core/properties-panel.js @@ -1076,6 +1076,9 @@ PropertiesPanel.prototype.render = function( app.viewer.DOMObject.find('.designer-region-canvas') .css('zIndex', canvasZIndexVal); + // Update layer manager + app.viewer.layerManager.render(); + // Don't save the rest of the form return; } diff --git a/ui/src/layout-editor/main.js b/ui/src/layout-editor/main.js index a2bb73c0fd..149ad1de19 100644 --- a/ui/src/layout-editor/main.js +++ b/ui/src/layout-editor/main.js @@ -3571,6 +3571,12 @@ lD.loadPrefs = function() { // Update moveable UI self.viewer.updateMoveableUI(); } + + if (loadedData.layerManagerOptions) { + // Render layer manager + self.viewer.layerManager + .setVisible(loadedData.layerManagerOptions.visible); + } } else { // Login Form needed? if (res.login) { @@ -3608,6 +3614,9 @@ lD.savePrefs = function(clearPrefs = false) { option: 'editor', value: JSON.stringify({ snapOptions: this.viewer.moveableOptions, + layerManagerOptions: { + visible: this.viewer.layerManager.visible, + }, }), }, ], diff --git a/ui/src/layout-editor/viewer.js b/ui/src/layout-editor/viewer.js index 90369cd263..0123fc1161 100644 --- a/ui/src/layout-editor/viewer.js +++ b/ui/src/layout-editor/viewer.js @@ -23,6 +23,7 @@ // VIEWER Module // Load templates +const LayerManager = require('../editor-core/layer-manager.js'); const viewerTemplate = require('../templates/viewer.hbs'); const viewerWidgetTemplate = require('../templates/viewer-widget.hbs'); const viewerLayoutPreview = require('../templates/viewer-layout-preview.hbs'); @@ -83,6 +84,13 @@ const Viewer = function(parent, container) { // Fullscreen mode flag this.fullscreenMode = false; + + // Initialize layer manager + this.layerManager = new LayerManager( + lD, + this.parent.editorContainer.find('#layerManager'), + this.DOMObject, + ); }; /** @@ -315,6 +323,9 @@ Viewer.prototype.render = function(forceReload = false) { this.parent.common.reloadTooltips( this.DOMObject.parent(), ); + + // Update layer manager + this.layerManager.render(); }; /** @@ -777,13 +788,18 @@ Viewer.prototype.handleInteractions = function() { }); // Handle fullscreen button - $viewerContainer.parent().find('#fullscreenBtn').off().click(function() { + $viewerContainer.siblings('#fullscreenBtn').off().click(function() { this.reload = true; this.toggleFullscreen(); }.bind(this)); + // Handle layer manager button + $viewerContainer.siblings('#layerManagerBtn').off().click(function(ev) { + this.layerManager.setVisible(); + }.bind(this)); + // Handle snap buttons - $viewerContainer.parent().find('#snapToGrid').off().click(function(ev) { + $viewerContainer.siblings('#snapToGrid').off().click(function(ev) { this.moveableOptions.snapToGrid = !this.moveableOptions.snapToGrid; // Turn off snap to element if grid is on @@ -1140,6 +1156,9 @@ Viewer.prototype.updateElement = _.throttle(function( lD.viewer.renderElementContent( element, ); + + // Update layer manager + lD.viewer.layerManager.render(); }, drawThrottle); /** @@ -1174,6 +1193,9 @@ Viewer.prototype.updateElementGroup = _.throttle(function( lD.viewer.renderElementContent( element, ); + + // Update layer manager + lD.viewer.layerManager.render(); }); }, drawThrottle); @@ -1226,6 +1248,9 @@ Viewer.prototype.updateRegion = _.throttle(function( } else { lD.viewer.updateRegionContent(region, changed); } + + // Update layer manager + lD.viewer.layerManager.render(); }, drawThrottle); @@ -2338,8 +2363,10 @@ Viewer.prototype.selectElement = function( const self = this; // Deselect all elements - (!multiSelect) && - this.DOMObject.find('.selected').removeClass('selected'); + if (!multiSelect) { + this.DOMObject.find('.selected, .selected-from-layer-manager') + .removeClass('selected selected-from-layer-manager'); + } // Remove all editing from groups // if we're not selecting an element from that group @@ -2381,6 +2408,9 @@ Viewer.prototype.selectElement = function( }, ); } + + // Update layer manager + this.layerManager.render(); }; /** diff --git a/ui/src/style/layout-editor.scss b/ui/src/style/layout-editor.scss index 05a99ac0e9..fd7fed0861 100644 --- a/ui/src/style/layout-editor.scss +++ b/ui/src/style/layout-editor.scss @@ -215,6 +215,26 @@ body { } } + #layerManagerBtn { + position: absolute; + bottom: 6px; + left: 6px; + opacity: 0.9; + font-size: 1rem; + background-color: $xibo-color-neutral-0; + color: $xibo-color-primary; + z-index: $viewer-button-z-index; + + &.active { + background-color: $xibo-color-primary; + color: $xibo-color-neutral-0; + } + + &:hover { + opacity: 1; + } + } + .snap-controls { position: absolute; display: flex; @@ -1356,6 +1376,11 @@ body { /* Layout viewer theme */ .viewer-object { + .selected-from-layer-manager { + opacity: 0.95; + z-index: $viewer-moveable-z-index !important; + } + &.theme-light { .designer-region::before, .designer-widget.selected { @include set-transparent-color(outline-color, $xibo-color-neutral-0, 0.5); @@ -1534,11 +1559,110 @@ div#bg_media_name { word-break: break-all; } +#layerManager { + width: 240px; + min-height: 60px; + border-radius: 4px; + background-color: $xibo-color-neutral-0; + color: $xibo-color-primary; + outline: 1px solid $xibo-color-primary; + z-index: $viewer-button-z-index; + + .empty-layout-message { + height: 32px; + font-weight: bold; + display: flex; + justify-content: center; + align-items: center; + } + + .layer-manager-header { + background-color: $xibo-color-primary-d60; + color: $xibo-color-neutral-0; + font-weight: bold; + font-size: 1.2rem; + padding: 0 0.5rem; + display: flex; + align-items: center; + justify-content: space-between; + cursor: grab; + + i { + font-size: 1rem; + padding: 3px 5px; + cursor: pointer; + border-radius: 4px; + margin-right: -4px; + + &:hover { + background-color: lighten($xibo-color-primary-d60, 30%); + } + } + } + + .layer-manager-layer-header, .layer-manager-canvas-layer-header { + width: 100%; + background-color: $xibo-color-primary-l20; + color: $xibo-color-neutral-900; + font-weight: bold; + padding-left: 4px; + height: 34px; + display: flex; + align-items: center; + gap: 6px; + } + + .layer-manager-layer-item { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 6px; + padding: 4px; + height: 34px; + + .name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + } + + &.selected { + color: $xibo-color-neutral-0; + background-color: $xibo-color-accent !important; + } + + &.selectable { + cursor: pointer; + + &:hover { + color: $xibo-color-secondary; + @include set-transparent-color(background, $xibo-color-accent, 0.4); + } + } + } + + .layer-manager-canvas-layer-header { + padding-left: 8px; + background-color: $xibo-color-primary; + color: $xibo-color-neutral-0; + } + + .layer-manager-canvas-layers { + .layer-manager-layer-item { + padding-left: 16px; + background-color: lighten($xibo-color-primary, 10%); + color: $xibo-color-neutral-0; + } + } +} + #actionsTab { .header { margin-left: 0; font-size: 16px; - color: #121A5E; + color: $xibo-color-secondary; } .no-actions { diff --git a/ui/src/templates/layer-manager.hbs b/ui/src/templates/layer-manager.hbs new file mode 100644 index 0000000000..2f985c1131 --- /dev/null +++ b/ui/src/templates/layer-manager.hbs @@ -0,0 +1,54 @@ +
+
+ {{trans.title}} +
+ +
+ +
+ {{#each layerStructure}} +
+
{{../trans.layer}} {{@index}}
+ + {{#each this}} + {{#eq type 'canvas'}} +
+ {{#each layers}} +
{{../../../trans.canvasLayer}} {{@index}}
+ {{#each this}} +
+ {{#eq type 'element'}} + + {{/eq}} + {{name}} + {{#if duration}} + {{duration}} + {{/if}} +
+ {{/each}} + {{/each}} +
+ {{else}} +
+ {{#eq type 'playlist'}} + + {{/eq}} + {{#eq type 'staticWidget'}} + + {{/eq}} + + {{name}} + {{#if duration}} + {{duration}} + {{/if}} +
+ {{/eq}} + {{/each}} +
+ {{else}} +
+ {{trans.emptyLayout}} +
+ {{/each}} +
\ No newline at end of file diff --git a/ui/src/templates/layout-editor.hbs b/ui/src/templates/layout-editor.hbs index b9be1ff7e5..fc8e2882a8 100644 --- a/ui/src/templates/layout-editor.hbs +++ b/ui/src/templates/layout-editor.hbs @@ -25,6 +25,10 @@ +
+ +
+ {{!-- Full screen button --}} + {{!-- Layer manager button --}} + + {{!-- Snap controls --}}