diff --git a/addons/html_editor/static/src/main/link/navbar_link_popover.js b/addons/html_editor/static/src/main/link/navbar_link_popover.js
new file mode 100644
index 0000000000000..00ebf1ff09a55
--- /dev/null
+++ b/addons/html_editor/static/src/main/link/navbar_link_popover.js
@@ -0,0 +1,27 @@
+import { LinkPopover } from "./link_popover";
+
+export class NavbarLinkPopover extends LinkPopover {
+ static template = "html_editor.navbarLinkPopover";
+ static props = {
+ ...LinkPopover.props,
+ onClickEditLink: Function,
+ onClickEditMenu: Function,
+ };
+
+ /**
+ * @override
+ */
+ onClickEdit() {
+ const updateUrlAndLabel = this.updateUrlAndLabel.bind(this);
+ const applyDeducedUrl = this.applyDeducedUrl.bind(this);
+ const callback = () => {
+ updateUrlAndLabel();
+ applyDeducedUrl();
+ };
+ this.props.onClickEditLink(this, callback);
+ }
+
+ onClickEditMenu() {
+ this.props.onClickEditMenu();
+ }
+}
diff --git a/addons/html_editor/static/src/main/link/navbar_link_popover.xml b/addons/html_editor/static/src/main/link/navbar_link_popover.xml
new file mode 100644
index 0000000000000..0ab41c4d4ea5f
--- /dev/null
+++ b/addons/html_editor/static/src/main/link/navbar_link_popover.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/html_editor/static/src/main/list/list_plugin.js b/addons/html_editor/static/src/main/list/list_plugin.js
index e4c61a56ab5e5..9ecfbdf02145e 100644
--- a/addons/html_editor/static/src/main/list/list_plugin.js
+++ b/addons/html_editor/static/src/main/list/list_plugin.js
@@ -955,7 +955,7 @@ export class ListPlugin extends Plugin {
* @param {HTMLLIElement} li - LI element inside a checklist.
*/
isPointerInsideCheckbox(li, pointerOffsetX, pointerOffsetY) {
- const beforeStyle = this.document.defaultView.getComputedStyle(li, ":before");
+ const beforeStyle = this.window.getComputedStyle(li, ":before");
const checkboxPosition = {
left: parseInt(beforeStyle.left),
top: parseInt(beforeStyle.top),
@@ -1047,13 +1047,11 @@ export class ListPlugin extends Plugin {
return;
}
const defaultPadding =
- parseFloat(
- this.document.defaultView.getComputedStyle(document.documentElement).fontSize
- ) * 2; // 2rem
+ parseFloat(this.window.getComputedStyle(document.documentElement).fontSize) * 2; // 2rem
// Align the whole list based on the item that requires the largest padding.
const requiredPaddings = [...list.children].map((li) => {
const markerWidth = Math.floor(
- parseFloat(this.document.defaultView.getComputedStyle(li, "::marker").width)
+ parseFloat(this.window.getComputedStyle(li, "::marker").width)
);
// For `UL` with large font size the marker width is so big that more padding is needed.
const paddingForMarker =
diff --git a/addons/html_editor/static/src/main/list/list_selector.xml b/addons/html_editor/static/src/main/list/list_selector.xml
index f9d1b55f8dc9d..be4e93c80f74f 100644
--- a/addons/html_editor/static/src/main/list/list_selector.xml
+++ b/addons/html_editor/static/src/main/list/list_selector.xml
@@ -3,16 +3,18 @@
-
-
-
-
+
+
+
+
+
+
diff --git a/addons/html_editor/static/src/main/media/dblclick_image_preview_plugin.js b/addons/html_editor/static/src/main/media/dblclick_image_preview_plugin.js
new file mode 100644
index 0000000000000..b6dd8343a79e8
--- /dev/null
+++ b/addons/html_editor/static/src/main/media/dblclick_image_preview_plugin.js
@@ -0,0 +1,14 @@
+import { Plugin } from "@html_editor/plugin";
+
+export class DoubleClickImagePreviewPlugin extends Plugin {
+ static id = "dblclickImagePreview";
+ static dependencies = ["image"];
+
+ setup() {
+ this.addDomListener(this.editable, "dblclick", (e) => {
+ if (e.target.tagName === "IMG") {
+ this.dependencies.image.previewImage();
+ }
+ });
+ }
+}
diff --git a/addons/html_editor/static/src/main/media/image_crop.js b/addons/html_editor/static/src/main/media/image_crop.js
index f94742be00cf1..1570ef434267f 100644
--- a/addons/html_editor/static/src/main/media/image_crop.js
+++ b/addons/html_editor/static/src/main/media/image_crop.js
@@ -1,9 +1,8 @@
import {
- applyModifications,
- cropperDataFields,
activateCropper,
loadImage,
loadImageInfo,
+ cropperDataFieldsWithAspectRatio,
} from "@html_editor/utils/image_processing";
import { IMAGE_SHAPES } from "./image_plugin";
import { _t } from "@web/core/l10n/translation";
@@ -19,24 +18,25 @@ import {
import { useService } from "@web/core/utils/hooks";
import { scrollTo, closestScrollableY } from "@web/core/utils/scrolling";
+export const cropperAspectRatios = {
+ "0/0": { label: _t("Flexible"), value: 0 },
+ "16/9": { label: "16:9", value: 16 / 9 },
+ "4/3": { label: "4:3", value: 4 / 3 },
+ "1/1": { label: "1:1", value: 1 },
+ "2/3": { label: "2:3", value: 2 / 3 },
+};
+
export class ImageCrop extends Component {
static template = "html_editor.ImageCrop";
static props = {
document: { validate: (p) => p.nodeType === Node.DOCUMENT_NODE },
media: { optional: true },
- mimetype: { type: String, optional: true },
onClose: { type: Function, optional: true },
onSave: { type: Function, optional: true },
};
setup() {
- this.aspectRatios = {
- "0/0": { label: _t("Flexible"), value: 0 },
- "16/9": { label: "16:9", value: 16 / 9 },
- "4/3": { label: "4:3", value: 4 / 3 },
- "1/1": { label: "1:1", value: 1 },
- "2/3": { label: "2:3", value: 2 / 3 },
- };
+ this.aspectRatios = cropperAspectRatios;
this.notification = useService("notification");
this.media = this.props.media;
this.document = this.props.document;
@@ -90,9 +90,9 @@ export class ImageCrop extends Component {
this.cropper.reset();
if (this.aspectRatio !== "0/0") {
this.aspectRatio = "0/0";
- this.cropper.setAspectRatio(this.aspectRatios[this.aspectRatio].value);
+ this.cropper.setAspectRatio(cropperAspectRatios[this.aspectRatio].value);
}
- await this.save(false);
+ await this.save();
}
}
@@ -105,15 +105,9 @@ export class ImageCrop extends Component {
const data = { ...this.media.dataset };
this.initialSrc = src;
this.aspectRatio = data.aspectRatio || "0/0";
- const mimetype =
- data.mimetype || src.endsWith(".png")
- ? "image/png"
- : src.endsWith(".webp")
- ? "image/webp"
- : "image/jpeg";
- this.mimetype = this.props.mimetype || mimetype;
- await loadImageInfo(this.media);
+ // todo: check that the mutations of loadImage are not problematic (they most probably are).
+ Object.assign(this.media.dataset, await loadImageInfo(this.media));
const isIllustration = /^\/(?:html|web)_editor\/shape\/illustration\//.test(
this.media.dataset.originalSrc
);
@@ -164,10 +158,9 @@ export class ImageCrop extends Component {
offset = { top: 0, left: 0 };
} else {
const rect = this.media.getBoundingClientRect();
- const win = this.media.ownerDocument.defaultView;
offset = {
- top: rect.top + win.pageYOffset,
- left: rect.left + win.pageXOffset,
+ top: rect.top,
+ left: rect.left,
};
}
@@ -187,7 +180,7 @@ export class ImageCrop extends Component {
this.cropper = await activateCropper(
cropperImage,
- this.aspectRatios[this.aspectRatio].value,
+ cropperAspectRatios[this.aspectRatio]?.value || 0,
this.media.dataset
);
@@ -211,36 +204,13 @@ export class ImageCrop extends Component {
* @private
* @param {boolean} [cropped=true]
*/
- async save(cropped = true) {
- // Mark the media for later creation of cropped attachment
- this.media.classList.add("o_modified_image_to_save");
-
- [...cropperDataFields, "aspectRatio"].forEach((attr) => {
- delete this.media.dataset[attr];
- const value = this.getAttributeValue(attr);
- if (value) {
- this.media.dataset[attr] = value;
- }
- });
- delete this.media.dataset.resizeWidth;
- this.initialSrc = await applyModifications(this.media, this.cropper, {
- forceModification: true,
- mimetype: this.mimetype,
+ async save() {
+ const cropperData = this.getCropperData(this.cropper);
+ this.props.onSave?.({
+ aspectRatio: this.aspectRatio,
+ ...cropperData,
});
- this.media.classList.toggle("o_we_image_cropped", cropped);
this.closeCropper();
- this.props.onSave?.();
- }
- /**
- * Returns an attribute's value for saving.
- *
- * @private
- */
- getAttributeValue(attr) {
- if (cropperDataFields.includes(attr)) {
- return this.cropper.getData()[attr];
- }
- return this[attr];
}
/**
* Resets the crop box to prevent it going outside the image.
@@ -300,7 +270,7 @@ export class ImageCrop extends Component {
setAspectRatio(ratio) {
this.cropper.reset();
this.aspectRatio = ratio;
- this.cropper.setAspectRatio(this.aspectRatios[this.aspectRatio].value);
+ this.cropper.setAspectRatio(cropperAspectRatios[this.aspectRatio].value);
}
/**
@@ -332,6 +302,16 @@ export class ImageCrop extends Component {
return this.closeCropper();
}
}
+ /**
+ * @param {Cropper} cropper
+ */
+ getCropperData(cropper) {
+ return Object.fromEntries(
+ cropperDataFieldsWithAspectRatio
+ .map((field) => [field, cropper.getData()[field]])
+ .filter(([, value]) => value)
+ );
+ }
/**
* Resets the cropbox on zoom to prevent crop box overflowing.
*
diff --git a/addons/html_editor/static/src/main/media/image_crop_plugin.js b/addons/html_editor/static/src/main/media/image_crop_plugin.js
index 71b1ef9e49538..3ce206db760e3 100644
--- a/addons/html_editor/static/src/main/media/image_crop_plugin.js
+++ b/addons/html_editor/static/src/main/media/image_crop_plugin.js
@@ -1,12 +1,12 @@
+import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Plugin } from "../../plugin";
-import { _t } from "@web/core/l10n/translation";
import { ImageCrop } from "./image_crop";
-import { loadBundle } from "@web/core/assets";
export class ImageCropPlugin extends Plugin {
static id = "imageCrop";
- static dependencies = ["selection", "history"];
+ static dependencies = ["selection", "history", "imagePostProcess"];
+ static shared = ["openCropImage"];
resources = {
user_commands: [
{
@@ -25,39 +25,37 @@ export class ImageCropPlugin extends Plugin {
],
};
- setup() {
- this.imageCropProps = {
- media: undefined,
- mimetype: undefined,
- };
- }
-
getSelectedImage() {
const selectedNodes = this.dependencies.selection.getSelectedNodes();
return selectedNodes.find((node) => node.tagName === "IMG");
}
- async openCropImage() {
- const selectedImg = this.getSelectedImage();
+ async openCropImage(selectedImg, imageCropProps = {}) {
+ selectedImg = selectedImg || this.getSelectedImage();
if (!selectedImg) {
return;
}
-
- this.imageCropProps.media = selectedImg;
-
- const onClose = () => {
- registry.category("main_components").remove("ImageCropping");
- };
-
- const onSave = () => {
- this.dependencies.history.addStep();
- };
-
- await loadBundle("html_editor.assets_image_cropper");
-
- registry.category("main_components").add("ImageCropping", {
+ return registry.category("main_components").add("ImageCropping", {
Component: ImageCrop,
- props: { ...this.imageCropProps, onClose, onSave, document: this.document },
+ props: {
+ media: selectedImg,
+ onSave: async (newDataset) => {
+ // todo: should use the mutex if there is one?
+ const updateImageAttributes =
+ await this.dependencies.imagePostProcess.processImage(
+ selectedImg,
+ newDataset
+ );
+ updateImageAttributes();
+ this.dependencies.history.addStep();
+ },
+ document: this.document,
+ ...imageCropProps,
+ onClose: () => {
+ registry.category("main_components").remove("ImageCropping");
+ imageCropProps.onClose?.();
+ },
+ },
});
}
}
diff --git a/addons/html_editor/static/src/main/media/image_description.js b/addons/html_editor/static/src/main/media/image_description.js
index a757c5a566da5..6af3d52c18c4b 100644
--- a/addons/html_editor/static/src/main/media/image_description.js
+++ b/addons/html_editor/static/src/main/media/image_description.js
@@ -6,10 +6,10 @@ import { toolbarButtonProps } from "@html_editor/main/toolbar/toolbar";
export class ImageDescription extends Component {
static components = { Dialog };
static props = {
+ ...toolbarButtonProps,
getDescription: Function,
getTooltip: Function,
updateImageDescription: Function,
- ...toolbarButtonProps,
};
static template = "html_editor.ImageDescription";
diff --git a/addons/html_editor/static/src/main/media/image_plugin.js b/addons/html_editor/static/src/main/media/image_plugin.js
index f1ab10d5b5a84..5a2a09b83b2f1 100644
--- a/addons/html_editor/static/src/main/media/image_plugin.js
+++ b/addons/html_editor/static/src/main/media/image_plugin.js
@@ -36,7 +36,7 @@ const IMAGE_SIZE = [
export class ImagePlugin extends Plugin {
static id = "image";
static dependencies = ["history", "link", "powerbox", "dom", "selection"];
- static shared = ["getSelectedImage"];
+ static shared = ["getSelectedImage", "previewImage"];
resources = {
user_commands: [
{
@@ -191,11 +191,6 @@ export class ImagePlugin extends Plugin {
setup() {
this.imageSize = reactive({ displayName: "Default" });
- this.addDomListener(this.editable, "dblclick", (e) => {
- if (e.target.tagName === "IMG") {
- this.previewImage();
- }
- });
this.addDomListener(this.editable, "pointerup", (e) => {
if (e.target.tagName === "IMG") {
const [anchorNode, anchorOffset, focusNode, focusOffset] = boundariesOut(e.target);
@@ -373,6 +368,7 @@ export class ImagePlugin extends Plugin {
return {
id: "image_transform",
icon: "fa-object-ungroup",
+ title: _t("Transform the picture (click twice to reset transformation)"),
getSelectedImage: this.getSelectedImage.bind(this),
resetImageTransformation: this.resetImageTransformation.bind(this),
addStep: this.dependencies.history.addStep.bind(this),
diff --git a/addons/html_editor/static/src/main/media/image_post_process_plugin.js b/addons/html_editor/static/src/main/media/image_post_process_plugin.js
new file mode 100644
index 0000000000000..c048042f26b58
--- /dev/null
+++ b/addons/html_editor/static/src/main/media/image_post_process_plugin.js
@@ -0,0 +1,471 @@
+import {
+ activateCropper,
+ getAspectRatio,
+ getDataURLBinarySize,
+ getImageSizeFromCache,
+ isGif,
+ loadImage,
+ loadImageDataURL,
+ loadImageInfo,
+} from "@html_editor/utils/image_processing";
+import { Plugin } from "../../plugin";
+import { getAffineApproximation, getProjective } from "@html_editor/utils/perspective_utils";
+
+export const DEFAULT_IMAGE_QUALITY = "75";
+
+export class ImagePostProcessPlugin extends Plugin {
+ static id = "imagePostProcess";
+ static dependencies = ["style"];
+ static shared = ["processImage"];
+
+ /**
+ * Applies data-attributes modifications to an img tag and returns a dataURL
+ * containing the result. This function does not modify the original image.
+ *
+ * @param {HTMLImageElement} img the image to which modifications are applied
+ * @param {Object} newDataset an object containing the modifications to apply
+ * @returns {Function} callback that sets dataURL of the image with the
+ * applied modifications to `img` element
+ */
+ async processImage(img, newDataset = {}) {
+ const processContext = {};
+ if (!newDataset.originalSrc || !newDataset.mimetypeBeforeConversion) {
+ Object.assign(newDataset, await loadImageInfo(img));
+ }
+ for (const cb of this.getResource("process_image_warmup_handlers")) {
+ const addedContext = await cb(img, newDataset);
+ if (addedContext) {
+ if (addedContext.newDataset) {
+ Object.assign(newDataset, addedContext.newDataset);
+ }
+ Object.assign(processContext, addedContext);
+ }
+ }
+
+ const data = getImageTransformationData({ ...img.dataset, ...newDataset });
+ const {
+ mimetypeBeforeConversion,
+ formatMimetype,
+ width,
+ height,
+ resizeWidth,
+ filter,
+ glFilter,
+ filterOptions,
+ aspectRatio,
+ quality,
+ } = data;
+
+ const { postProcessCroppedCanvas, perspective, getHeight } = processContext;
+
+ // loadImage may have ended up loading a different src (see: LOAD_IMAGE_404)
+ const originalImg = await loadImage(data.originalSrc);
+ const originalSrc = originalImg.getAttribute("src");
+
+ if (shouldPreventGifTransformation(data)) {
+ const [postUrl, postDataset] = await this.postProcessImage(
+ await loadImageDataURL(originalSrc),
+ newDataset,
+ processContext
+ );
+ return () => this.updateImageAttributes(img, postUrl, postDataset);
+ }
+ // Crop
+ const container = document.createElement("div");
+ container.appendChild(originalImg);
+ const cropper = await activateCropper(originalImg, aspectRatio, data);
+ const croppedCanvas = cropper.getCroppedCanvas(width, height);
+ cropper.destroy();
+ const processedCanvas = (await postProcessCroppedCanvas?.(croppedCanvas)) || croppedCanvas;
+
+ // Width
+ const canvas = document.createElement("canvas");
+ canvas.width = resizeWidth || processedCanvas.width;
+ canvas.height = getHeight
+ ? getHeight(canvas)
+ : (processedCanvas.height * canvas.width) / processedCanvas.width;
+ const ctx = canvas.getContext("2d");
+ ctx.imageSmoothingQuality = "high";
+ ctx.mozImageSmoothingEnabled = true;
+ ctx.webkitImageSmoothingEnabled = true;
+ ctx.msImageSmoothingEnabled = true;
+ ctx.imageSmoothingEnabled = true;
+
+ // Perspective 3D
+ if (perspective) {
+ // x, y coordinates of the corners of the image as a percentage
+ // (relative to the width or height of the image) needed to apply
+ // the 3D effect.
+ const points = JSON.parse(perspective);
+ const divisions = 10;
+ const w = processedCanvas.width,
+ h = processedCanvas.height;
+
+ const project = getProjective(w, h, [
+ [(canvas.width / 100) * points[0][0], (canvas.height / 100) * points[0][1]], // Top-left [x, y]
+ [(canvas.width / 100) * points[1][0], (canvas.height / 100) * points[1][1]], // Top-right [x, y]
+ [(canvas.width / 100) * points[2][0], (canvas.height / 100) * points[2][1]], // bottom-right [x, y]
+ [(canvas.width / 100) * points[3][0], (canvas.height / 100) * points[3][1]], // bottom-left [x, y]
+ ]);
+
+ for (let i = 0; i < divisions; i++) {
+ for (let j = 0; j < divisions; j++) {
+ const [dx, dy] = [w / divisions, h / divisions];
+
+ const upper = {
+ origin: [i * dx, j * dy],
+ sides: [dx, dy],
+ flange: 0.1,
+ overlap: 0,
+ };
+ const lower = {
+ origin: [i * dx + dx, j * dy + dy],
+ sides: [-dx, -dy],
+ flange: 0,
+ overlap: 0.1,
+ };
+
+ for (const { origin, sides, flange, overlap } of [upper, lower]) {
+ const [[a, c, e], [b, d, f]] = getAffineApproximation(project, [
+ origin,
+ [origin[0] + sides[0], origin[1]],
+ [origin[0], origin[1] + sides[1]],
+ ]);
+
+ const ox = (i !== divisions ? overlap * sides[0] : 0) + flange * sides[0];
+ const oy = (j !== divisions ? overlap * sides[1] : 0) + flange * sides[1];
+
+ origin[0] += flange * sides[0];
+ origin[1] += flange * sides[1];
+
+ sides[0] -= flange * sides[0];
+ sides[1] -= flange * sides[1];
+
+ ctx.save();
+ ctx.setTransform(a, b, c, d, e, f);
+
+ ctx.beginPath();
+ ctx.moveTo(origin[0] - ox, origin[1] - oy);
+ ctx.lineTo(origin[0] + sides[0], origin[1] - oy);
+ ctx.lineTo(origin[0] + sides[0], origin[1]);
+ ctx.lineTo(origin[0], origin[1] + sides[1]);
+ ctx.lineTo(origin[0] - ox, origin[1] + sides[1]);
+ ctx.closePath();
+ ctx.clip();
+ ctx.drawImage(processedCanvas, 0, 0);
+
+ ctx.restore();
+ }
+ }
+ }
+ } else {
+ ctx.drawImage(
+ processedCanvas,
+ 0,
+ 0,
+ processedCanvas.width,
+ processedCanvas.height,
+ 0,
+ 0,
+ canvas.width,
+ canvas.height
+ );
+ }
+
+ // GL filter
+ if (glFilter) {
+ const glf = new window.WebGLImageFilter();
+ const cv = document.createElement("canvas");
+ cv.width = canvas.width;
+ cv.height = canvas.height;
+ applyAll = _applyAll.bind(null, canvas);
+ glFilters[glFilter](glf, cv, filterOptions);
+ const filtered = glf.apply(canvas);
+ ctx.drawImage(
+ filtered,
+ 0,
+ 0,
+ filtered.width,
+ filtered.height,
+ 0,
+ 0,
+ canvas.width,
+ canvas.height
+ );
+ }
+
+ // Color filter
+ ctx.fillStyle = filter || "#0000";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ // Quality
+ newDataset.mimetype = formatMimetype || mimetypeBeforeConversion;
+ const dataURL = canvas.toDataURL(newDataset.mimetype, quality / 100);
+ const newSize = getDataURLBinarySize(dataURL);
+ const originalSize = getImageSizeFromCache(originalSrc);
+ const isChanged =
+ !!perspective ||
+ !!glFilter ||
+ originalImg.width !== canvas.width ||
+ originalImg.height !== canvas.height ||
+ originalImg.width !== processedCanvas.width ||
+ originalImg.height !== processedCanvas.height;
+
+ let url =
+ isChanged || originalSize >= newSize ? dataURL : await loadImageDataURL(originalSrc);
+ [url, newDataset] = await this.postProcessImage(url, newDataset, processContext);
+ return () => this.updateImageAttributes(img, url, newDataset);
+ }
+ async postProcessImage(url, newDataset, processContext) {
+ for (const cb of this.getResource("process_image_post_handlers")) {
+ const [newUrl, handlerDataset] = (await cb(url, newDataset, processContext)) || [];
+ url = newUrl || url;
+ newDataset = handlerDataset || newDataset;
+ }
+ return [url, newDataset];
+ }
+ updateImageAttributes(el, url, newDataset) {
+ el.classList.add("o_modified_image_to_save");
+ if (el.tagName === "IMG") {
+ el.setAttribute("src", url);
+ } else {
+ this.dependencies.style.setBackgroundImageUrl(el, url);
+ }
+ for (const key in newDataset) {
+ const value = newDataset[key];
+ if (value) {
+ el.dataset[key] = value;
+ } else {
+ delete el.dataset[key];
+ }
+ }
+ }
+}
+
+export function getImageTransformationData(dataset) {
+ const data = Object.assign(
+ {
+ glFilter: "",
+ filter: "#0000",
+ forceModification: false,
+ },
+ dataset
+ );
+ for (const key of ["width", "height", "resizeWidth"]) {
+ data[key] = parseFloat(data[key]);
+ }
+ if (!("quality" in data)) {
+ data.quality = DEFAULT_IMAGE_QUALITY;
+ }
+ // todo: this information could be inferred from x/y/width/height dataset
+ // properties.
+ data.aspectRatio = data.aspectRatio ? getAspectRatio(data.aspectRatio) : 0;
+ return data;
+}
+
+function shouldTransformImage(data) {
+ return (
+ data.perspective ||
+ data.glFilter ||
+ data.width ||
+ data.height ||
+ data.resizeWidth ||
+ data.aspectRatio
+ );
+}
+
+export function shouldPreventGifTransformation(data) {
+ return isGif(data.mimetypeBeforeConversion) && !shouldTransformImage(data);
+}
+
+export const defaultImageFilterOptions = {
+ blend: "normal",
+ filterColor: "",
+ blur: "0",
+ desaturateLuminance: "0",
+ saturation: "0",
+ contrast: "0",
+ brightness: "0",
+ sepia: "0",
+};
+
+// webgl color filters
+const _applyAll = (result, filter, filters) => {
+ filters.forEach((f) => {
+ if (f[0] === "blend") {
+ const cv = f[1];
+ const ctx = result.getContext("2d");
+ ctx.globalCompositeOperation = f[2];
+ ctx.globalAlpha = f[3];
+ ctx.drawImage(cv, 0, 0);
+ ctx.globalCompositeOperation = "source-over";
+ ctx.globalAlpha = 1.0;
+ } else {
+ filter.addFilter(...f);
+ }
+ });
+};
+let applyAll;
+
+const glFilters = {
+ blur: (filter) => filter.addFilter("blur", 10),
+
+ 1977: (filter, cv) => {
+ const ctx = cv.getContext("2d");
+ ctx.fillStyle = "rgb(243, 106, 188)";
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ["blend", cv, "screen", 0.3],
+ ["brightness", 0.1],
+ ["contrast", 0.1],
+ ["saturation", 0.3],
+ ]);
+ },
+
+ aden: (filter, cv) => {
+ const ctx = cv.getContext("2d");
+ ctx.fillStyle = "rgb(66, 10, 14)";
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ["blend", cv, "darken", 0.2],
+ ["brightness", 0.2],
+ ["contrast", -0.1],
+ ["saturation", -0.15],
+ ["hue", 20],
+ ]);
+ },
+
+ brannan: (filter, cv) => {
+ const ctx = cv.getContext("2d");
+ ctx.fillStyle = "rgb(161, 44, 191)";
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ["blend", cv, "lighten", 0.31],
+ ["sepia", 0.5],
+ ["contrast", 0.4],
+ ]);
+ },
+
+ earlybird: (filter, cv) => {
+ const ctx = cv.getContext("2d");
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2,
+ cv.height / 2,
+ 0,
+ cv.width / 2,
+ cv.height / 2,
+ Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(0.2, "#D0BA8E");
+ gradient.addColorStop(1, "#1D0210");
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ["blend", cv, "overlay", 0.2],
+ ["sepia", 0.2],
+ ["contrast", -0.1],
+ ]);
+ },
+
+ inkwell: (filter, cv) => {
+ applyAll(filter, [
+ ["sepia", 0.3],
+ ["brightness", 0.1],
+ ["contrast", -0.1],
+ ["desaturateLuminance"],
+ ]);
+ },
+
+ // Needs hue blending mode for perfect reproduction. Close enough?
+ maven: (filter, cv) => {
+ applyAll(filter, [
+ ["sepia", 0.25],
+ ["brightness", -0.05],
+ ["contrast", -0.05],
+ ["saturation", 0.5],
+ ]);
+ },
+
+ toaster: (filter, cv) => {
+ const ctx = cv.getContext("2d");
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2,
+ cv.height / 2,
+ 0,
+ cv.width / 2,
+ cv.height / 2,
+ Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(0, "#0F4E80");
+ gradient.addColorStop(1, "#3B003B");
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ["blend", cv, "screen", 0.5],
+ ["brightness", -0.1],
+ ["contrast", 0.5],
+ ]);
+ },
+
+ walden: (filter, cv) => {
+ const ctx = cv.getContext("2d");
+ ctx.fillStyle = "#CC4400";
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ["blend", cv, "screen", 0.3],
+ ["sepia", 0.3],
+ ["brightness", 0.1],
+ ["saturation", 0.6],
+ ["hue", 350],
+ ]);
+ },
+
+ valencia: (filter, cv) => {
+ const ctx = cv.getContext("2d");
+ ctx.fillStyle = "#3A0339";
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ["blend", cv, "exclusion", 0.5],
+ ["sepia", 0.08],
+ ["brightness", 0.08],
+ ["contrast", 0.08],
+ ]);
+ },
+
+ xpro: (filter, cv) => {
+ const ctx = cv.getContext("2d");
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2,
+ cv.height / 2,
+ 0,
+ cv.width / 2,
+ cv.height / 2,
+ Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(0.4, "#E0E7E6");
+ gradient.addColorStop(1, "#2B2AA1");
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ["blend", cv, "color-burn", 0.7],
+ ["sepia", 0.3],
+ ]);
+ },
+
+ custom: (filter, cv, filterOptions) => {
+ const options = Object.assign(defaultImageFilterOptions, JSON.parse(filterOptions || "{}"));
+ const filters = [];
+ if (options.filterColor) {
+ const ctx = cv.getContext("2d");
+ ctx.fillStyle = options.filterColor;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ filters.push(["blend", cv, options.blend, 1]);
+ }
+ delete options.blend;
+ delete options.filterColor;
+ filters.push(
+ ...Object.entries(options).map(([filter, amount]) => [filter, parseInt(amount) / 100])
+ );
+ applyAll(filter, filters);
+ },
+};
diff --git a/addons/html_editor/static/src/main/media/image_transform_button.js b/addons/html_editor/static/src/main/media/image_transform_button.js
index 175dfa32db147..42d46774f3234 100644
--- a/addons/html_editor/static/src/main/media/image_transform_button.js
+++ b/addons/html_editor/static/src/main/media/image_transform_button.js
@@ -8,6 +8,7 @@ export class ImageTransformButton extends Component {
static props = {
id: String,
icon: String,
+ title: String,
getSelectedImage: Function,
resetImageTransformation: Function,
addStep: Function,
diff --git a/addons/html_editor/static/src/main/media/media_dialog/file_selector.js b/addons/html_editor/static/src/main/media/media_dialog/file_selector.js
index acb4f0e0e4a1c..64344befde3d9 100644
--- a/addons/html_editor/static/src/main/media/media_dialog/file_selector.js
+++ b/addons/html_editor/static/src/main/media/media_dialog/file_selector.js
@@ -366,7 +366,9 @@ export class FileSelector extends Component {
.then(async (result) => {
const blob = await result.blob();
blob.id = new Date().getTime();
- blob.name = new URL(url).pathname.split("/").findLast((s) => s);
+ blob.name = new URL(url, window.location.href).pathname
+ .split("/")
+ .findLast((s) => s);
await this.uploadFiles([blob]);
})
.catch(async () => {
@@ -386,22 +388,25 @@ export class FileSelector extends Component {
resolve();
};
imageEl.onload = () => {
- this.uploadService
- .uploadUrl(
- url,
- {
- resModel: this.props.resModel,
- resId: this.props.resId,
- },
- (attachment) => this.onUploaded(attachment)
- )
- .then(resolve);
+ this.onLoadUploadedUrl(url, resolve);
};
imageEl.src = url;
});
});
}
+ async onLoadUploadedUrl(url, resolve) {
+ await this.uploadService.uploadUrl(
+ url,
+ {
+ resModel: this.props.resModel,
+ resId: this.props.resId,
+ },
+ (attachment) => this.onUploaded(attachment)
+ );
+ resolve();
+ }
+
async onUploaded(attachment) {
this.state.attachments = [
attachment,
diff --git a/addons/html_editor/static/src/main/media/media_dialog/image_selector.js b/addons/html_editor/static/src/main/media/media_dialog/image_selector.js
index 534d51fbe5491..5ce3121d28135 100644
--- a/addons/html_editor/static/src/main/media/media_dialog/image_selector.js
+++ b/addons/html_editor/static/src/main/media/media_dialog/image_selector.js
@@ -5,6 +5,7 @@ import { KeepLast } from "@web/core/utils/concurrency";
import { DEFAULT_PALETTE } from "@html_editor/utils/color";
import { getCSSVariableValue, getHtmlStyle } from "@html_editor/utils/formatting";
import { Attachment, FileSelector, IMAGE_EXTENSIONS, IMAGE_MIMETYPES } from "./file_selector";
+import { isSrcCorsProtected } from "@html_editor/utils/image";
export class AutoResizeImage extends Attachment {
static template = "html_editor.AutoResizeImage";
@@ -92,6 +93,8 @@ export class ImageSelector extends FileSelector {
this.MIN_ROW_HEIGHT = 128;
this.fileMimetypes = IMAGE_MIMETYPES.join(",");
+ this.isImageField =
+ !!this.props.media?.closest("[data-oe-type=image]") || !!this.env.addFieldImage;
}
get canLoadMore() {
@@ -192,6 +195,30 @@ export class ImageSelector extends FileSelector {
return { isValidFileFormat, isValidUrl };
}
+ async onLoadUploadedUrl(url, resolve) {
+ const urlPathname = new URL(url, window.location.href).pathname;
+ const imageExtension = IMAGE_EXTENSIONS.find((format) => urlPathname.endsWith(format));
+ if (this.isImageField && imageExtension === ".webp") {
+ // Do not allow the user to replace an image field by a
+ // webp CORS protected image as we are not currently
+ // able to manage the report creation if such images are
+ // in there (as the equivalent jpeg can not be
+ // generated). It also causes a problem for resize
+ // operations as 'libwep' can not be used.
+ this.notificationService.add(
+ _t(
+ "You can not replace a field by this image. If you want to use this image, first save it on your computer and then upload it here."
+ ),
+ {
+ title: _t("Error"),
+ sticky: true,
+ }
+ );
+ return resolve();
+ }
+ super.onLoadUploadedUrl(url, resolve);
+ }
+
isInitialMedia(attachment) {
if (this.props.media.dataset.originalSrc) {
return this.props.media.dataset.originalSrc === attachment.image_src;
@@ -201,6 +228,20 @@ export class ImageSelector extends FileSelector {
async fetchAttachments(limit, offset) {
const attachments = await super.fetchAttachments(limit, offset);
+ if (this.isImageField) {
+ // The image is a field; mark the attachments if they are linked to
+ // a webp CORS protected image. Indeed, in this case, they should
+ // not be selectable on the media dialog (due to a problem of image
+ // resize and report creation).
+ for (const attachment of attachments) {
+ if (
+ attachment.mimetype === "image/webp" &&
+ (await isSrcCorsProtected(attachment.image_src))
+ ) {
+ attachment.unselectable = true;
+ }
+ }
+ }
// Color-substitution for dynamic SVG attachment
const primaryColors = {};
const htmlStyle = getHtmlStyle(document);
@@ -300,6 +341,18 @@ export class ImageSelector extends FileSelector {
}
async onClickAttachment(attachment) {
+ if (attachment.unselectable) {
+ this.notificationService.add(
+ _t(
+ "You can not replace a field by this image. If you want to use this image, first save it on your computer and then upload it here."
+ ),
+ {
+ title: _t("Error"),
+ sticky: true,
+ }
+ );
+ return;
+ }
this.selectAttachment(attachment);
if (!this.props.multiSelect) {
await this.props.save();
diff --git a/addons/html_editor/static/src/main/media/media_dialog/image_selector.xml b/addons/html_editor/static/src/main/media/media_dialog/image_selector.xml
index ddf44dbd14cd2..78d59c665807c 100644
--- a/addons/html_editor/static/src/main/media/media_dialog/image_selector.xml
+++ b/addons/html_editor/static/src/main/media/media_dialog/image_selector.xml
@@ -1,10 +1,11 @@
-
+
-
@@ -47,6 +48,7 @@
src="attachment.thumbnail_src or attachment.image_src"
name="attachment.name"
title="attachment.name"
+ unselectable = "!!attachment.unselectable"
altDescription="attachment.altDescription"
model="attachment.res_model"
minRowHeight="MIN_ROW_HEIGHT"
diff --git a/addons/html_editor/static/src/main/media/media_dialog/media_dialog.js b/addons/html_editor/static/src/main/media/media_dialog/media_dialog.js
index acf447e392ada..72cef1ab5924a 100644
--- a/addons/html_editor/static/src/main/media/media_dialog/media_dialog.js
+++ b/addons/html_editor/static/src/main/media/media_dialog/media_dialog.js
@@ -309,9 +309,9 @@ export class MediaDialog extends Component {
if (saveSelectedMedia) {
const elements = await this.renderMedia(selectedMedia);
if (this.props.multiImages) {
- this.props.save(elements);
+ await this.props.save(elements, selectedMedia, this.state.activeTab);
} else {
- this.props.save(elements[0]);
+ await this.props.save(elements[0], selectedMedia, this.state.activeTab);
}
}
this.props.close();
diff --git a/addons/html_editor/static/src/main/media/media_dialog/video_selector.js b/addons/html_editor/static/src/main/media/media_dialog/video_selector.js
index 606c9e236ea5c..d11622940cba0 100644
--- a/addons/html_editor/static/src/main/media/media_dialog/video_selector.js
+++ b/addons/html_editor/static/src/main/media/media_dialog/video_selector.js
@@ -37,7 +37,7 @@ export class VideoSelector extends Component {
errorMessages: Function,
vimeoPreviewIds: { type: Array, optional: true },
isForBgVideo: { type: Boolean, optional: true },
- media: { type: Object, optional: true },
+ media: { validate: (p) => p.nodeType === Node.ELEMENT_NODE, optional: true },
"*": true,
};
static defaultProps = {
diff --git a/addons/html_editor/static/src/main/media/media_plugin.js b/addons/html_editor/static/src/main/media/media_plugin.js
index 3e91f8ecefdd1..013af4a1a004e 100644
--- a/addons/html_editor/static/src/main/media/media_plugin.js
+++ b/addons/html_editor/static/src/main/media/media_plugin.js
@@ -1,11 +1,16 @@
import { Plugin } from "@html_editor/plugin";
import {
ICON_SELECTOR,
+ MEDIA_SELECTOR,
isIconElement,
isProtected,
isProtecting,
} from "@html_editor/utils/dom_info";
-import { backgroundImageCssToParts, backgroundImagePartsToCss } from "@html_editor/utils/image";
+import {
+ backgroundImageCssToParts,
+ backgroundImagePartsToCss,
+ getImageSrc,
+} from "@html_editor/utils/image";
import { _t } from "@web/core/l10n/translation";
import { rpc } from "@web/core/network/rpc";
import { MediaDialog } from "./media_dialog/media_dialog";
@@ -13,8 +18,6 @@ import { rightPos } from "@html_editor/utils/position";
import { withSequence } from "@html_editor/utils/resource";
import { closestElement } from "@html_editor/utils/dom_traversal";
-const MEDIA_SELECTOR = `${ICON_SELECTOR} , .o_image, .media_iframe_video`;
-
/**
* @typedef { Object } MediaShared
* @property { MediaPlugin['savePendingImages'] } savePendingImages
@@ -23,7 +26,7 @@ const MEDIA_SELECTOR = `${ICON_SELECTOR} , .o_image, .media_iframe_video`;
export class MediaPlugin extends Plugin {
static id = "media";
static dependencies = ["selection", "history", "dom", "dialog"];
- static shared = ["savePendingImages"];
+ static shared = ["savePendingImages", "openMediaDialog"];
static defaultConfig = {
allowImage: true,
allowMediaDialogVideo: true,
@@ -69,10 +72,11 @@ export class MediaPlugin extends Plugin {
clipboard_text_processors: (text) => text.replace(/\u200B/g, ""),
selectors_for_feff_providers: () => ICON_SELECTOR,
+ before_save_handlers: this.savePendingImages.bind(this),
};
- get recordInfo() {
- return this.config.getRecordInfo ? this.config.getRecordInfo() : {};
+ getRecordInfo(editableEl = null) {
+ return this.config.getRecordInfo ? this.config.getRecordInfo(editableEl) : {};
}
replaceImage() {
@@ -97,7 +101,9 @@ export class MediaPlugin extends Plugin {
"contenteditable",
el.hasAttribute("contenteditable") ? el.getAttribute("contenteditable") : "false"
);
- if (isIconElement(el)) {
+ // Do not update the text if it's already OK to avoid recording a
+ // mutation on Firefox. (Chrome filters them out.)
+ if (isIconElement(el) && el.textContent !== "\u200B") {
el.textContent = "\u200B";
}
}
@@ -148,8 +154,8 @@ export class MediaPlugin extends Plugin {
this.dependencies.history.addStep();
}
- openMediaDialog(params = {}) {
- const { resModel, resId, field, type } = this.recordInfo;
+ openMediaDialog(params = {}, editableEl = null) {
+ const { resModel, resId, field, type } = this.getRecordInfo(editableEl);
const mediaDialogClosedPromise = this.dependencies.dialog.addDialog(MediaDialog, {
resModel,
resId,
@@ -164,40 +170,27 @@ export class MediaPlugin extends Plugin {
onAttachmentChange: this.config.onAttachmentChange || (() => {}),
noVideos: !this.config.allowMediaDialogVideo,
noImages: !this.config.allowImage,
- extraTabs: this.getResource("media_dialog_extra_tabs"),
+ extraTabs: this.getResource("media_dialog_extra_tabs").filter(
+ (tab) => !(tab.id === "DOCUMENTS" && params.noDocuments)
+ ),
...this.config.mediaModalParams,
...params,
});
return mediaDialogClosedPromise;
}
- async savePendingImages() {
- const editableEl = this.editable;
- const { resModel, resId } = this.recordInfo;
+ async savePendingImages(editableEl = this.editable) {
+ const { resModel, resId } = this.getRecordInfo(editableEl);
// When saving a webp, o_b64_image_to_save is turned into
// o_modified_image_to_save by saveB64Image to request the saving
// of the pre-converted webp resizes and all the equivalent jpgs.
const b64Proms = [...editableEl.querySelectorAll(".o_b64_image_to_save")].map(
async (el) => {
- const dirtyEditable = el.closest(".o_dirty");
- if (dirtyEditable && dirtyEditable !== editableEl) {
- // Do nothing as there is an editable element closer to the
- // image that will perform the `saveB64Image()` call with
- // the correct "resModel" and "resId" parameters.
- return;
- }
await this.saveB64Image(el, resModel, resId);
}
);
const modifiedProms = [...editableEl.querySelectorAll(".o_modified_image_to_save")].map(
async (el) => {
- const dirtyEditable = el.closest(".o_dirty");
- if (dirtyEditable && dirtyEditable !== editableEl) {
- // Do nothing as there is an editable element closer to the
- // image that will perform the `saveModifiedImage()` call
- // with the correct "resModel" and "resId" parameters.
- return;
- }
await this.saveModifiedImage(el, resModel, resId);
}
);
@@ -289,7 +282,7 @@ export class MediaPlugin extends Plugin {
// Generate alternate sizes and format for reports.
altData = {};
const image = document.createElement("img");
- image.src = isBackground ? el.dataset.bgSrc : el.getAttribute("src");
+ image.src = getImageSrc(el);
await new Promise((resolve) => image.addEventListener("load", resolve));
const originalSize = Math.max(image.width, image.height);
const smallerSizes = [1024, 512, 256, 128].filter((size) => size < originalSize);
@@ -327,7 +320,7 @@ export class MediaPlugin extends Plugin {
{
res_model: resModel,
res_id: parseInt(resId),
- data: (isBackground ? el.dataset.bgSrc : el.getAttribute("src")).split(",")[1],
+ data: getImageSrc(el).split(",")[1],
alt_data: altData,
mimetype: isBackground
? el.dataset.mimetype
@@ -341,7 +334,6 @@ export class MediaPlugin extends Plugin {
parts.url = `url('${newAttachmentSrc}')`;
const combined = backgroundImagePartsToCss(parts);
el.style["background-image"] = combined;
- delete el.dataset.bgSrc;
} else {
el.setAttribute("src", newAttachmentSrc);
}
diff --git a/addons/html_editor/static/src/main/movenode_plugin.js b/addons/html_editor/static/src/main/movenode_plugin.js
index f047e371c7c5b..96cb7960002cd 100644
--- a/addons/html_editor/static/src/main/movenode_plugin.js
+++ b/addons/html_editor/static/src/main/movenode_plugin.js
@@ -445,10 +445,11 @@ export class MoveNodePlugin extends Plugin {
}
}
isNodeMovable(node) {
- return (
- node.parentElement?.getAttribute("contentEditable") === "true" &&
- !node.matches(this.getResource("move_node_blacklist_selectors").join(", "))
- );
+ const blacklistSelectors = this.getResource("move_node_blacklist_selectors").join(", ");
+ if (blacklistSelectors && node.matches(blacklistSelectors)) {
+ return false;
+ }
+ return (node.parentElement?.getAttribute("contentEditable") === "true");
}
}
diff --git a/addons/html_editor/static/src/main/position_plugin.js b/addons/html_editor/static/src/main/position_plugin.js
index 683024238da76..772e0205f26ca 100644
--- a/addons/html_editor/static/src/main/position_plugin.js
+++ b/addons/html_editor/static/src/main/position_plugin.js
@@ -22,8 +22,8 @@ export class PositionPlugin extends Plugin {
this.resizeObserver.observe(this.document.body);
this.resizeObserver.observe(this.editable);
this.addDomListener(window, "resize", this.layoutGeometryChange);
- if (this.document.defaultView !== window) {
- this.addDomListener(this.document.defaultView, "resize", this.layoutGeometryChange);
+ if (this.window !== window) {
+ this.addDomListener(this.window, "resize", this.layoutGeometryChange);
}
const scrollableElements = [this.editable, ...ancestors(this.editable)].filter(
(node) => couldBeScrollableX(node) || couldBeScrollableY(node)
diff --git a/addons/html_editor/static/src/main/power_buttons_plugin.js b/addons/html_editor/static/src/main/power_buttons_plugin.js
index 2f07bb9c338ad..370dcb09ca05a 100644
--- a/addons/html_editor/static/src/main/power_buttons_plugin.js
+++ b/addons/html_editor/static/src/main/power_buttons_plugin.js
@@ -107,9 +107,9 @@ export class PowerButtonsPlugin extends Plugin {
updatePowerButtons() {
this.powerButtonsContainer.classList.add("d-none");
- const { editableSelection, documentSelectionIsInEditable } =
+ const { editableSelection, currentSelectionIsInEditable } =
this.dependencies.selection.getSelectionData();
- if (!documentSelectionIsInEditable) {
+ if (!currentSelectionIsInEditable) {
return;
}
const block = closestBlock(editableSelection.anchorNode);
@@ -141,15 +141,16 @@ export class PowerButtonsPlugin extends Plugin {
}
getPlaceholderWidth(block) {
- this.dependencies.history.disableObserver();
- const clone = block.cloneNode(true);
- clone.innerText = clone.getAttribute("o-we-hint-text");
- clone.style.width = "fit-content";
- clone.style.visibility = "hidden";
- this.editable.appendChild(clone);
- const { width } = clone.getBoundingClientRect();
- this.editable.removeChild(clone);
- this.dependencies.history.enableObserver();
+ let width;
+ this.dependencies.history.ignoreDOMMutations(() => {
+ const clone = block.cloneNode(true);
+ clone.innerText = clone.getAttribute("o-we-hint-text");
+ clone.style.width = "fit-content";
+ clone.style.visibility = "hidden";
+ this.editable.appendChild(clone);
+ width = clone.getBoundingClientRect().width;
+ this.editable.removeChild(clone);
+ });
return width;
}
diff --git a/addons/html_editor/static/src/main/toolbar/toolbar.xml b/addons/html_editor/static/src/main/toolbar/toolbar.xml
index 34cb926ca621d..c3122eee89857 100644
--- a/addons/html_editor/static/src/main/toolbar/toolbar.xml
+++ b/addons/html_editor/static/src/main/toolbar/toolbar.xml
@@ -2,7 +2,7 @@