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
- -
- - - - - - -
-