diff --git a/addons/html_builder/static/src/builder/options/add_element_option.js b/addons/html_builder/static/src/builder/options/add_element_option.js index 4756e6ecb001a..76f65b0386615 100644 --- a/addons/html_builder/static/src/builder/options/add_element_option.js +++ b/addons/html_builder/static/src/builder/options/add_element_option.js @@ -6,6 +6,8 @@ export class AddElementOption extends Component { static components = { ...defaultOptionComponents, }; + static props = {}; + addText() { console.log("addText"); } diff --git a/addons/html_builder/static/src/builder/options/layout_option.js b/addons/html_builder/static/src/builder/options/layout_option.js index 5677a0ff16c23..ae52bafeaf242 100644 --- a/addons/html_builder/static/src/builder/options/layout_option.js +++ b/addons/html_builder/static/src/builder/options/layout_option.js @@ -4,13 +4,11 @@ import { defaultOptionComponents } from "../components/defaultComponents"; import { useDomState } from "../builder_helpers"; import { SpacingOption } from "./spacing_option"; import { AddElementOption } from "./add_element_option"; - -// TODO to import in html_builder import { - _convertToNormalColumn, - _reloadLazyImages, - _toggleGridMode, -} from "@web_editor/js/common/grid_layout_utils"; + convertToNormalColumn, + reloadLazyImages, + toggleGridMode, +} from "@html_builder/builder/utils/grid_layout_utils"; export class LayoutOption extends Component { static template = "html_builder.LayoutOption"; @@ -35,7 +33,7 @@ export class LayoutOption extends Component { // Prevent toggling grid mode twice. return; } - _toggleGridMode(this.env.getEditingElement().querySelector(".container")); + toggleGridMode(this.env.getEditingElement().querySelector(".container")); this.env.editor.shared.history.addStep(); } setColumnLayout() { @@ -52,9 +50,9 @@ export class LayoutOption extends Component { for (const columnEl of columnEls) { // Reloading the images. - _reloadLazyImages(columnEl); + reloadLazyImages(columnEl); // Removing the grid properties. - _convertToNormalColumn(columnEl); + convertToNormalColumn(columnEl); } // Removing the grid properties. delete rowEl.dataset.rowCount; diff --git a/addons/html_builder/static/src/builder/options/spacing_option.js b/addons/html_builder/static/src/builder/options/spacing_option.js index 51d0138341eef..a2263f84e916b 100644 --- a/addons/html_builder/static/src/builder/options/spacing_option.js +++ b/addons/html_builder/static/src/builder/options/spacing_option.js @@ -7,6 +7,8 @@ export class SpacingOption extends Component { static components = { ...defaultOptionComponents, }; + static props = {}; + setup() { this.target = this.env.getEditingElement().querySelector(".o_grid_mode"); this.targetComputedStyle = getComputedStyle(this.target); diff --git a/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.js b/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.js index c66a50886a215..6266512e507dc 100644 --- a/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.js +++ b/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.js @@ -1,119 +1,179 @@ -import { Component, useRef, useState } from "@odoo/owl"; -import { usePosition } from "@web/core/position/position_hook"; -import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder_owl"; -import { pick } from "@web/core/utils/objects"; - -const useDraggableWithoutFollow = makeDraggableHook({ - name: "useDraggable", - onComputeParams: ({ ctx }) => { - ctx.followCursor = false; - }, - onWillStartDrag: ({ ctx }) => pick(ctx.current, "element"), - onDragStart: ({ ctx }) => pick(ctx.current, "element"), - onDrag: ({ ctx }) => pick(ctx.current, "element"), - onDragEnd: ({ ctx }) => pick(ctx.current, "element"), - onDrop: ({ ctx }) => pick(ctx.current, "element"), -}); - -export class BuilderOverlay extends Component { - static template = "html_builder.BuilderOverlay"; - static props = ["*"]; // TODO add props - setup() { - this.overlay = useRef("overlay"); - this.target = this.props.target; - this.size = useState({ - height: this.target.clientHeight, - width: this.target.clientWidth, - }); - this.spacingConfig = this.buildSpacingConfig(); +import { renderToElement } from "@web/core/utils/render"; +import { isMobileView } from "@html_builder/builder/utils/utils"; +import { + addBackgroundGrid, + setElementToMaxZindex, + getGridProperties, + resizeGrid, +} from "@html_builder/builder/utils/grid_layout_utils"; - usePosition("root", () => this.target, { - position: "center", - container: () => this.props.container, - onPositioned: this.updateOverlaySize.bind(this), - }); +const sizingY = { + selector: "section, .row > div, .parallax, .s_hr, .carousel-item, .s_rating", + exclude: + "section:has(> .carousel), .s_image_gallery .carousel-item, .s_col_no_resize.row > div, .s_col_no_resize", +}; +const sizingX = { + selector: ".row > div", + exclude: ".s_col_no_resize.row > div, .s_col_no_resize", +}; +const sizingGrid = { + selector: ".row > div", + exclude: ".s_col_no_resize.row > div, .s_col_no_resize", +}; - useDraggableWithoutFollow({ - ref: { el: window.document.body }, - elements: ".o_handle", - onDragStart: ({ x, y, element }) => { - const direction = this.getCurrentDirection(element); - const spacingConfigIndex = this.getSpacingIndexFromTarget(direction); - this.currentDraggable = { - initialX: x, - initialY: y, - direction, - spacingConfigIndex, - initialSpacingConfigIndex: spacingConfigIndex, - }; - }, - onDrag: ({ y }) => { - // TODO: handle x - const spacingConfig = this.spacingConfig[this.currentDraggable.direction]; - const spacingConfigIndex = this.currentDraggable.spacingConfigIndex; - const isLastSize = spacingConfigIndex + 1 === spacingConfig.classes.length; - const nextSizeIndex = isLastSize ? spacingConfigIndex : spacingConfigIndex + 1; - const prevSizeIndex = spacingConfigIndex ? spacingConfigIndex - 1 : 0; - const deltaY = - y - - this.currentDraggable.initialY + - spacingConfig.values[this.currentDraggable.initialSpacingConfigIndex]; - let indexToApply; - - // 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 ( - deltaY > - (2 * spacingConfig.values[nextSizeIndex] + - spacingConfig.values[spacingConfigIndex]) / - 3 - ) { - indexToApply = nextSizeIndex; - } +export class BuilderOverlay { + constructor(overlayTarget, { overlayContainer, addStep }) { + this.addStep = addStep; + this.overlayContainer = overlayContainer; + this.overlayElement = renderToElement("html_builder.BuilderOverlay"); + this.overlayTarget = overlayTarget; + this.hasSizingHandles = this.hasSizingHandles(); + this.handlesWrapperEl = this.overlayElement.querySelector(".o_handles"); + this.handleEls = this.overlayElement.querySelectorAll(".o_handle"); + // Avoid "querySelectoring" the handles every time. + this.yHandles = this.handlesWrapperEl.querySelectorAll( + `.n:not(.o_grid_handle), .s:not(.o_grid_handle)` + ); + this.xHandles = this.handlesWrapperEl.querySelectorAll( + `.e:not(.o_grid_handle), .w:not(.o_grid_handle)` + ); + this.gridHandles = this.handlesWrapperEl.querySelectorAll(".o_grid_handle"); - // Same as above but to the left/up. - if ( - deltaY < - (2 * spacingConfig.values[prevSizeIndex] + - spacingConfig.values[spacingConfigIndex]) / - 3 - ) { - indexToApply = prevSizeIndex; - } + this.initHandles(); + this.initSizing(); + this.refreshHandles(); + } - if (indexToApply) { - this.props.target.classList.remove(spacingConfig.classes[spacingConfigIndex]); - this.props.target.classList.add(spacingConfig.classes[indexToApply]); - this.currentDraggable.spacingConfigIndex = indexToApply; - this.updateOverlaySize(); - } - }, + hasSizingHandles() { + return this.isResizableY() || this.isResizableX() || this.isResizableGrid(); + } + + // displayOverlayOptions(el) { + // // TODO when options will be more clear: + // // - moving + // // - timeline + // // (maybe other where `displayOverlayOptions: true`) + // } + + isActive() { + // TODO active still necessary ? (check when we have preview mode) + return this.overlayElement.classList.contains("oe_active"); + } + + refreshPosition() { + if (!this.isActive()) { + return; + } + + // TODO transform + const overlayContainerRect = this.overlayContainer.getBoundingClientRect(); + const targetRect = this.overlayTarget.getBoundingClientRect(); + Object.assign(this.overlayElement.style, { + width: `${targetRect.width}px`, + height: `${targetRect.height}px`, + top: `${targetRect.y - overlayContainerRect.y + window.scrollY}px`, + left: `${targetRect.x - overlayContainerRect.x + window.scrollX}px`, }); + this.handlesWrapperEl.style.height = `${targetRect.height}px`; + } + + refreshHandles() { + if (!this.hasSizingHandles || !this.isActive()) { + return; + } + + if (this.overlayTarget.parentNode?.classList.contains("row")) { + const isMobile = isMobileView(this.overlayTarget); + const isGridOn = this.overlayTarget.classList.contains("o_grid_item"); + const isGrid = !isMobile && isGridOn; + // Hiding/showing the correct resize handles if we are in grid mode + // or not. + this.handleEls.forEach((handleEl) => { + const isGridHandle = handleEl.classList.contains("o_grid_handle"); + handleEl.classList.toggle("d-none", isGrid ^ isGridHandle); + // Disabling the vertical resize if we are in mobile view. + const isVerticalSizing = handleEl.matches(".n, .s"); + handleEl.classList.toggle("readonly", isMobile && isVerticalSizing && isGridOn); + }); + } + + this.updateHandleY(); + } + + toggleOverlay(show) { + this.overlayElement.classList.add("oe_active", show); + this.refreshPosition(); + this.refreshHandles(); + } + + toggleOverlayVisibility(show) { + if (!this.isActive()) { + return; + } + this.overlayElement.classList.toggle("o_overlay_hidden", !show); + } + + destroy() { + if (!this.hasSizingHandles) { + return; + } + + this.handleEls.forEach((handleEl) => + handleEl.removeEventListener("pointerdown", this._onSizingStart) + ); } - updateOverlaySize() { - this.size.height = this.target.clientHeight; - this.size.width = this.target.clientWidth; - this.size.paddingBottom = window - .getComputedStyle(this.target) - .getPropertyValue("padding-bottom"); - this.size.paddingTop = window.getComputedStyle(this.target).getPropertyValue("padding-top"); + //-------------------------------------------------------------------------- + // Sizing + //-------------------------------------------------------------------------- + + isResizableY() { + return this.overlayTarget.matches(`${sizingY.selector}:not(${sizingY.exclude})`); } - buildSpacingConfig() { - let topClass = "pt"; - let topStyleName = "padding-top"; - let bottomClass = "pb"; - let bottomStyleName = "padding-bottom"; + isResizableX() { + return this.overlayTarget.matches(`${sizingX.selector}:not(${sizingX.exclude})`); + } + + isResizableGrid() { + return this.overlayTarget.matches(`${sizingGrid.selector}:not(${sizingGrid.exclude})`); + } - if (this.target.tagName === "HR") { - topClass = "mt"; - topStyleName = "margin-top"; - bottomClass = "mb"; - bottomStyleName = "margin-bottom"; + initHandles() { + if (this.isResizableY()) { + this.yHandles.forEach((handleEl) => handleEl.classList.remove("readonly")); } + if (this.isResizableX()) { + this.xHandles.forEach((handleEl) => handleEl.classList.remove("readonly")); + } + if (this.isResizableGrid()) { + this.gridHandles.forEach((handleEl) => handleEl.classList.remove("readonly")); + } + } + + initSizing() { + if (!this.hasSizingHandles) { + return; + } + + this._onSizingStart = this.onSizingStart.bind(this); + this.handleEls.forEach((handleEl) => + handleEl.addEventListener("pointerdown", this._onSizingStart) + ); + } + + replaceSizingClass(classRegex, newClass) { + const newClassName = (this.overlayTarget.className || "").replace(classRegex, ""); + this.overlayTarget.className = newClassName; + this.overlayTarget.classList.add(newClass); + } + + getSizingYConfig() { + const isTargetHR = this.overlayTarget.matches("hr"); + const nClass = isTargetHR ? "mt" : "pt"; + const nProperty = isTargetHR ? "margin-top" : "padding-top"; + const sClass = isTargetHR ? "mb" : "pb"; + const sProperty = isTargetHR ? "margin-bottom" : "padding-bottom"; const values = [0, 4]; for (let i = 1; i <= 256 / 8; i++) { @@ -121,44 +181,419 @@ export class BuilderOverlay extends Component { } return { - top: { classes: values.map((v) => topClass + v), values, styleName: topStyleName }, - bottom: { - classes: values.map((v) => bottomClass + v), - values, - styleName: bottomStyleName, + n: { classes: values.map((v) => nClass + v), values: values, cssProperty: nProperty }, + s: { classes: values.map((v) => sClass + v), values: values, cssProperty: sProperty }, + }; + } + + onResizeY(compass, initialClasses, currentIndex) { + this.updateHandleY(); + } + + updateHandleY() { + this.yHandles.forEach((handleEl) => { + const topOrBottom = handleEl.matches(".n") ? "top" : "bottom"; + const padding = window.getComputedStyle(this.overlayTarget)[`padding-${topOrBottom}`]; + handleEl.style.height = padding; // TODO outerHeight (deduce borders ?) + }); + } + + getSizingXConfig() { + const resolutionModifier = this.isMobile ? "" : "lg-"; + const rowWidth = this.overlayTarget.closest(".row").getBoundingClientRect().width; + const valuesE = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + const valuesW = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + return { + e: { + classes: valuesE.map((v) => `col-${resolutionModifier}${v}`), + values: valuesE.map((v) => (rowWidth / 12) * v), + cssProperty: "width", + }, + w: { + classes: valuesW.map((v) => `offset-${resolutionModifier}${v}`), + values: valuesW.map((v) => (rowWidth / 12) * v), + cssProperty: "margin-left", + }, + }; + } + + onResizeX(compass, initialClasses, currentIndex) { + const resolutionModifier = this.isMobile ? "" : "lg-"; + // (?!\S): following char cannot be a non-space character + const offsetRegex = new RegExp(`(?:^|\\s+)offset-${resolutionModifier}(\\d{1,2})(?!\\S)`); + const colRegex = new RegExp(`(?:^|\\s+)col-${resolutionModifier}(\\d{1,2})(?!\\S)`); + + const initialOffset = Number(initialClasses.match(offsetRegex)?.[1] || 0); + + if (compass === "w") { + // Replacing the col class so the right border does not move when we + // change the offset. + const initialCol = Number(initialClasses.match(colRegex)?.[1] || 12); + let offset = Number(this.overlayTarget.className.match(offsetRegex)?.[1] || 0); + const offsetClass = `offset-${resolutionModifier}${offset}`; + + let colSize = initialCol - (offset - initialOffset); + if (colSize <= 0) { + colSize = 1; + offset = initialOffset + initialCol - 1; + } + this.overlayTarget.classList.remove(offsetClass); + this.replaceSizingClass(colRegex, `col-${resolutionModifier}${colSize}`); + if (offset > 0) { + this.overlayTarget.classList.add(`offset-${resolutionModifier}${offset}`); + } + + // Add/remove the `offset-lg-0` class when needed. + if (this.isMobile && offset === 0) { + this.overlayTarget.classList.remove("offset-lg-0"); + } else { + const className = this.overlayTarget.className; + const hasDesktopClass = !!className.match(/(^|\s+)offset-lg-\d{1,2}(?!\S)/); + const hasMobileClass = !!className.match(/(^|\s+)offset-\d{1,2}(?!\S)/); + if ( + (this.isMobile && offset > 0 && !hasDesktopClass) || + (!this.isMobile && offset === 0 && hasMobileClass) + ) { + this.overlayTarget.classList.add("offset-lg-0"); + } + } + } else if (initialOffset > 0) { + const col = Number(this.overlayTarget.className.match(colRegex)?.[1] || 0); + // Avoid overflowing to the right if the column size + the offset + // exceeds 12. + if (col + initialOffset > 12) { + this.replaceSizingClass(colRegex, `col-${resolutionModifier}${12 - initialOffset}`); + } + } + } + + getSizingGridConfig() { + const rowEl = this.overlayTarget.closest(".row"); + const gridProp = getGridProperties(rowEl); + + const rowStart = this.overlayTarget.style.gridRowStart; + const rowEnd = this.overlayTarget.style.gridRowEnd; + const columnStart = this.overlayTarget.style.gridColumnStart; + const columnEnd = this.overlayTarget.style.gridColumnEnd; + + const valuesN = []; + const valuesS = []; + for (let i = 1; i < parseInt(rowEnd) + 12; i++) { + valuesN.push(i); + valuesS.push(i + 1); + } + const valuesW = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + const valuesE = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; + + return { + n: { + classes: valuesN.map((v) => "g-height-" + (rowEnd - v)), + values: valuesN.map((v) => (gridProp.rowSize + gridProp.rowGap) * (v - 1)), + cssProperty: "grid-row-start", + }, + s: { + classes: valuesS.map((v) => "g-height-" + (v - rowStart)), + values: valuesS.map((v) => (gridProp.rowSize + gridProp.rowGap) * (v - 1)), + cssProperty: "grid-row-end", + }, + w: { + classes: valuesW.map((v) => "g-col-lg-" + (columnEnd - v)), + values: valuesW.map((v) => (gridProp.columnSize + gridProp.columnGap) * (v - 1)), + cssProperty: "grid-column-start", + }, + e: { + classes: valuesE.map((v) => "g-col-lg-" + (v - columnStart)), + values: valuesE.map((v) => (gridProp.columnSize + gridProp.columnGap) * (v - 1)), + cssProperty: "grid-column-end", }, }; } - getSpacingIndexFromTarget(direction) { - // Find the index of the current padding class applied to the target - const spacingConfig = this.spacingConfig[direction]; - const styleName = spacingConfig.styleName; - for (let i = 0; i < spacingConfig.classes.length; i++) { - const paddingClass = spacingConfig.classes[i]; - const paddingValue = spacingConfig.values[i]; - if ( - this.target.classList.contains(paddingClass) || - window.getComputedStyle(this.target).getPropertyValue(styleName) === - paddingValue + "px" - ) { - return i; + onResizeGrid(compass, initialClasses, currentIndex) { + const style = this.overlayTarget.style; + if (compass === "n") { + const rowEnd = parseInt(style.gridRowEnd); + if (currentIndex < 0) { + style.gridRowStart = 1; + } else if (currentIndex + 1 >= rowEnd) { + style.gridRowStart = rowEnd - 1; + } else { + style.gridRowStart = currentIndex + 1; + } + } else if (compass === "s") { + const rowStart = parseInt(style.gridRowStart); + const rowEnd = parseInt(style.gridRowEnd); + if (currentIndex + 2 <= rowStart) { + style.gridRowEnd = rowStart + 1; + } else { + style.gridRowEnd = currentIndex + 2; + } + + // Updating the grid height. + const rowEl = this.overlayTarget.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 (style.gridRowEnd > rowEnd && style.gridRowEnd > rowCount + 1) { + rowMove = style.gridRowEnd - rowEnd; + } else if (style.gridRowEnd < rowEnd && style.gridRowEnd >= rowCount + 1) { + rowMove = style.gridRowEnd - rowEnd; } + backgroundGridEl.style.gridRowEnd = backgroundGridRowEnd + rowMove; + } else if (compass === "w") { + const columnEnd = parseInt(style.gridColumnEnd); + if (currentIndex < 0) { + style.gridColumnStart = 1; + } else if (currentIndex + 1 >= columnEnd) { + style.gridColumnStart = columnEnd - 1; + } else { + style.gridColumnStart = currentIndex + 1; + } + } else if (compass === "e") { + const columnStart = parseInt(style.gridColumnStart); + if (currentIndex + 2 > 13) { + style.gridColumnEnd = 13; + } else if (currentIndex + 2 <= columnStart) { + style.gridColumnEnd = columnStart + 1; + } else { + style.gridColumnEnd = currentIndex + 2; + } + } + + if (compass === "n" || compass === "s") { + const numberRows = style.gridRowEnd - style.gridRowStart; + this.replaceSizingClass(/\s*(g-height-)([0-9-]+)/g, `g-height-${numberRows}`); + } + + if (compass === "w" || compass === "e") { + const numberColumns = style.gridColumnEnd - style.gridColumnStart; + this.replaceSizingClass(/\s*(g-col-lg-)([0-9-]+)/g, `g-col-lg-${numberColumns}`); + } + } + + getDirections(ev, handleEl, sizingConfig) { + let compass = false; + let XY = false; + if (handleEl.matches(".n")) { + compass = "n"; + XY = "Y"; + } else if (handleEl.matches(".s")) { + compass = "s"; + XY = "Y"; + } else if (handleEl.matches(".e")) { + compass = "e"; + XY = "X"; + } else if (handleEl.matches(".w")) { + compass = "w"; + XY = "X"; + } else if (handleEl.matches(".nw")) { + compass = "nw"; + XY = "YX"; + } else if (handleEl.matches(".ne")) { + compass = "ne"; + XY = "YX"; + } else if (handleEl.matches(".sw")) { + compass = "sw"; + XY = "YX"; + } else if (handleEl.matches(".se")) { + compass = "se"; + XY = "YX"; + } + + const currentConfig = []; + for (let i = 0; i < compass.length; i++) { + currentConfig.push(sizingConfig[compass[i]]); + } + + const directions = []; + for (const [i, config] of currentConfig.entries()) { + // Compute the current index based on the current class/style. + let currentIndex = 0; + const cssProperty = config.cssProperty; + const cssPropertyValue = parseInt( + window.getComputedStyle(this.overlayTarget)[cssProperty] + ); + config.classes.forEach((c, index) => { + if (this.overlayTarget.classList.contains(c)) { + currentIndex = index; + } else if (config.values[index] === cssPropertyValue) { + currentIndex = index; + } + }); + + directions.push({ + config, + currentIndex, + initialIndex: currentIndex, + initialClasses: this.overlayTarget.className, + classRegex: new RegExp( + "\\s*" + config.classes[currentIndex].replace(/[-]*[0-9]+/, "[-]*[0-9]+"), + "g" + ), + initialPageXY: ev["page" + XY[i]], + XY: XY[i], + compass: compass[i], + }); } + + return directions; } - getCurrentDirection(handleElement) { - const handleClasses = handleElement.classList; - if (handleClasses.contains("top")) { - return "top"; - } else if (handleClasses.contains("bottom")) { - return "bottom"; - } else if (handleClasses.contains("end")) { - return "end"; - } else if (handleClasses.contains("start")) { - return "start"; + onSizingStart(ev) { + ev.preventDefault(); + const pointerDownTime = ev.timeStamp; + + const handleEl = ev.currentTarget; + const isGridHandle = handleEl.classList.contains("o_grid_handle"); + this.isMobile = isMobileView(this.overlayTarget); + + // If we are in grid mode, add a background grid and place it in front + // of the other elements. + let rowEl, backgroundGridEl; + if (isGridHandle) { + rowEl = this.overlayTarget.parentNode; + backgroundGridEl = addBackgroundGrid(rowEl, 0); + setElementToMaxZindex(backgroundGridEl, rowEl); + } + + let sizingConfig, onResize; + if (isGridHandle) { + sizingConfig = this.getSizingGridConfig(); + onResize = this.onResizeGrid.bind(this); + } else if (handleEl.matches(".n, .s")) { + sizingConfig = this.getSizingYConfig(); + onResize = this.onResizeY.bind(this); } else { - return ""; + sizingConfig = this.getSizingXConfig(); + onResize = this.onResizeX.bind(this); } + + const directions = this.getDirections(ev, handleEl, sizingConfig); + + // Set the cursor. + const cursorClass = `${window.getComputedStyle(handleEl)["cursor"]}-important`; + window.document.body.classList.add(cursorClass); + // Prevent the iframe from absorbing the pointer events. + const iframeEl = this.overlayTarget.ownerDocument.defaultView.frameElement; + iframeEl.classList.add("o_resizing"); + + this.overlayElement.classList.remove("o_handlers_idle"); + + const onSizingMove = (ev) => { + let changeTotal = false; + for (const dir of directions) { + const configValues = dir.config.values; + const currentIndex = dir.currentIndex; + const currentValue = configValues[currentIndex]; + + // Get the number of pixels by which the pointer moved, compared + // to the initial position of the handle. + const delta = + ev[`page${dir.XY}`] - dir.initialPageXY + configValues[dir.initialIndex]; + + // Compute the indexes of the next step and the step before it, + // based on the delta. + let nextIndex, beforeIndex; + if (delta > currentValue) { + const nextValue = configValues.find((v) => v > delta); + nextIndex = nextValue + ? configValues.indexOf(nextValue) + : configValues.length - 1; + beforeIndex = nextIndex > 0 ? nextIndex - 1 : currentIndex; + } else if (delta < currentValue) { + const nextValue = configValues.findLast((v) => v < delta); + nextIndex = nextValue ? configValues.indexOf(nextValue) : 0; + beforeIndex = + nextIndex < configValues.length - 1 ? nextIndex + 1 : currentIndex; + } + + let change = false; + if (delta !== currentValue) { + // First, catch up with the pointer (in the case we moved + // really fast). + if (beforeIndex !== currentIndex) { + this.replaceSizingClass(dir.classRegex, dir.config.classes[beforeIndex]); + dir.currentIndex = beforeIndex; + change = true; + } + // If the pointer moved by at least 2/3 of the space between + // the current and the next step, the handle is snapped to + // the next step and the class is replaced by the one + // matching this step. + const threshold = + (2 * configValues[nextIndex] + configValues[dir.currentIndex]) / 3; + if ( + (delta > currentValue && delta > threshold) || + (delta < currentValue && delta < threshold) + ) { + this.replaceSizingClass(dir.classRegex, dir.config.classes[nextIndex]); + dir.currentIndex = nextIndex; + change = true; + } + } + + if (change) { + onResize(dir.compass, dir.initialClasses, dir.currentIndex); + // TODO notify other options (e.g. steps) + } + changeTotal = changeTotal || change; + } + + if (changeTotal) { + this.refreshPosition(); + } + }; + + const onSizingStop = (ev) => { + ev.preventDefault(); + window.removeEventListener("pointermove", onSizingMove); + window.removeEventListener("pointerup", onSizingStop); + window.document.body.classList.remove(cursorClass); + iframeEl.classList.remove("o_resizing"); + this.overlayElement.classList.add("o_handlers_idle"); + + // 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 (isGridHandle) { + backgroundGridEl.remove(); + resizeGrid(rowEl); + + const colClass = [...this.overlayTarget.classList].find((c) => /^col-/.test(c)); + const gColClass = [...this.overlayTarget.classList].find((c) => /^g-col-/.test(c)); + this.overlayTarget.classList.remove(colClass); + this.overlayTarget.classList.add(gColClass.substring(2)); + } + + // If no resizing happened and if the pointer was down less than + // 500 ms, we assume that the user wanted to click on the element + // behind the handle. + if (directions.every((dir) => dir.initialIndex === dir.currentIndex)) { + const pointerUpTime = ev.timeStamp; + const pointerDownDuration = pointerUpTime - pointerDownTime; + if (pointerDownDuration < 500) { + // Find the first element behind the overlay. + const sameCoordinatesEls = this.overlayTarget.ownerDocument.elementsFromPoint( + ev.pageX, + ev.pageY + ); + // Check if it has native JS `click` function + const toBeClickedEl = sameCoordinatesEls.find( + (el) => + !this.overlayContainer.contains(el) && typeof el.click === "function" + ); + if (toBeClickedEl) { + toBeClickedEl.click(); + } + } + return; + } + + this.addStep(); + }; + + window.addEventListener("pointermove", onSizingMove); + window.addEventListener("pointerup", onSizingStop); } } diff --git a/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.scss b/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.scss index 0dfea570083f2..47fd55e1ca478 100644 --- a/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.scss +++ b/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.scss @@ -1,203 +1,246 @@ -// // HANDLES -.o_handles { - @include o-position-absolute(-$o-we-handles-offset-to-hide, 0, auto, 0); - border-color: inherit; - pointer-events: auto; - - > .o_handle { - position: absolute; - - &.o_side_y { - height: $o-we-handle-edge-size; - } - &.o_side_x { - width: $o-we-handle-edge-size; - } - &.start { - inset: $o-we-handles-offset-to-hide auto $o-we-handles-offset-to-hide * -1 $o-we-handle-border-width * 0.5; - transform: translateX(-50%); - cursor: ew-resize; +div[data-oe-local-overlay-id="builder-overlay-container"] { + position: absolute; + pointer-events: none; + + .oe_overlay { + @include o-position-absolute; + display: none; + border-color: $o-we-handles-accent-color; + background: transparent; + text-align: center; + font-size: 16px; + transition: opacity 400ms linear 0s; + + &.o_overlay_hidden { + opacity: 0 !important; + transition: none; } - &.end { - inset: $o-we-handles-offset-to-hide $o-we-handle-border-width * 0.5 $o-we-handles-offset-to-hide * -1 auto; - transform: translateX(50%); - cursor: ew-resize; + &.oe_active { + display: block; + z-index: 1; } - &.top { - inset: $o-we-handles-offset-to-hide 0 auto 0; - cursor: ns-resize; - &.o_grid_handle { - transform: translateY(-50%); + // HANDLES + .o_handles { + @include o-position-absolute(-$o-we-handles-offset-to-hide, 0, auto, 0); + border-color: inherit; + pointer-events: auto; + + > .o_handle { + position: absolute; - &:before { - transform: translateY($o-we-handle-border-width * 0.5); + &.o_side_y { + height: $o-we-handle-edge-size; } - } - } - &.bottom { - inset: auto 0 $o-we-handles-offset-to-hide * -1 0; - cursor: ns-resize; + &.o_side_x { + width: $o-we-handle-edge-size; + } + &.w { + inset: $o-we-handles-offset-to-hide auto $o-we-handles-offset-to-hide * -1 $o-we-handle-border-width * 0.5; + transform: translateX(-50%); + cursor: ew-resize; + } + &.e { + inset: $o-we-handles-offset-to-hide $o-we-handle-border-width * 0.5 $o-we-handles-offset-to-hide * -1 auto; + transform: translateX(50%); + cursor: ew-resize; + } + &.n { + inset: $o-we-handles-offset-to-hide 0 auto 0; + cursor: ns-resize; - &.o_grid_handle { - transform: translateY(50%); + &.o_grid_handle { + transform: translateY(-50%); - &:before { - transform: translateY($o-we-handle-border-width * -0.5); + &:before { + transform: translateY($o-we-handle-border-width * 0.5); + } + } } - } - } - &.ne { - inset: ($o-we-handles-offset-to-hide + $o-we-handle-border-width * 0.5) $o-we-handle-border-width * 0.5 auto auto; - transform: translate(50%, -50%); - cursor: nesw-resize; - } - &.se { - inset: auto $o-we-handle-border-width * 0.5 ($o-we-handles-offset-to-hide * -1 + $o-we-handle-border-width * 0.5) auto; - transform: translate(50%, 50%); - cursor: nwse-resize; - } - &.sw { - inset: auto auto ($o-we-handles-offset-to-hide * -1 + $o-we-handle-border-width * 0.5) $o-we-handle-border-width * 0.5; - transform: translate(-50%, 50%); - cursor: nesw-resize; - } - &.nw { - inset: ($o-we-handles-offset-to-hide + $o-we-handle-border-width * 0.5) auto auto $o-we-handle-border-width * 0.5; - transform: translate(-50%, -50%); - cursor: nwse-resize; - } - .o_handle_indicator { - position: absolute; - inset: $o-we-handles-btn-size * -0.5; - display: block; - width: $o-we-handles-btn-size; - height: $o-we-handles-btn-size; - margin: auto; - border: solid $o-we-handle-border-width $o-we-handles-accent-color; - border-radius: $o-we-handles-btn-size; - background: $o-we-fg-lighter; - outline: $o-we-handle-inside-line-width solid $o-we-fg-lighter; - outline-offset: -($o-we-handles-btn-size * 0.5); - transition: $transition-base; - - &::before { - content: ''; - position: absolute; - inset: -$o-we-handles-btn-size; - display: block; - border-radius: inherit; - } - } + &.s { + inset: auto 0 $o-we-handles-offset-to-hide * -1 0; + cursor: ns-resize; - &.o_column_handle.o_side_y { - background-color: rgba($o-we-handles-accent-color, .1); + &.o_grid_handle { + transform: translateY(50%); - &::after { - content: ''; - position: absolute; - height: $o-we-handles-btn-size; - } - &.top { - border-bottom: dashed $o-we-handle-border-width * 0.5 rgba($o-we-handles-accent-color, 0.5); + &:before { + transform: translateY($o-we-handle-border-width * -0.5); + } + } + } + &.ne { + inset: ($o-we-handles-offset-to-hide + $o-we-handle-border-width * 0.5) $o-we-handle-border-width * 0.5 auto auto; + transform: translate(50%, -50%); + cursor: nesw-resize; + } + &.se { + inset: auto $o-we-handle-border-width * 0.5 ($o-we-handles-offset-to-hide * -1 + $o-we-handle-border-width * 0.5) auto; + transform: translate(50%, 50%); + cursor: nwse-resize; + } + &.sw { + inset: auto auto ($o-we-handles-offset-to-hide * -1 + $o-we-handle-border-width * 0.5) $o-we-handle-border-width * 0.5; + transform: translate(-50%, 50%); + cursor: nesw-resize; + } + &.nw { + inset: ($o-we-handles-offset-to-hide + $o-we-handle-border-width * 0.5) auto auto $o-we-handle-border-width * 0.5; + transform: translate(-50%, -50%); + cursor: nwse-resize; + } + .o_handle_indicator { + position: absolute; + inset: $o-we-handles-btn-size * -0.5; + display: block; + width: $o-we-handles-btn-size; + height: $o-we-handles-btn-size; + margin: auto; + border: solid $o-we-handle-border-width $o-we-handles-accent-color; + border-radius: $o-we-handles-btn-size; + background: $o-we-fg-lighter; + outline: $o-we-handle-inside-line-width solid $o-we-fg-lighter; + outline-offset: -($o-we-handles-btn-size * 0.5); + transition: $transition-base; + + &::before { + content: ''; + position: absolute; + inset: -$o-we-handles-btn-size; + display: block; + border-radius: inherit; + } + } - &::after { - inset: 0 0 auto 0; - transform: translateY(-50%); + &.o_column_handle.o_side_y { + background-color: rgba($o-we-handles-accent-color, .1); + + &::after { + content: ''; + position: absolute; + height: $o-we-handles-btn-size; + } + &.n { + border-bottom: dashed $o-we-handle-border-width * 0.5 rgba($o-we-handles-accent-color, 0.5); + + &::after { + inset: 0 0 auto 0; + transform: translateY(-50%); + } + } + &.s { + border-top: dashed $o-we-handle-border-width * 0.5 rgba($o-we-handles-accent-color, 0.5); + + &::after { + inset: auto 0 0 0; + transform: translateY(50%); + } + } } - } - &.bottom { - border-top: dashed $o-we-handle-border-width * 0.5 rgba($o-we-handles-accent-color, 0.5); + &.o_side { + &::before { + content: ''; + position: absolute; + inset: 0; + background: $o-we-handles-accent-color; + } + &.o_side_x { + + &::before { + width: $o-we-handle-border-width; + margin: 0 auto; + } + } + &.o_side_y { + + &::before { + height: $o-we-handle-border-width; + margin: auto 0; + } + } + &.o_column_handle { + + &.n::before { + margin: 0 auto auto; + } + + &.s::before { + margin: auto auto 0; + } + } + } + + &.readonly { + cursor: default; + pointer-events: none; - &::after { - inset: auto 0 0 0; - transform: translateY(50%); + &.o_column_handle.o_side_y { + border: none; + background: none; + } + + &::after, .o_handle_indicator { + display: none; + } } } } - &.o_side { - &::before { - content: ''; - position: absolute; - inset: 0; - background: $o-we-handles-accent-color; - } - &.o_side_x { - &::before { - width: $o-we-handle-border-width; - margin: 0 auto; - } - } - &.o_side_y { + // HANDLES - ACTIVE AND HOVER STATES + // By using `o_handlers_idle` class, we can avoid hovering another + // handle when we're already dragging another one. + &.o_handlers_idle .o_handle:hover, .o_handle:active { - &::before { - height: $o-we-handle-border-width; - margin: auto 0; - } + .o_handle_indicator { + outline-color: $o-we-handles-accent-color; } - &.o_column_handle { + } - &.top::before { - margin: 0 auto auto; - } + &.o_handlers_idle .o_corner_handle:hover, .o_corner_handle:active { - &.bottom::before { - margin: auto auto 0; - } + .o_handle_indicator { + transform: scale(1.25); } } - &.readonly { - cursor: default; - pointer-events: none; + &.o_handlers_idle .o_column_handle.o_side_y:hover, .o_column_handle.o_side_y:active { + background: repeating-linear-gradient( + 45deg, + rgba($o-we-handles-accent-color, .1), + rgba($o-we-handles-accent-color, .1) 5px, + darken(rgba($o-we-handles-accent-color, .25), 5%) 5px, + darken(rgba($o-we-handles-accent-color, .25), 5%) 10px + ); + } - &.o_column_handle.o_side_y { - border: none; - background: none; - } + &.o_handlers_idle .o_side_x:hover, .o_side_x:active { - &::after, .o_handle_indicator { - display: none; + &::before { + width: $o-we-handle-border-width * 2; + } + .o_handle_indicator { + height: $o-we-handles-btn-size * 2; } } - } -} -.o_column_handle.o_side_y:hover, .o_column_handle.o_side_y:active { - background: repeating-linear-gradient( - 45deg, - rgba($o-we-handles-accent-color, .1), - rgba($o-we-handles-accent-color, .1) 5px, - darken(rgba($o-we-handles-accent-color, .25), 5%) 5px, - darken(rgba($o-we-handles-accent-color, .25), 5%) 10px - ); -} - -// HANDLES - ACTIVE AND HOVER STATES -.o_handle:hover, .o_handle:active { + &.o_handlers_idle .o_side_y:hover, .o_side_y:active { - .o_handle_indicator { - outline-color: $o-we-handles-accent-color; + &::before { + height: $o-we-handle-border-width * 2; + } + .o_handle_indicator { + width: $o-we-handles-btn-size * 2; + } + } } } -.o_side_x:hover, .o_side_x:active { - - &::before { - width: $o-we-handle-border-width * 2; - } - .o_handle_indicator { - height: $o-we-handles-btn-size * 2; +@each $cursor in (nesw-resize, nwse-resize, ns-resize, ew-resize, move) { + .#{$cursor}-important * { + cursor: $cursor !important; } } -.o_side_y:hover, .o_side_y:active { - - &::before { - height: $o-we-handle-border-width * 2; - } - .o_handle_indicator { - width: $o-we-handles-btn-size * 2; - } +.o_resizing { + pointer-events: none; } diff --git a/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.xml b/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.xml index 53f0bcbbe7285..1fdea3ec48062 100644 --- a/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.xml +++ b/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay.xml @@ -2,20 +2,47 @@ -
-
-
+
+
+ +
-
+
-
+
-
+
+ + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
diff --git a/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay_plugin.js b/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay_plugin.js index dce8e5079ec24..ca53e7b0b9ffe 100644 --- a/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay_plugin.js +++ b/addons/html_builder/static/src/builder/plugins/builder_overlay/builder_overlay_plugin.js @@ -1,34 +1,131 @@ import { Plugin } from "@html_editor/plugin"; +import { throttleForAnimation } from "@web/core/utils/timing"; +import { getScrollingElement, getScrollingTarget } from "@web/core/utils/scrolling"; import { BuilderOverlay } from "./builder_overlay"; export class BuilderOverlayPlugin extends Plugin { - static id = "builder_overlay"; - static dependencies = ["selection", "overlay"]; + static id = "builderOverlay"; + static dependencies = ["selection", "localOverlay", "history"]; resources = { + step_added_handlers: this._update.bind(this), change_current_options_containers_listeners: this.openBuilderOverlay.bind(this), }; setup() { - this.overlay = this.dependencies.overlay.createOverlay(BuilderOverlay, { - positionOptions: { - position: "center", - }, + this.overlayContainer = this.dependencies.localOverlay.makeLocalOverlay( + "builder-overlay-container" + ); + /** @type {[BuilderOverlay]} */ + this.overlays = []; + + this.update = throttleForAnimation(this._update.bind(this)); + + // Recompute the overlay when the window is resized. + this.addDomListener(window, "resize", this.update); + + // On keydown, hide the overlay and then show it again when the mouse + // moves. + const onMouseMoveOrDown = throttleForAnimation((ev) => { + this.toggleOverlaysVisibility(true); + this.refreshPosition(); + ev.currentTarget.removeEventListener("mousemove", onMouseMoveOrDown); + ev.currentTarget.removeEventListener("mousedown", onMouseMoveOrDown); }); - } + this.addDomListener(this.editable, "keydown", (ev) => { + this.toggleOverlaysVisibility(false); + ev.currentTarget.addEventListener("mousemove", onMouseMoveOrDown); + ev.currentTarget.addEventListener("mousedown", onMouseMoveOrDown); + }); + + // Hide the overlay when scrolling. Show it again when the scroll is + // over and recompute its position. + const scrollingElement = getScrollingElement(this.document); + const scrollingTarget = getScrollingTarget(scrollingElement); + this.addDomListener( + scrollingTarget, + "scroll", + throttleForAnimation(() => { + this.toggleOverlaysVisibility(false); + clearTimeout(this.scrollingTimeout); + this.scrollingTimeout = setTimeout(() => { + this.toggleOverlaysVisibility(true); + this.refreshPosition(); + }, 250); + }), + { capture: true } + ); - destroy() { - this.removeCurrentOverlay?.(); + this._cleanups.push(() => this.removeBuilderOverlay()); } - openBuilderOverlay(optionsContainers) { - const optionContainer = optionsContainers[0]; - this.removeCurrentOverlay?.(); - if (!optionContainer) { + openBuilderOverlay(optionsContainer) { + this.removeBuilderOverlay(); + if (!optionsContainer.length) { return; } - this.removeCurrentOverlay = this.services.overlay.add(BuilderOverlay, { - target: optionContainer.element, - container: this.document.documentElement, + + // Create the overlays. + optionsContainer.forEach((option) => { + const overlay = new BuilderOverlay(option.element, { + overlayContainer: this.overlayContainer, + addStep: this.dependencies.history.addStep, + }); + this.overlays.push(overlay); + this.overlayContainer.append(overlay.overlayElement); + }); + + // Activate the last overlay. + const innermostOverlay = this.overlays.at(-1); + innermostOverlay.toggleOverlay(true); + + // Also activate the closest overlay that should have sizing + // handles. + if (!innermostOverlay.hasSizingHandles) { + for (let i = this.overlays.length - 2; i >= 0; i--) { + const parentOverlay = this.overlays[i]; + if (parentOverlay.hasSizingHandles) { + parentOverlay.toggleOverlay(true); + break; + } + } + } + + // TODO check if resizeObserver still needed. + // this.resizeObserver = new ResizeObserver(this.update.bind(this)); + // this.resizeObserver.observe(this.overlayTarget); + } + + removeBuilderOverlay() { + this.overlays.forEach((overlay) => { + overlay.destroy(); + overlay.overlayElement.remove(); + }); + this.overlays = []; + // this.resizeObserver?.disconnect(); + } + + _update() { + this.overlays.forEach((overlay) => { + overlay.refreshPosition(); + overlay.refreshHandles(); + }); + } + + refreshPosition() { + this.overlays.forEach((overlay) => { + overlay.refreshPosition(); + }); + } + + refreshHandles() { + this.overlays.forEach((overlay) => { + overlay.refreshHandles(); + }); + } + + toggleOverlaysVisibility(show) { + this.overlays.forEach((overlay) => { + overlay.toggleOverlayVisibility(show); }); } } diff --git a/addons/html_builder/static/src/builder/plugins/grid_layout.inside.scss b/addons/html_builder/static/src/builder/plugins/grid_layout.inside.scss new file mode 100644 index 0000000000000..1662bf1805de6 --- /dev/null +++ b/addons/html_builder/static/src/builder/plugins/grid_layout.inside.scss @@ -0,0 +1,77 @@ +// TODO move this file elsewhere + +// GRID LAYOUT +// we-button.o_grid { +// min-width: fit-content; +// padding-left: 4.5px !important; +// padding-right: 4.5px !important; +// } + +// we-select.o_grid we-toggler { +// width: fit-content !important; +// } + +// Background grid. +.o_we_background_grid { + padding: 0 !important; + + .o_we_cell { + fill: $o-we-fg-lighter; + fill-opacity: .1; + stroke: $o-we-bg-darkest; + stroke-opacity: .2; + stroke-width: 1px; + filter: drop-shadow(-1px -1px 0px rgba(255, 255, 255, 0.3)); + } + + // &.o_we_grid_preview { + // @include media-breakpoint-down(lg) { + // // Hiding the preview in mobile view (-> no grid in mobile view). We + // // cannot use `display: none` because it would prevent the animation + // // to be played and so its listener would not remove the preview. + // height: 0; + // } + // pointer-events: none; + + // .o_we_cell { + // animation: gridPreview 2s 0.5s; + // } + // } +} + +// Grid preview. +// @keyframes gridPreview { +// to { +// fill-opacity: 0; +// stroke-opacity: 0; +// } +// } + +// .o_we_drag_helper { +// padding: 0; +// border: $o-we-handle-border-width * 2 solid $o-we-accent; +// border-radius: $o-we-item-border-radius; +// } + +// // Highlight of the grid items padding. +// @keyframes highlightPadding { +// from { +// border: solid rgba($o-we-handles-accent-color, 0.2); +// border-width: var(--grid-item-padding-y) var(--grid-item-padding-x); +// } +// to { +// border: solid rgba($o-we-handles-accent-color, 0); +// border-width: var(--grid-item-padding-y) var(--grid-item-padding-x); +// } +// } + +// .o_we_padding_highlight.o_grid_item { +// position: relative; + +// &::after { +// content: ""; +// @include o-position-absolute(0, 0, 0, 0); +// animation: highlightPadding 2s; +// pointer-events: none; +// } +// } diff --git a/addons/html_builder/static/src/builder/plugins/grid_layout.xml b/addons/html_builder/static/src/builder/plugins/grid_layout.xml new file mode 100644 index 0000000000000..eda136b934b9e --- /dev/null +++ b/addons/html_builder/static/src/builder/plugins/grid_layout.xml @@ -0,0 +1,19 @@ + + + + + +
+ + + + + + + + + +
+
+ +
diff --git a/addons/html_builder/static/src/builder/snippets_menu.js b/addons/html_builder/static/src/builder/snippets_menu.js index 9b5121509f7d9..7321643950bf2 100644 --- a/addons/html_builder/static/src/builder/snippets_menu.js +++ b/addons/html_builder/static/src/builder/snippets_menu.js @@ -56,6 +56,7 @@ export class SnippetsMenu extends Component { closeEditor: { type: Function }, snippetsName: { type: String }, toggleMobile: { type: Function }, + overlayRef: { type: Function }, }; setup() { @@ -110,6 +111,10 @@ export class SnippetsMenu extends Component { type: editableEl.dataset["oeType"], }; }, + localOverlayContainers: { + key: this.env.localOverlayContainerKey, + ref: this.props.overlayRef, + }, }, this.env.services ); diff --git a/addons/html_builder/static/src/builder/utils/grid_layout_utils.js b/addons/html_builder/static/src/builder/utils/grid_layout_utils.js new file mode 100644 index 0000000000000..b72ce10309219 --- /dev/null +++ b/addons/html_builder/static/src/builder/utils/grid_layout_utils.js @@ -0,0 +1,381 @@ +import { renderToElement } from "@web/core/utils/render"; +import { descendants, preserveCursor } from "@web_editor/js/editor/odoo-editor/src/utils/utils"; +export const rowSize = 50; // 50px. +// Maximum number of rows that can be added when dragging a grid item. +export const additionalRowLimit = 10; +const defaultGridPadding = 10; // 10px (see `--grid-item-padding-(x|y)` CSS variables). + +/** + * 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) => { + return el !== element && !el.classList.contains("o_we_grid_preview"); + }); + 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 = renderToElement("html_builder.background_grid", { + rowCount: rowCount + 1, + rowGap: gridProp.rowGap, + rowSize: gridProp.rowSize, + columnGap: gridProp.columnGap, + columnSize: gridProp.columnSize, + }); + rowEl.prepend(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(":scope > .row"); + const outOfRowEls = [...containerEl.children].filter((el) => !el.classList.contains("row")); + // Avoid an unwanted rollback that prevents from deleting the text. + const avoidRollback = (el) => { + for (const node of descendants(el)) { + node.ouid = undefined; + } + }; + // Keep the text selection. + const restoreCursor = + !rowEl || outOfRowEls.length > 0 ? preserveCursor(containerEl.ownerDocument) : () => {}; + + // For the snippets having elements outside of the row (and therefore not in + // a column), create a column and put these elements in it so they can also + // be placed in the grid. + if (rowEl && outOfRowEls.length > 0) { + const columnEl = document.createElement("div"); + columnEl.classList.add("col-lg-12"); + for (let i = outOfRowEls.length - 1; i >= 0; i--) { + columnEl.prepend(outOfRowEls[i]); + } + avoidRollback(columnEl); + rowEl.prepend(columnEl); + } + + // 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]); + } + avoidRollback(columnEl); + rowEl.appendChild(columnEl); + containerEl.appendChild(rowEl); + } + restoreCursor(); + + // 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 = []; + let zIndex = 1; + const imageColumns = []; // array of boolean telling if it is a column with only an image. + + for (const columnEl of columnEls) { + // Finding out if the images are alone in their column. + const isImageColumn = checkIfImageColumn(columnEl); + const imageEl = columnEl.querySelector("img"); + // Checking if the column has a background color to take that into + // account when computing its size and padding (to make it look good). + const hasBackgroundColor = columnEl.classList.contains("o_cc"); + const isImageWithoutPadding = isImageColumn && !hasBackgroundColor; + + // Placing the column. + const style = window.getComputedStyle(columnEl); + // Horizontal placement. + const borderLeft = parseFloat(style.borderLeft); + const columnLeft = + isImageWithoutPadding && !borderLeft ? imageEl.offsetLeft : columnEl.offsetLeft; + // Getting the width of the column. + const paddingLeft = parseFloat(style.paddingLeft); + let width = isImageWithoutPadding + ? parseFloat(imageEl.scrollWidth) + : parseFloat(columnEl.scrollWidth) - (hasBackgroundColor ? 0 : 2 * paddingLeft); + const borderX = borderLeft + parseFloat(style.borderRight); + width += borderX + (hasBackgroundColor || isImageColumn ? 0 : 2 * defaultGridPadding); + let columnSpan = Math.round((width + columnGap) / (columnSize + columnGap)); + if (columnSpan < 1) { + columnSpan = 1; + } + const columnStart = Math.round(columnLeft / (columnSize + columnGap)) + 1; + const columnEnd = columnStart + columnSpan; + + // Vertical placement. + const borderTop = parseFloat(style.borderTop); + const columnTop = + isImageWithoutPadding && !borderTop ? imageEl.offsetTop : 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. + let height = isImageWithoutPadding + ? parseFloat(imageEl.scrollHeight) + : parseFloat(columnEl.scrollHeight) - + (hasBackgroundColor ? 0 : paddingTop + paddingBottom); + const borderY = borderTop + parseFloat(style.borderBottom); + height += borderY + (hasBackgroundColor || isImageColumn ? 0 : 2 * defaultGridPadding); + const rowSpan = Math.ceil((height + rowGap) / (rowSize + rowGap)); + const rowStart = + Math.round(columnTop / (rowSize + rowGap)) + + 1 + + (hasBackgroundColor || isImageWithoutPadding ? 0 : rowOffsetTop); + const rowEnd = rowStart + rowSpan; + + columnEl.style.gridArea = `${rowStart} / ${columnStart} / ${rowEnd} / ${columnEnd}`; + columnEl.classList.add("o_grid_item"); + + // Adding the grid classes. + columnEl.classList.add(`g-col-lg-${columnSpan}`, `g-height-${rowSpan}`); + // Setting the initial z-index. + columnEl.style.zIndex = zIndex++; + // Setting the paddings. + if (hasBackgroundColor) { + columnEl.style.setProperty("--grid-item-padding-y", `${paddingTop}px`); + columnEl.style.setProperty("--grid-item-padding-x", `${paddingLeft}px`); + } + // Reload the images. + reloadLazyImages(columnEl); + + maxRowEnd = Math.max(rowEnd, maxRowEnd); + columnSpans.push(columnSpan); + imageColumns.push(isImageColumn); + } + + for (const [i, columnEl] of [...columnEls].entries()) { + // Removing padding and offset classes. + const regex = /^(((pt|pb)\d{1,3}$)|col-lg-|offset-lg-)/; + const toRemove = [...columnEl.classList].filter((c) => { + return regex.test(c); + }); + columnEl.classList.remove(...toRemove); + columnEl.classList.add("col-lg-" + columnSpans[i]); + + // If the column only has an image, convert it. + if (imageColumns[i]) { + convertImageColumn(columnEl); + } + } + + 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.getAttribute("src"); + imageEl.src = ""; + imageEl.src = src; + } +} +/** + * Computes the column and row spans of the column thanks to its width and + * height and returns them. Also adds the grid classes to the column. + * + * @private + * @param {Element} rowEl + * @param {Element} columnEl + * @param {Number} columnWidth the width in pixels of the column. + * @param {Number} columnHeight the height in pixels of the column. + * @returns {Object} + */ +export function convertColumnToGrid(rowEl, columnEl, columnWidth, columnHeight) { + // First, checking if the column only contains an image and if it is the + // case, converting it. + if (checkIfImageColumn(columnEl)) { + convertImageColumn(columnEl); + } + + // Taking the grid padding into account. + const paddingX = + parseFloat(rowEl.style.getPropertyValue("--grid-item-padding-x")) || defaultGridPadding; + const paddingY = + parseFloat(rowEl.style.getPropertyValue("--grid-item-padding-y")) || defaultGridPadding; + columnWidth += 2 * paddingX; + columnHeight += 2 * paddingY; + + // Computing the column and row spans. + const gridProp = getGridProperties(rowEl); + const columnColCount = Math.round( + (columnWidth + gridProp.columnGap) / (gridProp.columnSize + gridProp.columnGap) + ); + const columnRowCount = Math.ceil( + (columnHeight + gridProp.rowGap) / (gridProp.rowSize + gridProp.rowGap) + ); + + // Removing the padding and offset classes. + const regex = /^(pt|pb|col-|offset-)/; + const toRemove = [...columnEl.classList].filter((c) => regex.test(c)); + columnEl.classList.remove(...toRemove); + + // Adding the grid classes. + columnEl.classList.add( + `g-col-lg-${columnColCount}`, + `g-height-${columnRowCount}`, + `col-lg-${columnColCount}` + ); + columnEl.classList.add("o_grid_item"); + + return { columnColCount: columnColCount, columnRowCount: columnRowCount }; +} +/** + * Removes the grid properties from the grid column when it becomes a normal + * column. + * + * @param {Element} columnEl + */ +export function convertToNormalColumn(columnEl) { + const gridSizeClasses = columnEl.className.match(/(g-col-lg|g-height)-[0-9]+/g); + columnEl.classList.remove( + "o_grid_item", + "o_grid_item_image", + "o_grid_item_image_contain", + ...gridSizeClasses + ); + columnEl.style.removeProperty("z-index"); + columnEl.style.removeProperty("--grid-item-padding-x"); + columnEl.style.removeProperty("--grid-item-padding-y"); + columnEl.style.removeProperty("grid-area"); +} +/** + * Checks whether the column only contains an image or not. An image is + * considered alone if the column only contains empty textnodes and line breaks + * in addition to the image. Note that "image" also refers to an image link + * (i.e. `a > img`). + * + * @private + * @param {Element} columnEl + * @returns {Boolean} + */ +export function checkIfImageColumn(columnEl) { + let isImageColumn = false; + const imageEls = columnEl.querySelectorAll(":scope > img, :scope > a > img"); + const columnChildrenEls = [...columnEl.children].filter((el) => el.nodeName !== "BR"); + if (imageEls.length === 1 && columnChildrenEls.length === 1) { + // If there is only one image and if this image is the only "real" + // child of the column, we need to check if there is text in it. + const textNodeEls = [...columnEl.childNodes].filter((el) => el.nodeType === Node.TEXT_NODE); + const areTextNodesEmpty = [...textNodeEls].every((textNodeEl) => { + return textNodeEl.nodeValue.trim() === ""; + }); + isImageColumn = areTextNodesEmpty; + } + return isImageColumn; +} +/** + * Removes the line breaks and textnodes of the column, adds the grid class and + * sets the image width to default so it can be displayed as expected. + * + * @private + * @param {Element} columnEl a column containing only an image. + */ +function convertImageColumn(columnEl) { + columnEl.querySelectorAll("br").forEach((el) => el.remove()); + const textNodeEls = [...columnEl.childNodes].filter((el) => el.nodeType === Node.TEXT_NODE); + textNodeEls.forEach((el) => el.remove()); + const imageEl = columnEl.querySelector("img"); + columnEl.classList.add("o_grid_item_image"); + imageEl.style.removeProperty("width"); +} diff --git a/addons/html_builder/static/src/builder/utils/utils.js b/addons/html_builder/static/src/builder/utils/utils.js new file mode 100644 index 0000000000000..a01306face200 --- /dev/null +++ b/addons/html_builder/static/src/builder/utils/utils.js @@ -0,0 +1,15 @@ +import { SIZES, MEDIAS_BREAKPOINTS } from "@web/core/ui/ui_service"; + +/** + * Checks if the view of the targeted element is mobile. + * + * @param {HTMLElement} targetEl - target of the editor + * @returns {boolean} + */ +export function isMobileView(targetEl) { + const mobileViewThreshold = MEDIAS_BREAKPOINTS[SIZES.LG].minWidth; + const clientWidth = + targetEl.ownerDocument.defaultView?.frameElement?.clientWidth || + targetEl.ownerDocument.documentElement.clientWidth; + return clientWidth && clientWidth < mobileViewThreshold; +} diff --git a/addons/html_builder/static/src/website_builder_action.js b/addons/html_builder/static/src/website_builder_action.js index 99d6f0d723c0a..c6a7cda2b3887 100644 --- a/addons/html_builder/static/src/website_builder_action.js +++ b/addons/html_builder/static/src/website_builder_action.js @@ -1,9 +1,11 @@ import { Component, onWillDestroy, onWillStart, useRef, useState, useSubEnv } from "@odoo/owl"; import { LazyComponent, loadBundle } from "@web/core/assets"; import { registry } from "@web/core/registry"; -import { useService } from "@web/core/utils/hooks"; +import { useService, useChildRef } from "@web/core/utils/hooks"; import { standardActionServiceProps } from "@web/webclient/actions/action_service"; import { WebsiteSystrayItem } from "./website_systray_item"; +import { uniqueId } from "@web/core/utils/functions"; +import { LocalOverlayContainer } from "@html_editor/local_overlay_container"; function unslugHtmlDataObject(repr) { const match = repr && repr.match(/(.+)\((\d+),(.*)\)/); @@ -18,7 +20,7 @@ function unslugHtmlDataObject(repr) { class WebsiteBuilder extends Component { static template = "html_builder.WebsiteBuilder"; - static components = { LazyComponent }; + static components = { LazyComponent, LocalOverlayContainer }; static props = { ...standardActionServiceProps }; setup() { @@ -33,6 +35,11 @@ class WebsiteBuilder extends Component { // when using the "website preview" app. this.websiteService.useMysterious = true; + this.overlayRef = useChildRef(); + useSubEnv({ + localOverlayContainerKey: uniqueId("website"), + }); + onWillStart(async () => { const [backendWebsiteRepr] = await Promise.all([ this.orm.call("website", "get_current_website"), @@ -56,6 +63,7 @@ class WebsiteBuilder extends Component { closeEditor: this.closeEditor.bind(this), snippetsName: "website.snippets", toggleMobile: this.toggleMobile.bind(this), + overlayRef: this.overlayRef, }; } diff --git a/addons/html_builder/static/src/website_builder_action.xml b/addons/html_builder/static/src/website_builder_action.xml index 30b645ab9c739..10db8f0786350 100644 --- a/addons/html_builder/static/src/website_builder_action.xml +++ b/addons/html_builder/static/src/website_builder_action.xml @@ -11,6 +11,7 @@
+
diff --git a/addons/html_builder/static/tests/builder_overlay.test.js b/addons/html_builder/static/tests/builder_overlay.test.js new file mode 100644 index 0000000000000..3dc9e264341b6 --- /dev/null +++ b/addons/html_builder/static/tests/builder_overlay.test.js @@ -0,0 +1,161 @@ +import { expect, test } from "@odoo/hoot"; +import { click } from "@odoo/hoot-dom"; +import { animationFrame } from "@odoo/hoot-mock"; +import { defineWebsiteModels, setupWebsiteBuilder } from "./helpers"; +import { contains } from "@web/../tests/web_test_helpers"; + +defineWebsiteModels(); + +test("Toggle the overlays when clicking on an option element", async () => { + // TODO improve when more options will be defined. + await setupWebsiteBuilder(` +
+
+
+
+

TEST

+
+
+
+
+ `); + await click(":iframe section"); + await animationFrame(); + expect(".oe_overlay").toHaveCount(1); + expect(".oe_overlay").toHaveRect(":iframe section"); + + await click(":iframe .col-lg-3"); + await animationFrame(); + expect(".oe_overlay").toHaveCount(2); + expect(".oe_overlay.oe_active").toHaveCount(1); + expect(".oe_overlay.oe_active").toHaveRect(":iframe .col-lg-3"); +}); + +test("Resize vertically (sizingY)", async () => { + await setupWebsiteBuilder( + ` +
+
+
+
+

TEST

+
+
+
+
+ `, + { loadIframeBundles: true } + ); + await click(":iframe section"); + await animationFrame(); + expect(".oe_overlay.oe_active").toHaveCount(1); + + const nHandleSelector = ".oe_overlay .o_handle.n:not(.o_grid_handle)"; + let dragActions = await contains(nHandleSelector).drag({ position: { x: 0, y: 0 } }); + await dragActions.moveTo(nHandleSelector, { position: { x: 0, y: 80 } }); + await dragActions.drop(); + await animationFrame(); + expect(":iframe section").toHaveClass("pt80"); + expect(".oe_overlay").toHaveRect(":iframe section"); + + const sHandleSelector = ".oe_overlay .o_handle.s:not(.o_grid_handle)"; + dragActions = await contains(sHandleSelector).drag({ position: { x: 0, y: 120 } }); + await dragActions.moveTo(sHandleSelector, { position: { x: 0, y: 160 } }); + await dragActions.drop(); + await animationFrame(); + expect(":iframe section").toHaveClass("pt80 pb40"); + expect(".oe_overlay").toHaveRect(":iframe section"); +}); + +test("Resize horizontally (sizingX)", async () => { + await setupWebsiteBuilder( + ` +
+
+
+
+

TEST

+
+
+
+
+ `, + { loadIframeBundles: true } + ); + await click(":iframe .col-lg-6"); + await animationFrame(); + expect(".oe_overlay.oe_active").toHaveCount(1); + + const eHandleSelector = ".oe_overlay.oe_active .o_handle.e:not(.o_grid_handle)"; + let dragActions = await contains(eHandleSelector).drag({ position: { x: 300, y: 0 } }); + await dragActions.moveTo(eHandleSelector, { position: { x: 600, y: 0 } }); + await dragActions.drop(); + await animationFrame(); + expect(":iframe .row > div").toHaveClass("col-lg-12"); + expect(".oe_overlay.oe_active").toHaveRect(":iframe .row > div"); + + const wHandleSelector = ".oe_overlay.oe_active .o_handle.w:not(.o_grid_handle)"; + dragActions = await contains(wHandleSelector).drag({ position: { x: 0, y: 0 } }); + await dragActions.moveTo(wHandleSelector, { position: { x: 600, y: 0 } }); + await dragActions.drop(); + await animationFrame(); + expect(":iframe .row > div").toHaveClass("col-lg-1 offset-lg-11"); + expect(".oe_overlay.oe_active").toHaveRect(":iframe .row > div"); +}); + +test("Resize in grid mode (sizingGrid)", async () => { + await setupWebsiteBuilder( + ` +
+
+
+
+

TEST

+
+
+
+
+ `, + { loadIframeBundles: true } + ); + await click(":iframe .col-lg-6"); + await animationFrame(); + expect(".oe_overlay.oe_active").toHaveCount(1); + + const eHandleSelector = ".oe_overlay.oe_active .o_grid_handle.e"; + let dragActions = await contains(eHandleSelector).drag({ position: { x: 300, y: 100 } }); + await dragActions.moveTo(eHandleSelector, { position: { x: 600, y: 100 } }); + await dragActions.drop(); + await animationFrame(); + expect(":iframe .o_grid_item").toHaveClass("g-col-lg-12 col-lg-12"); + expect(":iframe .o_grid_item").toHaveStyle({ gridArea: "1 / 1 / 5 / 13" }); + expect(".oe_overlay.oe_active").toHaveRect(":iframe .o_grid_item"); + + const wHandleSelector = ".oe_overlay.oe_active .o_grid_handle.w"; + dragActions = await contains(wHandleSelector).drag({ position: { x: 0, y: 100 } }); + await dragActions.moveTo(eHandleSelector, { position: { x: 600, y: 100 } }); + await dragActions.drop(); + await animationFrame(); + expect(":iframe .o_grid_item").toHaveClass("g-col-lg-1 col-lg-1"); + expect(":iframe .o_grid_item").toHaveStyle({ gridArea: "1 / 12 / 5 / 13" }); + expect(".oe_overlay.oe_active").toHaveRect(":iframe .o_grid_item"); + + const nHandleSelector = ".oe_overlay.oe_active .o_grid_handle.n"; + dragActions = await contains(nHandleSelector).drag({ position: { x: 575, y: 0 } }); + await dragActions.moveTo(nHandleSelector, { position: { x: 0, y: 300 } }); + await dragActions.drop(); + await animationFrame(); + expect(":iframe .o_grid_item").toHaveClass("g-col-lg-1 col-lg-1 g-height-1"); + expect(":iframe .o_grid_item").toHaveStyle({ gridArea: "4 / 12 / 5 / 13" }); + expect(".oe_overlay.oe_active").toHaveRect(":iframe .o_grid_item"); + + const sHandleSelector = ".oe_overlay.oe_active .o_grid_handle.s"; + dragActions = await contains(sHandleSelector).drag({ position: { x: 575, y: 200 } }); + await dragActions.moveTo(sHandleSelector, { position: { x: 0, y: 300 } }); + await dragActions.drop(); + await animationFrame(); + expect(":iframe .o_grid_item").toHaveClass("g-col-lg-1 col-lg-1 g-height-3"); + expect(":iframe .o_grid_item").toHaveStyle({ gridArea: "4 / 12 / 7 / 13" }); + expect(":iframe .row").toHaveAttribute("data-row-count", "6"); + expect(".oe_overlay.oe_active").toHaveRect(":iframe .o_grid_item"); +}); diff --git a/addons/html_builder/static/tests/helpers.js b/addons/html_builder/static/tests/helpers.js index bd3b95c3724a0..ee48d9b6b027b 100644 --- a/addons/html_builder/static/tests/helpers.js +++ b/addons/html_builder/static/tests/helpers.js @@ -40,7 +40,10 @@ export function defineWebsiteModels() { defineModels([Website, IrUiView]); } -export async function setupWebsiteBuilder(websiteContent, { snippets, openEditor = true } = {}) { +export async function setupWebsiteBuilder( + websiteContent, + { snippets, openEditor = true, loadIframeBundles = false } = {} +) { const pyEnv = await startServer(); pyEnv["website"].create({}); let editor; @@ -68,6 +71,15 @@ export async function setupWebsiteBuilder(websiteContent, { snippets, openEditor const iframe = queryOne("iframe[data-src='/website/force/1']"); iframe.contentDocument.body.innerHTML = `
${websiteContent}
`; + if (loadIframeBundles) { + loadBundle("html_builder.inside_builder_style", { + targetDoc: iframe.contentDocument, + }); + loadBundle("web.assets_frontend", { + targetDoc: iframe.contentDocument, + js: false, + }); + } if (openEditor) { await openSnippetsMenu(); } diff --git a/addons/web/static/src/core/position/utils.js b/addons/web/static/src/core/position/utils.js index 674601240074d..96f63ac4ff57b 100644 --- a/addons/web/static/src/core/position/utils.js +++ b/addons/web/static/src/core/position/utils.js @@ -210,7 +210,6 @@ function computePosition(popper, target, { container, flip, margin, position }) }; } - // Find best solution const matches = []; for (const d of directions) { diff --git a/addons/web/static/src/core/utils/scrolling.js b/addons/web/static/src/core/utils/scrolling.js index bf9ddfe85723a..ced138c2b1f99 100644 --- a/addons/web/static/src/core/utils/scrolling.js +++ b/addons/web/static/src/core/utils/scrolling.js @@ -192,3 +192,8 @@ export function getScrollingElement(document = window.document) { } return baseScrollingElement; } + +export function getScrollingTarget(scrollingElement = window.document) { + const document = scrollingElement.ownerDocument; + return scrollingElement === document.scrollingElement ? document.defaultView : scrollingElement; +}