odoo.define('mail/static/src/components/attachment_viewer/attachment_viewer.js', function (require) { 'use strict'; const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js'); const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); const { Component, QWeb } = owl; const { useRef } = owl.hooks; const MIN_SCALE = 0.5; const SCROLL_ZOOM_STEP = 0.1; const ZOOM_STEP = 0.5; class AttachmentViewer extends Component { /** * @override */ constructor(...args) { super(...args); this.MIN_SCALE = MIN_SCALE; useShouldUpdateBasedOnProps(); useStore(props => { const attachmentViewer = this.env.models['mail.attachment_viewer'].get(props.localId); return { attachment: attachmentViewer && attachmentViewer.attachment ? attachmentViewer.attachment.__state : undefined, attachments: attachmentViewer ? attachmentViewer.attachments.map(attachment => attachment.__state) : [], attachmentViewer: attachmentViewer ? attachmentViewer.__state : undefined, }; }); /** * Used to ensure that the ref is always up to date, which seems to be needed if the element * has a t-key, which was added to force the rendering of a new element when the src of the image changes. * This was made to remove the display of the previous image as soon as the src changes. */ this._getRefs = useRefs(); /** * Determine whether the user is currently dragging the image. * This is useful to determine whether a click outside of the image * should close the attachment viewer or not. */ this._isDragging = false; /** * Reference of the zoomer node. Useful to apply translate * transformation on image visualisation. */ this._zoomerRef = useRef('zoomer'); /** * Tracked translate transformations on image visualisation. This is * not observed with `useStore` because they are used to compute zoomer * style, and this is changed directly on zoomer for performance * reasons (overhead of making vdom is too significant for each mouse * position changes while dragging) */ this._translate = { x: 0, y: 0, dx: 0, dy: 0 }; this._onClickGlobal = this._onClickGlobal.bind(this); } mounted() { this.el.focus(); this._handleImageLoad(); document.addEventListener('click', this._onClickGlobal); } /** * When a new image is displayed, show a spinner until it is loaded. */ patched() { this._handleImageLoad(); } willUnmount() { document.removeEventListener('click', this._onClickGlobal); } //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @returns {mail.attachment_viewer} */ get attachmentViewer() { return this.env.models['mail.attachment_viewer'].get(this.props.localId); } /** * Compute the style of the image (scale + rotation). * * @returns {string} */ get imageStyle() { const attachmentViewer = this.attachmentViewer; let style = `transform: ` + `scale3d(${attachmentViewer.scale}, ${attachmentViewer.scale}, 1) ` + `rotate(${attachmentViewer.angle}deg);`; if (attachmentViewer.angle % 180 !== 0) { style += `` + `max-height: ${window.innerWidth}px; ` + `max-width: ${window.innerHeight}px;`; } else { style += `` + `max-height: 100%; ` + `max-width: 100%;`; } return style; } /** * Mandatory method for dialog components. * Prevent closing the dialog when clicking on the mask when the user is * currently dragging the image. * * @returns {boolean} */ isCloseable() { return !this._isDragging; } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Close the dialog with this attachment viewer. * * @private */ _close() { this.attachmentViewer.close(); } /** * Download the attachment. * * @private */ _download() { const id = this.attachmentViewer.attachment.id; this.env.services.navigate(`/web/content/ir.attachment/${id}/datas`, { download: true }); } /** * Determine whether the current image is rendered for the 1st time, and if * that's the case, display a spinner until loaded. * * @private */ _handleImageLoad() { if (!this.attachmentViewer || !this.attachmentViewer.attachment) { return; } const refs = this._getRefs(); const image = refs[`image_${this.attachmentViewer.attachment.id}`]; if ( this.attachmentViewer.attachment.fileType === 'image' && (!image || !image.complete) ) { this.attachmentViewer.update({ isImageLoading: true }); } } /** * Display the previous attachment in the list of attachments. * * @private */ _next() { const attachmentViewer = this.attachmentViewer; const index = attachmentViewer.attachments.findIndex(attachment => attachment === attachmentViewer.attachment ); const nextIndex = (index + 1) % attachmentViewer.attachments.length; attachmentViewer.update({ attachment: [['link', attachmentViewer.attachments[nextIndex]]], }); } /** * Display the previous attachment in the list of attachments. * * @private */ _previous() { const attachmentViewer = this.attachmentViewer; const index = attachmentViewer.attachments.findIndex(attachment => attachment === attachmentViewer.attachment ); const nextIndex = index === 0 ? attachmentViewer.attachments.length - 1 : index - 1; attachmentViewer.update({ attachment: [['link', attachmentViewer.attachments[nextIndex]]], }); } /** * Prompt the browser print of this attachment. * * @private */ _print() { const printWindow = window.open('about:blank', '_new'); printWindow.document.open(); printWindow.document.write(` `); printWindow.document.close(); } /** * Rotate the image by 90 degrees to the right. * * @private */ _rotate() { this.attachmentViewer.update({ angle: this.attachmentViewer.angle + 90 }); } /** * Stop dragging interaction of the user. * * @private */ _stopDragging() { this._isDragging = false; this._translate.x += this._translate.dx; this._translate.y += this._translate.dy; this._translate.dx = 0; this._translate.dy = 0; this._updateZoomerStyle(); } /** * Update the style of the zoomer based on translate transformation. Changes * are directly applied on zoomer, instead of triggering re-render and * defining them in the template, for performance reasons. * * @private * @returns {string} */ _updateZoomerStyle() { const attachmentViewer = this.attachmentViewer; const refs = this._getRefs(); const image = refs[`image_${this.attachmentViewer.attachment.id}`]; const tx = image.offsetWidth * attachmentViewer.scale > this._zoomerRef.el.offsetWidth ? this._translate.x + this._translate.dx : 0; const ty = image.offsetHeight * attachmentViewer.scale > this._zoomerRef.el.offsetHeight ? this._translate.y + this._translate.dy : 0; if (tx === 0) { this._translate.x = 0; } if (ty === 0) { this._translate.y = 0; } this._zoomerRef.el.style = `transform: ` + `translate(${tx}px, ${ty}px)`; } /** * Zoom in the image. * * @private * @param {Object} [param0={}] * @param {boolean} [param0.scroll=false] */ _zoomIn({ scroll = false } = {}) { this.attachmentViewer.update({ scale: this.attachmentViewer.scale + (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP), }); this._updateZoomerStyle(); } /** * Zoom out the image. * * @private * @param {Object} [param0={}] * @param {boolean} [param0.scroll=false] */ _zoomOut({ scroll = false } = {}) { if (this.attachmentViewer.scale === MIN_SCALE) { return; } const unflooredAdaptedScale = ( this.attachmentViewer.scale - (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP) ); this.attachmentViewer.update({ scale: Math.max(MIN_SCALE, unflooredAdaptedScale), }); this._updateZoomerStyle(); } /** * Reset the zoom scale of the image. * * @private */ _zoomReset() { this.attachmentViewer.update({ scale: 1 }); this._updateZoomerStyle(); } //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Called when clicking on mask of attachment viewer. * * @private * @param {MouseEvent} ev */ _onClick(ev) { if (this._isDragging) { return; } // TODO: clicking on the background should probably be handled by the dialog? // task-2092965 this._close(); } /** * Called when clicking on cross icon. * * @private * @param {MouseEvent} ev */ _onClickClose(ev) { this._close(); } /** * Called when clicking on download icon. * * @private * @param {MouseEvent} ev */ _onClickDownload(ev) { ev.stopPropagation(); this._download(); } /** * @private * @param {MouseEvent} ev */ _onClickGlobal(ev) { if (!this._isDragging) { return; } ev.stopPropagation(); this._stopDragging(); } /** * Called when clicking on the header. Stop propagation of event to prevent * closing the dialog. * * @private * @param {MouseEvent} ev */ _onClickHeader(ev) { ev.stopPropagation(); } /** * Called when clicking on image. Stop propagation of event to prevent * closing the dialog. * * @private * @param {MouseEvent} ev */ _onClickImage(ev) { if (this._isDragging) { return; } ev.stopPropagation(); } /** * Called when clicking on next icon. * * @private * @param {MouseEvent} ev */ _onClickNext(ev) { ev.stopPropagation(); this._next(); } /** * Called when clicking on previous icon. * * @private * @param {MouseEvent} ev */ _onClickPrevious(ev) { ev.stopPropagation(); this._previous(); } /** * Called when clicking on print icon. * * @private * @param {MouseEvent} ev */ _onClickPrint(ev) { ev.stopPropagation(); this._print(); } /** * Called when clicking on rotate icon. * * @private * @param {MouseEvent} ev */ _onClickRotate(ev) { ev.stopPropagation(); this._rotate(); } /** * Called when clicking on embed video player. Stop propagation to prevent * closing the dialog. * * @private * @param {MouseEvent} ev */ _onClickVideo(ev) { ev.stopPropagation(); } /** * Called when clicking on zoom in icon. * * @private * @param {MouseEvent} ev */ _onClickZoomIn(ev) { ev.stopPropagation(); this._zoomIn(); } /** * Called when clicking on zoom out icon. * * @private * @param {MouseEvent} ev */ _onClickZoomOut(ev) { ev.stopPropagation(); this._zoomOut(); } /** * Called when clicking on reset zoom icon. * * @private * @param {MouseEvent} ev */ _onClickZoomReset(ev) { ev.stopPropagation(); this._zoomReset(); } /** * @private * @param {KeyboardEvent} ev */ _onKeydown(ev) { switch (ev.key) { case 'ArrowRight': this._next(); break; case 'ArrowLeft': this._previous(); break; case 'Escape': this._close(); break; case 'q': this._close(); break; case 'r': this._rotate(); break; case '+': this._zoomIn(); break; case '-': this._zoomOut(); break; case '0': this._zoomReset(); break; default: return; } ev.stopPropagation(); } /** * Called when new image has been loaded * * @private * @param {Event} ev */ _onLoadImage(ev) { ev.stopPropagation(); this.attachmentViewer.update({ isImageLoading: false }); } /** * @private * @param {DragEvent} ev */ _onMousedownImage(ev) { if (this._isDragging) { return; } if (ev.button !== 0) { return; } ev.stopPropagation(); this._isDragging = true; this._dragstartX = ev.clientX; this._dragstartY = ev.clientY; } /** * @private * @param {DragEvent} */ _onMousemoveView(ev) { if (!this._isDragging) { return; } this._translate.dx = ev.clientX - this._dragstartX; this._translate.dy = ev.clientY - this._dragstartY; this._updateZoomerStyle(); } /** * @private * @param {Event} ev */ _onWheelImage(ev) { ev.stopPropagation(); if (!this.el) { return; } if (ev.deltaY > 0) { this._zoomOut({ scroll: true }); } else { this._zoomIn({ scroll: true }); } } } Object.assign(AttachmentViewer, { props: { localId: String, }, template: 'mail.AttachmentViewer', }); QWeb.registerComponent('AttachmentViewer', AttachmentViewer); return AttachmentViewer; });