%PDF- %PDF-
Direktori : /home1/lightco1/public_html/media/sigplus/engines/boxplusx/js/ |
Current File : //home1/lightco1/public_html/media/sigplus/engines/boxplusx/js/boxplusx.js |
/**@license boxplusx: a versatile lightweight pop-up window engine * @author Levente Hunyadi * @version 1.0 * @remarks Copyright (C) 2009-2017 Levente Hunyadi * @remarks Licensed under GNU/GPLv3, see http://www.gnu.org/licenses/gpl-3.0.html * @see http://hunyadi.info.hu/projects/boxplusx **/ /* * boxplusx: a versatile lightweight pop-up window engine * Copyright 2009-2017 Levente Hunyadi * * boxplusx is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * boxplusx is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with boxplusx. If not, see <http://www.gnu.org/licenses/>. */ /*{"compilation_level":"ADVANCED_OPTIMIZATIONS"}*/ 'use strict'; /** * Attributes for an item. * The object has the following properties: * + url: URL pointing to the item to display (either relative or absolute). * + image: Optional placeholder image for the item. * + title: Short caption text associated with the item. May contain HTML tags. * + description: Longer description text associated with the item. May contain HTML tags. * + download: URL pointing to a high-resolution original to be downloaded. * * @typedef {{ * url: string, * image: (HTMLImageElement|undefined), * title: string, * description: string, * download: string * }} */ let BoxPlusXItemProperties; /** * Position of control with respect to the viewport area. * @enum {string} */ const BoxPlusXPosition = { Hidden: 'hidden', Above: 'above', Top: 'top', Bottom: 'bottom', Below: 'below' }; /** * Text writing system. * @enum {string} */ const BoxPlusXWritingSystem = { LeftToRight: 'ltr', RightToLeft: 'rtl' }; /** * Options for the boxplusx lightbox pop-up window. * Unrecognized options set via the loosely-typed parameter object are silently ignored. * The object has the following properties: * + id: A unique identifier to assign to the pop-up window container. This helps add individual styling to dialog instances. * + slideshow: Time spent viewing an image when slideshow mode is active, or 0 to disable slideshow mode. * + autostart: Whether to start a slideshow when the dialog opens. * + loop: Whether the image/content sequence loops such that the first image/content follows the last. * + preferredWidth: Preferred default width for the content shown in the dialog. * + preferredHeight: Preferred default height for the content shown in the dialog. * + navigation: Position of quick-access navigation bar w.r.t. main viewport area. * + controls: Position of control buttons w.r.t. main viewport area. * + captions: Position of captions w.r.t. main viewport area. * + metadata: Whether to show image metadata. (Requires third-party plugin Exif.js, see <https://github.com/exif-js/exif-js>.) * + contextmenu: Whether to permit the user to open the context menu inside the dialog. * * @typedef {{ * id: ?string, * slideshow: number, * autostart: boolean, * loop: boolean, * preferredWidth: number, * preferredHeight: number, * useDevicePixelRatio: boolean, * navigation: !BoxPlusXPosition, * controls: !BoxPlusXPosition, * captions: !BoxPlusXPosition, * contextmenu: boolean, * metadata: boolean, * dir: BoxPlusXWritingSystem * }} */ let BoxPlusXOptions; /** @type {!BoxPlusXOptions} */ const boxplusDefaults = { 'id': null, 'slideshow': 0, 'autostart': false, 'loop': false, 'preferredWidth': 800, 'preferredHeight': 600, 'useDevicePixelRatio': true, 'navigation': BoxPlusXPosition.Bottom, 'controls': BoxPlusXPosition.Below, 'captions': BoxPlusXPosition.Below, 'contextmenu': true, 'metadata': false, 'dir': BoxPlusXWritingSystem.LeftToRight }; /** * Content type shown in the pop-up window. * @enum {number} */ const BoxPlusXContentType = { None: -1, Unavailable: 0, Image: 1, Video: 2, EmbeddedContent: 3, DocumentFragment: 4, Frame: 5 }; /** * Determine how content behaves when the container is resized. * @enum {number} */ const BoxPlusXDimensionBehavior = { /** The item does not permit resizing (e.g. HTML <object> element with fixed width and height). */ FixedSize: 1, /** The item has fixed aspect ratio (e.g. HTML <video> element). */ FixedAspectRatio: 2, /** The item width and height can be set independently. */ Resizable: 3, /** The item has an intrinsic width and height but either of these may be set to a smaller value when there is insufficient space. */ ResizableBestFit: 4 }; /** * Orientation constants. * Position names represent how row #0 and column #0 are oriented, e.g. TopLeft is the upright orientation. * @enum {number} */ const BoxPlusXOrientation = { WrongImageType: -2, // image type cannot have EXIF information; e.g. GIF and PNG do not support orientation NoInformation: -1, // EXIF information is missing from file or cannot be retrieved Unknown: 0, TopLeft: 1, TopRight: 2, BottomRight: 3, BottomLeft: 4, LeftTop: 5, RightTop: 6, RightBottom: 7, LeftBottom: 8 }; /** * @constructor * Allows viewing obscured parts of a scrollable element by making drag gestures with the mouse. * @param {!HTMLElement} interceptor The element that intercepts drag events. * @param {!HTMLElement} scrollable The element that scrolls in response to mouse movement. */ function BoxPlusXDraggable(interceptor, scrollable) { /** @type {boolean} */ let dragged = false; /** @type {number} */ let lastClientX = 0; /** @type {number} */ let lastClientY = 0; /** @type {!Array<string>} */ let scrollablePropertyValues = ['auto','scroll']; function dragStart(/** @type {Event} */ event) { let style = window.getComputedStyle(scrollable); let canScroll = scrollablePropertyValues.indexOf(style['overflowX']) >= 0 || scrollablePropertyValues.indexOf(style['overflowY']) >= 0; if (canScroll) { let mouseEvent = /** @type {MouseEvent} */ (event); lastClientX = mouseEvent.clientX; lastClientY = mouseEvent.clientY; dragged = true; mouseEvent.preventDefault(); } } function dragEnd(/** @type {Event} */ event) { dragged = false; } function dragMove(/** @type {Event} */ event) { if (dragged) { let mouseEvent = /** @type {MouseEvent} */ (event); scrollable.scrollLeft -= mouseEvent.clientX - lastClientX; scrollable.scrollTop -= mouseEvent.clientY - lastClientY; lastClientX = mouseEvent.clientX; lastClientY = mouseEvent.clientY; } } interceptor.addEventListener('mousedown', dragStart); interceptor.addEventListener('mouseup', dragEnd); interceptor.addEventListener('mouseout', dragEnd); interceptor.addEventListener('mousemove', dragMove); } (function () { // use anonymous wrapper to make sure we do not pollute global namespace whether we use the closure compiler or not /** * The boxplusx lightbox pop-up window instance. * Though typically used as a singleton, the interface permits instantiating multiple instances. * @constructor * @param {Object=} options */ function BoxPlusXDialog(options) { this.initialize(options); } window['BoxPlusXDialog'] = BoxPlusXDialog; /** * Record pushed into the browser history. * @constructor */ function BoxPlusXHistoryState() { } /** @type {string} */ BoxPlusXHistoryState.prototype.agent = 'boxplusx'; // // Private static functions // /** * Parses a query string into name/value pairs. * @param {string} querystring A string of "name=value" pairs, separated by "&". * @return {!Object<string>} An object where keys are parameter names, and value are parameter values. */ function fromQueryString(querystring) { let parameters = {}; if (querystring.length > 1) { querystring.substr(1).split('&').forEach(function (keyvalue) { let index = keyvalue.indexOf('='); let key = index >= 0 ? keyvalue.substr(0, index) : keyvalue; let value = index >= 0 ? keyvalue.substr(index + 1) : ''; parameters[decodeURIComponent(key)] = decodeURIComponent(value); }); } return parameters; } /** * Parses a URL string into URL components. * @param {string} url A URL string. * @return {{ * protocol: string, * host: string, * hostname: string, * port: string, * pathname: string, * search: string, * queryparams: !Object<string>, * hash: string, * id: string, * fragmentparams: !Object<string> * }} */ function parseURL(url) { let parser = /** @type {!HTMLAnchorElement} */ (document.createElement('a')); parser.href = url; const hashBangIndex = parser.hash.indexOf('!'); return { protocol: parser.protocol, host: parser.host, hostname: parser.hostname, port: parser.port, pathname: parser.pathname, search: parser.search, // starts with & unless empty queryparams: fromQueryString(parser.search), hash: parser.hash, // starts with # unless empty id: parser.hash.substr(1, (hashBangIndex >= 0 ? hashBangIndex : parser.hash.length) - 1), /** * Fragment parameters. Recognizes any of the following syntax: * #key1=value1&key2=value2 * #id!key1=value1&key2=value2 */ fragmentparams: fromQueryString(parser.hash.substr(Math.max(0, hashBangIndex))) }; } /** * Determines whether navigating to a URL would entail only a hash change. * @param {string} url A URL string. * @return {boolean} True if changing the location would trigger only an onhashchange event. */ function isHashChange(url) { let actual = parseURL(url); let expected = parseURL(location.href); // parse location URL for compatibility with Internet Explorer return actual.protocol === expected.protocol && actual.host === expected.host && actual.pathname === expected.pathname // compare path && actual.search === expected.search; // compare query string } /** * Builds a query string from an object. * @param {!Object<string,string>} parameters An object where keys are parameter names, and values are parameter values. * @return {string} A URL query string. */ function buildQuery(parameters) { return Object.keys(parameters).map(function (key) { return encodeURIComponent(key) + '=' + encodeURIComponent(parameters[key]); }).join('&'); } /** * Checks if a location identifies an image. * @param {string} path A path or the path component of a URL. * @return {boolean} True if the path is likely to identify an image. */ function isImageFile(path) { return /\.(gif|jpe?g|png|svg)$/i.test(path); } /** * Sets all undefined properties on an object using a reference object. * @param {Object|null|undefined} obj * @param {!Object} ref * @return {!Object} */ function applyDefaults(obj, ref) { /** @type {!Object} */ let extended = obj || {}; for (const prop in /** @type {!Object} */ (JSON.parse(JSON.stringify(ref)))) { // use JSON functions to clone object if (!Object.prototype.hasOwnProperty.call(extended, prop)) { extended[prop] = /** @type {*} */ (ref[prop]); } } return extended; } /** * Gets the value of an attribute with a fallback. * @param {!Element} elem The HTML element to inspect. * @param {string} attr The name of the attribute to search for. * @param {string} def The default value to return if the element does not have the given attribute. * @return {string} The value of the attribute if it exists, or the default value. */ function getAttributeOrDefault(elem, attr, def) { if (elem.hasAttribute(attr)) { return elem.getAttribute(attr); } else { return def; } } /** * Gets the value of an attribute * @param {!Element} elem The HTML element to inspect. * @param {string} attr The name of the attribute to search for. * @return {?string} The value of the attribute if it exists, or null. */ function getAttributeOrNull(elem, attr) { if (elem.hasAttribute(attr)) { return elem.getAttribute(attr); } else { return null; } } /** * Removes all children of an HTML element. * @param {!Node} elem The HTML element whose children to remove. */ function removeChildNodes(elem) { while (elem.hasChildNodes()) { elem.removeChild(elem.lastChild); } } /** * Determines whether the element is of the specified HTML element type. * @param {Element} elem The HTML element to test. * @param {string} type The tag name to test against. * @return {boolean} */ function hasElementType(elem, type) { return elem !== null && type === elem.tagName.toLowerCase(); } /** * Determines whether an element is either of the listed HTML element types. * @param {Element} elem The HTML element to test. * @param {!Array<string>} types The tag names to test against. * @return {boolean} */ function hasElementEitherType(elem, types) { return elem !== null && types.indexOf(elem.tagName.toLowerCase()) >= 0; } /** * Creates an HTML element. * @param {string} tagName The HTML tag name such as "div" or "table". * @return {!HTMLElement} The newly created element, ready for injection into the DOM. */ function createHTMLElement(tagName) { return /** @type {!HTMLElement} */ (document.createElement(tagName)); } /** * @param {!NodeList<!HTMLElement>|!Array<!HTMLElement>} nodeList * @return {!Array<!HTMLElement>} */ function convertToArray(nodeList) { return [].slice.call(nodeList); } /** * Sets the visibility of an HTML element. * @param {!Element} elem The HTML element to inspect. * @param {boolean} state True if the object is to be made visible. */ function setVisible(elem, state) { if (state) { elem.classList.remove('boxplusx-hidden'); } else { elem.classList.add('boxplusx-hidden'); } } /** * Determines the visibility of an HTML element. * @param {!Element} elem The HTML element to inspect. * @return {boolean} True if the object is visible. */ function isVisible(elem) { return !elem.classList.contains('boxplusx-hidden'); } /** * Toggles a CSS class on an element. * @param {!Element} elem The HTML element to add the class to or remove the class from. * @param {string} cls The CSS class name. * @param {boolean} state If true, the class is added; if false, removed. */ function toggleClass(elem, cls, state) { let classList = elem.classList; if (state) { classList.add(cls); } else { classList.remove(cls); } } /** * Creates a HTML <div> element, acting as a building block for the dialog. * @param {string} name The class name the element gets. * @param {boolean=} hidden Whether the element is initially hidden. * @param {!Array<!Element>=} children Any children the element should have. * @return {!HTMLDivElement} The newly created element, ready for injection into the DOM. */ function createElement(name, hidden, children) { let elem = /** @type {!HTMLDivElement} */ (createHTMLElement('div')); elem.classList.add('boxplusx-' + name); if (hidden) { elem.classList.add('boxplusx-hidden'); } if (children) { children.forEach(function (child) { elem.appendChild(child); }); } return elem; } /** * Creates several HTML <div> elements, acting as building blocks for the dialog. * @param {!Array<string>} names * @return {!Array<!HTMLDivElement>} */ function createElements(names) { return names.map(function (name) { return createElement(name); }); } /** * Retrieves image EXIF orientation of the camera relative to the scene. * @param {string} url The image URL. * @param {function(BoxPlusXOrientation):void} callback Invoked passing the EXIF orientation. */ function getImageOrientationFromURL(url, callback) { if (!/\.jpe?g$/i.test(url)) { callback(BoxPlusXOrientation.WrongImageType); // wrong image format, no EXIF data present in image formats GIF or PNG } else { var xhr = new XMLHttpRequest(); xhr.open('get', url); xhr.responseType = 'blob'; xhr.onload = function () { getImageOrientationFromBlob(/** @type {!Blob} */ (xhr.response), callback); }; xhr.onerror = function () { callback(BoxPlusXOrientation.NoInformation); } xhr.send(); } } /** * Retrieves image EXIF orientation of the camera relative to the scene. * @param {!Blob} blob The image data as a binary large object. * @param {function(BoxPlusXOrientation):void} callback Invoked passing the EXIF orientation. */ function getImageOrientationFromBlob(blob, callback) { let reader = new FileReader(); reader.onload = function () { let view = new DataView(/** @type {!ArrayBuffer} */ (reader.result)); if (view.getUint16(0) != 0xFFD8) { callback(BoxPlusXOrientation.WrongImageType); // wrong image format, not a JPEG image return; } let length = view.byteLength; let offset = 2; while (offset < length) { let marker = view.getUint16(offset); offset += 2; if (marker == 0xFFE1) { // application marker APP1 // EXIF header if (view.getUint32(offset += 2) != 0x45786966) { // corresponds to string "Exif" callback(BoxPlusXOrientation.NoInformation); // EXIF data absent return; } // TIFF header let little = view.getUint16(offset += 6) == 0x4949; // check if "Intel" (little-endian) byte alignment is used offset += view.getUint32(offset + 4, little); // last four bytes are offset to Image file directory (IFD) // IFD (Image file directory) let tags = view.getUint16(offset, little); offset += 2; for (let i = 0; i < tags; i++) { if (view.getUint16(offset + (i * 12), little) == 0x0112) { // corresponds to IFD0 (main image) Orientation return callback(/** @type {BoxPlusXOrientation} */ (view.getUint16(offset + (i * 12) + 8, little))); } } } else if ((marker & 0xFF00) != 0xFF00) { // not an application marker break; } else { offset += view.getUint16(offset); } } return callback(BoxPlusXOrientation.NoInformation); // application marker APP1 not found }; reader.readAsArrayBuffer(blob); } /** * Returns the title text for a content item. * @param {!Element} elem The HTML element whose short textual description to extract. * @return {string} A text suitable for a caption or the title of a browser tab or window. */ function getItemTitle(elem) { if (hasElementType(elem, 'a')) { const title = getAttributeOrNull(elem, 'data-title'); if (null !== title) { return title; } // an HTML anchor element that nests an HTML image element with an "alt" attribute const image = /** @type {HTMLImageElement} */ (elem.querySelector('img')); if (image) { const alternateText = getAttributeOrNull(image, 'alt'); if (null !== alternateText) { return alternateText; } } } return ''; } /** * Returns the description text for a content item. * @param {!Element} elem The HTML element whose longer textual description to extract. * @return {string} A text suitable for a caption or description text. */ function getItemDescription(elem) { if (hasElementType(elem, 'a')) { const description = getAttributeOrNull(elem, 'data-summary'); if (null !== description) { return description; } // an HTML anchor element with a "title" attribute const title = getAttributeOrNull(elem, 'title'); if (null !== title) { return title; } } return ''; } /** * Generates item properties from an HTML element collection. * @param {!NodeList<!HTMLElement>|!Array<!HTMLElement>} items * @return {!Array<!BoxPlusXItemProperties>} */ function elementsToProperties(items) { let elems = convertToArray(items); return elems.map(function (/** @type {!HTMLElement} */ elem) { let title = getItemTitle(elem); let description = getItemDescription(elem); if (title === description) { description = ''; } let url = ''; if (hasElementType(elem, 'a')) { let anchor = /** @type {HTMLAnchorElement} */ (elem); url = anchor.href; } // extract the HTML data attribute "download", which tells the engine where to look for the high-resolution // original, should the visitor choose to save a copy of the image to their computer let download = (elem.dataset && elem.dataset['download']) || ''; /** @type {HTMLImageElement} */ let image; let images = /** @type {!NodeList<!HTMLImageElement>} */ (elem.getElementsByTagName('img')); if (images.length > 0) { image = /** @type {!HTMLImageElement} */ (images[0]); } return { 'url': url, 'image': image, 'title': title, 'description': description, 'download': download }; }); } // // Public instance functions // /** * Binds a set of elements to this dialog instance. * @param {!NodeList<!HTMLElement>|!Array<!HTMLElement>} items * @return {function(number)} */ BoxPlusXDialog.prototype.bind = function (items) { let self = this; let elems = convertToArray(items); let properties = elementsToProperties(elems); let openfun = function (/** @type {number} */ index) { self.open(properties, index); }; elems.forEach(function (elem, index) { elem.addEventListener('click', function (event) { event.preventDefault(); openfun(index); }, false); }); return openfun; }; /** * Initializes the layout and behavior of the pop-up dialog. * @param {Object=} options */ BoxPlusXDialog.prototype.initialize = function (options) { /** @type {!BoxPlusXOptions} */ this.options = /** @type {!BoxPlusXOptions} */ (applyDefaults(options, boxplusDefaults)); // builds the boxplusx pop-up window HTML structure, as if by injecting the following into the DOM: // // <div class="boxplusx-container boxplusx-hidden"> // <div class="boxplusx-dialog"> // <div class="boxplusx-wrapper boxplusx-hidden"> // <div class="boxplusx-wrapper"> // <div class="boxplusx-wrapper"> // <div class="boxplusx-viewport"> // <div class="boxplusx-aspect"></div> // <div class="boxplusx-content"></div> // <div class="boxplusx-expander"></div> // <div class="boxplusx-previous"></div> // <div class="boxplusx-next"></div> // </div> // <div class="boxplusx-navigation"> // <div class="boxplusx-navbar"> // <div class="boxplusx-navitem"> // <div class="boxplusx-aspect"></div> // <div class="boxplusx-navimage"></div> // </div> // </div> // <div class="boxplusx-rewind"></div> // <div class="boxplusx-forward"></div> // </div> // </div> // <div class="boxplusx-controls"> // <div class="boxplusx-previous"></div> // <div class="boxplusx-next"></div> // <div class="boxplusx-close"></div> // <div class="boxplusx-start"></div> // <div class="boxplusx-stop"></div> // <div class="boxplusx-download"></div> // <div class="boxplusx-metadata"></div> // </div> // </div> // <div class="boxplusx-caption"> // <div class="boxplusx-title"></div> // <div class="boxplusx-description"></div> // </div> // </div> // <div class="boxplusx-progress boxplusx-hidden"></div> // </div> // </div> // create elements let aspectHolder = createElement('aspect'); let innerContainer = createElement('content'); let expander = createElement('expander'); let navigationBar = createElement('navbar'); let navigationArea = createElement('navigation', false, [navigationBar].concat(createElements(['rewind','forward']))); let viewport = createElement('viewport', false, [aspectHolder,innerContainer,expander].concat(createElements(['previous','next']))); let controls = createElement('controls', false, createElements(['previous','next','close','start','stop','download','metadata'])); let captionTitle = createElement('title'); let captionDescription = createElement('description'); let caption = createElement('caption', false, [captionTitle,captionDescription]); let innerWrapper = createElement('wrapper', false, [viewport,navigationArea]); let outerWrapper = createElement('wrapper', false, [innerWrapper,controls]); let contentWrapper = createElement('wrapper', true, [outerWrapper,caption]); let progressIndicator = createElement('progress', true); let dialog = createElement('dialog', false, [contentWrapper, progressIndicator]); let outerContainer = createElement('container', true, [dialog]); if (this.options.id) { outerContainer.id = this.options.id; } // arrange layout caption.classList.add('boxplusx-' + /** @type {string} */ (this.options['captions'])); controls.classList.add('boxplusx-' + /** @type {string} */ (this.options['controls'])); navigationArea.classList.add('boxplusx-' + /** @type {string} */ (this.options['navigation'])); document.body.appendChild(outerContainer); /** @type {!HTMLDivElement} */ this.outerContainer = outerContainer; /** @type {!HTMLDivElement} */ this.dialog = dialog; /** @type {!HTMLDivElement} */ this.contentWrapper = contentWrapper; /** @type {!HTMLDivElement} */ this.viewport = viewport; /** @type {!HTMLDivElement} */ this.caption = caption; /** @type {!HTMLDivElement} */ this.captionTitle = captionTitle; /** @type {!HTMLDivElement} */ this.captionDescription = captionDescription; /** @type {!HTMLDivElement} */ this.aspectHolder = aspectHolder; /** @type {!HTMLDivElement} */ this.innerContainer = innerContainer; /** @type {!HTMLDivElement} */ this.expander = expander; /** @type {!HTMLDivElement} */ this.navigationArea = navigationArea; /** @type {!HTMLDivElement} */ this.navigationBar = navigationBar; /** @type {!HTMLDivElement} */ this.progressIndicator = progressIndicator; /** * Information about elements, part of the same group, to be displayed in the pop-up window. * @type {!Array<!BoxPlusXItemProperties>} */ this.members = []; /** * A sequence of integers corresponding to item indexes previously seen since the pop-up window was opened * @type {!Array<number>} */ this.trail = []; /** * Timer to track when the slideshow delay expires. * @type {?number} */ this.timer = null; /** * True if a slideshow is currently activated. * @type {boolean} */ this.isSlideshowRunning = false; /** * Aspect behavior for the item currently displayed. * @type {!BoxPlusXDimensionBehavior} */ this.aspect = BoxPlusXDimensionBehavior.FixedAspectRatio; /** * Preferred width for the item currently displayed. * @type {number} */ this.preferredWidth = this.options.preferredWidth; /** * Preferred height for the item currently displayed. * @type {number} */ this.preferredHeight = this.options.preferredHeight; /** * Content type currently shown in the pop-up window. * @type {BoxPlusXContentType} */ this.contentType = BoxPlusXContentType.None; /** * Whether content size is reduced to fit available space. * @type {boolean} */ this.shrinkToFit = true; let self = this; this.outerContainer.addEventListener('click', function (event) { if (event.target === self.outerContainer) { self.close.call(self); } }, false); addEventToAllElements.call(this, 'click', { 'previous': this.previous, 'next': this.next, 'close': this.close, 'start': this.start, 'stop': this.stop, 'metadata': this.metadata, 'download': this.download, 'rewind': stopNavigationBar, 'forward': stopNavigationBar }); addEventToAllElements.call(this, 'mouseover', { 'rewind': rewindNavigationBar, 'forward': forwardNavigationBar }); addEventToAllElements.call(this, 'mouseout', { 'rewind': stopNavigationBar, 'forward': stopNavigationBar }); if (!this.options['contextmenu']) { dialog.addEventListener('contextmenu', function (/** @type {Event} */ event) { event.preventDefault(); }); } const writingsystem = /** @type {BoxPlusXWritingSystem} */ (this.options['dir']); this.outerContainer.dir = writingsystem; new BoxPlusXDraggable(viewport, innerContainer); function toggleShrinkToFit() { if (self.preferredWidth > self.viewport.clientWidth || self.preferredHeight > self.viewport.clientHeight) { self.shrinkToFit = !self.shrinkToFit; const index = getCurrentIndex.call(self); navigateToIndex.call(self, index); } } expander.addEventListener('click', toggleShrinkToFit); viewport.addEventListener('dblclick', toggleShrinkToFit); // prevent mouse wheel events from view area from propagating to document view innerContainer.addEventListener('mousewheel', function (/** @type {Event} */ event) { let wheelEvent = /** @type {WheelEvent} */ (event); let canScroll = window.getComputedStyle(innerContainer).overflowY != 'hidden'; let maxScroll = innerContainer.scrollHeight - innerContainer.clientHeight; if (canScroll && maxScroll > 0) { let scrollTop = innerContainer.scrollTop; let deltaY = wheelEvent.deltaY; if ((scrollTop === maxScroll && deltaY > 0) || (scrollTop === 0 && deltaY < 0)) { wheelEvent.preventDefault(); } } }); // pressing a key window.addEventListener('keydown', function (/** @type {Event} */ event) { let keyboardEvent = /** @type {KeyboardEvent} */ (event); if (isVisible(self.outerContainer)) { // let form elements handle their own input if (hasElementEitherType(/** @type {Element} */ (keyboardEvent.target), ['input','select','textarea'])) { return; } const keys = [27,36,35]; // keys are [ESC, home, end] switch (writingsystem) { case 'ltr': keys.push(37,39); // keys are [ESC, home, end, left arrow, right arrow] break; case 'rtl': keys.push(39, 37); // keys are [ESC, home, end, right arrow, left arrow] break; } /** @type {number} */ const keyindex = keys.indexOf(keyboardEvent.which || keyboardEvent.keyCode); if (keyindex >= 0) { /** @type function(this:BoxPlusXDialog) */ let func = [self.close,self.first,self.last,self.previous,self.next][keyindex]; func.call(self); // call function with proper context for "this" keyboardEvent.preventDefault(); } } }, false); // navigation by swipe /** @type {number} */ let touchStartX; /** @type {number} */ let lastTouch = 0; viewport.addEventListener('touchstart', function (/** @type {Event} */ event) { let touchEvent = /** @type {TouchEvent} */ (event); touchStartX = touchEvent.changedTouches[0].pageX; }); viewport.addEventListener('touchend', function (/** @type {Event} */ event) { let touchEvent = /** @type {TouchEvent} */ (event); let now = new Date().getTime(); let delta = now - lastTouch; if (delta > 0 && delta < 500) { // double tap (two successive taps one shortly after the other) touchEvent.preventDefault(); } else if (self.shrinkToFit) { // single tap /** @type {number} */ let x = touchEvent.changedTouches[0].pageX; if (x - touchStartX >= 50) { // swipe to the right self.previous.call(self); } else if (touchStartX - x >= 50) { // swipe to the left self.next.call(self); } } lastTouch = now; }); // mobile-friendly forward and rewind for quick-access navigation bar navigationBar.addEventListener('touchstart', function (/** @type {Event} */ event) { let touchEvent = /** @type {TouchEvent} */ (event); touchStartX = touchEvent.changedTouches[0].pageX; stopNavigationBar.call(self); }); navigationBar.addEventListener('touchend', function (/** @type {Event} */ event) { let touchEvent = /** @type {TouchEvent} */ (event); /** @type {number} */ let x = touchEvent.changedTouches[0].pageX; if (x - touchStartX >= 50) { // swipe to the right rewindNavigationBar.call(self); } else if (touchStartX - x >= 50) { // swipe to the left forwardNavigationBar.call(self); } }); // history (browser back and forward buttons) window.addEventListener('popstate', function (/** @type {Event} */ event) { if (isVisible(self.outerContainer)) { self.trail.pop(); // discard internal state that has been the active state if (self.trail.length > 0) { // re-inject popped artificial state into the history stack window.history.pushState({ agent: 'boxplusx' }, ''); const index = getCurrentIndex.call(self); self.trail.pop(); // pop off state that will be pushed as the active state shortly navigateToIndex.call(self, index); } else { hideWindow.call(self); } } }, false); // window resize window.addEventListener('resize', function (/** @type {Event} */ event){ if (isVisible(self.outerContainer)) { setMaximumDialogSize.call(self); repositionNavigationBar.call(self); updateExpanderState.call(self); } }); }; /** * @param {!Array<!BoxPlusXItemProperties>} members * @param {number} index */ BoxPlusXDialog.prototype.open = function (members, index) { this.members = members; // populate quick-access navigation bar let self = this; const isNavigationVisible = members.length > 1 && this.options['navigation'] != BoxPlusXPosition.Hidden; setVisible(this.navigationArea, isNavigationVisible); if (isNavigationVisible) { members.forEach(function (member, i) { let navigationAspect = createElement('aspect'); let navigationImage = createElement('navimage'); let navigationItem = createElement('navitem', false, [navigationAspect,navigationImage]); let allowAction = true; navigationItem.addEventListener('touchstart', function () { if (isNavigationBarSliding.call(self)) { allowAction = false; } }); navigationItem.addEventListener('click', function () { if (allowAction) { self.navigate.call(self, i); } allowAction = true; }); let image = /** @type {HTMLImageElement} */ (member['image']); if (image) { let setNavigationImage = function () { let aspectStyle = navigationAspect.style; aspectStyle.setProperty('width', image.naturalWidth + 'px'); aspectStyle.setProperty('padding-top', (100.0 * image.naturalHeight / image.naturalWidth) + '%'); navigationImage.style.setProperty('background-image', 'url("' + image.src + '")'); }; if (image.src && image.complete) { // make sure the image is available setNavigationImage(); } else { // set aspect properties immediately when the image is loaded image.addEventListener('load', setNavigationImage); // trigger pre-loader service if registered by another script if (image['preloader']) { let preloader = image['preloader']; if (preloader['load']) { preloader['load'](); } } } } navigationImage.innerText = (i + 1) + ''; self.navigationBar.appendChild(navigationItem); }); } this.show(index); }; /** * @param {number} index */ BoxPlusXDialog.prototype.show = function (index) { this.trail = []; // push boxplusx-specific single artificial state to history stack if (window.history.state && (/** @type {!BoxPlusXHistoryState} */ (window.history.state)).agent === 'boxplusx') { window.history.replaceState(new BoxPlusXHistoryState(), ''); } else { window.history.pushState(new BoxPlusXHistoryState(), ''); } if (/** @type {boolean} */ (this.options['autostart']) && /** @type {number} */ (this.options['slideshow']) > 0) { this.isSlideshowRunning = true; } setVisible(this.outerContainer, true); setVisible(this.progressIndicator, true); this.navigate(index); }; BoxPlusXDialog.prototype.close = function () { stopSlideshow.call(this); // call private method that does not manipulate history hideWindow.call(this); // clear history track this.trail = []; // discard artificial state on the history stack that corresponds to boxplusx window.history.go(-1); }; /** * @param {number} index */ BoxPlusXDialog.prototype.navigate = function (index) { const current = getCurrentIndex.call(this); if (index != current) { navigateToIndex.call(this, index); } }; BoxPlusXDialog.prototype.first = function () { this.navigate(0); }; BoxPlusXDialog.prototype.previous = function () { const index = getCurrentIndex.call(this); if (index > 0) { this.navigate(index - 1); } else if (this.options['loop']) { this.last(); } }; BoxPlusXDialog.prototype.next = function () { const index = getCurrentIndex.call(this); if (index < this.members.length - 1) { this.navigate(index + 1); } else if (this.options['loop']) { this.first(); } }; BoxPlusXDialog.prototype.last = function () { this.navigate(this.members.length - 1); }; BoxPlusXDialog.prototype.start = function () { if (this.options['slideshow'] > 0) { this.isSlideshowRunning = true; startSlideshow.call(this); updateControls.call(this); } }; BoxPlusXDialog.prototype.stop = function () { if (this.options['slideshow'] > 0) { this.isSlideshowRunning = false; stopSlideshow.call(this); updateControls.call(this); } }; BoxPlusXDialog.prototype.metadata = function () { let metadata = queryElement.call(this, 'detail'); if (metadata) { setVisible(metadata, !isVisible(metadata)); } }; BoxPlusXDialog.prototype.download = function () { const index = getCurrentIndex.call(this); let anchor = /** @type {HTMLAnchorElement} */ (document.createElement('a')); anchor.href = this.members[index].download; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); }; // // Private instance functions // /** * @param {string} identifier * @return {Element} * @this {BoxPlusXDialog} */ function queryElement(identifier) { return this.dialog.querySelector('.boxplusx-' + identifier); } /** * @param {string} identifier * @return {NodeList} * @this {BoxPlusXDialog} */ function queryAllElements(identifier) { return this.dialog.querySelectorAll('.boxplusx-' + identifier); } /** * @param {string} identifier * @param {function(!Element)} func * @this {BoxPlusXDialog} */ function applyAllElements(identifier, func) { // Microsoft Edge does not support function forEach (or iterator) on NodeList objects, i.e. the following does not work: // queryAllElements.call(this, identifier).forEach(func); let elems = queryAllElements.call(this, identifier); for (let i = 0; i < elems.length; ++i) { func(elems[i]); } } /** * @param {string} eventName * @param {!Object<string,function(this:BoxPlusXDialog)>} map * @this {BoxPlusXDialog} */ function addEventToAllElements(eventName, map) { let self = this; Object.keys(map).forEach(function (identifier) { applyAllElements.call(self, identifier, function (elem) { elem.addEventListener(eventName, map[identifier].bind(self), false); }); }); } /** * @param {BoxPlusXContentType} type * @return {boolean} */ function isContentInteractive(type) { switch (type) { case BoxPlusXContentType.Unavailable: case BoxPlusXContentType.Image: return false; } return true; } /** * Sets a content type that helps identify what is shown in the pop-up window viewport area. * @param {BoxPlusXContentType} contentType * @this {BoxPlusXDialog} */ function setContentType(contentType) { /** * @param {BoxPlusXContentType} type * @return {string} */ function getContentTypeString(type) { switch (type) { case BoxPlusXContentType.Unavailable: return 'unavailable'; case BoxPlusXContentType.Image: return 'image'; case BoxPlusXContentType.Video: return 'video'; case BoxPlusXContentType.EmbeddedContent: return 'embed'; case BoxPlusXContentType.DocumentFragment: return 'document'; case BoxPlusXContentType.Frame: return 'frame'; case BoxPlusXContentType.None: default: return 'none'; } } let classList = this.innerContainer.classList; classList.remove('boxplusx-' + getContentTypeString(this.contentType)); classList.remove('boxplusx-interactive'); this.contentType = contentType; classList.add('boxplusx-' + getContentTypeString(contentType)); if (isContentInteractive(contentType)) { classList.add('boxplusx-interactive'); } } /** * @this {BoxPlusXDialog} */ function updateControls() { let self = this; let index = getCurrentIndex.call(this); let isFirstItem = index == 0; let members = this.members; let isLastItem = index >= members.length - 1; let loop = /** @type {boolean} */ (this.options['loop']) && !(isFirstItem && isLastItem); let slideshow = this.options['slideshow'] > 0; applyAllElements.call(this, 'previous', function (elem) { setVisible(elem, loop || !isFirstItem); }); applyAllElements.call(this, 'next', function (elem) { setVisible(elem, loop || !isLastItem); }); applyAllElements.call(this, 'start', function (elem) { setVisible(elem, slideshow && !self.isSlideshowRunning && !isLastItem); }); applyAllElements.call(this, 'stop', function (elem) { setVisible(elem, slideshow && self.isSlideshowRunning); }); applyAllElements.call(this, 'download', function (elem) { setVisible(elem, !!members[index].download); }); applyAllElements.call(this, 'metadata', function (elem) { setVisible(elem, /** @type {boolean} */ (self.options['metadata']) && !!queryElement.call(self, 'detail')); }); } /** * @this {BoxPlusXDialog} */ function updateExpanderState() { let isOversize = this.preferredWidth > this.viewport.clientWidth || this.preferredHeight > this.viewport.clientHeight; setVisible(this.expander, isOversize && !isContentInteractive(this.contentType)); toggleClass(this.expander, 'boxplusx-collapse', !this.shrinkToFit); toggleClass(this.expander, 'boxplusx-expand', this.shrinkToFit); } /** * @this {BoxPlusXDialog} */ function hideWindow() { removeAnimationProperties.call(this); clearContent.call(this); setContentType.call(this, BoxPlusXContentType.None); removeChildNodes(this.navigationBar); setVisible(this.contentWrapper, false); setVisible(this.outerContainer, false); // must come before manipulating history } /** * Gets the currently shown item. * @return {number} The zero-based index of the item currently displayed. * @this {BoxPlusXDialog} */ function getCurrentIndex() { return this.trail[this.trail.length - 1]; } /** * Reveals the content to be displayed. * @this {BoxPlusXDialog} */ function showContent() { removeAnimationProperties.call(this); setVisible(this.progressIndicator, false); let index = getCurrentIndex.call(this); if (index >= this.members.length - 1) { this.isSlideshowRunning = false; } updateControls.call(this); setVisible(this.contentWrapper, true); // dialog must be visible to have valid offset values repositionNavigationBar.call(this); updateExpanderState.call(this); if (this.isSlideshowRunning) { startSlideshow.call(this); } } /** * Trigger dialog animation to morph into a size suitable for the next item. * @param {!BoxPlusXDimensionBehavior} aspect Specifies how the dialog should respond when resized. * @param {string=} originalWidth The original dialog CSS width to start with. * @param {string=} originalHeight The original dialog CSS height to start with. * @this {BoxPlusXDialog} */ function morphDialog(aspect, originalWidth, originalHeight) { this.aspect = aspect; // save current dialog dimensions and aspect ratio let computedStyle = window.getComputedStyle(this.dialog); const currentWidth = originalWidth || computedStyle.getPropertyValue('width'); const currentHeight = originalHeight || computedStyle.getPropertyValue('height'); removeAnimationProperties.call(this); // use temporarily exposed elements for calculations setVisible(this.contentWrapper, true); let viewportClassList = this.viewport.classList; viewportClassList.remove('boxplusx-fixedaspect'); viewportClassList.remove('boxplusx-draggable'); if (BoxPlusXDimensionBehavior.FixedSize === aspect || BoxPlusXDimensionBehavior.FixedAspectRatio === aspect) { // set new aspect ratio // if specified as a percentage, CSS padding is expressed in terms of container width (even for top // and bottom padding), which we utilize here to make item grow/shrink vertically as it grows/shrinks // horizontally let aspectStyle = this.aspectHolder.style; aspectStyle.setProperty('width', this.preferredWidth + 'px'); aspectStyle.setProperty('padding-top', (100.0 * this.preferredHeight / this.preferredWidth) + '%'); viewportClassList.add('boxplusx-fixedaspect'); } else if (BoxPlusXDimensionBehavior.ResizableBestFit === aspect) { viewportClassList.add('boxplusx-draggable'); } else if (BoxPlusXDimensionBehavior.Resizable === aspect) { let containerStyle = this.innerContainer.style; containerStyle.setProperty('width', this.preferredWidth + 'px'); containerStyle.setProperty('max-height', this.preferredHeight + 'px'); } setMaximumDialogSize.call(this); // get desired target size with all inner controls temporarily visible /** @type {string} */ const desiredWidth = computedStyle.getPropertyValue('width'); /** @type {string} */ const desiredHeight = computedStyle.getPropertyValue('height'); /** @type {string} */ const desiredMaxWidth = computedStyle.getPropertyValue('max-width'); // animation transition end function let self = this; let appliedStyle = this.dialog.style; let fn = function () { if (isVisible(self.outerContainer)) { appliedStyle.setProperty('max-width', desiredMaxWidth); showContent.call(self); } } if (currentWidth != desiredWidth || currentHeight != desiredHeight) { // dialog animation required to fit new content size // hide elements after calculations have been made setVisible(this.contentWrapper, false); // reset previous dialog dimensions appliedStyle.removeProperty('max-width'); appliedStyle.setProperty('width', currentWidth); appliedStyle.setProperty('height', currentHeight); this.dialog.classList.add('boxplusx-animation'); // determine when event "transitionend" would be fired // helps thwart deadlock when event "transitionend" is never fired due to race condition const duration = Math.max.apply(null, computedStyle.getPropertyValue('transition-duration').split(',').map(function (item) { let value = parseFloat(item); if (/\ds$/.test(item)) { return 1000 * value; } else { return value; } })); window.setTimeout(fn, duration); } else { // no dialog animation required, only swap content fn(); } // start CSS transition by setting desired size for pop-up window as transition target appliedStyle.setProperty('width', desiredWidth); appliedStyle.setProperty('height', desiredHeight); } /** * Removes all element properties associated with dialog animation. * @this {BoxPlusXDialog} */ function removeAnimationProperties() { this.dialog.classList.remove('boxplusx-animation'); // remove any explicit sizes applied for the sake of the CSS transition animation let appliedStyle = this.dialog.style; appliedStyle.removeProperty('width'); appliedStyle.removeProperty('height'); } /** * Uses the bisection algorithm to determine the dialog size. * @param {number} a Lower bound (percentage) value at which the dialog fits. * @param {number} b Upper bound (percentage) value at which the dialog does not fit. * @param {function(number)} applyFun Applies a value (e.g. sets content width or height). * @return {number} The (percentage) value at which the dialog fits exactly. * @this {BoxPlusXDialog} */ function bisectionSearch(a, b, applyFun) { let self = this; /** * Evaluates the dialog height at a particular value. * @param {number} value A parameter value to apply. * @return {number} The dialog height in pixels (including border and padding) when the value is applied. */ function evaluateFun(value) { applyFun(value); return self.dialog.offsetHeight; } const containerHeight = this.outerContainer.clientHeight; let dlgHeightB = evaluateFun(b); // no extra horizontal constraints if (dlgHeightB <= containerHeight) { return b; // nothing to do; pop-up window fits vertically } let dlgHeightA = evaluateFun(a); // force dialog take its minimum size if (dlgHeightA >= containerHeight) { applyFun(b); // reset constraints return b; // nothing to do; pop-up window too large to fit even with most constraints } // use bisection method to find least restrictive horizontal constraint that still allows the pop-up window // to fit vertically for (let n = 1; n < 10; ++n) { // use a maximum iteration count to avoid problems with slow convergence let c = ((a + b) / 2) | 0; // cast to integer for improved performance let dlgHeightC = evaluateFun(c); if (dlgHeightC < containerHeight) { a = c; // found a better lower bound dlgHeightA = dlgHeightC; } else { b = c; // found a better upper bound dlgHeightB = dlgHeightC; } } // when the algorithm terminates, lower and upper bound are close; apply the lower bound as the value we seek applyFun(a); return a; } /** * Set maximum width for dialog so that it does not exceed viewport dimensions. * CSS property max-height: 100% is not respected by browsers in this context: the height of the containing * block is not specified explicitly (i.e., it depends on content height), and the element is not absolutely * positioned, therefore the percentage value is treated as none (to avoid infinite re-calculation loops in * layout); as a work-around, we set an upper limit on width instead. * @this {BoxPlusXDialog} */ function setMaximumDialogSize() { if (BoxPlusXDimensionBehavior.FixedAspectRatio === this.aspect) { // for fixed aspect ratio, we vary the maximum dialog width in terms of the width of the container element // (browser viewport), expressed as a percentage value let dialogStyle = this.dialog.style; bisectionSearch.call(this, 0, 1000, function (value) { dialogStyle.setProperty('max-width', (value / 10) + '%'); }) } else if (BoxPlusXDimensionBehavior.ResizableBestFit === this.aspect || BoxPlusXDimensionBehavior.Resizable === this.aspect) { // for dynamic aspect ratio, we vary the content holder element pixel height let containerStyle = this.innerContainer.style; containerStyle.removeProperty('max-height'); let value = bisectionSearch.call(this, 0, window.innerHeight, function (value) { containerStyle.setProperty('height', value + 'px'); }) containerStyle.removeProperty('height'); containerStyle.setProperty('max-height', Math.min(value, this.preferredHeight) + 'px'); } } /** * Retrieves EXIF image orientation and other metadata. * @param {!HTMLImageElement} image The image from which to extract information. * @param {function(number, !Object=)} callback Invoked passing the EXIF orientation and metadata. * @this {BoxPlusXDialog} */ function getImageMetadata(image, callback) { let url = image.src; if (/^file:/.test(url)) { callback(-3); // cross-origin requests are only supported for protocol schemes such as 'http' and 'https' return; } let EXIF = window['EXIF']; if (/** @type {boolean} */ (this.options['metadata']) && !!EXIF) { // use third-party plugin Exif.js to extract orientation and metadata, see <https://github.com/exif-js/exif-js> EXIF.getData(image, function() { let orientation = 0; let metadata; let m = Object.assign({}, /** @type {!Object} */ (image['iptcdata']), /** @type {!Object} */ (image['exifdata'])); if (Object.keys(m).length > 0) { metadata = m; let o = /** @type {string|number|undefined} */ (m['Orientation']); if (o) { orientation = +o; // coerce to number } } callback(orientation, metadata); }); } else { // use simple built-in method to extract orientation getImageOrientationFromURL(url, function (orientation) { callback(orientation); }); } } /** * Makes the specified item currently active. * @param {number} index The zero-based index of the item to be displayed. * @this {BoxPlusXDialog} */ function navigateToIndex(index) { let self = this; const member = this.members[index]; this.trail.push(index); let computedStyle = window.getComputedStyle(this.dialog); /** @type {string} */ const currentWidth = computedStyle.getPropertyValue('width'); /** @type {string} */ const currentHeight = computedStyle.getPropertyValue('height'); stopSlideshow.call(this); setVisible(this.progressIndicator, true); // save caption text let title = /** @type {string} */ (member['title']); let description = /** @type {string} */ (member['description']); const href = /** @type {string} */ (member['url']); const urlparts = parseURL(href); const path = urlparts.pathname; /** @type {!Object<string,string>} */ const parameters = Object.assign({}, urlparts.queryparams, urlparts.fragmentparams); this.preferredWidth = parseInt(parameters['width'], 10) || /** @type {number} */ (this.options['preferredWidth']); this.preferredHeight = parseInt(parameters['height'], 10) || /** @type {number} */ (this.options['preferredHeight']); if (isHashChange(href)) { const target = urlparts.id ? urlparts.id : parameters['target']; let elem = document.getElementById(target); if (elem) { let content = elem.cloneNode(true); replaceContent.call(this, content, title, description); setContentType.call(this, BoxPlusXContentType.DocumentFragment); morphDialog.call(this, BoxPlusXDimensionBehavior.Resizable, currentWidth, currentHeight); } else { displayUnavailable.call(this); } } else if (isImageFile(path)) { // download image in the background let image = /** @type {!HTMLImageElement} */ (document.createElement('img')); image.addEventListener('load', function (event) { // try extracting image EXIF orientation for photos getImageMetadata.call(self, image, function (orientation, metadata) { let container = document.createDocumentFragment(); // set image let rotationContainer = createHTMLElement('div'); let imageElement = createHTMLElement('div'); if (orientation > 0) { imageElement.classList.add('boxplusx-orientation-' + orientation); } let imageElementStyle = imageElement.style; imageElementStyle.setProperty('background-image', 'url("' + image.src + '")'); let dpr = self.options['useDevicePixelRatio'] ? (/** @type {number} */ (window['devicePixelRatio']) || 1) : 1; let h = Math.floor(image.naturalHeight / dpr); let w = Math.floor(image.naturalWidth / dpr); if (orientation >= 5 && orientation <= 8) { // image rotated by 90 or 270 degrees self.preferredWidth = h; self.preferredHeight = w; // CSS transform does not affect bounding box for layout, enlarge/shrink CSS width/height // to accommodate for transformation results imageElementStyle.setProperty('width', (100 * w / h) + '%'); imageElementStyle.setProperty('height', (100 * h / w) + '%'); } else { // image rotated by 0 or 180 degrees self.preferredWidth = w; self.preferredHeight = h; // necessary when we re-use existing container accommodating previous image imageElementStyle.removeProperty('width'); imageElementStyle.removeProperty('height'); } if (!self.shrinkToFit) { let rotationContainerStyle = rotationContainer.style; rotationContainerStyle.setProperty('width', self.preferredWidth + 'px'); rotationContainerStyle.setProperty('height', self.preferredHeight + 'px'); } rotationContainer.appendChild(imageElement); container.appendChild(rotationContainer); // get image metadata information if (metadata) { let textElement = createElement('detail', true); let table = createHTMLElement('table'); let keys = Object.keys(metadata); let len = keys.length; keys.sort(); for (let i = 0; i < len; ++i) { let key = keys[i]; let row = createHTMLElement('tr'); let header = createHTMLElement('td'); header.innerText = key; let value = createHTMLElement('td'); value.innerText = metadata[key]; row.appendChild(header); row.appendChild(value); table.appendChild(row); } textElement.appendChild(table); container.appendChild(textElement); } replaceContent.call(self, container, title, description); self.caption.style.setProperty('max-width', self.preferredWidth + 'px'); // must come after replacing content to have any effect setContentType.call(self, BoxPlusXContentType.Image); // start dialog animation morphDialog.call(self, self.shrinkToFit ? BoxPlusXDimensionBehavior.FixedAspectRatio : BoxPlusXDimensionBehavior.ResizableBestFit, currentWidth, currentHeight); }); }, false); image.addEventListener('error', displayUnavailable.bind(this), false); image.src = href; // pre-fetch next image (unless last is shown) to speed up slideshows and viewing images one after the other if (index < self.members.length - 1) { const nextmember = self.members[index + 1]; const nexthref = /** @type {string} */ (nextmember['url']); const nexturlparts = parseURL(nexthref); if (isImageFile(nexturlparts.pathname)) { let nextimage = /** @type {!HTMLImageElement} */ (document.createElement('img')); nextimage.src = nexthref; } } } else if (/\.(mov|mpe?g|mp4|ogg|webm)$/i.test(path)) { // supported by HTML5-native <video> tag let video = /** @type {!HTMLVideoElement} */ (document.createElement('video')); video.controls = true; video.addEventListener('loadedmetadata', function (event) { // set video replaceContent.call(self, video, title, description); setContentType.call(self, BoxPlusXContentType.Video); self.preferredWidth = video.videoWidth; self.preferredHeight = video.videoHeight; morphDialog.call(self, BoxPlusXDimensionBehavior.FixedAspectRatio, currentWidth, currentHeight); }, false); video.addEventListener('error', displayUnavailable.bind(this), false); video.src = href; } else if (/\.pdf$/.test(path)) { let embed = /** @type {!HTMLEmbedElement} */ (document.createElement('embed')); embed.src = href; embed.type = 'application/pdf'; replaceContent.call(self, embed, title, description); setContentType.call(self, BoxPlusXContentType.EmbeddedContent); morphDialog.call(self, BoxPlusXDimensionBehavior.FixedAspectRatio, currentWidth, currentHeight); } else { // check for YouTube URLs let match = /^https?:\/\/(?:www\.)youtu(?:\.be|be\.com)\/(?:embed\/|watch\?v=|v\/|)([-_0-9A-Z]{11,})/i.exec(href); if (match !== null) { displayFrame.call( this, 'https://www.youtube.com/embed/' + match[1] + '?' + buildQuery({ rel: '0', controls: '1', showinfo: '0' }), title, description ); return; } // URL to unrecognized target (a plain URL to an external location) displayFrame.call(this, href, title, description); } } /** * Clears the content in the inner container. * This function clears all CSS properties set from script so they revert to their values specified * in the stylesheet file. * @this {BoxPlusXDialog} */ function clearContent() { // remove all HTML child elements removeChildNodes(this.innerContainer); let dialogStyle = this.dialog.style; let aspectStyle = this.aspectHolder.style; let containerStyle = this.innerContainer.style; // remove CSS properties that force the aspect ratio aspectStyle.removeProperty('padding-top'); aspectStyle.removeProperty('width'); // remove content and content styling containerStyle.removeProperty('width'); // preferred width // remove fit to window constraints dialogStyle.removeProperty('max-width'); containerStyle.removeProperty('max-height'); } /** * Replaces the content currently displayed in the pop-up window. * @param {!DocumentFragment|!Element} content HTML content to place in the viewport area. * @param {string} title The caption text title to associate with the item. * @param {string} description The caption text description to associate with the item. * @this {BoxPlusXDialog} */ function replaceContent(content, title, description) { clearContent.call(this); this.innerContainer.appendChild(content); this.caption.style.removeProperty('max-width'); // reset caption style this.captionTitle.innerHTML = title; this.captionDescription.innerHTML = description; } /** * @this {BoxPlusXDialog} */ function displayUnavailable() { // set unavailable image setContentType.call(this, BoxPlusXContentType.Unavailable); // start dialog animation morphDialog.call(this, BoxPlusXDimensionBehavior.FixedAspectRatio); } /** * Displays the contents of an external page in the pop-up window. * @param {string} src The URL to the source to be displayed. * @param {string} title The caption text title to associate with the item. * @param {string} description The caption text description to associate with the item. * @this {BoxPlusXDialog} */ function displayFrame(src, title, description) { let self = this; let frame = /** @type {!HTMLIFrameElement} */ (document.createElement('iframe')); frame.width = '' + this.preferredWidth; frame.height = '' + this.preferredHeight; frame.src = src; // HTML iframe must be added to the DOM in order for the 'load' event to be triggered replaceContent.call(this, frame, title, description); // must register 'load' event after adding to the DOM to avoid the event being triggered for blank document let hasFired = false; frame.addEventListener('load', function (event) { // make sure spurious 'load' events are ignored // (the third parameter to addEventListener called 'options' is not supported in all browsers) if (hasFired) { return; } hasFired = true; setContentType.call(self, BoxPlusXContentType.Frame); morphDialog.call(self, BoxPlusXDimensionBehavior.FixedSize); }, false); } /** * Restarts the slideshow timer. * @this {BoxPlusXDialog} */ function startSlideshow() { stopSlideshow.call(this); this.timer = window.setTimeout(this.next.bind(this), this.options['slideshow']); } /** * Stops the slideshow timer. * @this {BoxPlusXDialog} */ function stopSlideshow() { if (this.timer) { window.clearTimeout(this.timer); this.timer = null; } } /** * Returns the current offset of an element from the edge, taking into account text directionality. * @param {!HTMLElement} item * @return {number} * @this {BoxPlusXDialog} */ function getItemEdgeOffset(item) { const writingsystem = /** @type {BoxPlusXWritingSystem} */ (this.options['dir']); switch (writingsystem) { case 'rtl': const parentItem = /** @type {!HTMLElement} */ (item.offsetParent); return parentItem.offsetWidth - item.offsetWidth - item.offsetLeft; // an implementation of function offsetRight case 'ltr': default: return item.offsetLeft; } } /** * Returns the maximum value for positioning the quick-access navigation bar. * Values in the range [-maximum; 0] are permitted as pixel length values for the CSS left property in order for * the navigation bar to remain in view. * @return {number} * @this {BoxPlusXDialog} */ function getNavigationRange() { return Math.max(this.navigationBar.offsetWidth - this.navigationArea.offsetWidth, 0); } /** * Returns the current navigation bar position, taking into account text directionality. * @return {number} * @this {BoxPlusXDialog} */ function getNavigationPosition() { // negate computed value because the property offsetLeft or offsetRight takes values in the range [-maximum; 0] return -getItemEdgeOffset.call(this, this.navigationBar); } /** * Starts moving the navigation bar towards the specified target position. * @param {number} targetPosition A nonnegative number, indicating target position. * @param {number} duration A nonnegative number, indicating number of milliseconds for the animation to take. * @this {BoxPlusXDialog} */ function slideNavigationBar(targetPosition, duration) { const rtl = /** @type {BoxPlusXWritingSystem} */ (this.options['dir']) == BoxPlusXWritingSystem.RightToLeft; let navigationStyle = this.navigationBar.style; navigationStyle.setProperty(rtl ? 'right' : 'left', (-targetPosition) + 'px'); navigationStyle.setProperty('transition-duration', duration > 0 ? (5 * duration) + 'ms' : ''); } /** * @return {boolean} * @this {BoxPlusXDialog} */ function isNavigationBarSliding() { return !!this.navigationBar.style.getPropertyValue('transition-duration'); } /** * Re-position the navigation bar so that the active item is aligned with the left edge of the navigation area. * @this {BoxPlusXDialog} */ function repositionNavigationBar() { if (isVisible(this.navigationArea)) { // remove focus from navigation item corresponding to previously active item for (let k = 0; k < this.navigationBar.childNodes.length; ++k) { /** @type {HTMLElement} */ (this.navigationBar.childNodes[k]).classList.remove('boxplusx-current'); } // set focus on navigation item corresponding to currently active item const index = getCurrentIndex.call(this); const maximum = getNavigationRange.call(this); // the maximum permitted offset let item = /** @type {HTMLElement} */ (this.navigationBar.childNodes[index]); item.classList.add('boxplusx-current'); // get the current scroll offset, which may possibly be out of view let scrollPosition = getNavigationPosition.call(this); const itemEdgeOffset = getItemEdgeOffset.call(this, item); // the last position to scroll forward to before the current item goes (partially) out of view let lastForwardScrollFit = Math.min(maximum, itemEdgeOffset); if (scrollPosition > lastForwardScrollFit) { scrollPosition = lastForwardScrollFit; } // the last position to scroll backward to before the current item goes (partially) out of view // subtract item width because items are left offset-aligned let lastBackwardScrollFit = Math.max(0, itemEdgeOffset - this.navigationArea.offsetWidth + item.offsetWidth); if (scrollPosition < lastBackwardScrollFit) { scrollPosition = lastBackwardScrollFit; } slideNavigationBar.call(this, scrollPosition, 0); // temporarily disable any transition animation } } /** * @this {BoxPlusXDialog} */ function rewindNavigationBar() { const maximum = getNavigationRange.call(this); const current = maximum - getNavigationPosition.call(this); // set target position for navigation bar, reached via CSS transition animation // furthermost position for rewinding corresponds to the navigation bar pushed to the rightmost permitted // position (left offset value 0), set transition duration depending on how far we are from the furthermost // position to get a constant movement speed, regardless of what the current navigation bar position is slideNavigationBar.call(this, 0, maximum - current); } /** * @this {BoxPlusXDialog} */ function forwardNavigationBar() { const maximum = getNavigationRange.call(this); const current = getNavigationPosition.call(this); // set target position for navigation bar, reached via CSS transition animation // furthermost position for forwarding corresponds to the navigation bar pushed to the leftmost permitted // position (greatest absolute value), set transition duration depending on how far we are from the furthermost // position to get a constant movement speed, regardless of what the current navigation bar position is slideNavigationBar.call(this, maximum, maximum - current); } /** * @this {BoxPlusXDialog} */ function stopNavigationBar() { // stop CSS transition animation by forcing the current offset values returned by computed style slideNavigationBar.call(this, getNavigationPosition.call(this), 0); // temporarily disable any transition animation } // // Examples // /** * Discovers boxplusx links on a web page. * boxplusx links are regular HTML <a> elements whose 'rel' attribute has a value with the pattern 'boxplusx-NNN' * where NNN is a unique name. All items that share the same unique name are organized into the same gallery. When * the user clicks an item that is part of a gallery, the item opens in the pop-up window and users can navigate * between this and other items in the gallery without closing the pop-up window. * @param {boolean} strict * @param {string=} activator * @param {Object=} options */ BoxPlusXDialog['discover'] = function (strict, activator, options) { activator = activator || 'boxplusx'; /** * Discovers groups of pop-up window display items on a web page. * @param {!NodeList<!HTMLAnchorElement>} items A list of elements to inspect. * @return {!Object<string,!Array<!HTMLAnchorElement>>} */ function findGroups(items) { // make groups by name /** @type {!Object<string,!Array<!HTMLAnchorElement>>} */ let groups = {}; [].forEach.call(items, function (/** @type {!HTMLAnchorElement} */ item) { let identifier = item.getAttribute('rel'); if (!Object.prototype.hasOwnProperty.call(groups, identifier)) { groups[identifier] = []; } groups[identifier].push(item); }); return groups; } let dialog = new BoxPlusXDialog(options); // links with "rel" attribute that start with (but are not identical to) the activation string const groups = findGroups(/** @type {!NodeList<!HTMLAnchorElement>} */ (document.querySelectorAll('a[href][rel^=' + activator + ']:not([rel=' + activator + '])'))); Object.keys(groups).forEach(function (identifier) { dialog.bind(groups[identifier]); }); [].filter.call(/** @type {!NodeList<!HTMLAnchorElement>} */ (document.querySelectorAll('a[href][rel=' + activator + ']')), function (/** @type {!HTMLAnchorElement} */ item) { dialog.bind([item]); }); if (!strict) { // individual links to images or video not part of a gallery let items = /** @type {!NodeList<!HTMLAnchorElement>} */ (document.querySelectorAll('a[href]:not([rel^=' + activator + '])')); [].filter.call(items, function (/** @type {!HTMLAnchorElement} */ item) { return /\.(gif|jpe?g|png|svg|mov|mpe?g|ogg|webm)$/i.test(item.pathname) && !item.target; }).forEach(function (/** @type {!HTMLAnchorElement} */ item) { dialog.bind([item]); }); } }; })();