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