/** * EGroupware eTemplate2 - Portlet base * * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package etemplate * @subpackage api * @link https://www.egroupware.org * @author Nathan Gray * @copyright 2022 Nathan Gray */ import {Et2Widget} from "../Et2Widget/Et2Widget"; import {SlCard} from "@shoelace-style/shoelace"; import interact from "@interactjs/interactjs"; import {egw} from "../../jsapi/egw_global"; import {classMap, css, html} from "@lion/core"; import {HasSlotController} from "@shoelace-style/shoelace/dist/internal/slot"; import shoelace from "../Styles/shoelace"; import {Et2Dialog} from "../Et2Dialog/Et2Dialog"; import {et2_IResizeable} from "../et2_core_interfaces"; import {HomeApp} from "../../../../home/js/app"; /** * Participate in Home */ export class Et2Portlet extends (Et2Widget(SlCard)) { static get properties() { return { ...super.properties, /** * Give a title * Goes in the header at the top with the icons */ title: {type: String}, /** * Custom etemplate used to customize / set up the portlet */ editTemplate: {type: String}, /** * Set the portlet color */ color: {type: String}, /** * Array of customization settings, similar in structure to preference settings */ settings: {type: Object}, actions: {type: Object}, } } static get styles() { return [ ...shoelace, ...(super.styles || []), css` .portlet__header { flex: 1 0 auto; display: flex; font-style: inherit; font-variant: inherit; font-weight: inherit; font-stretch: inherit; font-family: inherit; font-size: var(--sl-font-size-medium); line-height: var(--sl-line-height-dense); padding: var(--header-spacing); margin: 0px; } .portlet__title { flex: 1 1 auto; font-size: var(--sl-font-size-medium); user-select: none; } .portlet__header et2-button-icon { display: none; order: 99; margin-left: auto; } .portlet__header:hover et2-button-icon { display: initial; } .card { width: 100%; height: 100% } .card__body { /* display block to prevent overflow from our size */ display: block; overflow: hidden; flex: 1 1 auto; } ::slotted(div) { } ` ] } protected readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image'); /** * These are the "normal" actions that every portlet is expected to have. * The widget provides default actions for all of these, but they can * be added to or overridden if needed by setting the action attribute. */ protected static default_actions : any = { edit_settings: { icon: "edit", caption: "Configure", "default": true, hideOnDisabled: true, group: "portlet" }, remove_portlet: { icon: "delete", caption: "Remove", group: "portlet" } }; constructor() { super(); this.editTemplate = egw.webserverUrl + "/home/templates/default/edit.xet" this.actions = {}; this._onMoveResize = this._onMoveResize.bind(this); this._onMoveResizeEnd = this._onMoveResizeEnd.bind(this); } connectedCallback() { super.connectedCallback(); Promise.all([/* any others here...*/ this.updateComplete]) .then(() => this._setupMoveResize()); } /** * Overriden from parent to add in default actions */ set_actions(actions) { // Set targets for actions let defaults : any = {}; for(let action_name in Et2Portlet.default_actions) { defaults[action_name] = Et2Portlet.default_actions[action_name]; // Translate caption here, as translations aren't available earlier defaults[action_name].caption = this.egw().lang(Et2Portlet.default_actions[action_name].caption); if(typeof this[action_name] == "function") { defaults[action_name].onExecute = this[action_name].bind(this); } } // Add in defaults, but let provided actions override them this.actions = jQuery.extend(true, {}, defaults, actions); } /** * Set up moving & resizing */ _setupMoveResize() { // Quick calculation of min size - dialog is made up of header, content & buttons let minHeight = 0; for(let e of this.children) { minHeight += e.getBoundingClientRect().height + parseFloat(getComputedStyle(e).marginTop) + parseFloat(getComputedStyle(e).marginBottom) } // Get parent's dimensions let style = getComputedStyle(this.parentElement); let parentDimensions = { width: parseInt(style.gridAutoColumns) + parseInt(style.gap) || HomeApp.GRID, height: parseInt(style.gridAutoRows) + parseInt(style.gap) || HomeApp.GRID }; let gridTarget = interact.snappers.grid({ x: parentDimensions.width, y: parentDimensions.height }); interact(this) .resizable({ edges: {bottom: true, right: true}, listeners: { move: this._onMoveResize, end: this._onMoveResizeEnd }, modifiers: [ // Snap to grid interact.modifiers.snap({ targets: [gridTarget], offset: "startCoords", limits: {top: 0, left: 0} }), // keep the edges inside the parent interact.modifiers.restrictEdges({ outer: 'parent' }) ] }) .draggable({ allowFrom: ".portlet__header", autoScroll: true, listeners: { move: this._onMoveResize, end: this._onMoveResizeEnd }, modifiers: [ // Restrict interferes with grid making it act strangely //interact.modifiers.restrict({ // restriction: 'parent' //}), // Snap to grid interact.modifiers.snap({ targets: [gridTarget], offset: "startCoords", limits: {top: 0, left: 0} }) ] }); } /** * Handle moving and resizing * * @param event */ _onMoveResize(event : InteractEvent) { let target = event.target let x = (parseFloat(target.getAttribute('data-x')) || 0) + (event.deltaRect ? 0 : event.dx); let y = (parseFloat(target.getAttribute('data-y')) || 0) + (event.deltaRect ? 0 : event.dy); // update the element's style // Size target.style.width = event.rect.width + 'px' target.style.height = event.rect.height + 'px' // Position target.style.transform = 'translate(' + x + 'px,' + y + 'px)'; target.setAttribute('data-x', x); target.setAttribute('data-y', y); } /** * Move or resize has completed. Now into parent grid and update settings. * * @param {InteractEvent} event */ _onMoveResizeEnd(event : InteractEvent) { // Get parent's dimensions let style = getComputedStyle(this.parentElement); let parentDimensions = { x: parseInt(style.gridAutoColumns) || 1, y: parseInt(style.gridAutoRows) || 1 } let target = event.target let dx = Math.round((parseInt(target.getAttribute('data-x')) || 0) / parentDimensions.x); let dy = Math.round((parseInt(target.getAttribute('data-y')) || 0) / parentDimensions.y); let dwidth = Math.round((event.deltaRect?.width || 1) / parentDimensions.x); let dheight = Math.round((event.deltaRect?.height || 1) / parentDimensions.y); let [o_x, o_width] = this.style.gridColumn.split(" / span"); let [o_y, o_height] = this.style.gridRow.split(" / span"); // Clear temp stuff from moving target.style.transform = ""; target.style.width = ""; target.style.height = ""; target.removeAttribute('data-x'); target.removeAttribute('data-y'); let col = Math.max(1, (dx + (parseInt(o_x) || 0))); let row = Math.max(1, (dy + (parseInt(o_y) || 0))); let width = (dwidth + parseInt(o_width)) || 1; let height = (dheight + parseInt(o_height)) || 1; // Set grid position target.style.gridArea = row + " / " + col + "/ span " + height + " / span " + width; // Update position settings this.update_settings({row: row, col: col, width: width, height: height}); } imageTemplate() { return ''; } headerTemplate() { return html`

${this.title}

`; } footerTemplate() { return ''; } /** * Create & show a dialog for customizing this portlet * * Properties for customization are sent in the 'settings' attribute */ edit_settings() { let dialog = new Et2Dialog(this.egw()); dialog.transformAttributes({ callback: this._process_edit.bind(this), template: this.editTemplate, value: { content: this.settings }, buttons: Et2Dialog.BUTTONS_OK_CANCEL }); // Set separately to avoid translation dialog.title = this.egw().lang("Edit") + " " + (this.title || ''); this.appendChild(dialog); } _process_edit(button_id, value) { if(button_id != Et2Dialog.OK_BUTTON) { return; } // Pass updated settings, unless we're removing this.update_settings((typeof value == 'string') ? {} : this.settings || {}); // Extend, not replace, because settings has types while value has just value if(typeof value == 'object') { this.settings = {...this.settings, value}; } } protected update_settings(settings) { // Save settings - server might reply with new content if the portlet needs an update, // but ideally it doesn't this.classList.add("loading"); this.egw().jsonq("home.home_ui.ajax_set_properties", [this.id, [], settings, this.settings ? this.settings.group : false], function(data) { // This section not for us if(!data || typeof data.attributes == 'undefined') { return false; } this.classList.remove("loading"); this.transformAttributes(data.attributes); // Flagged as needing to edit settings? Open dialog if(typeof data.edit_settings != 'undefined' && data.edit_settings) { this.edit_settings(); } // Only resize once, and only if needed if(data.attributes.width || data.attributes.height) { this.style.columnSpan = data.attributes.width; this.style.rowSpan = data.attributes.height; // Tell children try { this.iterateOver(function(widget) { if(typeof widget.resize === 'function') { widget.resize(); } }, null, et2_IResizeable); } catch(e) { // Something went wrong, but do not stop this.egw().debug('warn', e, this); } } }, this); } render() { return html`
${this.imageTemplate()} ${this.headerTemplate()} ${this.footerTemplate()}
`; } } if(!customElements.get("et2-portlet")) { customElements.define("et2-portlet", Et2Portlet); }