diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/attachment_viewer | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/attachment_viewer')
3 files changed, 889 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js new file mode 100644 index 00000000..30755fd9 --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js @@ -0,0 +1,598 @@ +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(` + <html> + <head> + <script> + function onloadImage() { + setTimeout('printImage()', 10); + } + function printImage() { + window.print(); + window.close(); + } + </script> + </head> + <body onload='onloadImage()'> + <img src="${this.attachmentViewer.attachment.defaultSource}" alt=""/> + </body> + </html>`); + 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; + +}); diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss new file mode 100644 index 00000000..54f00c1a --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss @@ -0,0 +1,198 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentViewer { + display: flex; + width: 100%; + height: 100%; + flex-flow: column; + align-items: center; + z-index: -1; +} + +.o_AttachmentViewer_buttonNavigation { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + top: 50%; + transform: translateY(-50%); +} + +.o_AttachmentViewer_buttonNavigationNext { + right: 15px; + + > .fa { + margin: 1px 0 0 1px; // not correctly centered for some reasons + } +} + +.o_AttachmentViewer_buttonNavigationPrevious { + left: 15px; + + > .fa { + margin: 1px 1px 0 0; // not correctly centered for some reasons + } +} + +.o_AttachmentViewer_header { + display: flex; + height: $o-navbar-height; + align-items: center; + padding: 0 15px; + width: 100%; +} + +.o_AttachmentViewer_headerItem { + margin: 0 5px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } +} + +.o_AttachmentViewer_loading { + position: absolute; +} + +.o_AttachmentViewer_main { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: -1; + padding: 45px 0; + + &.o_with_img { + overflow: hidden; + } +} + +.o_AttachmentViewer_toolbar { + position: absolute; + bottom: 45px; + transform: translateY(100%); + display: flex; +} + +.o_AttachmentViewer_toolbarButton { + padding: 8px; +} + +.o_AttachmentViewer_viewImage { + max-height: 100%; + max-width: 100%; +} + +.o_AttachmentViewer_viewIframe { + width: 90%; + height: 100%; +} + +.o_AttachmentViewer_viewVideo { + width: 75%; + height: 75%; +} + +.o_AttachmentViewer_zoomer { + position: absolute; + padding: 45px 0; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_AttachmentViewer { + outline: none; +} + +.o_AttachmentViewer_buttonNavigation { + color: gray('400'); + background-color: lighten(black, 15%); + border-radius: 100%; + cursor: pointer; + + &:hover { + color: lighten(gray('400'), 15%); + background-color: black; + } +} + +.o_AttachmentViewer_header { + background-color: rgba(0, 0, 0, 0.7); + color: gray('400'); +} + +.o_AttachmentViewer_headerItemButton { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + color: lighten(gray('400'), 15%); + } +} + +.o_AttachmentViewer_headerItemButtonClose { + cursor: pointer; + font-size: 1.3rem; +} + +.o_AttachmentViewer_toolbar { + cursor: pointer; +} + +.o_AttachmentViewer_toolbarButton { + background-color: lighten(black, 15%); + + &.o_disabled { + cursor: not-allowed; + filter: brightness(1.3); + } + + &:not(.o_disabled) { + color: gray('400'); + cursor: pointer; + + &:hover { + background-color: black; + color: lighten(gray('400'), 15%); + } + } +} + +.o_AttachmentViewer_view { + background-color: black; + box-shadow: 0 0 40px black; + outline: none; + border: none; + + &.o_text { + background-color: white; + } +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_AttachmentViewer_viewImage { + transition: transform 0.3s ease; +} + diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml new file mode 100644 index 00000000..8791bd09 --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentViewer" owl="1"> + <div class="o_AttachmentViewer" t-on-click="_onClick" t-on-keydown="_onKeydown" tabindex="0"> + <div class="o_AttachmentViewer_header" t-on-click="_onClickHeader"> + <t t-if="attachmentViewer.attachment.fileType"> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_icon"> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <i class="fa fa-picture-o" role="img" title="Image"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'"> + <i class="fa fa-file-text" role="img" title="PDF file"/> + </t> + <t t-if="attachmentViewer.attachment.isTextFile"> + <i class="fa fa-file-text" role="img" title="Text file"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'video'"> + <i class="fa fa-video-camera" role="img" title="Video"/> + </t> + </div> + </t> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_name"> + <t t-esc="attachmentViewer.attachment.displayName"/> + </div> + <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton" t-on-click="_onClickDownload" role="button" title="Download"> + <i class="fa fa-download fa-fw" role="img"/> + </div> + <div class="o-autogrow"/> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton o_AttachmentViewer_headerItemButtonClose" t-on-click="_onClickClose" role="button" title="Close (Esc)" aria-label="Close"> + <i class="fa fa-fw fa-times" role="img"/> + </div> + </div> + <div class="o_AttachmentViewer_main" t-att-class="{ o_with_img: attachmentViewer.attachment.fileType === 'image' }" t-on-mousemove="_onMousemoveView"> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <div class="o_AttachmentViewer_zoomer" t-ref="zoomer"> + <t t-if="attachmentViewer.isImageLoading"> + <div class="o_AttachmentViewer_loading"> + <i class="fa fa-3x fa-circle-o-notch fa-fw fa-spin" role="img" title="Loading"/> + </div> + </t> + <img class="o_AttachmentViewer_view o_AttachmentViewer_viewImage" t-on-click="_onClickImage" t-on-mousedown="_onMousedownImage" t-on-wheel="_onWheelImage" t-on-load="_onLoadImage" t-att-src="attachmentViewer.attachment.defaultSource" t-att-style="imageStyle" draggable="false" alt="Viewer" t-key="'image_' + attachmentViewer.attachment.id" t-ref="image_{{ attachmentViewer.attachment.id }}"/> + </div> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'"> + <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_viewPdf" t-att-src="attachmentViewer.attachment.defaultSource"/> + </t> + <t t-if="attachmentViewer.attachment.isTextFile"> + <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_text" t-att-src="attachmentViewer.attachment.defaultSource"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'youtu'"> + <iframe allow="autoplay; encrypted-media" class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_youtube" t-att-src="attachmentViewer.attachment.defaultSource" height="315" width="560"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'video'"> + <video class="o_AttachmentViewer_view o_AttachmentViewer_viewVideo" t-on-click="_onClickVideo" controls="controls"> + <source t-att-data-type="attachmentViewer.attachment.mimetype" t-att-src="attachmentViewer.attachment.defaultSource"/> + </video> + </t> + </div> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <div class="o_AttachmentViewer_toolbar" role="toolbar"> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickZoomIn" title="Zoom In (+)" role="button"> + <i class="fa fa-fw fa-plus" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === 1 }" t-on-click="_onClickZoomReset" role="button" title="Reset Zoom (0)"> + <i class="fa fa-fw fa-search" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === MIN_SCALE }" t-on-click="_onClickZoomOut" title="Zoom Out (-)" role="button"> + <i class="fa fa-fw fa-minus" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickRotate" title="Rotate (r)" role="button"> + <i class="fa fa-fw fa-repeat" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickPrint" title="Print" role="button"> + <i class="fa fa-fw fa-print" role="img"/> + </div> + <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_toolbarButton" t-on-click="_onClickDownload" title="Download" role="button"> + <i class="fa fa-download fa-fw" role="img"/> + </div> + </div> + </t> + <t t-if="attachmentViewer.attachments.length > 1"> + <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationPrevious" t-on-click="_onClickPrevious" title="Previous (Left-Arrow)" role="button"> + <span class="fa fa-chevron-left" role="img"/> + </div> + <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationNext" t-on-click="_onClickNext" title="Next (Right-Arrow)" role="button"> + <span class="fa fa-chevron-right" role="img"/> + </div> + </t> + </div> + </t> + +</templates> |
