/** * EGroupware eTemplate2 - JS object implementing expose view of media and a gallery view * * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package etemplate * @subpackage api * @link https://www.egroupware.org * @author Hadi Nategh */ /*egw:uses /vendor/bower-asset/jquery/dist/jquery.js; /api/js/jquery/blueimp/js/blueimp-gallery.min.js; */ "use strict"; import {et2_createWidget} from "./et2_core_widget"; import {et2_dialog} from "./et2_widget_dialog"; import {egw, egw_get_file_editor_prefered_mimes} from "../jsapi/egw_global"; import type {et2_nextmatch} from "./et2_extension_nextmatch"; import {ET2_DATAVIEW_STEPSIZE} from "./et2_dataview_controller"; type Constructor = new (...args: any[]) => T; /** * This function extends the given widget with blueimp gallery plugin * * @param {type} widget * @returns {widget} */ export function expose(Base: TBase) { "use strict"; // Minimum data to qualify as an image and not cause errors const IMAGE_DEFAULT = { title: egw.lang('loading'), href: '', type: 'image/png', thumbnail: '', loading: true }; // For filtering to only show things we can handle const MIME_REGEX = (navigator.userAgent.match(/(MSIE|Trident)/)) ? // IE only supports video/mp4 mime type new RegExp(/(video\/mp4)|(image\/:*(?!tif|x-xcf|pdf))|(audio\/:*)/) : new RegExp(/(video\/(mp4|ogg|webm))|(image\/:*(?!tif|x-xcf|pdf))|(audio\/:*)/); const MIME_AUDIO_REGEX = new RegExp(/(audio\/:*)/); // open office document mime type currently supported by webodf editor const MIME_ODF_REGEX = new RegExp(/application\/vnd\.oasis\.opendocument\.text/); // Only one gallery var gallery = null; /** * See if the current widget is in a nextmatch, as this allows us to display * thumbnails underneath * * @param {et2_IExposable} widget * @returns {et2_nextmatch | null} */ var find_nextmatch = function (widget) { let current = widget; let nextmatch = null; while (nextmatch == null && current) { current = current.getParent(); if (typeof current != 'undefined' && current.instanceOf(et2_nextmatch)) { nextmatch = current; } } // No nextmatch, or nextmatch not quite ready // At the moment only filemanger nm would work // as gallery, thus we disable other nestmatches // to build up gallery but filemanager if (nextmatch == null || nextmatch.controller == null || !nextmatch.dom_id.match(/filemanager/, 'ig')) return null; return nextmatch; }; /** * Read images out of the data for the nextmatch * * @param {et2_nextmatch} nm * @param {Object[]} images * @param {number} start_at * @returns {undefined} */ var read_from_nextmatch = function (nm, images, start_at) { if (!start_at) start_at = 0; let image_index = start_at; let stop = Math.max.apply(null, Object.keys(nm.controller._indexMap)); for (let i = start_at; i <= stop; i++) { if (!nm.controller._indexMap[i] || !nm.controller._indexMap[i].uid) { // Returning instead of using IMAGE_DEFAULT means we stop as // soon as a hole is found, instead of getting everything that is // available. The gallery can't fill in the holes. images[image_index++] = IMAGE_DEFAULT; continue; } let uid = nm.controller._indexMap[i].uid; if (!uid) continue; let data = egw.dataGetUIDdata(uid); if (data && data.data && data.data.mime && MIME_REGEX.test(data.data.mime) && !MIME_AUDIO_REGEX.test(data.data.mime)) { let media = this.getMedia(data.data); images[image_index++] = jQuery.extend({}, data.data, media[0]); } } }; /** * Set a particular index/image in the gallery instead of just appending * it to the end * * @param {integer} index * @param {Object} image * @returns {undefined} */ var set_slide = function (index, image) { let active = (index == gallery.index); // Pad with blanks until length is right while (index > gallery.getNumber()) { gallery.add([jQuery.extend({}, IMAGE_DEFAULT)]); } // Don't bother with adding a default, we just did that if (image.loading) { //Add load class if it's really a slide with error if (gallery.slidesContainer.find('[data-index="' + index + '"]').hasClass(gallery.options.slideErrorClass)) jQuery(gallery.slides[index]) .addClass(gallery.options.slideLoadingClass) .removeClass(gallery.options.slideErrorClass); return; } // Remove the loading class if the slide is loaded else { jQuery(gallery.slides[index]).removeClass(gallery.options.slideLoadingClass); } // Just use add to let gallery create everything it needs let new_index = gallery.num; gallery.add([image]); // Move it to where we want it. // Gallery uses arrays and indexes and has several internal variables // that need to be updated. // // list gallery.list[index] = gallery.list[new_index]; gallery.list.splice(new_index, 1); // indicators & slides let dom_nodes = ['indicators', 'slides']; for (let i in dom_nodes) { let var_name = dom_nodes[i]; // Remove old one from DOM jQuery(gallery[var_name][index]).remove(); // Move new one into it's place in gallery gallery[var_name][index] = gallery[var_name][new_index]; // Move into place in DOM let node = jQuery(gallery[var_name][index]); node.attr('data-index', index) .insertAfter(jQuery("[data-index='" + (index - 1) + "']", node.parent())); if (active) node.addClass(gallery.options.activeIndicatorClass); gallery[var_name].splice(new_index, 1); } if (active) { gallery.activeIndicator = jQuery(gallery.indicators[index]); } // positions gallery.positions[index] = active ? 0 : (index > gallery.index ? gallery.slideWidth : -gallery.slideWidth); gallery.positions.splice(new_index, 1); // elements - removing will allow to re-do the slide if (gallery.elements[index]) { delete gallery.elements[index]; gallery.loadElement(index); } // Remove the one we just added gallery.num -= 1; }; return class exposable extends Base { public mime_regexp: RegExp; private mime_odf_regex: RegExp; private expose_options: { container: string; toggleControlsOnReturn: boolean; closeOnSwipeUpOrDown: boolean; clearSlides: boolean; onopen: any; emulateTouchEvents: boolean; onslideend: (index, slide) => void; rightEdgeClass: string; thumbnailWithImgTag: boolean; onslidecomplete: (index, slide) => void; continuous: boolean; startSlideshow: boolean; fullscreenClass: string; onslide: (index, slide) => void; playingClass: string; slideClass: string; urlProperty: string; closeOnEscape: boolean; singleClass: string; slideErrorClass: string; index: number; preloadRange: number; slideContentClass: string; onclosed: any; hidePageScrollbars: boolean; displayTransition: boolean; indicatorContainer: string; disableScroll: boolean; unloadElements: boolean; nextClass: string; stopTouchEventsPropagation: boolean; transitionSpeed: number; carousel: boolean; titleProperty: string; prevClass: string; typeProperty: string; enableKeyboardNavigation: boolean; slidesContainer: string; closeOnSlideClick: boolean; stretchImages: boolean; onclose: any; onopened: any; playPauseClass: string; thumbnailProperty: string; titleElement: string; slideLoadingClass: string; toggleSlideshowOnSpace: boolean; thumbnailIndicators: boolean; activeIndicatorClass: string; displayClass: string; closeClass: string; slideshowInterval: number; toggleClass: string; hideControlsOnSlideshow: boolean; controlsClass: string; toggleFullscreenOnSlideShow: boolean; leftEdgeClass: string; slideshowTransitionSpeed: undefined }; constructor(...args: any[]) { // Call the inherited constructor super(...args); this.mime_regexp = MIME_REGEX; this.mime_audio_regexp = MIME_AUDIO_REGEX; this.mime_odf_regex = MIME_ODF_REGEX; let self = this; this.expose_options = { // The Id, element or querySelector of the gallery widget: container: '#blueimp-gallery', // The tag name, Id, element or querySelector of the slides container: slidesContainer: 'div', // The tag name, Id, element or querySelector of the title element: titleElement: 'h3', // The class to add when the gallery is visible: displayClass: 'blueimp-gallery-display', // The class to add when the gallery controls are visible: controlsClass: 'blueimp-gallery-controls', // The class to add when the gallery only displays one element: singleClass: 'blueimp-gallery-single', // The class to add when the left edge has been reached: leftEdgeClass: 'blueimp-gallery-left', // The class to add when the right edge has been reached: rightEdgeClass: 'blueimp-gallery-right', // The class to add when the automatic slideshow is active: playingClass: 'blueimp-gallery-playing', // The class for all slides: slideClass: 'slide', // The slide class for loading elements: slideLoadingClass: '', // The slide class for elements that failed to load: slideErrorClass: 'slide-error', // The class for the content element loaded into each slide: slideContentClass: 'slide-content', // The class for the "toggle" control: toggleClass: 'toggle', // The class for the "prev" control: prevClass: 'prev', // The class for the "next" control: nextClass: 'next', // The class for the "close" control: closeClass: 'close', // The class for the "play-pause" toggle control: playPauseClass: 'play-pause', // The class to add for fullscreen button option fullscreenClass: 'fullscreen', // The list object property (or data attribute) with the object type: typeProperty: 'type', // The list object property (or data attribute) with the object title: titleProperty: 'title', // The list object property (or data attribute) with the object URL: urlProperty: 'href', // The gallery listens for transitionend events before triggering the // opened and closed events, unless the following option is set to false: displayTransition: true, // Defines if the gallery slides are cleared from the gallery modal, // or reused for the next gallery initialization: clearSlides: true, // Defines if images should be stretched to fill the available space, // while maintaining their aspect ratio (will only be enabled for browsers // supporting background-size="contain", which excludes IE < 9). // Set to "cover", to make images cover all available space (requires // support for background-size="cover", which excludes IE < 9): stretchImages: true, // Toggle the controls on pressing the Return key: toggleControlsOnReturn: true, // Toggle the automatic slideshow interval on pressing the Space key: toggleSlideshowOnSpace: true, // Navigate the gallery by pressing left and right on the keyboard: enableKeyboardNavigation: true, // Close the gallery on pressing the ESC key: closeOnEscape: true, // Close the gallery when clicking on an empty slide area: closeOnSlideClick: false, // Close the gallery by swiping up or down: closeOnSwipeUpOrDown: true, // Emulate touch events on mouse-pointer devices such as desktop browsers: emulateTouchEvents: true, // Stop touch events from bubbling up to ancestor elements of the Gallery: stopTouchEventsPropagation: false, // Hide the page scrollbars: hidePageScrollbars: true, // Stops any touches on the container from scrolling the page: disableScroll: true, // Carousel mode (shortcut for carousel specific options): carousel: true, // Allow continuous navigation, moving from last to first // and from first to last slide: continuous: false, // Remove elements outside of the preload range from the DOM: unloadElements: true, // Start with the automatic slideshow: startSlideshow: false, // Delay in milliseconds between slides for the automatic slideshow: slideshowInterval: 3000, // The starting index as integer. // Can also be an object of the given list, // or an equal object with the same url property: index: 0, // The number of elements to load around the current index: preloadRange: 2, // The transition speed between slide changes in milliseconds: transitionSpeed: 400, //Hide controls when the slideshow is playing hideControlsOnSlideshow: true, //Request fullscreen on slide show toggleFullscreenOnSlideShow: true, // The transition speed for automatic slide changes, set to an integer // greater 0 to override the default transition speed: slideshowTransitionSpeed: undefined, // The tag name, Id, element or querySelector of the indicator container: indicatorContainer: 'ol', // The class for the active indicator: activeIndicatorClass: 'active', // The list object property (or data attribute) with the thumbnail URL, // used as alternative to a thumbnail child element: thumbnailProperty: 'thumbnail', // Defines if the gallery indicators should display a thumbnail: thumbnailIndicators: true, //thumbnail with image tag thumbnailWithImgTag: true, // Callback function executed when the Gallery is initialized. // Is called with the gallery instance as "this" object: onopen: jQuery.proxy(this.expose_onopen, this), // Callback function executed when the Gallery has been initialized // and the initialization transition has been completed. // Is called with the gallery instance as "this" object: onopened: jQuery.proxy(this.expose_onopened, this), // Callback function executed on slide change. // Is called with the gallery instance as "this" object and the // current index and slide as arguments: onslide: function (index, slide) { // Call our onslide method, and include gallery as an attribute self.expose_onslide.apply(self, [this, index, slide]); }, // Callback function executed after the slide change transition. // Is called with the gallery instance as "this" object and the // current index and slide as arguments: onslideend: function (index, slide) { // Call our onslide method, and include gallery as an attribute self.expose_onslideend.apply(self, [this, index, slide]); }, //// Callback function executed on slide content load. // Is called with the gallery instance as "this" object and the // slide index and slide element as arguments: onslidecomplete: function (index, slide) { // Call our onslide method, and include gallery as an attribute self.expose_onslidecomplete.apply(self, [this, index, slide]); }, //// Callback function executed when the Gallery is about to be closed. // Is called with the gallery instance as "this" object: onclose: jQuery.proxy(this.expose_onclose, this), // Callback function executed when the Gallery has been closed // and the closing transition has been completed. // Is called with the gallery instance as "this" object: onclosed: jQuery.proxy(this.expose_onclosed, this) }; let $body = jQuery('body'); if ($body.find('#blueimp-gallery').length == 0) { // Gallery Main DIV container let $expose_node = jQuery(document.createElement('div')).attr({ id: "blueimp-gallery", class: "blueimp-gallery" }); // Create Gallery DOM NODE $expose_node.append('

×
    '); // Append the gallery Node to DOM $body.append($expose_node); } } set_value(_value) { //todo: not sure if we need that with the new construction //if (typeof this._super == 'undefined') return; // @ts-ignore super.set_value(_value); // Do not run set value of expose if expose_view is not set // it causes a wired error on nested image widgets which // seems the expose is not its child widget // @ts-ignore if (!this.options.expose_view) { return; } let fe = egw_get_file_editor_prefered_mimes(); let self = this; // If the media type is not supported do not bind the click handler if (!_value || typeof _value.mime != 'string' || (!_value.mime.match(MIME_REGEX, 'ig') && (!fe || fe.mime && !fe.mime[_value.mime])) || typeof _value.download_url == 'undefined') { return; } // @ts-ignore if (typeof this.options.expose_view != 'undefined' && this.options.expose_view) { // @ts-ignore jQuery(this.node).on('click', function (event) { // Do not trigger expose view if one of the operator keys are held if (!event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey) { if (_value.mime.match(MIME_REGEX, 'ig') && !_value.mime.match(MIME_AUDIO_REGEX,'ig')) { self._init_blueimp_gallery(event, _value); } else if (_value.mime.match(MIME_AUDIO_REGEX,'ig')) { self._audio_player(_value); } else if (fe && fe.mime && fe.edit && fe.mime[_value.mime]) { egw.open_link(egw.link('/index.php', { menuaction: fe.edit.menuaction, path: _value.path, cd: 'no' // needed to not reload framework in sharing }), '', fe.edit_popup); } } event.stopImmediatePropagation(); }).addClass('et2_clickable'); } } private _init_blueimp_gallery(event, _value) { let mediaContent = []; let nm = find_nextmatch(this); let current_index = 0; if (nm && !this._is_target_indepth(nm, event.target)) { // Get the row that was clicked, find its index in the list let current_entry = nm.controller.getRowByNode(event.target); // But before it goes, we'll pull everything we can read_from_nextmatch.call(this, nm, mediaContent); // find current_entry in array and set it's array-index for (let i = 0; i < mediaContent.length; i++) { if ('filemanager::' + mediaContent[i].path == current_entry.uid) { current_index = i; break; } } // This will trigger nm to refresh and get just the ones we can handle // but it might take a while, so do it later - make sure our current // one is loaded first. window.setTimeout(function () { nm.applyFilters({col_filter: {mime: '/' + MIME_REGEX.source + '/'}}); }, 1); } else { // @ts-ignore mediaContent = this.getMedia(_value); // Do not show thumbnail indicator on single expose view this.expose_options.thumbnailIndicators = false; } this.expose_options.index = current_index; // @ts-ignore gallery = blueimp.Gallery(mediaContent, this.expose_options); } /** * audio player expose * @param _value * @private */ private _audio_player(_value) { let button = [ {"button_id": 1, "text": egw.lang('close'), id: '1', image: 'cancel', default: true} ]; // @ts-ignore let mediaContent = this.getMedia(_value)[0]; et2_createWidget("dialog",{ callback: function(_btn, value){ if (_btn == et2_dialog.OK_BUTTON) { } }, beforeClose: function(){ }, title: mediaContent.title, buttons: button, minWidth: 350, minHeight: 200, modal: false, position:"right bottom,right-50 bottom-10", value: { content: { src:mediaContent.download_href } }, resizable: false, template: egw.webserverUrl+'/api/templates/default/audio_player.xet', dialogClass:"audio_player" }); } /** * Check if clicked target from nm is in depth * * @param nm nextmatch widget * @param target selected target dom node * * @return {boolean} returns false if target is not in depth otherwise True */ private _is_target_indepth(nm, target?) { let res = false; if (nm) { if (!target) { // @ts-ignore let target = this.getDOMNode(); } let entry = nm.controller.getRowByNode(target); if (entry && entry.controller.getDepth() > 0) { res = true; } } return res; } expose_onopen(event) {} expose_onopened() { // Check to see if we're in a nextmatch, do magic let nm = find_nextmatch(this); let self = this; if (nm) { // Add scrolling to the indicator list let total_count = nm.controller._grid.getTotalCount(); if (total_count >= gallery.num) { let $indicator = gallery.container.find('.indicator'); $indicator.off() .addClass('paginating') .swipe(function (event, direction, distance) { // @ts-ignore if (direction == jQuery.fn.swipe.directions.LEFT) { distance *= -1; } // @ts-ignore else if (direction == jQuery.fn.swipe.directions.RIGHT) { // OK. } else { return; } jQuery(this).css('left', Math.min(0, parseInt(jQuery(this).css('left')) - (distance * 30)) + 'px'); }); // Bind the mousewheel handler for FF (DOMMousewheel), and other browsers (mousewheel) $indicator.bind('mousewheel DOMMousewheel', function (event, _delta) { var delta = _delta || event.originalEvent.wheelDelta / 120; if (delta > 0 && parseInt(jQuery(this).css('left')) > gallery.container.width() / 2) return; //Reload next pictures into the gallery by scrolling on thumbnails if (delta < 0 && jQuery(this).width() + parseInt(jQuery(this).css('left')) < gallery.container.width()) { var nextIndex = gallery.indicatorContainer.find('[title="loading"]')[0]; if (nextIndex) self.expose_onslideend(gallery, nextIndex.dataset.index - 1); return; } // Move it about 5 indicators jQuery(this).css('left', parseInt(jQuery(this).css('left')) - (-delta * gallery.activeIndicator.width() * 5) + 'px'); event.preventDefault(); }); } } } /** * Trigger on slide left/right */ expose_onslide(gallery, index, slide) { //todo //if (typeof this._super == 'undefined') return; // First let parent try super.expose_onslide(gallery, index, slide); let nm = find_nextmatch(this); if (nm) { // See if we need to move the indicator let indicator = gallery.container.find('.indicator'); let current = jQuery('.active', indicator).position(); if (current) { indicator.animate({left: (gallery.container.width() / 2) - current.left}, 10); } } } expose_onslideend(gallery, index) { // Check to see if we're in a nextmatch, do magic let nm = find_nextmatch(this); if (nm) { // Check to see if we're near the end, or maybe some pagination // would be good. let total_count = nm.controller._grid.getTotalCount(); // Already at the end, don't bother if (index == total_count - 1 || index == 0) return; // Try to determine direction from state of next & previous slides let direction = 1; for (let i in gallery.elements) { // Loading or error if (gallery.elements[i] == 1 || gallery.elements[i] == 3 || gallery.list[i].loading) { direction = i >= index ? 1 : -1; break; } } if (!gallery.list[index + direction] || gallery.list[index + direction].loading || total_count > gallery.getNumber() && index + ET2_DATAVIEW_STEPSIZE > gallery.getNumber()) { // This will get the next batch of rows let start = Math.max(0, direction > 0 ? index : index - ET2_DATAVIEW_STEPSIZE); let end = Math.min(total_count - 1, start + ET2_DATAVIEW_STEPSIZE); nm.controller._gridCallback(start, end); let images = []; read_from_nextmatch.call(this, nm, images, start); // Gallery always adds to the end, causing problems with pagination for (let i in images) { //if(i == index || i < gallery.num) continue; set_slide(i, images[i]); //gallery.add([images[i]]); } } } } expose_onslidecomplete() {} expose_onclose() { // Check to see if we're in a nextmatch, remove magic let nm = find_nextmatch(this); if (nm && !this._is_target_indepth(nm)) { // Remove scrolling from thumbnails gallery.container.find('.indicator') .removeClass('paginating') .off('mousewheel') .off('swipe'); // Remove applied mime filter nm.applyFilters({col_filter: {mime: ''}}); } } expose_onclosed() {} } }