From cc406afcea7bf5846233a9f97a4a8ac5f618f3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Souk=C3=A9ina=20Bojabza?= Date: Wed, 8 Jun 2022 17:19:41 +0000 Subject: [PATCH] [IMP] web_editor, website: add a grid layout This commit adds the possibility to use a grid to move and resize columns inside some building blocks. More precisely, for the majority of the building blocks having columns, it is now possible to toggle between the current layout (= normal mode) and the grid layout (= grid mode). The grid mode can be triggered in two ways: - by using the "Grid" button in the options (when available), - by dragging a column using the move handle. The toggle places the different columns in the grid as close as possible as how they were placed in normal mode. The normal mode is toggled back by clicking on the "Cols" button (when available). Since the move handle is now used to trigger the grid mode, the columns in normal mode are now moved using the arrows. NB: to avoid any confusion with the word 'column' in the explanation below, the BS column will be called 'grid item' and the grid column will be 'column'. When in grid mode, the building block is a grid with 12 columns and a number of rows which depends on the height of the grid items. This mode offers the following functionalities: - Moving a grid item: When a grid item is dragged, it can be placed anywhere in the grid. If it is dragged towards the bottom, new rows are added in the grid to increase its height and to place the grid item lower. These added rows are removed when it is dragged towards the top. A grid item can also be placed over an other item (-> they can overlap) and their order can be changed using the two new buttons to place an item in front of/behind all the others. A grid item can be moved from a grid to another, and also from a grid to a building block in normal mode. - Resizing a grid item: A grid item can be resized vertically, horizontally and diagonally with the help of the grid. When resized towards the bottom, new rows are added in the grid and they are removed if it is towards the top (like with the drag and drop). - Adding elements: The "Add Elements" option allows the user to add as many images, text blocks or buttons as wanted in the grid. Inner contents can still be dragged and dropped inside a grid item. - Changing the padding of the grid items: The padding option allows to change the vertical/horizontal padding of all the grid items at the same time. - Mobile view: When in mobile view, the grid items are not in a grid anymore (the display is back to flex) but they keep their grid properties. They cannot be resized, nor dragged and dropped but they can be moved using the arrows. By moving this way, it does not impact the desktop view. task-2825241 closes odoo/odoo#93144 Signed-off-by: Quentin Smetz (qsm) --- .../img/snippets_options/bring-backward.svg | 6 + .../img/snippets_options/bring-forward.svg | 6 + .../static/src/js/common/grid_layout_utils.js | 208 ++++++++ .../static/src/js/editor/snippets.editor.js | 361 ++++++++++++- .../static/src/js/editor/snippets.options.js | 483 ++++++++++++++++-- .../static/src/scss/web_editor.frontend.scss | 33 ++ .../static/src/scss/wysiwyg_snippets.scss | 131 ++++- .../web_editor/static/src/xml/grid_layout.xml | 18 + addons/web_editor/static/src/xml/snippets.xml | 12 + addons/website/views/snippets/snippets.xml | 76 ++- 10 files changed, 1253 insertions(+), 81 deletions(-) create mode 100644 addons/web_editor/static/src/img/snippets_options/bring-backward.svg create mode 100644 addons/web_editor/static/src/img/snippets_options/bring-forward.svg create mode 100644 addons/web_editor/static/src/js/common/grid_layout_utils.js create mode 100644 addons/web_editor/static/src/xml/grid_layout.xml diff --git a/addons/web_editor/static/src/img/snippets_options/bring-backward.svg b/addons/web_editor/static/src/img/snippets_options/bring-backward.svg new file mode 100644 index 00000000000..30cc48ac4d4 --- /dev/null +++ b/addons/web_editor/static/src/img/snippets_options/bring-backward.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/addons/web_editor/static/src/img/snippets_options/bring-forward.svg b/addons/web_editor/static/src/img/snippets_options/bring-forward.svg new file mode 100644 index 00000000000..727c0154b63 --- /dev/null +++ b/addons/web_editor/static/src/img/snippets_options/bring-forward.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/addons/web_editor/static/src/js/common/grid_layout_utils.js b/addons/web_editor/static/src/js/common/grid_layout_utils.js new file mode 100644 index 00000000000..f3051b2a567 --- /dev/null +++ b/addons/web_editor/static/src/js/common/grid_layout_utils.js @@ -0,0 +1,208 @@ +/** @odoo-module **/ +'use strict'; + +import {qweb} from 'web.core'; +const rowSize = 50; // 50px. + +/** + * Returns the grid properties: rowGap, rowSize, columnGap and columnSize. + * + * @private + * @param {Element} rowEl the grid element + * @returns {Object} + */ +export function _getGridProperties(rowEl) { + const style = window.getComputedStyle(rowEl); + const rowGap = parseFloat(style.rowGap); + const columnGap = parseFloat(style.columnGap); + const columnSize = (rowEl.clientWidth - 11 * columnGap) / 12; + return {rowGap: rowGap, rowSize: rowSize, columnGap: columnGap, columnSize: columnSize}; +} +/** + * Sets the z-index property of the element to the maximum z-index present in + * the grid increased by one (so it is in front of all the other elements). + * + * @private + * @param {Element} element the element of which we want to set the z-index + * @param {Element} rowEl the parent grid element of the element + */ +export function _setElementToMaxZindex(element, rowEl) { + const childrenEls = [...rowEl.children].filter(el => el !== element); + element.style.zIndex = Math.max(...childrenEls.map(el => el.style.zIndex)) + 1; +} +/** + * Creates the background grid appearing everytime a change occurs in a grid. + * + * @private + * @param {Element} rowEl + * @param {Number} gridHeight + */ +export function _addBackgroundGrid(rowEl, gridHeight) { + const gridProp = _getGridProperties(rowEl); + const rowCount = Math.max(rowEl.dataset.rowCount, gridHeight); + + const backgroundGrid = qweb.render('web_editor.background_grid', { + rowCount: rowCount + 1, rowGap: gridProp.rowGap, rowSize: gridProp.rowSize, + columnGap: gridProp.columnGap, columnSize: gridProp.columnSize, + }); + rowEl.insertAdjacentHTML("afterbegin", backgroundGrid); + return rowEl.firstElementChild; +} +/** + * Updates the number of rows in the grid to the end of the lowest column + * present in it. + * + * @private + * @param {Element} rowEl + */ +export function _resizeGrid(rowEl) { + const columnEls = [...rowEl.children].filter(c => c.classList.contains('o_grid_item')); + rowEl.dataset.rowCount = Math.max(...columnEls.map(el => el.style.gridRowEnd)) - 1; +} +/** + * Removes the properties and elements added to make the drag work. + * + * @private + * @param {Element} rowEl + * @param {Element} column + */ +export function _gridCleanUp(rowEl, columnEl) { + columnEl.style.removeProperty('position'); + columnEl.style.removeProperty('top'); + columnEl.style.removeProperty('left'); + columnEl.style.removeProperty('height'); + columnEl.style.removeProperty('width'); + rowEl.style.removeProperty('position'); +} +/** + * Toggles the row (= child element of containerEl) in grid mode. + * + * @private + * @param {Element} containerEl element with the class "container" + */ +export function _toggleGridMode(containerEl) { + let rowEl = containerEl.querySelector('.row'); + + // If the number of columns is "None", create a column with the content. + if (!rowEl) { + rowEl = document.createElement('div'); + rowEl.classList.add('row'); + + const columnEl = document.createElement('div'); + columnEl.classList.add('col-lg-12'); + + const containerChildren = containerEl.children; + // Looping backwards because elements are removed, so the indexes are + // not lost. + for (let i = containerChildren.length - 1; i >= 0; i--) { + columnEl.prepend(containerChildren[i]); + } + rowEl.appendChild(columnEl); + containerEl.appendChild(rowEl); + } + + // Converting the columns to grid and getting back the number of rows. + const columnEls = rowEl.children; + const columnSize = (rowEl.clientWidth) / 12; + rowEl.style.position = 'relative'; + const rowCount = _placeColumns(columnEls, rowSize, 0, columnSize, 0) - 1; + rowEl.style.removeProperty('position'); + rowEl.dataset.rowCount = rowCount; + + // Removing the classes that break the grid. + const classesToRemove = [...rowEl.classList].filter(c => { + return /^align-items/.test(c); + }); + rowEl.classList.remove(...classesToRemove); + + rowEl.classList.add('o_grid_mode'); +} +/** + * Places each column in the grid based on their position and returns the + * lowest row end. + * + * @private + * @param {HTMLCollection} columnEls + * The children of the row element we are toggling in grid mode. + * @param {Number} rowSize + * @param {Number} rowGap + * @param {Number} columnSize + * @param {Number} columnGap + * @returns {Number} + */ +function _placeColumns(columnEls, rowSize, rowGap, columnSize, columnGap) { + let maxRowEnd = 0; + const columnSpans = []; + const columnCount = columnEls.length; // number of column in the grid. + for (const columnEl of columnEls) { + const style = window.getComputedStyle(columnEl); + // Horizontal placement. + const columnLeft = columnEl.offsetLeft; + // Getting the width of the column. + const paddingLeft = parseFloat(style.paddingLeft); + const width = parseFloat(columnEl.scrollWidth) - 2 * paddingLeft; + const columnSpan = Math.ceil((width + columnGap) / (columnSize + columnGap)); + const columnStart = Math.round(columnLeft / (columnSize + columnGap)) + 1; + const columnEnd = columnStart + columnSpan; + + // Vertical placement. + const columnTop = columnEl.offsetTop; + // Getting the top and bottom paddings and computing the row offset. + const paddingTop = parseFloat(style.paddingTop); + const paddingBottom = parseFloat(style.paddingBottom); + const rowOffsetTop = Math.floor((paddingTop + rowGap) / (rowSize + rowGap)); + // Getting the height of the column. + const height = parseFloat(columnEl.scrollHeight) - paddingTop - paddingBottom; + const rowSpan = Math.ceil((height + rowGap) / (rowSize + rowGap)); + const rowStart = Math.round(columnTop / (rowSize + rowGap)) + 1 + rowOffsetTop; + const rowEnd = rowStart + rowSpan; + + columnEl.style.gridArea = `${rowStart} / ${columnStart} / ${rowEnd} / ${columnEnd}`; + columnEl.classList.add('o_grid_item'); + + // Removing the grid classes (since they end with 0) and adding the + // correct ones. + const regex = /^(g-)/; + const toRemove = [...columnEl.classList].filter(c => { + return regex.test(c); + }); + columnEl.classList.remove(...toRemove); + columnEl.classList.add('g-col-lg-' + columnSpan, 'g-height-' + rowSpan); + + // Setting the initial z-index to the number of columns. + columnEl.style.zIndex = columnCount; + + // Reload the images. + _reloadLazyImages(columnEl); + + maxRowEnd = Math.max(rowEnd, maxRowEnd); + columnSpans.push(columnSpan); + } + + // Removing padding and offset classes. + for (const [i, columnEl] of [...columnEls].entries()) { + const regex = /^(pt|pb|col-|offset-)/; + const toRemove = [...columnEl.classList].filter(c => { + return regex.test(c); + }); + columnEl.classList.remove(...toRemove); + columnEl.classList.add('col-lg-' + columnSpans[i]); + } + + return maxRowEnd; +} +/** + * Removes and sets back the 'src' attribute of the images inside a column. + * (To avoid the disappearing image problem in Chrome). + * + * @private + * @param {Element} columnEl + */ +export function _reloadLazyImages(columnEl) { + const imageEls = columnEl.querySelectorAll('img'); + for (const imageEl of imageEls) { + const src = imageEl.src; + imageEl.src = ''; + imageEl.src = src; + } +} diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index e01db7a1f38..324a50bdcfd 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -5,12 +5,13 @@ var concurrency = require('web.concurrency'); var core = require('web.core'); var Dialog = require('web.Dialog'); var dom = require('web.dom'); -const {Markup, sprintf} = require('web.utils'); +const {Markup, sprintf, confine} = require('web.utils'); var Widget = require('web.Widget'); var options = require('web_editor.snippets.options'); const {ColorPaletteWidget} = require('web_editor.ColorPalette'); const SmoothScrollOnDrag = require('web/static/src/js/core/smooth_scroll_on_drag.js'); const {getCSSVariableValue} = require('web_editor.utils'); +const gridUtils = require('@web_editor/js/common/grid_layout_utils'); const QWeb = core.qweb; var _t = core._t; @@ -133,6 +134,8 @@ var SnippetEditor = Widget.extend({ events: { 'click .oe_snippet_remove': '_onRemoveClick', 'wheel': '_onMouseWheel', + 'click .o_send_back': '_onSendBackClick', + 'click .o_bring_front': '_onBringFrontClick', }, custom_events: { 'option_update': '_onOptionUpdate', @@ -214,6 +217,7 @@ var SnippetEditor = Widget.extend({ this._onDragAndDropStop(...args); }, 0); }, + refreshPositions: true, // So the dropzone expands when its size increases. }, }); const modalAncestorEl = this.$target[0].closest('.modal'); @@ -510,6 +514,14 @@ var SnippetEditor = Widget.extend({ this.$target.remove(); this.$el.remove(); + // Resize the grid to have the correct row count. + // Must be done here and not in a dedicated onRemove method because + // onRemove is called before actually removing the element and it + // should be the case in order to resize the grid. + if (this.$target[0].classList.contains('o_grid_item')) { + gridUtils._resizeGrid($parent[0]); + } + var node = $parent[0]; if (node && node.firstChild) { if (!node.firstChild.tagName && node.firstChild.textContent === ' ') { @@ -911,10 +923,47 @@ var SnippetEditor = Widget.extend({ * @private */ _onDragAndDropStart: function () { - this.options.wysiwyg.odooEditor.observerUnactive('dragAndDropMoveSnippet'); this.trigger_up('drag_and_drop_start'); this.options.wysiwyg.odooEditor.automaticStepUnactive(); var self = this; + + // Number of grid columns and rows in the grid item (BS column). + let columnColCount; + let columnRowCount; + const rowEl = this.$target[0].parentNode; + if (rowEl.classList.contains('row') && this.options.isWebsite) { + // Toggle grid mode if it is not already on. + if (!rowEl.classList.contains('o_grid_mode')) { + const containerEl = rowEl.parentNode; + gridUtils._toggleGridMode(containerEl); + } + + this.dragState = {}; + // Computing the moving column width and height in terms of columns + // and rows. + const columnStart = self.$target[0].style.gridColumnStart; + const columnEnd = self.$target[0].style.gridColumnEnd; + const rowStart = self.$target[0].style.gridRowStart; + const rowEnd = self.$target[0].style.gridRowEnd; + + columnColCount = columnEnd - columnStart; + columnRowCount = rowEnd - rowStart; + this.dragState.columnColCount = columnColCount; + this.dragState.columnRowCount = columnRowCount; + + // Deactivate the snippet so the overlay doesn't show. + this.trigger_up('deactivate_snippet', {$snippet: self.$target}); + // Storing the current grid and grid area to use them for the + // history. + this.dragState.previousGrid = rowEl; + this.dragState.prevGridArea = self.$target[0].style.gridArea; + + // Reload the images. + gridUtils._reloadLazyImages(this.$target[0]); + } + + const isPopup = this.$target[0].closest('div.s_popup'); + this.dropped = false; this._dropSiblings = { prev: self.$target.prev()[0], @@ -946,11 +995,35 @@ var SnippetEditor = Widget.extend({ } const canBeSanitizedUnless = this._canBeSanitizedUnless(this.$target[0]); + // Remove the siblings that belong to a snippet in grid mode + // and put the identified grid mode snippets in their own "selector". + const selectorGrids = new Set(); + if (this.$target[0].classList.contains('o_grid_item')) { + if ($selectorSiblings) { + // Looping backwards because elements are removed, so the + // indexes are not lost. + for (let i = $selectorSiblings.length - 1; i >= 0; i--) { + if (isPopup && !$selectorSiblings[i].closest('div.s_popup')) { + // Removing the siblings that are outside the popup if + // the grid item is in a popup. + $selectorSiblings.splice(i, 1); + } else { + const gridSnippet = $selectorSiblings[i].closest('div.o_grid_mode'); + if (gridSnippet) { + $selectorSiblings.splice(i, 1); + selectorGrids.add(gridSnippet); + } + } + } + } + } + this.trigger_up('activate_snippet', {$snippet: this.$target.parent()}); this.trigger_up('activate_insertion_zones', { $selectorSiblings: $selectorSiblings, $selectorChildren: $selectorChildren, canBeSanitizedUnless: canBeSanitizedUnless, + selectorGrids: selectorGrids, }); this.$body.addClass('move-important'); @@ -968,9 +1041,74 @@ var SnippetEditor = Widget.extend({ $('.oe_drop_zone').removeClass('invisible'); } self.dropped = true; - $(this).first().after(self.$target).addClass('invisible'); + const $dropzone = $(this).first().after(self.$target); + $dropzone.addClass('invisible'); + + if ($dropzone[0].classList.contains('oe_grid_zone')) { + // Case where the column we are dragging is over a grid + // dropzone. + const rowEl = $dropzone[0].parentNode; + + // Creating the drag helper. + const dragHelperEl = document.createElement('div'); + dragHelperEl.classList.add('o_we_drag_helper'); + dragHelperEl.style.gridArea = `1 / 1 / ${1 + columnRowCount} / ${1 + columnColCount}`; + rowEl.append(dragHelperEl); + + // Creating the background grid and updating the dropzone + // (in the case where the column over the dropzone is + // bigger than the grid). + const backgroundGridEl = gridUtils._addBackgroundGrid(rowEl, columnRowCount); + const rowCount = Math.max(rowEl.dataset.rowCount, columnRowCount); + $dropzone[0].style.gridRowEnd = rowCount + 1; + + // Setting the background grid, the moving grid item and + // the drag helper z-indexes so they are in front of the + // other elements and in this order. + gridUtils._setElementToMaxZindex(backgroundGridEl, rowEl); + gridUtils._setElementToMaxZindex(self.$target[0], rowEl); + gridUtils._setElementToMaxZindex(dragHelperEl, rowEl); + + // Setting the column height and width to keep its size + // when the grid-area is removed (as it prevents it from + // moving with the mouse). + const gridProp = gridUtils._getGridProperties(rowEl); + const columnHeight = columnRowCount * (gridProp.rowSize + gridProp.rowGap) - gridProp.rowGap; + const columnWidth = columnColCount * (gridProp.columnSize + gridProp.columnGap) - gridProp.columnGap; + self.$target[0].style.height = columnHeight + 'px'; + self.$target[0].style.width = columnWidth + 'px'; + self.$target[0].style.position = 'absolute'; + self.$target[0].style.removeProperty('grid-area'); + rowEl.style.position = 'relative'; + + // Storing useful information and adding an event listener. + self.dragState.startingHeight = rowEl.clientHeight; + self.dragState.currentHeight = rowEl.clientHeight; + self.dragState.dragHelperEl = dragHelperEl; + self.dragState.backgroundGridEl = backgroundGridEl; + self.dragState.dropzoneEl = $dropzone[0]; + self.onDragMove = self._onDragMove.bind(self); + document.body.addEventListener('mousemove', self.onDragMove, false); + } }, out: function () { + const dropzoneEl = this; + const rowEl = dropzoneEl.parentNode; + if (rowEl.classList.contains('o_grid_mode')) { + // Removing the listener + cleaning. + document.body.removeEventListener('mousemove', self.onDragMove, false); + gridUtils._gridCleanUp(rowEl, self.$target[0]); + self.$target[0].style.removeProperty('z-index'); + + // Removing the drag helper and the background grid and + // resizing the grid and the dropzone. + self.dragState.dragHelperEl.remove(); + self.dragState.backgroundGridEl.remove(); + gridUtils._resizeGrid(rowEl); + const rowCount = parseInt(rowEl.dataset.rowCount); + dropzoneEl.style.gridRowEnd = Math.max(rowCount + 1, 1); + } + var prev = self.$target.prev(); if (this === prev[0]) { self.dropped = false; @@ -999,6 +1137,41 @@ var SnippetEditor = Widget.extend({ this.options.wysiwyg.odooEditor.automaticStepSkipStack(); this.options.wysiwyg.odooEditor.unbreakableStepUnactive(); + const rowEl = this.$target[0].parentNode; + if (rowEl && rowEl.classList.contains('o_grid_mode')) { + // Case when dropping the column in a grid. + + // Removing the event listener. + document.body.removeEventListener('mousemove', this.onDragMove, false); + + // Defining the column grid area with its position. + const gridProp = gridUtils._getGridProperties(rowEl); + + const top = parseFloat(this.$target[0].style.top); + const left = parseFloat(this.$target[0].style.left); + + const rowStart = Math.round(top / (gridProp.rowSize + gridProp.rowGap)) + 1; + const columnStart = Math.round(left / (gridProp.columnSize + gridProp.columnGap)) + 1; + const rowEnd = rowStart + this.dragState.columnRowCount; + const columnEnd = columnStart + this.dragState.columnColCount; + + this.$target[0].style.gridArea = `${rowStart} / ${columnStart} / ${rowEnd} / ${columnEnd}`; + + // Cleaning, removing the drag helper and the background grid and + // resizing the grid. + gridUtils._gridCleanUp(rowEl, this.$target[0]); + this.dragState.dragHelperEl.remove(); + this.dragState.backgroundGridEl.remove(); + gridUtils._resizeGrid(rowEl); + + // Setting the z-index to the maximum of the grid. + gridUtils._setElementToMaxZindex(this.$target[0], rowEl); + } else if (this.$target[0].classList.contains('o_grid_item') && this.dropped) { + // Case when dropping a grid item in a non-grid dropzone. + this.$target[0].classList.remove('o_grid_item'); + this.$target[0].style.removeProperty('grid-area'); + } + // TODO lot of this is duplicated code of the d&d feature of snippets if (!this.dropped) { const { nearest } = this.$body[0].ownerDocument.defaultView.$; @@ -1006,6 +1179,25 @@ var SnippetEditor = Widget.extend({ // Some drop zones might have been disabled. $el = $el.filter(this.$dropZones); if ($el.length) { + // If the column is not dropped inside a dropzone. + if (this.$target[0].classList.contains('o_grid_item')) { + if ($el[0].classList.contains('oe_grid_zone')) { + // Case when a column is dropped near a grid. + // Placing it in the top left corner. + this.$target[0].style.gridArea = `1 / 1 / ${1 + this.dragState.columnRowCount} / ${1 + this.dragState.columnColCount}`; + const rowEl = $el[0].parentNode; + const rowCount = Math.max(rowEl.dataset.rowCount, 1 + this.dragState.columnRowCount); + rowEl.dataset.rowCount = rowCount; + + // Setting the z-index to the maximum of the grid. + gridUtils._setElementToMaxZindex(this.$target[0], rowEl); + } else { + // Case when a column is dropped near a non-grid dropzone. + this.$target[0].classList.remove('o_grid_item'); + this.$target[0].style.removeProperty('z-index'); + } + } + $el.after(this.$target); this.dropped = true; } @@ -1053,11 +1245,15 @@ var SnippetEditor = Widget.extend({ $snippet: this.$target, }); this.draggableComponent.$scrollTarget.off('scroll.scrolling_element'); - const samePositionAsStart = this._dropSiblings.prev === this.$target.prev()[0] && this._dropSiblings.next === this.$target.next()[0]; + const samePositionAsStart = this.$target[0].classList.contains('o_grid_item') + ? (this.$target[0].parentNode === this.dragState.previousGrid + && this.$target[0].style.gridArea === this.dragState.prevGridArea) + : this._dropSiblings.prev === this.$target.prev()[0] && this._dropSiblings.next === this.$target.next()[0]; if (!samePositionAsStart) { this.options.wysiwyg.odooEditor.historyStep(); } delete this.$dropZones; + delete this.dragState; }, /** * @private @@ -1177,6 +1373,106 @@ var SnippetEditor = Widget.extend({ this.$el.css('pointer-events', ''); }, 250); }, + /** + * Called when the "send to back" overlay button is clicked. + * + * @private + * @param {Event} ev + */ + _onSendBackClick(ev) { + ev.stopPropagation(); + const rowEl = this.$target[0].parentNode; + const columnEls = [...rowEl.children].filter(el => el !== this.$target[0]); + const minZindex = Math.min(...columnEls.map(el => el.style.zIndex)); + + // While the minimum z-index is not 0, it is OK to decrease it and to + // set the column to it. Otherwise, the column is set to 0 and the + // other columns z-index are increased by one. + if (minZindex > 0) { + this.$target[0].style.zIndex = minZindex - 1; + } else { + for (const columnEl of columnEls) { + columnEl.style.zIndex++; + } + this.$target[0].style.zIndex = 0; + } + }, + /** + * Called when the "bring to front" overlay button is clicked. + * + * @private + * @param {Event} ev + */ + _onBringFrontClick(ev) { + ev.stopPropagation(); + const rowEl = this.$target[0].parentNode; + gridUtils._setElementToMaxZindex(this.$target[0], rowEl); + }, + /** + * Called when the mouse is moved to place a column in a grid. + * + * @private + * @param {Event} ev + */ + _onDragMove(ev) { + const columnEl = this.$target[0]; + const rowEl = columnEl.parentNode; + + // Computing the rowEl position. + const rowElTop = rowEl.getBoundingClientRect().top + document.documentElement.scrollTop; + const rowElLeft = rowEl.getBoundingClientRect().left; + + // Getting the column dimensions. + const borderWidth = parseFloat(window.getComputedStyle(columnEl).borderWidth); + const columnHeight = columnEl.clientHeight + 2 * borderWidth; + const columnWidth = columnEl.clientWidth + 2 * borderWidth; + const columnMiddle = columnWidth / 2; + + // Placing the column where the mouse is. + const top = ev.pageY - rowElTop; + const bottom = top + columnHeight; + let left = ev.pageX - rowElLeft - columnMiddle; + + // Horizontal overflow. + left = confine(left, 0, rowEl.clientWidth - columnWidth); + + columnEl.style.top = top + 'px'; + columnEl.style.left = left + 'px'; + + // Computing the drag helper corresponding grid area. + const gridProp = gridUtils._getGridProperties(rowEl); + + const rowStart = Math.round(top / (gridProp.rowSize + gridProp.rowGap)) + 1; + const columnStart = Math.round(left / (gridProp.columnSize + gridProp.columnGap)) + 1; + const rowEnd = rowStart + this.dragState.columnRowCount; + const columnEnd = columnStart + this.dragState.columnColCount; + + const dragHelperEl = this.dragState.dragHelperEl; + if (parseInt(dragHelperEl.style.gridRowStart) !== rowStart) { + dragHelperEl.style.gridRowStart = rowStart; + dragHelperEl.style.gridRowEnd = rowEnd; + } + + if (parseInt(dragHelperEl.style.gridColumnStart) !== columnStart) { + dragHelperEl.style.gridColumnStart = columnStart; + dragHelperEl.style.gridColumnEnd = columnEnd; + } + + // Vertical overflow/underflow. + // Updating the reference heights, the dropzone and the background grid. + const startingHeight = this.dragState.startingHeight; + const currentHeight = this.dragState.currentHeight; + const backgroundGridEl = this.dragState.backgroundGridEl; + const dropzoneEl = this.dragState.dropzoneEl; + const rowOverflow = Math.round((bottom - currentHeight) / (gridProp.rowSize + gridProp.rowGap)); + const updateRows = bottom > currentHeight || bottom <= currentHeight && bottom > startingHeight; + if (Math.abs(rowOverflow) >= 1 && updateRows) { + const dropzoneEnd = parseInt(dropzoneEl.style.gridRowEnd); + dropzoneEl.style.gridRowEnd = dropzoneEnd + rowOverflow; + backgroundGridEl.style.gridRowEnd = dropzoneEnd + rowOverflow; + this.dragState.currentHeight += rowOverflow * (gridProp.rowSize + gridProp.rowGap); + } + } }); /** @@ -1664,8 +1960,15 @@ var SnippetsMenu = Widget.extend({ * @param {jQuery} [$selectorChildren] * elements which must have child drop zones between each of existing * child + * @param {string or boolean} canBeSanitizedUnless + * true: always allows, + * false: always forbid, + * string: specific type of forbidden sanitization + * @param {Object} [selectorGrids = []] + * elements which are in grid mode and for which a grid dropzone + * needs to be inserted */ - _activateInsertionZones: function ($selectorSiblings, $selectorChildren, canBeSanitizedUnless) { + _activateInsertionZones($selectorSiblings, $selectorChildren, canBeSanitizedUnless, selectorGrids = []) { var self = this; // If a modal or a dropdown is open, the drop zones must be created @@ -1719,9 +2022,9 @@ var SnippetsMenu = Widget.extend({ }; } - // Firstly, add a dropzone after the clone + // Firstly, add a dropzone after the clone (if we are not in grid mode). var $clone = this.$body.find('.oe_drop_clone'); - if ($clone.length) { + if ($clone.length && $clone.closest('div.o_grid_mode').length === 0) { var $neighbor = $clone.prev(); if (!$neighbor.length) { $neighbor = $clone.next(); @@ -1841,6 +2144,11 @@ var SnippetsMenu = Widget.extend({ }; } }); + + // Inserting a grid dropzone for each row in grid mode. + for (const rowEl of selectorGrids) { + self._insertGridDropzone(rowEl); + } }, /** * Adds an entry for every invisible snippet in the left panel box. @@ -2490,6 +2798,24 @@ var SnippetsMenu = Widget.extend({ $hook.replaceWith($dropzone); return $dropzone; }, + /** + * Creates a dropzone taking the entire area of the row in grid mode in + * which it will be added. It allows to place elements dragged over it + * inside the grid it belongs to. + * + * @param {Element} rowEl + */ + _insertGridDropzone(rowEl) { + const columnCount = 12; + const rowCount = parseInt(rowEl.dataset.rowCount); + let $dropzone = $('
', { + 'class': 'oe_drop_zone oe_insert oe_grid_zone', + 'style': 'grid-area: ' + 1 + '/' + 1 + '/' + (rowCount + 1) + '/' + (columnCount + 1), + }); + $dropzone[0].style.minHeight = window.getComputedStyle(rowEl).height; + $dropzone[0].style.width = window.getComputedStyle(rowEl).width; + rowEl.append($dropzone[0]); + }, /** * Make given snippets be draggable/droppable thanks to their thumbnail. * @@ -2905,7 +3231,7 @@ var SnippetsMenu = Widget.extend({ * @param {OdooEvent} ev */ _onActivateInsertionZones: function (ev) { - this._activateInsertionZones(ev.data.$selectorSiblings, ev.data.$selectorChildren, ev.data.canBeSanitizedUnless); + this._activateInsertionZones(ev.data.$selectorSiblings, ev.data.$selectorChildren, ev.data.canBeSanitizedUnless, ev.data.selectorGrids); }, /** * Called when a child editor asks to deactivate the current snippet @@ -3544,18 +3870,37 @@ var SnippetsMenu = Widget.extend({ }); }); }, false); + + // Reload images inside grid items so that no image disappears when + // activating mobile preview. + const gridItemEls = this.getEditableArea().find('div.o_grid_item'); + for (const gridItemEl of gridItemEls) { + gridUtils._reloadLazyImages(gridItemEl); + } }, /** * Undo.. */ _onUndo: async function () { this.options.wysiwyg.undo(); + // Resizing all the grids. + // TODO maybe to remove when history will be fixed. + const $gridModeRows = this.getEditableArea().find('.row.o_grid_mode'); + for (const rowEl of $gridModeRows) { + gridUtils._resizeGrid(rowEl); + } }, /** * Redo. */ _onRedo: async function () { this.options.wysiwyg.redo(); + // Resizing all the grids. + // TODO maybe to remove when history will be fixed. + const $gridModeRows = this.getEditableArea().find('.row.o_grid_mode'); + for (const rowEl of $gridModeRows) { + gridUtils._resizeGrid(rowEl); + } }, /** * @private diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index cec98913686..cff72fd145d 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -13,6 +13,7 @@ const utils = require('web.utils'); var Widget = require('web.Widget'); var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget; const weUtils = require('web_editor.utils'); +const gridUtils = require('@web_editor/js/common/grid_layout_utils'); const { normalizeColor, getBgImageURL, @@ -3744,11 +3745,17 @@ const SnippetOptionWidget = Widget.extend({ * @returns {Promise|boolean} */ _computeWidgetVisibility: async function (widgetName, params) { - if (widgetName === 'move_up_opt' || widgetName === 'move_left_opt') { - return !this.$target.is(':first-child'); - } - if (widgetName === 'move_down_opt' || widgetName === 'move_right_opt') { - return !this.$target.is(':last-child'); + const moveUpOrLeft = widgetName === 'move_up_opt' || widgetName === 'move_left_opt'; + const moveDownOrRight = widgetName === 'move_down_opt' || widgetName === 'move_right_opt'; + + if (moveUpOrLeft || moveDownOrRight) { + // The arrows are not displayed if the target is in a grid and if not in mobile view. + const isMobileView = this.$target[0].ownerDocument.defaultView.frameElement.clientWidth < 768; + if (this.$target[0].classList.contains('o_grid_item') && !isMobileView) { + return false; + } + const firstOrLastChild = moveUpOrLeft ? ':first-child' : ':last-child'; + return !this.$target.is(firstOrLastChild); } return true; }, @@ -4185,22 +4192,28 @@ registry.sizing = SnippetOptionWidget.extend({ * @override */ start: function () { - var self = this; - var def = this._super.apply(this, arguments); + const self = this; + const def = this._super.apply(this, arguments); this.$handles = this.$overlay.find('.o_handle'); - var resizeValues = this._getSize(); + let resizeValues = this._getSize(); this.$handles.on('mousedown', function (ev) { ev.preventDefault(); + // If the handle has the class 'readonly', don't allow to resize. + // (For the grid handles when we are in mobile view). + if (ev.currentTarget.classList.contains('readonly')) { + return; + } + // First update size values as some element sizes may not have been // initialized on option start (hidden slides, etc) resizeValues = self._getSize(); - var $handle = $(ev.currentTarget); + const $handle = $(ev.currentTarget); - var compass = false; - var XY = false; + let compass = false; + let XY = false; if ($handle.hasClass('n')) { compass = 'n'; XY = 'Y'; @@ -4213,74 +4226,156 @@ registry.sizing = SnippetOptionWidget.extend({ } else if ($handle.hasClass('w')) { compass = 'w'; XY = 'X'; + } else if ($handle.hasClass('nw')) { + compass = 'nw'; + XY = 'YX'; + } else if ($handle.hasClass('ne')) { + compass = 'ne'; + XY = 'YX'; + } else if ($handle.hasClass('sw')) { + compass = 'sw'; + XY = 'YX'; + } else if ($handle.hasClass('se')) { + compass = 'se'; + XY = 'YX'; } - var resize = resizeValues[compass]; - if (!resize) { + // Don't call the normal resize methods if we are in a grid and + // vice-versa. + const isGrid = Object.keys(resizeValues).length === 4; + const isGridHandle = $handle[0].classList.contains('o_grid_handle'); + if (isGrid && !isGridHandle || !isGrid && isGridHandle) { return; } - var current = 0; - var cssProperty = resize[2]; - var cssPropertyValue = parseInt(self.$target.css(cssProperty)); - _.each(resize[0], function (val, key) { - if (self.$target.hasClass(val)) { - current = key; - } else if (resize[1][key] === cssPropertyValue) { - current = key; - } - }); - var begin = current; - var beginClass = self.$target.attr('class'); - var regClass = new RegExp('\\s*' + resize[0][begin].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g'); + let resizeVal; + if (compass.length > 1) { + resizeVal = [resizeValues[compass[0]], resizeValues[compass[1]]]; + } else { + resizeVal = [resizeValues[compass]]; + } - var cursor = $handle.css('cursor') + '-important'; - var $body = $(this.ownerDocument.body); + if (resizeVal.some(rV => !rV)) { + return; + } + + // If we are in grid mode, add a background grid and place the element + // we are resizing in front of it. + const rowEl = self.$target[0].parentNode; + let backgroundGridEl; + if (rowEl.classList.contains('o_grid_mode')) { + self.options.wysiwyg.odooEditor.observerUnactive('displayBackgroundGrid'); + backgroundGridEl = gridUtils._addBackgroundGrid(rowEl, 0); + self.options.wysiwyg.odooEditor.observerActive('displayBackgroundGrid'); + gridUtils._setElementToMaxZindex(backgroundGridEl, rowEl); + gridUtils._setElementToMaxZindex(self.$target[0], rowEl); + } + + // For loop to handle the cases where it is ne, nw, se or sw. Since there are two + // directions, we compute for both directions and we store the values in an array. + const directions = []; + for (const [i, resize] of resizeVal.entries()) { + const props = {}; + let current = 0; + const cssProperty = resize[2]; + const cssPropertyValue = parseInt(self.$target.css(cssProperty)); + _.each(resize[0], function (val, key) { + if (self.$target.hasClass(val)) { + current = key; + } else if (resize[1][key] === cssPropertyValue) { + current = key; + } + }); + + props.resize = resize; + props.current = current; + props.begin = current; + props.beginClass = self.$target.attr('class'); + props.regClass = new RegExp('\\s*' + resize[0][current].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g'); + props.xy = ev['page' + XY[i]]; + props.XY = XY[i]; + props.compass = compass[i]; + + directions.push(props); + } + + const cursor = $handle.css('cursor') + '-important'; + const $body = $(this.ownerDocument.body); $body.addClass(cursor); - var xy = ev['page' + XY]; - var bodyMouseMove = function (ev) { + const bodyMouseMove = function (ev) { ev.preventDefault(); - var dd = ev['page' + XY] - xy + resize[1][begin]; - var next = current + (current + 1 === resize[1].length ? 0 : 1); - var prev = current ? (current - 1) : 0; + let changeTotal = false; + for (const dir of directions) { + // dd is the number of pixels by which the mouse moved, compared to the + // initial position of the handle. + const dd = ev['page' + dir.XY] - dir.xy + dir.resize[1][dir.begin]; + const next = dir.current + (dir.current + 1 === dir.resize[1].length ? 0 : 1); + const prev = dir.current ? (dir.current - 1) : 0; - var change = false; - if (dd > (2 * resize[1][next] + resize[1][current]) / 3) { - self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, '')); - self.$target.addClass(resize[0][next]); - current = next; - change = true; - } - if (prev !== current && dd < (2 * resize[1][prev] + resize[1][current]) / 3) { - self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, '')); - self.$target.addClass(resize[0][prev]); - current = prev; - change = true; + let change = false; + // If the mouse moved to the right/down by at least 2/3 of the space between the + // previous and the next steps, the handle is snapped to the next step and the + // class is replaced by the one matching this step. + if (dd > (2 * dir.resize[1][next] + dir.resize[1][dir.current]) / 3) { + self.$target.attr('class', (self.$target.attr('class') || '').replace(dir.regClass, '')); + self.$target.addClass(dir.resize[0][next]); + dir.current = next; + change = true; + } + // Same as above but to the left/up. + if (prev !== dir.current && dd < (2 * dir.resize[1][prev] + dir.resize[1][dir.current]) / 3) { + self.$target.attr('class', (self.$target.attr('class') || '').replace(dir.regClass, '')); + self.$target.addClass(dir.resize[0][prev]); + dir.current = prev; + change = true; + } + + if (change) { + self._onResize(dir.compass, dir.beginClass, dir.current); + } + + changeTotal = changeTotal || change; } - if (change) { - self._onResize(compass, beginClass, current); + if (changeTotal) { self.trigger_up('cover_update'); $handle.addClass('o_active'); } }; - var bodyMouseUp = function () { + const bodyMouseUp = function () { $body.off('mousemove', bodyMouseMove); $body.off('mouseup', bodyMouseUp); $body.removeClass(cursor); $handle.removeClass('o_active'); + // If we are in grid mode, removes the background grid. + // Also sync the col-* class with the g-col-* class so the toggle to normal mode + // and the mobile view are well done. + if (rowEl.classList.contains('o_grid_mode')) { + self.options.wysiwyg.odooEditor.observerUnactive('displayBackgroundGrid'); + backgroundGridEl.remove(); + self.options.wysiwyg.odooEditor.observerActive('displayBackgroundGrid'); + gridUtils._resizeGrid(rowEl); + gridUtils._setElementToMaxZindex(self.$target[0], rowEl); + + const colClass = [...self.$target[0].classList].find(c => /^col-/.test(c)); + const gColClass = [...self.$target[0].classList].find(c => /^g-col-/.test(c)); + self.$target[0].classList.remove(colClass); + self.$target[0].classList.add(gColClass.substring(2)); + } + // Highlights the previews for a while - var $handlers = self.$overlay.find('.o_handle'); + const $handlers = self.$overlay.find('.o_handle'); $handlers.addClass('o_active').delay(300).queue(function () { $handlers.removeClass('o_active').dequeue(); }); - if (begin === current) { + if (directions.every(dir => dir.begin === dir.current)) { return; } + setTimeout(function () { self.options.wysiwyg.odooEditor.historyStep(); }, 0); @@ -4292,6 +4387,9 @@ registry.sizing = SnippetOptionWidget.extend({ _.each(resizeValues, (value, key) => { this.$handles.filter('.' + key).toggleClass('readonly', !value); }); + if (this.$target[0].classList.contains('o_grid_item')) { + this.$handles.filter('.o_grid_handle').toggleClass('readonly', false); + } return def; }, @@ -4316,6 +4414,45 @@ registry.sizing = SnippetOptionWidget.extend({ // updateUI is called when the target is changed this._onResize(); }, + /** + * @override + */ + async updateUIVisibility() { + await this._super(...arguments); + + const isMobileView = this.$target[0].ownerDocument.defaultView.frameElement.clientWidth < 768; + const isGrid = this.$target[0].classList.contains('o_grid_item'); + if (this.$target[0].parentNode.classList.contains('row')) { + // Hiding/showing the correct resize handles if we are in grid mode or not. + for (const handle of this.$handles) { + const isGridHandle = handle.classList.contains('o_grid_handle'); + handle.classList.toggle('d-none', isGrid ^ isGridHandle); + // Disabling the resize if we are in mobile view. + handle.classList.toggle('readonly', isMobileView && isGridHandle); + } + + // Hiding the move handle for some snippets so we can't drag them and + // so we can't toggle grid mode. + const moveHandle = this.$overlay[0].querySelector('.o_move_handle'); + const untoggleableColumns = '.s_masonry_block, .s_showcase, .s_features_grid, .s_website_form, .s_color_blocks_2'; + const disableToggle = this.$target[0].closest(untoggleableColumns); + moveHandle.classList.toggle('d-none', disableToggle || isMobileView); + + // Hiding/showing the arrows. + if (isGrid) { + const moveLeftArrow = this.$overlay[0].querySelector('.fa-angle-left'); + const moveRightArrow = this.$overlay[0].querySelector('.fa-angle-right'); + const showLeft = await this._computeWidgetVisibility('move_left_opt'); + const showRight = await this._computeWidgetVisibility('move_right_opt'); + moveLeftArrow.classList.toggle('d-none', !showLeft); + moveRightArrow.classList.toggle('d-none', !showRight); + } + + // Show/hide the buttons to send back/front a grid item. + const bringFrontBack = this.$overlay[0].querySelectorAll('.o_front_back'); + bringFrontBack.forEach(button => button.classList.toggle('d-none', !isGrid || isMobileView)); + } + }, //-------------------------------------------------------------------------- // Private @@ -4505,6 +4642,108 @@ registry['sizing_x'] = registry.sizing.extend({ }, }); +/** + * Handles the sizing in grid mode: edition of grid-{column|row}-{start|end}. + */ +registry['sizing_grid'] = registry.sizing.extend({ + /** + * @override + */ + _getSize() { + const rowEl = this.$target.closest('.row')[0]; + const gridProp = gridUtils._getGridProperties(rowEl); + + const rowStart = this.$target[0].style.gridRowStart; + const rowEnd = parseInt(this.$target[0].style.gridRowEnd); + const columnStart = this.$target[0].style.gridColumnStart; + const columnEnd = this.$target[0].style.gridColumnEnd; + + const gridN = []; + const gridS = []; + for (let i = 1; i < rowEnd + 12; i++) { + gridN.push(i); + gridS.push(i + 1); + } + const gridW = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + const gridE = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; + + this.grid = { + n: [_.map(gridN, v => ('g-height-' + (rowEnd - v))), _.map(gridN, v => ((gridProp.rowSize + gridProp.rowGap) * (v - 1))), 'grid-row-start'], + s: [_.map(gridS, v => ('g-height-' + (v - rowStart))), _.map(gridS, v => ((gridProp.rowSize + gridProp.rowGap) * (v - 1))), 'grid-row-end'], + w: [_.map(gridW, v => ('g-col-lg-' + (columnEnd - v))), _.map(gridW, v => ((gridProp.columnSize + gridProp.columnGap) * (v - 1))), 'grid-column-start'], + e: [_.map(gridE, v => ('g-col-lg-' + (v - columnStart))), _.map(gridE, v => ((gridProp.columnSize + gridProp.columnGap) * (v - 1))), 'grid-column-end'], + }; + + return this.grid; + }, + /** + * @override + */ + _onResize(compass, beginClass, current) { + if (compass === 'n') { + const rowEnd = parseInt(this.$target[0].style.gridRowEnd); + if (current < 0) { + this.$target[0].style.gridRowStart = 1; + } else if (current + 1 >= rowEnd) { + this.$target[0].style.gridRowStart = rowEnd - 1; + } else { + this.$target[0].style.gridRowStart = current + 1; + } + } else if (compass === 's') { + const rowStart = parseInt(this.$target[0].style.gridRowStart); + const rowEnd = parseInt(this.$target[0].style.gridRowEnd); + if (current + 2 <= rowStart) { + this.$target[0].style.gridRowEnd = rowStart + 1; + } else { + this.$target[0].style.gridRowEnd = current + 2; + } + + // Updating the grid height. + const rowEl = this.$target[0].parentNode; + const rowCount = parseInt(rowEl.dataset.rowCount); + const backgroundGridEl = rowEl.querySelector('.o_we_background_grid'); + const backgroundGridRowEnd = parseInt(backgroundGridEl.style.gridRowEnd); + let rowMove = 0; + if (this.$target[0].style.gridRowEnd > rowEnd && this.$target[0].style.gridRowEnd > rowCount + 1) { + rowMove = this.$target[0].style.gridRowEnd - rowEnd; + } else if (this.$target[0].style.gridRowEnd < rowEnd && this.$target[0].style.gridRowEnd >= rowCount + 1) { + rowMove = this.$target[0].style.gridRowEnd - rowEnd; + } + backgroundGridEl.style.gridRowEnd = backgroundGridRowEnd + rowMove; + } else if (compass === 'w') { + const columnEnd = parseInt(this.$target[0].style.gridColumnEnd); + if (current < 0) { + this.$target[0].style.gridColumnStart = 1; + } else if (current + 1 >= columnEnd) { + this.$target[0].style.gridColumnStart = columnEnd - 1; + } else { + this.$target[0].style.gridColumnStart = current + 1; + } + } else if (compass === 'e') { + const columnStart = parseInt(this.$target[0].style.gridColumnStart); + if (current + 2 > 13) { + this.$target[0].style.gridColumnEnd = 13; + } else if (current + 2 <= columnStart) { + this.$target[0].style.gridColumnEnd = columnStart + 1; + } else { + this.$target[0].style.gridColumnEnd = current + 2; + } + } + + if (compass === 'n' || compass === 's') { + const numberRows = this.$target[0].style.gridRowEnd - this.$target[0].style.gridRowStart; + this.$target.attr('class', this.$target.attr('class').replace(/\s*(g-height-)([0-9-]+)/g, '')); + this.$target.addClass('g-height-' + numberRows); + } + + if (compass === 'w' || compass === 'e') { + const numberColumns = this.$target[0].style.gridColumnEnd - this.$target[0].style.gridColumnStart; + this.$target.attr('class', this.$target.attr('class').replace(/\s*(g-col-lg-)([0-9-]+)/g, '')); + this.$target.addClass('g-col-lg-' + numberColumns); + } + }, +}); + /** * Controls box properties. */ @@ -4664,6 +4903,112 @@ registry.layout_column = SnippetOptionWidget.extend({ name: 'change_columns', }); }, + /** + * Changes the layout (columns or grid). + * + * @see this.selectClass for parameters + */ + async selectLayout(previewMode, widgetValue, params) { + if (widgetValue === "grid") { + const rowEl = this.$target[0].querySelector('.row'); + if (!rowEl || !rowEl.classList.contains('o_grid_mode')) { // Prevent toggling grid mode twice. + gridUtils._toggleGridMode(this.$target[0]); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + } + } else { + // Toggle normal mode only if grid mode was activated (as it's in normal mode by default). + const rowEl = this.$target[0].querySelector('.row'); + if (rowEl && rowEl.classList.contains('o_grid_mode')) { + this._toggleNormalMode(rowEl); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + } + } + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'change_columns', + }); + }, + /** + * Adds an image, some text or a button in the grid. + * + * @see this.selectClass for parameters + */ + async addElement(previewMode, widgetValue, params) { + const rowEl = this.$target[0].querySelector('.row'); + const elementType = widgetValue; + + // If it has been less than 15 seconds that we have added an element, shift the new element + // right and down by one cell. Otherwise, put it on the top left corner. + const currentTime = new Date().getTime(); + if (this.lastAddTime && (currentTime - this.lastAddTime) / 1000 < 15) { + this.lastStartPosition = [this.lastStartPosition[0] + 1, this.lastStartPosition[1] + 1]; + } else { + this.lastStartPosition = [1, 1]; // [rowStart, columnStart] + } + this.lastAddTime = currentTime; + + // Create the new column. + const newColumn = document.createElement('div'); + newColumn.classList.add('o_grid_item'); + let numberColumns, numberRows; + + if (elementType === 'image') { + // Set the columns properties. + newColumn.classList.add('col-lg-6', 'g-col-lg-6', 'g-height-6'); + numberColumns = 6; + numberRows = 6; + + // Create a default image and add it to the new column. + const img = document.createElement('img'); + img.classList.add('img', 'img-fluid', 'mx-auto'); + img.src = '/web/image/website.s_text_image_default_image'; + img.alt = ''; + img.loading = 'lazy'; + + newColumn.appendChild(img); + + } else if (elementType === 'text') { + newColumn.classList.add('col-lg-4', 'g-col-lg-4', 'g-height-2'); + numberColumns = 4; + numberRows = 2; + + // Create default text content. + const p = document.createElement('p'); + p.classList.add('o_default_snippet_text'); + p.textContent = _t("Write something..."); + + newColumn.appendChild(p); + + } else if (elementType === 'button') { + newColumn.classList.add('col-lg-2', 'g-col-lg-2', 'g-height-1'); + numberColumns = 2; + numberRows = 1; + + // Create default button. + const a = document.createElement('a'); + a.href = '#'; + a.classList.add('mb-2', 'btn', 'btn-primary'); + a.textContent = "Button"; + + newColumn.appendChild(a); + } + // Place the column in the grid. + const rowStart = this.lastStartPosition[0]; + let columnStart = this.lastStartPosition[1]; + if (columnStart + numberColumns > 13) { + columnStart = 1; + this.lastStartPosition[1] = columnStart; + } + newColumn.style.gridArea = `${rowStart} / ${columnStart} / ${rowStart + numberRows} / ${columnStart + numberColumns}`; + + // Setting the z-index to the maximum of the grid. + gridUtils._setElementToMaxZindex(newColumn, rowEl); + + // Add the new column and update the grid height. + rowEl.appendChild(newColumn); + gridUtils._resizeGrid(rowEl); + this.trigger_up('activate_snippet', {$snippet: $(newColumn)}); + }, //-------------------------------------------------------------------------- // Private @@ -4675,6 +5020,14 @@ registry.layout_column = SnippetOptionWidget.extend({ _computeWidgetState: function (methodName, params) { if (methodName === 'selectCount') { return this.$('> .row').children().length; + + } else if (methodName === 'selectLayout') { + const rowEl = this.$target[0].querySelector('.row'); + if (rowEl && rowEl.classList.contains('o_grid_mode')) { + return "grid"; + } else { + return 'normal'; + } } return this._super(...arguments); }, @@ -4730,6 +5083,33 @@ registry.layout_column = SnippetOptionWidget.extend({ $columns.first().addClass('offset-lg-' + colOffset); } }, + /** + * Toggles the normal mode. + * + * @private + * @param {Element} rowEl + */ + _toggleNormalMode(rowEl) { + // Removing the grid class + rowEl.classList.remove('o_grid_mode'); + + const columns = rowEl.children; + for (const column of columns) { + // Reload the images. + gridUtils._reloadLazyImages(column); + + // Removing the grid properties. + column.classList.remove('o_grid_item'); + column.style.removeProperty('grid-area'); + column.style.removeProperty('z-index'); + } + + // Removing the grid properties. + delete rowEl.dataset.rowCount; + + // Adding back an align-items-* class. + rowEl.classList.add('align-items-start'); + }, }); /** @@ -4744,8 +5124,9 @@ registry.SnippetMove = SnippetOptionWidget.extend({ start: function () { var $buttons = this.$el.find('we-button'); var $overlayArea = this.$overlay.find('.o_overlay_move_options'); + // Putting the arrows side by side. + $overlayArea.prepend($buttons[1]); $overlayArea.prepend($buttons[0]); - $overlayArea.append($buttons[1]); return this._super(...arguments); }, diff --git a/addons/web_editor/static/src/scss/web_editor.frontend.scss b/addons/web_editor/static/src/scss/web_editor.frontend.scss index e9e2591b527..fef030a400e 100644 --- a/addons/web_editor/static/src/scss/web_editor.frontend.scss +++ b/addons/web_editor/static/src/scss/web_editor.frontend.scss @@ -44,3 +44,36 @@ transform: scale(-1); } +// GRID LAYOUT +.o_grid_mode { + @include media-breakpoint-up(md) { + display: grid !important; + grid-auto-rows: 50px; + grid-template-columns: repeat(12, 1fr); + row-gap: 0px; + column-gap: 0px; + } + --gutter-x: 0px; + --grid-item-padding-y: 10px; + --grid-item-padding-x: 10px; + + > * { + width: 100%; + margin: 0; + padding: var(--grid-item-padding-y) var(--grid-item-padding-x); + } +} + +.container-fluid > .o_grid_mode { + --gutter-x: 30px; +} + +.o_grid_item { + word-break: break-word; + + > img { + width: 100%; + height: 100%; + object-fit: cover !important; + } +} diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss index da89be77fe1..6844d8c7f6d 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -1949,11 +1949,16 @@ body.editor_enable.editor_has_snippets { position: relative; z-index: $o-we-overlay-zindex; width: 100%; + border: 2px dashed $o-we-border-color; + } +} + +.oe_drop_zone:not(.oe_grid_zone) { + &.oe_insert { min-width: $o-we-dropzone-size; height: $o-we-dropzone-size; min-height: $o-we-dropzone-size; margin: (-$o-we-dropzone-size/2) 0; - border: 2px dashed $o-we-border-color; &.oe_vertical { width: $o-we-dropzone-size; @@ -2015,10 +2020,18 @@ body.editor_enable.editor_has_snippets { border-right-width: 0; cursor: e-resize; + &.o_grid_handle { + cursor: ew-resize; + } + &:after { @include o-position-absolute($top: 50%, $left: 40%); margin-top: -$o-we-handles-btn-size/2; } + + &.o_grid_handle:after { + display: none; + } } &.e { @include o-position-absolute($o-we-handles-offset-to-hide, 0, -$o-we-handles-offset-to-hide, auto); @@ -2026,10 +2039,18 @@ body.editor_enable.editor_has_snippets { border-right-width: $o-we-handle-border-width; cursor: w-resize; + &.o_grid_handle { + cursor: ew-resize; + } + &:after { @include o-position-absolute($top: 50%, $right: 40%); margin-top: -$o-we-handles-btn-size/2; } + + &.o_grid_handle:after{ + display: none; + } } &.n { @include o-position-absolute($o-we-handles-offset-to-hide, 0, auto, 0); @@ -2041,6 +2062,10 @@ body.editor_enable.editor_has_snippets { @include o-position-absolute($left: 50%, $top: 40%); margin-left: -$o-we-handles-btn-size/2; } + + &.o_grid_handle:after{ + display: none; + } } &.s { @include o-position-absolute(auto, 0, -$o-we-handles-offset-to-hide, 0); @@ -2052,6 +2077,53 @@ body.editor_enable.editor_has_snippets { @include o-position-absolute($left: 50%, $bottom: 40%); margin-left: -$o-we-handles-btn-size/2; } + + &.o_grid_handle:after { + display: none; + } + } + &.ne { + @include o-position-absolute($o-we-handles-offset-to-hide + $o-we-handle-border-width, $o-we-handle-border-width, auto, auto); + height: $o-we-handle-edge-size; + cursor: nesw-resize; + } + &.se { + @include o-position-absolute(auto, $o-we-handle-border-width, -$o-we-handles-offset-to-hide - $o-we-handle-border-width, auto); + height: $o-we-handle-edge-size; + cursor: nwse-resize; + + &:after { + @include o-position-absolute($top: 50%, $right: 40%); + } + } + &.sw { + @include o-position-absolute(auto, auto, -$o-we-handles-offset-to-hide - $o-we-handle-border-width/2, $o-we-handle-border-width); + height: $o-we-handle-edge-size; + cursor: nesw-resize; + + &:after { + @include o-position-absolute($left: 40%, $bottom: 40%); + } + } + &.nw { + @include o-position-absolute($o-we-handles-offset-to-hide - $o-we-handle-border-width, auto, auto, $o-we-handle-border-width + 1); + height: $o-we-handle-edge-size; + cursor: nwse-resize; + + &:after { + @include o-position-absolute($left: 50%, $bottom: 40%); + } + } + + &.ne, + &.sw { + content: "\f065"; + } + + &.nw, + &.se { + content: "\f065"; + transform: rotate(90deg); } &::after { @@ -2092,6 +2164,17 @@ body.editor_enable.editor_has_snippets { content: "\f07d"; } + &.ne:after, + &.sw:after { + content: "\f065"; + } + + &.nw:after, + &.se:after { + content: "\f065"; + transform: rotate(180deg); + } + &.o_handle_start { &.w:after, @@ -2159,6 +2242,20 @@ body.editor_enable.editor_has_snippets { background-position: center; background-repeat: no-repeat; } + > .o_overlay_move_options > .o_send_back { + width: 30px; + height: 22px; + background-image: url('/web_editor/static/src/img/snippets_options/bring-backward.svg'); + background-position: center; + background-repeat: no-repeat; + } + > .o_overlay_move_options > .o_bring_front { + width: 30px; + height: 22px; + background-image: url('/web_editor/static/src/img/snippets_options/bring-forward.svg'); + background-position: center; + background-repeat: no-repeat; + } &:hover { .o_overlay_move_options > *, .o_overlay_edit_options > *, > we-button { @include o-hover-opacity(.6); @@ -2416,3 +2513,35 @@ we-button:hover .o_we_shape_animated_label > span { // also not work to override important inline style... this is a limitation. transition: none !important; } + +// GRID LAYOUT +we-button.o_grid { + min-width: fit-content; + padding-left: 3px !important; + padding-right: 3px !important; +} + +we-select.o_grid we-toggler { + width: fit-content !important; +} + +we-button-group.o_grid { + min-width: fit-content !important; +} + +// Background grid. +.o_we_background_grid { + padding: 0; +} + +.o_we_cell { + fill: $o-brand-odoo; + fill-opacity: .2; + stroke: $o-we-border-color; + stroke-width: 1px; +} + +.o_we_drag_helper { + padding: 0; + border: $o-we-handle-border-width solid $o-we-accent; +} diff --git a/addons/web_editor/static/src/xml/grid_layout.xml b/addons/web_editor/static/src/xml/grid_layout.xml new file mode 100644 index 00000000000..e1a5da2b1c9 --- /dev/null +++ b/addons/web_editor/static/src/xml/grid_layout.xml @@ -0,0 +1,18 @@ + + + + +
+ + + + + + + + + +
+
+ +
diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml index f269e3122d8..9b1c22bdbd0 100644 --- a/addons/web_editor/static/src/xml/snippets.xml +++ b/addons/web_editor/static/src/xml/snippets.xml @@ -9,10 +9,22 @@
+ +
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml index 46653192529..c0513f9681c 100644 --- a/addons/website/views/snippets/snippets.xml +++ b/addons/website/views/snippets/snippets.xml @@ -466,9 +466,8 @@
+ data-selector="section.s_features_grid, section.s_color_blocks_2" + data-target="> *:has(> .row:not(.s_nb_column_fixed)), > .s_allow_columns"> None 1 @@ -480,6 +479,54 @@
+
+ + + Grid + Cols + + + None + 1 + 2 + 3 + 4 + 5 + 6 + + + + Image + Text + Button + + + + + +
+ + +
+ + + + + + +
+
@@ -558,6 +605,11 @@ data-drop-near=".row:not(.s_col_no_resize) > div" data-exclude=".s_col_no_resize.row > div, .s_col_no_resize"/> +
+ section, .parallax
- -
- - - - - - -
-