[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) <wbr@odoo.com>
This commit is contained in:
William Braeckman
2022-08-30 03:44:50 +02:00
parent 1ecdbfcfbf
commit 75cb824902
11 changed files with 451 additions and 69 deletions
+2
View File
@@ -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',
@@ -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``;
@@ -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);
}
@@ -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();
@@ -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;
}
}
@@ -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
}
}
}
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="website_sale.ProductImageViewer" owl="1">
<div class="o_dialog" t-att-id="id" t-att-class="{ o_inactive_modal: !data.isActive }">
<div role="dialog" class="modal" t-ref="modalRef">
<div class="o_wsale_image_viewer flex-column align-items-center d-flex w-100 h-100" t-on-mousemove="onGlobalMousemove">
<!-- Header -->
<div class="o_wsale_image_viewer_header d-flex w-100 text-white">
<div class="flex-grow-1"/>
<div class="d-flex align-items-center mb-0 px-3 h4 text-reset cursor-pointer">
<span class="fa fa-times" t-on-click="data.close"/>
</div>
</div>
<!-- Content -->
<div class="o_wsale_image_viewer_image position-absolute top-0 bottom-0 start-0 end-0 align-items-center justify-content-center d-flex o_with_img overflow-hidden">
<div class="o_wsale_image_viewer_void position-absolute align-items-center justify-content-center d-flex w-100 h-100" t-ref="imageContainer" t-att-style="imageContainerStyle">
<img class="mw-100 mh-100 bg-black transition-base" t-att-src="selectedImage.src" draggable="false" alt="Viewer" t-att-style="imageStyle" t-on-wheel.stop="onWheelImage" t-on-mousedown="onMousedownImage"/>
</div>
</div>
<t t-if="images.length > 1">
<!-- Footer -->
<div class="o_wsale_image_viewer_carousel position-absolute bottom-0 d-flex" role="toolbar">
<ol class="d-flex justify-content-start ps-0 pt-2 pt-lg-0 mx-auto my-0 text-start">
<t t-foreach="images" t-as="image" t-key="image">
<li t-attf-class="align-top position-relative px-1 pb-1 {{image === selectedImage ? 'active' : ''}}" t-on-click="() => this.selectedImage = image">
<div>
<img t-att-src="image.thumbnailSrc" t-attf-class="img o_wsale_image_viewer_thumbnail {{image === selectedImage ? 'active' : ''}}" t-att-alt="props.title" loading="lazy"/>
</div>
</li>
</t>
</ol>
</div>
<!-- Controls -->
<div class="o_wsale_image_viewer_control o_wsale_image_viewer_previous btn btn-dark position-absolute top-0 bottom-0 start-0 align-items-center justify-content-center d-flex my-auto ms-3 rounded-circle" t-on-click="previousImage" title="Previous (Left-Arrow)" role="button">
<span class="fa fa-chevron-left" role="img"/>
</div>
<div class="o_wsale_image_viewer_control o_wsale_image_viewer_next btn btn-dark position-absolute top-0 bottom-0 end-0 align-items-center justify-content-center d-flex my-auto me-3 rounded-circle" t-on-click="nextImage" title="Next (Right-Arrow)" role="button">
<span class="fa fa-chevron-right" role="img"/>
</div>
</t>
</div>
</div>
</div>
</t>
</templates>
@@ -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: () => {},
},
]);
});
@@ -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
@@ -235,10 +235,16 @@
<we-button data-set-image-width="66_pc" data-img="/website_sale/static/src/img/snippet_options/image-width-66.svg" title="66 percent"/>
<we-button data-set-image-width="100_pc" data-img="/website_sale/static/src/img/snippet_options/image-width-100.svg" title="100 percent"/>
</we-button-group>
<we-select string="Layout" data-no-preview="true" data-reload="/">
<we-select string="Layout" data-name="o_wsale_image_layout" data-no-preview="true" data-reload="/">
<we-button data-set-image-layout="carousel">Carousel</we-button>
<we-button data-set-image-layout="grid">Grid</we-button>
</we-select>
<we-select string="Image Zoom" class="o_we_sublevel_1" data-name="o_wsale_zoom_mode" data-no-preview="true" data-reload="/">
<we-button data-name="o_wsale_zoom_hover" data-customize-website-views="website_sale.product_picture_magnify_hover">Magnifier on hover</we-button>
<we-button data-name="o_wsale_zoom_click" data-customize-website-views="website_sale.product_picture_magnify_click">Pop-up on Click</we-button>
<we-button data-name="o_wsale_zoom_both" data-customize-website-views="website_sale.product_picture_magnify_both">Both</we-button>
<we-button data-name="o_wsale_zoom_none" data-customize-website-views="">None</we-button>
</we-select>
<!-- Carousel config -->
<we-button-group string="Thumbnails" class="o_we_sublevel_1" data-name="o_wsale_thumbnail_pos" data-no-preview="true" data-reload="/">
<we-button class="fa fa-fw fa-long-arrow-left" title="Left" data-customize-website-views="website_sale.carousel_product_indicators_left"/>
@@ -252,17 +258,12 @@
<we-button data-set-columns="3">3</we-button>
</we-select>
<we-row string="Main image">
<we-button class="o_we_bg_brand_primary" data-replace-main-image="true" data-no-preview="true">Replace</we-button>
<we-button class="o_we_bg_brand_primary" data-name="o_wsale_replace_main_image" data-replace-main-image="true" data-no-preview="true">Replace</we-button>
</we-row>
<we-row string="Extra Images">
<we-button class="o_we_bg_success" data-add-images="true" data-no-preview="true">Add</we-button>
<we-button class="o_we_bg_danger" data-clear-images="true" data-no-preview="true">Remove all</we-button>
<we-button class="o_we_bg_success" data-name="o_wsale_add_extra_images" data-add-images="true" data-no-preview="true">Add</we-button>
<we-button class="o_we_bg_danger" data-name="o_wsale_clear_extra_images" data-clear-images="true" data-no-preview="true">Remove all</we-button>
</we-row>
<we-select string="Image Zoom" data-no-preview="true" data-reload="/">
<we-button data-customize-website-views="">None</we-button>
<we-button data-customize-website-views="website_sale.product_picture_magnify">On click</we-button>
<we-button data-customize-website-views="website_sale.product_picture_magnify_auto">On hover</we-button>
</we-select>
</div>
<!-- Checkout page -->
<div data-selector="main:has(.oe_website_sale .wizard)" data-page-options="true" data-no-check="true" string="Checkout Pages">
+16 -7
View File
@@ -630,7 +630,7 @@
t-att-data-image_width="website.product_page_image_width"
t-att-data-image_layout="website.product_page_image_layout">
<t t-set="image_cols" t-value="website._get_product_page_proportions()"/>
<div t-attf-class="col-lg-#{image_cols[0]} mt-lg-4 o_wsale_product_images" t-if="website.product_page_image_width != 'none'">
<div t-attf-class="col-lg-#{image_cols[0]} mt-lg-4 o_wsale_product_images position-relative" t-if="website.product_page_image_width != 'none'">
<t t-call="website_sale.shop_product_images"/>
</div>
<div t-attf-class="col-lg-#{image_cols[1]} mt-md-4" id="product_details">
@@ -770,17 +770,26 @@
</xpath>
</template>
<template inherit_id='website_sale.product' id="product_picture_magnify" name="Image Zoom">
<xpath expr='//div[hasclass("js_sale")]' position='attributes'>
<!-- Product options: Zoom -->
<template inherit_id='website_sale.product' id="product_picture_magnify_hover" name="Automatic Image Zoom">
<xpath expr='//div[hasclass("o_wsale_product_page")]' position='attributes'>
<attribute name="data-ecom-zoom-auto">1</attribute>
<attribute name="class" separator=" " add="ecom-zoomable zoomodoo-next" />
</xpath>
</template>
<template inherit_id='website_sale.product' id="product_picture_magnify_auto" active="False" name="Automatic Image Zoom">
<xpath expr='//div[hasclass("js_sale")]' position='attributes'>
<attribute name="data-ecom-zoom-auto">1</attribute>
<template inherit_id='website_sale.product' id="product_picture_magnify_click" active="False" name="Image Zoom On Click">
<xpath expr='//div[hasclass("o_wsale_product_page")]' position='attributes'>
<attribute name="data-ecom-zoom-click">1</attribute>
<attribute name="class" separator=" " add="ecom-zoomable zoomodoo-next" />
</xpath>
</template>
<template inherit_id='website_sale.product' id="product_picture_magnify_both" active="False" name="Automatic Image Zoom And On Click">
<xpath expr='//div[hasclass("o_wsale_product_page")]' position='attributes'>
<attribute name="data-ecom-zoom-auto">1</attribute>
<attribute name="data-ecom-zoom-click">1</attribute>
<attribute name="class" separator=" " add="ecom-zoomable zoomodoo-next" />
</xpath>
</template>
@@ -1992,7 +2001,7 @@
</template>
<template id="website_sale.shop_product_image">
<div t-if="product_image._name == 'product.image' and product_image.embed_code" t-att-class="image_classes + 'ratio ratio-16x9'">
<div t-if="product_image._name == 'product.image' and product_image.embed_code" t-att-class="image_classes + ' ratio ratio-16x9'">
<t t-out="product_image.embed_code"/>
</div>
<div t-else="" t-field="product_image.image_1920" t-att-class="image_classes + ' oe_unmovable'" t-options='{"widget": "image", "preview_image": "image_1024", "class": "oe_unmovable product_detail_img mh-100", "alt-field": "name", "zoom": product_image.can_image_1024_be_zoomed and "image_1920", "itemprop": "image"}'/>