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 @@
+
+
+
+ {{#each layerStructure}}
+
+
+
+ {{#each this}}
+ {{#eq type 'canvas'}}
+
+ {{#each layers}}
+
+ {{#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 --}}
@@ -32,6 +36,12 @@
+ {{!-- Layer manager button --}}
+
+
+
+
{{!-- Snap controls --}}
diff --git a/views/common.twig b/views/common.twig
index 2f2d3a6844..53b3736065 100644
--- a/views/common.twig
+++ b/views/common.twig
@@ -708,6 +708,16 @@
}
};
+ var layerManagerTrans = {
+ title: "{{ "Layers" |trans }}",
+ layer: "{{ "Layer" |trans }}",
+ canvasLayer: "{{ "Canvas Layer" |trans }}",
+ inGroup: "{{ "In group %groupId%" |trans }}",
+ name: "{{ "Name" |trans }}",
+ duration: "{{ "Duration" |trans }}",
+ emptyLayout: "{{ "Empty layout" |trans }}",
+ };
+
var playlistAddFilesTrans = {
uploadMessage: "{{ "Replace" |trans }}",
addFiles: "{{ "Add Replacement" |trans }}",
diff --git a/views/layout-designer-page.twig b/views/layout-designer-page.twig
index 3325b276b2..94072784bc 100644
--- a/views/layout-designer-page.twig
+++ b/views/layout-designer-page.twig
@@ -55,6 +55,7 @@
back: "{% trans "Back" %}",
exit: "{% trans "Exit" %}",
toggleFullscreen: "{% trans "Toggle Fullscreen Mode" %}",
+ layerManager: "{% trans "Layer Manager" %}",
snapToGrid: "{% trans "Snap to Grid" %}",
snapToBorders: "{% trans "Snap to Borders" %}",
snapToElements: "{% trans "Snap to Elements" %}",