From 75cb824902000b2c05716c8992ec486cf7102cea Mon Sep 17 00:00:00 2001 From: William Braeckman Date: Wed, 17 Aug 2022 13:54:17 +0000 Subject: [PATCH] [IMP] website_sale: improve zoom features in front-end This commit adds a new option for picture zoom on product pages. The designer may now select a attachment viewer like carousel upon clicking on images in the view. The hover zoom has also been fixed as it was broken due to the bootstrap 5 changes. The designer may choose between zoom on hover, on click, both or no zoom. TaskId-2922062 closes odoo/odoo#98305 Related: odoo/upgrade#3785 Signed-off-by: William Braeckman (wbr) --- addons/website_sale/__manifest__.py | 2 + .../components/website_sale_image_viewer.js | 158 ++++++++++++++++++ .../static/src/js/website_sale.editor.js | 59 +++++-- .../static/src/js/website_sale.js | 122 ++++++++++---- .../static/src/scss/website_sale.scss | 13 +- .../src/scss/website_sale_frontend.scss | 59 +++++++ .../src/xml/website_sale_image_viewer.xml | 45 +++++ .../tests/tours/website_sale_shop_zoom.js | 12 +- .../tests/test_website_sale_image.py | 8 + .../website_sale/views/snippets/snippets.xml | 19 ++- addons/website_sale/views/templates.xml | 23 ++- 11 files changed, 451 insertions(+), 69 deletions(-) create mode 100644 addons/website_sale/static/src/js/components/website_sale_image_viewer.js create mode 100644 addons/website_sale/static/src/xml/website_sale_image_viewer.xml diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index 8e87d0e95f2..c6a84b95274 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -59,6 +59,8 @@ 'website_sale/static/src/js/website_sale_tracking.js', 'website/static/lib/multirange/multirange_custom.js', 'website_sale/static/src/js/website_sale_category_link.js', + 'website_sale/static/src/xml/website_sale_image_viewer.xml', + 'website_sale/static/src/js/components/website_sale_image_viewer.js', ], 'web._assets_primary_variables': [ 'website_sale/static/src/scss/primary_variables.scss', diff --git a/addons/website_sale/static/src/js/components/website_sale_image_viewer.js b/addons/website_sale/static/src/js/components/website_sale_image_viewer.js new file mode 100644 index 00000000000..091bcf5215c --- /dev/null +++ b/addons/website_sale/static/src/js/components/website_sale_image_viewer.js @@ -0,0 +1,158 @@ +/** @odoo-module **/ + +import { useWowlService } from '@web/legacy/utils'; +import { Dialog } from "@web/core/dialog/dialog"; +import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; + +const { Component, onRendered, useRef, useEffect, useState, xml } = owl; + +const ZOOM_STEP = 0.1; + +export class ProductImageViewer extends Dialog { + setup() { + super.setup(); + this.imageContainerRef = useRef("imageContainer"); + this.images = [...this.props.images].map(image => { + return { + src: image.dataset.zoomImage || image.src, + thumbnailSrc: image.src.replace('/image_1024/', '/image_128/'), + }; + }); + this.state = useState({ + selectedImageIdx: this.props.selectedImageIdx || 0, + imageScale: 1, + }); + this.isDragging = false; + this.dragStartPos = { x: 0, y: 0 }; + // Doing a full render for the translate is too slow. + this.imageTranslate = { x: 0, y: 0 }; + useHotkey("arrowleft", this.previousImage.bind(this)); + useHotkey("arrowright", this.nextImage.bind(this)); + useHotkey("r", () => { + this.imageTranslate = { x: 0, y: 0 }; + this.isDragging = false; + this.state.imageScale = 1; + this.updateImage(); + }); + + // Not using a t-on-click on purpose because we want to be able to cancel the drag + // when we go outside of the window. + useEffect( + (document) => { + const onGlobalClick = this.onGlobalClick.bind(this); + document.addEventListener("click", onGlobalClick); + return () => {document.removeEventListener("click", onGlobalClick)}; + }, + () => [document], + ); + // For some reason the styling does not always update properly. + onRendered(() => { + this.updateImage(); + }) + } + + get selectedImage() { + return this.images[this.state.selectedImageIdx]; + } + + set selectedImage(image) { + this.state.imageScale = 1; + this.imageTranslate = { x: 0, y: 0 }; + this.state.selectedImageIdx = this.images.indexOf(image); + } + + get imageStyle() { + return `transform: + scale3d(${this.state.imageScale}, ${this.state.imageScale}, 1); + `; + } + + get imageContainerStyle() { + return `transform: translate(${this.imageTranslate.x}px, ${this.imageTranslate.y}px);`; + } + + previousImage() { + this.selectedImage = this.images[(this.state.selectedImageIdx - 1 + this.images.length) % this.images.length]; + } + + nextImage() { + this.selectedImage = this.images[(this.state.selectedImageIdx + 1) % this.images.length]; + } + + updateImage() { + if (!this.imageContainerRef || !this.imageContainerRef.el) { + return; + } + this.imageContainerRef.el.style = this.imageContainerStyle; + } + + onGlobalClick(ev) { + if (ev.target.tagName === "IMG") { + // Only zoom if the image did not move + if (this.dragStartPos.clientX === ev.clientX && this.dragStartPos.clientY === ev.clientY) { + this.zoomIn(ZOOM_STEP * 3); + } + } + if (ev.target.classList.contains('o_wsale_image_viewer_void') && !this.isDragging) { + ev.stopPropagation(); + ev.preventDefault(); + this.data.close(); + } else { + this.isDragging = false; + } + } + + zoomIn(step=undefined) { + this.state.imageScale += step || ZOOM_STEP; + } + + zoomOut(step=undefined) { + this.state.imageScale = Math.max(0.5, this.state.imageScale - (step || ZOOM_STEP)); + } + + onWheelImage(ev) { + if (ev.deltaY > 0) { + this.zoomOut(); + } else { + this.zoomIn(); + } + } + + onMousedownImage(ev) { + this.isDragging = true; + this.dragStartPos = { + x: ev.clientX - this.imageTranslate.x, + y: ev.clientY - this.imageTranslate.y, + clientX: ev.clientX, + clientY: ev.clientY, + }; + } + + onGlobalMousemove(ev) { + if (!this.isDragging) { + return; + } + this.imageTranslate.x = ev.clientX - this.dragStartPos.x; + this.imageTranslate.y = ev.clientY - this.dragStartPos.y; + this.updateImage(); + } +} +ProductImageViewer.props = { + ...Dialog.props, + images: { type: NodeList, required: true }, + selectedImageIdx: { type: Number, optional: true }, + close: Function, +}; +delete ProductImageViewer.props.slots; +ProductImageViewer.template = "website_sale.ProductImageViewer"; + +export class ProductImageViewerWrapper extends Component { + setup() { + this.dialogs = useWowlService('dialog'); + + onRendered(() => { + this.dialogs.add(ProductImageViewer, this.props); + }); + } +} +ProductImageViewerWrapper.template = xml``; diff --git a/addons/website_sale/static/src/js/website_sale.editor.js b/addons/website_sale/static/src/js/website_sale.editor.js index d3c3985af6b..481e48543ef 100644 --- a/addons/website_sale/static/src/js/website_sale.editor.js +++ b/addons/website_sale/static/src/js/website_sale.editor.js @@ -701,22 +701,44 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ }).then(() => this.trigger_up('request_save', {reload: true, optionSelector: this.data.selector})); }, - /** - * @override - */ - setImageWidth(previewMode, widgetValue, params) { - this._updateWebsiteConfig({ - product_page_image_width: widgetValue, + _getZoomOptionData() { + return this._userValueWidgets.find(widget => { + return widget.options && widget.options.dataAttributes && widget.options.dataAttributes.name === "o_wsale_zoom_mode"; }); }, /** * @override */ - setImageLayout(previewMode, widgetValue, params) { - this._updateWebsiteConfig({ - product_page_image_layout: widgetValue, - }); + async setImageWidth(previewMode, widgetValue, params) { + const zoomOption = this._getZoomOptionData(); + const updateWidth = this._updateWebsiteConfig.bind(this, { product_page_image_width: widgetValue }); + if (!zoomOption || widgetValue !== "100_pc") { + updateWidth(); + } else { + const defaultZoomOption = "website_sale.product_picture_magnify_click"; + await this._customizeWebsiteData(defaultZoomOption, { possibleValues: zoomOption._methodsParams.optionsPossibleValues["customizeWebsiteViews"] }, true); + updateWidth(); + } + }, + + /** + * @override + */ + async setImageLayout(previewMode, widgetValue, params) { + const zoomOption = this._getZoomOptionData(); + const updateLayout = this._updateWebsiteConfig.bind(this, { product_page_image_layout: widgetValue }); + if (!zoomOption) { + updateLayout(); + } else { + const imageWidthOption = this.productDetailMain.dataset.image_width; + let defaultZoomOption = widgetValue === "grid" ? "website_sale.product_picture_magnify_click" : "website_sale.product_picture_magnify_hover"; + if (imageWidthOption === "100_pc" && defaultZoomOption === "website_sale.product_picture_magnify_hover") { + defaultZoomOption = "website_sale.product_picture_magnify_click"; + } + await this._customizeWebsiteData(defaultZoomOption, { possibleValues: zoomOption._methodsParams.optionsPossibleValues["customizeWebsiteViews"] }, true); + updateLayout(); + } }, /** @@ -846,12 +868,25 @@ options.registry.WebsiteSaleProductPage = options.Class.extend({ }, async _computeWidgetVisibility(widgetName, params) { + const hasImages = this.productDetailMain.dataset.image_width != 'none'; + const isFullImage = this.productDetailMain.dataset.image_width == '100_pc'; switch (widgetName) { case 'o_wsale_thumbnail_pos': - return Boolean(this.productPageCarousel); + return Boolean(this.productPageCarousel) && hasImages; case 'o_wsale_grid_spacing': case 'o_wsale_grid_columns': - return Boolean(this.productPageGrid); + return Boolean(this.productPageGrid) && hasImages; + case 'o_wsale_image_layout': + case 'o_wsale_zoom_click': + case 'o_wsale_zoom_none': + case 'o_wsale_replace_main_image': + case 'o_wsale_add_extra_images': + case 'o_wsale_clear_extra_images': + case 'o_wsale_zoom_mode': + return hasImages; + case 'o_wsale_zoom_hover': + case 'o_wsale_zoom_both': + return hasImages && !isFullImage; } return this._super(widgetName, params); } diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 74c34253cb6..dfdc5b930cf 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -207,6 +207,8 @@ require("web.zoomodoo"); const {extraMenuUpdateCallbacks} = require('website.content.menu'); const dom = require('web.dom'); const { cartesian } = require('@web/core/utils/arrays'); +const { ComponentWrapper } = require('web.OwlCompatibility'); +const { ProductImageViewerWrapper } = require("@website_sale/js/components/website_sale_image_viewer"); publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, cartHandlerMixin, { selector: '.oe_website_sale', @@ -280,6 +282,10 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car this.getRedirectOption(); return def; }, + destroy() { + this._super.apply(this, arguments); + this._cleanupZoom(); + }, /** * The selector is different when using list view of variants. * @@ -460,38 +466,97 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car return VariantMixin._getProductId.apply(this, arguments); } }, + _getProductImageLayout: function () { + return document.querySelector("#product_detail_main").dataset.image_layout; + }, + _getProductImageWidth: function () { + return document.querySelector("#product_detail_main").dataset.image_width; + }, + _getProductImageContainerSelector: function () { + return { + 'carousel': "#o-carousel-product", + 'grid': "#o-grid-product", + }[this._getProductImageLayout()]; + }, + _getProductImageContainer: function () { + return document.querySelector(this._getProductImageContainerSelector()); + }, + _isEditorEnabled() { + return document.body.classList.contains("editor_enable"); + }, /** * @private */ _startZoom: function () { - // Do not activate image zoom for mobile devices, since it might prevent users from scrolling the page - if (!config.device.isMobile) { - var autoZoom = $('.ecom-zoomable').data('ecom-zoom-auto') || false, - attach = '#o-carousel-product'; - _.each($('.ecom-zoomable img[data-zoom]'), function (el) { - onImageLoaded(el, function () { - var $img = $(el); - $img.zoomOdoo({event: autoZoom ? 'mouseenter' : 'click', attach: attach}); - $img.attr('data-zoom', 1); - }); - }); + // Do not activate image zoom on hover for mobile devices + const salePage = document.querySelector(".o_wsale_product_page"); + if (!salePage || config.device.mobile || this._getProductImageWidth() === "none") { + return; } - - function onImageLoaded(img, callback) { - // On Chrome the load event already happened at this point so we - // have to rely on complete. On Firefox it seems that the event is - // always triggered after this so we can rely on it. - // - // However on the "complete" case we still want to keep listening to - // the event because if the image is changed later (eg. product - // configurator) a new load event will be triggered (both browsers). - $(img).on('load', function () { - callback(); - }); - if (img.complete) { - callback(); + this._cleanupZoom(); + this.zoomCleanup = []; + // Zoom on hover + if (salePage.dataset.ecomZoomAuto) { + const images = salePage.querySelectorAll("img[data-zoom]"); + for (const image of images) { + const $image = $(image); + const callback = () => { + $image.zoomOdoo({ + event: "mouseenter", + attach: this._getProductImageContainerSelector(), + preventClicks: salePage.dataset.ecomZoomClick, + attachToTarget: this._getProductImageLayout() === "grid", + }); + image.dataset.zoom = 1; + }; + image.addEventListener('load', callback); + this.zoomCleanup.push(() => { + image.removeEventListener('load', callback); + const zoomOdoo = $image.data("zoomOdoo"); + if (zoomOdoo) { + zoomOdoo.hide(); + $image.unbind(); + } + }); + if (image.complete) { + callback(); + } } } + // Zoom on click + if (salePage.dataset.ecomZoomClick) { + // In this case we want all the images not just the ones that are "zoomables" + const images = salePage.querySelectorAll(".product_detail_img"); + for (const image of images ) { + const handler = () => { + if (salePage.dataset.ecomZoomAuto) { + // Remove any flyout + const flyouts = document.querySelectorAll(".zoomodoo-flyout"); + for (const flyout of flyouts) { + flyout.remove(); + } + } + const dialog = new ComponentWrapper(this, ProductImageViewerWrapper, { + selectedImageIdx: [...images].indexOf(image), + images, + }); + dialog.mount(document.body); + }; + image.addEventListener("click", handler); + this.zoomCleanup.push(() => { + image.removeEventListener("click", handler); + }); + } + } + }, + _cleanupZoom() { + if (!this.zoomCleanup || !this.zoomCleanup.length) { + return; + } + for (const cleanup of this.zoomCleanup) { + cleanup(); + } + this.zoomCleanup = undefined; }, /** * On website, we display a carousel instead of only one image @@ -500,14 +565,11 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car * @private */ _updateProductImage: function ($productContainer, displayImage, productId, productTemplateId, newImages, isCombinationPossible) { - let $images = $productContainer.find('#o-carousel-product'); - if (!$images.length) { - $images = $productContainer.find("#o-grid-product"); - } + let $images = $productContainer.find(this._getProductImageContainerSelector()); // When using the web editor, don't reload this or the images won't // be able to be edited depending on if this is done loading before // or after the editor is ready. - if ($images.length && window.location.search.indexOf('enable_editor') === -1) { + if ($images.length && !this._isEditorEnabled()) { const $newImages = $(newImages); $images.after($newImages); $images.remove(); diff --git a/addons/website_sale/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale.scss index adfcb5eb754..6cfc8b1f8b8 100644 --- a/addons/website_sale/static/src/scss/website_sale.scss +++ b/addons/website_sale/static/src/scss/website_sale.scss @@ -516,8 +516,6 @@ a.no-decoration { .carousel-inner { img { - height: 100%; - width: 100%; object-fit: contain; } } @@ -666,21 +664,20 @@ a.no-decoration { } .ecom-zoomable { - &:not(.ecom-autozoom) { - img[data-zoom] { + &[data-ecom-zoom-click] { + img.product_detail_img { cursor: zoom-in; } } - &.ecom-autozoom { - img[data-zoom] { - cursor: crosshair; - } + img[data-zoom] { + cursor: zoom-in; } .o_editable img[data-zoom] { cursor: pointer; } .zoomodoo-flyout { box-shadow: 0 0 20px 2px rgba(black, 0.2); + z-index: 1050; } } diff --git a/addons/website_sale/static/src/scss/website_sale_frontend.scss b/addons/website_sale/static/src/scss/website_sale_frontend.scss index 997d3a52f83..8beecf01eb3 100644 --- a/addons/website_sale/static/src/scss/website_sale_frontend.scss +++ b/addons/website_sale/static/src/scss/website_sale_frontend.scss @@ -138,3 +138,62 @@ $o-wsale-wizard-label-completed: $success; } } } + +.o_wsale_image_viewer { + z-index: -1; + + .o_wsale_image_viewer_header { + height: 40px; + z-index: 1; + } + + .o_wsale_image_viewer_image { + .o_wsale_image_viewer_void { + padding-top: 64px; + padding-bottom: 156px; + } + + img { + cursor: zoom-in; + } + } + + .o_wsale_image_viewer_carousel { + z-index:1; + + ol { + list-style: none; + + .o_wsale_image_viewer_thumbnail { + width: 128px; + height: 128px; + } + + li img{ + border: 1px solid #CED4DA; + + &.active { + border-color: #35979c; + } + } + } + } + + .o_wsale_image_viewer_control { + z-index: 1; + width: 40px; + height: 40px; + + &:hover { + background-color: rgba($gray-400, 0.1); + } + + &.o_wsale_image_viewer_previous { + margin: 1px 1px 0 0; // not correctly centered for some reasons + } + + &.o_wsale_image_viewer_next { + margin: 1px 0 0 1px; // not correctly centered for some reasons + } + } +} diff --git a/addons/website_sale/static/src/xml/website_sale_image_viewer.xml b/addons/website_sale/static/src/xml/website_sale_image_viewer.xml new file mode 100644 index 00000000000..ca9ce6f7b91 --- /dev/null +++ b/addons/website_sale/static/src/xml/website_sale_image_viewer.xml @@ -0,0 +1,45 @@ + + + +
+ + + diff --git a/addons/website_sale/static/tests/tours/website_sale_shop_zoom.js b/addons/website_sale/static/tests/tours/website_sale_shop_zoom.js index 3c57436fc5c..b642bde8ef5 100644 --- a/addons/website_sale/static/tests/tours/website_sale_shop_zoom.js +++ b/addons/website_sale/static/tests/tours/website_sale_shop_zoom.js @@ -23,8 +23,13 @@ tour.register('shop_zoom', { run: 'clicknoleave', }, { - content: "check there is no zoom on that small image", - trigger: 'body:not(:has(.zoomodoo-flyout img))', + content: "check that the image viewer opened", + trigger: '.o_wsale_image_viewer', + run: () => {}, + }, + { + content: "close the image viewer", + trigger: '.o_wsale_image_viewer_header span.fa-times', }, { content: "change variant", @@ -42,7 +47,8 @@ tour.register('shop_zoom', { }, { content: "check there is a zoom on that big image", - trigger: '.zoomodoo-flyout img', + trigger: '.o_wsale_image_viewer', + run: () => {}, }, ]); }); diff --git a/addons/website_sale/tests/test_website_sale_image.py b/addons/website_sale/tests/test_website_sale_image.py index b50f7ae6f7a..b25520266fd 100644 --- a/addons/website_sale/tests/test_website_sale_image.py +++ b/addons/website_sale/tests/test_website_sale_image.py @@ -202,6 +202,14 @@ class TestWebsiteSaleImage(odoo.tests.HttpCase): # self.env.cr.commit() # uncomment to save the product to test in browser + # Make sure we have zoom on click + self.env['ir.ui.view'].with_context(active_test=False).search( + [('key', 'in', ('website_sale.product_picture_magnify_hover', 'website_sale.product_picture_magnify_click', 'website_sale.product_picture_magnify_both'))] + ).write({'active': False}) + self.env['ir.ui.view'].with_context(active_test=False).search( + [('key', '=', 'website_sale.product_picture_magnify_click')] + ).write({'active': True}) + self.start_tour("/", 'shop_zoom', login="admin") # CASE: unlink move image to fallback if fallback image empty diff --git a/addons/website_sale/views/snippets/snippets.xml b/addons/website_sale/views/snippets/snippets.xml index 84fcd92b501..7f71d52ed04 100644 --- a/addons/website_sale/views/snippets/snippets.xml +++ b/addons/website_sale/views/snippets/snippets.xml @@ -235,10 +235,16 @@ - + Carousel Grid + + Magnifier on hover + Pop-up on Click + Both + None + @@ -252,17 +258,12 @@ 3 - Replace + Replace - Add - Remove all + Add + Remove all - - None - On click - On hover -
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 8d406dc5a68..0de56282a7f 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -630,7 +630,7 @@ t-att-data-image_width="website.product_page_image_width" t-att-data-image_layout="website.product_page_image_layout"> -
+
@@ -770,17 +770,26 @@ -