From 6d63e1446ac97aee1e9ac1f68b450e3d066a50f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Souk=C3=A9ina=20Bojabza?= Date: Tue, 5 Nov 2024 18:11:04 +0100 Subject: [PATCH 1/7] [FIX] add an overlay for every options element --- .../builder_overlay/builder_overlay.js | 222 ++++------- .../builder_overlay/builder_overlay.scss | 368 ++++++++++-------- .../builder_overlay/builder_overlay.xml | 39 +- .../builder_overlay/builder_overlay_plugin.js | 125 +++++- .../static/src/builder/snippets_menu.js | 4 + .../static/src/builder/utils/utils.js | 15 + .../static/src/website_builder_action.js | 12 +- .../static/src/website_builder_action.xml | 1 + addons/web/static/src/core/position/utils.js | 1 - addons/web/static/src/core/utils/scrolling.js | 5 + 10 files changed, 451 insertions(+), 341 deletions(-) create mode 100644 addons/html_builder/static/src/builder/utils/utils.js 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..e6196c0d4d7e5 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,164 +1,98 @@ -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"; +import { renderToElement } from "@web/core/utils/render"; +import { isMobileView } from "@html_builder/builder/utils/utils"; -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"), -}); +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", +}; -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(); +export class BuilderOverlay { + constructor(overlayTarget, { overlayContainer }) { + 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"); + } - usePosition("root", () => this.target, { - position: "center", - container: () => this.props.container, - onPositioned: this.updateOverlaySize.bind(this), - }); + hasSizingHandles() { + return ( + this.overlayTarget.matches(`${sizingY.selector}:not(${sizingY.exclude})`) || + this.overlayTarget.matches(`${sizingX.selector}:not(${sizingX.exclude})`) || + this.overlayTarget.matches(`${sizingGrid.selector}:not(${sizingGrid.exclude})`) + ); + } - 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; + // displayOverlayOptions(el) { + // // TODO when options will be more clear: + // // - moving + // // - timeline + // // (maybe other where `displayOverlayOptions: true`) + // } - // 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; - } + isActive() { + // TODO active still necessary ? (check when we have preview mode) + return this.overlayElement.classList.contains("oe_active"); + } - // Same as above but to the left/up. - if ( - deltaY < - (2 * spacingConfig.values[prevSizeIndex] + - spacingConfig.values[spacingConfigIndex]) / - 3 - ) { - indexToApply = prevSizeIndex; - } + refreshPosition() { + if (!this.isActive()) { + return; + } - if (indexToApply) { - this.props.target.classList.remove(spacingConfig.classes[spacingConfigIndex]); - this.props.target.classList.add(spacingConfig.classes[indexToApply]); - this.currentDraggable.spacingConfigIndex = indexToApply; - this.updateOverlaySize(); - } - }, + // 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`; } - 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"); - } - - buildSpacingConfig() { - let topClass = "pt"; - let topStyleName = "padding-top"; - let bottomClass = "pb"; - let bottomStyleName = "padding-bottom"; - - if (this.target.tagName === "HR") { - topClass = "mt"; - topStyleName = "margin-top"; - bottomClass = "mb"; - bottomStyleName = "margin-bottom"; + refreshHandles() { + if (!this.hasSizingHandles || !this.isActive()) { + return; } - const values = [0, 4]; - for (let i = 1; i <= 256 / 8; i++) { - values.push(i * 8); + 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); + }); } - - return { - top: { classes: values.map((v) => topClass + v), values, styleName: topStyleName }, - bottom: { - classes: values.map((v) => bottomClass + v), - values, - styleName: bottomStyleName, - }, - }; } - 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; - } - } + toggleOverlay(show) { + this.overlayElement.classList.add("oe_active", show); + this.refreshPosition(); + this.refreshHandles(); } - 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"; - } else { - return ""; + toggleOverlayVisibility(show) { + if (!this.isActive()) { + return; } + this.overlayElement.classList.toggle("o_overlay_hidden", !show); } } 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..0564a007b196a 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,227 @@ -// // 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; +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; } - &.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; - } - &.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%); - - &:before { - transform: translateY($o-we-handle-border-width * 0.5); - } - } - } - &.bottom { - inset: auto 0 $o-we-handles-offset-to-hide * -1 0; - cursor: ns-resize; + // HANDLES + .o_handles { + @include o-position-absolute(-$o-we-handles-offset-to-hide, 0, auto, 0); + border-color: inherit; + pointer-events: auto; - &.o_grid_handle { - transform: translateY(50%); + > .o_handle { + position: absolute; - &:before { - transform: translateY($o-we-handle-border-width * -0.5); + &.o_side_y { + height: $o-we-handle-edge-size; } - } - } - &.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; + &.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; - &::before { - content: ''; - position: absolute; - inset: -$o-we-handles-btn-size; - display: block; - border-radius: inherit; - } - } + &.o_grid_handle { + transform: translateY(-50%); - &.o_column_handle.o_side_y { - background-color: rgba($o-we-handles-accent-color, .1); + &:before { + transform: translateY($o-we-handle-border-width * 0.5); + } + } + } + &.s { + inset: auto 0 $o-we-handles-offset-to-hide * -1 0; + cursor: ns-resize; - &::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); + &.o_grid_handle { + transform: translateY(50%); - &::after { - inset: 0 0 auto 0; - transform: translateY(-50%); + &:before { + transform: translateY($o-we-handle-border-width * -0.5); + } + } } - } - &.bottom { - 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%); + &.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; } - } - } - &.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; + &.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; } - } - &.o_side_y { - - &::before { - height: $o-we-handle-border-width; - margin: auto 0; + &.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; } - } - &.o_column_handle { - - &.top::before { - margin: 0 auto auto; + &.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; + } } - &.bottom::before { - margin: auto auto 0; + &.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%); + } + } + } + &.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; + &.readonly { + cursor: default; + pointer-events: none; - &.o_column_handle.o_side_y { - border: none; - background: none; - } + &.o_column_handle.o_side_y { + border: none; + background: none; + } - &::after, .o_handle_indicator { - display: none; + &::after, .o_handle_indicator { + display: none; + } + } } } - } -} -.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: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 { + // HANDLES - ACTIVE AND HOVER STATES + .o_handle:hover, .o_handle:active { - .o_handle_indicator { - outline-color: $o-we-handles-accent-color; - } -} + .o_handle_indicator { + outline-color: $o-we-handles-accent-color; + } + } -.o_side_x:hover, .o_side_x:active { + .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; - } -} + &::before { + width: $o-we-handle-border-width * 2; + } + .o_handle_indicator { + height: $o-we-handles-btn-size * 2; + } + } -.o_side_y:hover, .o_side_y:active { + .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; + &::before { + height: $o-we-handle-border-width * 2; + } + .o_handle_indicator { + width: $o-we-handles-btn-size * 2; + } + } } } 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..12724edf4641a 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,127 @@ 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"]; 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, + }); + 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.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/snippets_menu.js b/addons/html_builder/static/src/builder/snippets_menu.js index 9b5121509f7d9..819303b4c0088 100644 --- a/addons/html_builder/static/src/builder/snippets_menu.js +++ b/addons/html_builder/static/src/builder/snippets_menu.js @@ -110,6 +110,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/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/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; +} From 54c860ccf9ed4098f727fab8bc027c5c207dffdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Souk=C3=A9ina=20Bojabza?= Date: Fri, 15 Nov 2024 13:44:46 +0100 Subject: [PATCH 2/7] Add simple builder overlay test --- .../static/src/builder/snippets_menu.js | 1 + .../static/tests/builder_overlay.test.js | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 addons/html_builder/static/tests/builder_overlay.test.js diff --git a/addons/html_builder/static/src/builder/snippets_menu.js b/addons/html_builder/static/src/builder/snippets_menu.js index 819303b4c0088..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() { 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..0e1b12a2a1258 --- /dev/null +++ b/addons/html_builder/static/tests/builder_overlay.test.js @@ -0,0 +1,31 @@ +import { expect, test } from "@odoo/hoot"; +import { click } from "@odoo/hoot-dom"; +import { animationFrame } from "@odoo/hoot-mock"; +import { defineWebsiteModels, setupWebsiteBuilder } from "./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"); +}); From 285d8653de3093696cfce7eeb8825476acc7e79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Souk=C3=A9ina=20Bojabza?= Date: Wed, 27 Nov 2024 17:56:53 +0100 Subject: [PATCH 3/7] Convert sizing options --- .../builder_overlay/builder_overlay.js | 492 +++++++++++++++++- .../builder_overlay/builder_overlay.scss | 41 +- .../builder_overlay/builder_overlay_plugin.js | 8 +- .../builder/plugins/grid_layout.inside.scss | 77 +++ .../src/builder/plugins/grid_layout.xml | 19 + .../src/builder/utils/grid_layout_utils.js | 381 ++++++++++++++ 6 files changed, 999 insertions(+), 19 deletions(-) create mode 100644 addons/html_builder/static/src/builder/plugins/grid_layout.inside.scss create mode 100644 addons/html_builder/static/src/builder/plugins/grid_layout.xml create mode 100644 addons/html_builder/static/src/builder/utils/grid_layout_utils.js 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 e6196c0d4d7e5..f33b3bb6d5e91 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,5 +1,11 @@ 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"; const sizingY = { selector: "section, .row > div, .parallax, .s_hr, .carousel-item, .s_rating", @@ -16,21 +22,30 @@ const sizingGrid = { }; export class BuilderOverlay { - constructor(overlayTarget, { overlayContainer }) { + 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"); + + this.initHandles(); + this.initSizing(); + this.refreshHandles(); } hasSizingHandles() { - return ( - this.overlayTarget.matches(`${sizingY.selector}:not(${sizingY.exclude})`) || - this.overlayTarget.matches(`${sizingX.selector}:not(${sizingX.exclude})`) || - this.overlayTarget.matches(`${sizingGrid.selector}:not(${sizingGrid.exclude})`) - ); + return this.isResizableY() || this.isResizableX() || this.isResizableGrid(); } // displayOverlayOptions(el) { @@ -81,6 +96,8 @@ export class BuilderOverlay { handleEl.classList.toggle("readonly", isMobile && isVerticalSizing && isGridOn); }); } + + this.updateHandleY(); } toggleOverlay(show) { @@ -95,4 +112,467 @@ export class BuilderOverlay { } this.overlayElement.classList.toggle("o_overlay_hidden", !show); } + + destroy() { + if (!this.hasSizingHandles) { + return; + } + + this.handleEls.forEach((handleEl) => + handleEl.removeEventListener("pointerdown", this._onSizingStart) + ); + } + + //-------------------------------------------------------------------------- + // Sizing + //-------------------------------------------------------------------------- + + isResizableY() { + return this.overlayTarget.matches(`${sizingY.selector}:not(${sizingY.exclude})`); + } + + isResizableX() { + return this.overlayTarget.matches(`${sizingX.selector}:not(${sizingX.exclude})`); + } + + isResizableGrid() { + return this.overlayTarget.matches(`${sizingGrid.selector}:not(${sizingGrid.exclude})`); + } + + 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++) { + values.push(i * 8); + } + + return { + 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", + }, + }; + } + + 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; + } + + 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 { + 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; + // `delta` is the number of pixels by which the mouse moved, + // compared to the initial position of the handle. + const delta = + ev[`page${dir.XY}`] - dir.initialPageXY + configValues[dir.initialIndex]; + const next = + dir.currentIndex + (dir.currentIndex + 1 === configValues.length ? 0 : 1); + const prev = dir.currentIndex > 0 ? dir.currentIndex - 1 : 0; + + let change = false; + // If the mouse moved to the right/down by at least 2/3 of + // the space between the previous and the next steps, the + // handle is snapped to the next step and the class is + // replaced by the one matching this step. + if (delta > (2 * configValues[next] + configValues[dir.currentIndex]) / 3) { + this.replaceSizingClass(dir.classRegex, dir.config.classes[next]); + dir.currentIndex = next; + change = true; + } + // Same as above but to the left/up. + if ( + prev !== dir.currentIndex && + delta < (2 * configValues[prev] + configValues[dir.currentIndex]) / 3 + ) { + this.replaceSizingClass(dir.classRegex, dir.config.classes[prev]); + dir.currentIndex = prev; + 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 0564a007b196a..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 @@ -186,7 +186,24 @@ div[data-oe-local-overlay-id="builder-overlay-container"] { } } - .o_column_handle.o_side_y:hover, .o_column_handle.o_side_y:active { + // 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 { + + .o_handle_indicator { + outline-color: $o-we-handles-accent-color; + } + } + + &.o_handlers_idle .o_corner_handle:hover, .o_corner_handle:active { + + .o_handle_indicator { + transform: scale(1.25); + } + } + + &.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), @@ -196,15 +213,7 @@ div[data-oe-local-overlay-id="builder-overlay-container"] { ); } - // HANDLES - ACTIVE AND HOVER STATES - .o_handle:hover, .o_handle:active { - - .o_handle_indicator { - outline-color: $o-we-handles-accent-color; - } - } - - .o_side_x:hover, .o_side_x:active { + &.o_handlers_idle .o_side_x:hover, .o_side_x:active { &::before { width: $o-we-handle-border-width * 2; @@ -214,7 +223,7 @@ div[data-oe-local-overlay-id="builder-overlay-container"] { } } - .o_side_y:hover, .o_side_y:active { + &.o_handlers_idle .o_side_y:hover, .o_side_y:active { &::before { height: $o-we-handle-border-width * 2; @@ -225,3 +234,13 @@ div[data-oe-local-overlay-id="builder-overlay-container"] { } } } + +@each $cursor in (nesw-resize, nwse-resize, ns-resize, ew-resize, move) { + .#{$cursor}-important * { + cursor: $cursor !important; + } +} + +.o_resizing { + pointer-events: none; +} 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 12724edf4641a..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 @@ -5,7 +5,7 @@ import { BuilderOverlay } from "./builder_overlay"; export class BuilderOverlayPlugin extends Plugin { static id = "builderOverlay"; - static dependencies = ["selection", "localOverlay"]; + static dependencies = ["selection", "localOverlay", "history"]; resources = { step_added_handlers: this._update.bind(this), change_current_options_containers_listeners: this.openBuilderOverlay.bind(this), @@ -68,6 +68,7 @@ export class BuilderOverlayPlugin extends Plugin { 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); @@ -95,7 +96,10 @@ export class BuilderOverlayPlugin extends Plugin { } removeBuilderOverlay() { - this.overlays.forEach((overlay) => overlay.overlayElement.remove()); + this.overlays.forEach((overlay) => { + overlay.destroy(); + overlay.overlayElement.remove(); + }); this.overlays = []; // this.resizeObserver?.disconnect(); } 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/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"); +} From c714a96c83648ccb3e8a37d6991631c89c9244aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Souk=C3=A9ina=20Bojabza?= Date: Tue, 10 Dec 2024 11:27:50 +0100 Subject: [PATCH 4/7] [IMP] improve sizing so it works with fast pointer moves Needed in order for the sizing to be smoother (compared to master) but mostly to allow it to be tested, as the tests make big pointer moves, which therefore did not work with the previous implementation since it increments the indexes one by one, instead of considering the delta. --- .../builder_overlay/builder_overlay.js | 65 ++++++++++++------- 1 file changed, 43 insertions(+), 22 deletions(-) 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 f33b3bb6d5e91..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 @@ -484,32 +484,53 @@ export class BuilderOverlay { let changeTotal = false; for (const dir of directions) { const configValues = dir.config.values; - // `delta` is the number of pixels by which the mouse moved, - // compared to the initial position of the handle. + 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]; - const next = - dir.currentIndex + (dir.currentIndex + 1 === configValues.length ? 0 : 1); - const prev = dir.currentIndex > 0 ? dir.currentIndex - 1 : 0; - let change = false; - // If the mouse moved to the right/down by at least 2/3 of - // the space between the previous and the next steps, the - // handle is snapped to the next step and the class is - // replaced by the one matching this step. - if (delta > (2 * configValues[next] + configValues[dir.currentIndex]) / 3) { - this.replaceSizingClass(dir.classRegex, dir.config.classes[next]); - dir.currentIndex = next; - change = true; + // 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; } - // Same as above but to the left/up. - if ( - prev !== dir.currentIndex && - delta < (2 * configValues[prev] + configValues[dir.currentIndex]) / 3 - ) { - this.replaceSizingClass(dir.classRegex, dir.config.classes[prev]); - dir.currentIndex = prev; - change = true; + + 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) { From 9b0f8a19492d8632e1465964b7175e79eab6b5d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Souk=C3=A9ina=20Bojabza?= Date: Tue, 10 Dec 2024 11:37:27 +0100 Subject: [PATCH 5/7] Add tests for sizing --- .../static/tests/builder_overlay.test.js | 130 ++++++++++++++++++ addons/html_builder/static/tests/helpers.js | 14 +- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/addons/html_builder/static/tests/builder_overlay.test.js b/addons/html_builder/static/tests/builder_overlay.test.js index 0e1b12a2a1258..3dc9e264341b6 100644 --- a/addons/html_builder/static/tests/builder_overlay.test.js +++ b/addons/html_builder/static/tests/builder_overlay.test.js @@ -2,6 +2,7 @@ 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(); @@ -29,3 +30,132 @@ test("Toggle the overlays when clicking on an option element", async () => { 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(); } From 6c2a7b07fa88934b3cf081a5e7ff0ff42c41e687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Souk=C3=A9ina=20Bojabza?= Date: Tue, 10 Dec 2024 13:47:01 +0100 Subject: [PATCH 6/7] Use html_builder grid layout utils --- .../static/src/builder/options/layout_option.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) 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; From 9ff074bdedcb0c7cc6d3e56fb2d7e64460419d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Souk=C3=A9ina=20Bojabza?= Date: Tue, 10 Dec 2024 14:09:06 +0100 Subject: [PATCH 7/7] Remove some props warning --- .../static/src/builder/options/add_element_option.js | 2 ++ .../html_builder/static/src/builder/options/spacing_option.js | 2 ++ 2 files changed, 4 insertions(+) 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/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);