/*! Copyright 2019, Akamai Technologies, Inc. All Rights Reserved. akamai-viewer-0.7.2 *//** * Generic animation class with support for dropped frames both optional easing and duration. * * Optional duration is useful when the lifetime is defined by another condition than time * e.g. speed of an animating object, etc. * * Dropped frame logic allows to keep using the same updater logic independent from the actual * rendering. This eases a lot of cases where it might be pretty complex to break down a state * based on the pure time difference. */ (function(global) { var time = Date.now || function() { return +new Date(); }; var desiredFrames = 60; var millisecondsPerSecond = 1000; var running = {}; var counter = 1; // Create namespaces if (!global.core) { global.core = { effect : {} }; } else if (!core.effect) { core.effect = {}; } core.effect.Animate = { /** * A requestAnimationFrame wrapper / polyfill. * * @param callback {Function} The callback to be invoked before the next repaint. * @param root {HTMLElement} The root element for the repaint */ requestAnimationFrame: (function() { // Check for request animation Frame support var requestFrame = global.requestAnimationFrame || global.webkitRequestAnimationFrame || global.mozRequestAnimationFrame || global.oRequestAnimationFrame; var isNative = !!requestFrame; if (requestFrame && !/requestAnimationFrame\(\)\s*\{\s*\[native code\]\s*\}/i.test(requestFrame.toString())) { isNative = false; } if (isNative) { return function(callback, root) { requestFrame(callback, root) }; } var TARGET_FPS = 60; var requests = {}; var requestCount = 0; var rafHandle = 1; var intervalHandle = null; var lastActive = +new Date(); return function(callback, root) { var callbackHandle = rafHandle++; // Store callback requests[callbackHandle] = callback; requestCount++; // Create timeout at first request if (intervalHandle === null) { intervalHandle = setInterval(function() { var time = +new Date(); var currentRequests = requests; // Reset data structure before executing callbacks requests = {}; requestCount = 0; for(var key in currentRequests) { if (currentRequests.hasOwnProperty(key)) { currentRequests[key](time); lastActive = time; } } // Disable the timeout when nothing happens for a certain // period of time if (time - lastActive > 2500) { clearInterval(intervalHandle); intervalHandle = null; } }, 1000 / TARGET_FPS); } return callbackHandle; }; })(), /** * Stops the given animation. * * @param id {Integer} Unique animation ID * @return {Boolean} Whether the animation was stopped (aka, was running before) */ stop: function(id) { var cleared = running[id] != null; if (cleared) { running[id] = null; } return cleared; }, /** * Whether the given animation is still running. * * @param id {Integer} Unique animation ID * @return {Boolean} Whether the animation is still running */ isRunning: function(id) { return running[id] != null; }, /** * Start the animation. * * @param stepCallback {Function} Pointer to function which is executed on every step. * Signature of the method should be `function(percent, now, virtual) { return continueWithAnimation; }` * @param verifyCallback {Function} Executed before every animation step. * Signature of the method should be `function() { return continueWithAnimation; }` * @param completedCallback {Function} * Signature of the method should be `function(droppedFrames, finishedAnimation) {}` * @param duration {Integer} Milliseconds to run the animation * @param easingMethod {Function} Pointer to easing function * Signature of the method should be `function(percent) { return modifiedValue; }` * @param root {Element ? document.body} Render root, when available. Used for internal * usage of requestAnimationFrame. * @return {Integer} Identifier of animation. Can be used to stop it any time. */ start: function(stepCallback, verifyCallback, completedCallback, duration, easingMethod, root) { var start = time(); var lastFrame = start; var percent = 0; var dropCounter = 0; var id = counter++; if (!root) { root = document.body; } // Compacting running db automatically every few new animations if (id % 20 === 0) { var newRunning = {}; for (var usedId in running) { newRunning[usedId] = true; } running = newRunning; } // This is the internal step method which is called every few milliseconds var step = function(virtual) { // Normalize virtual value var render = virtual !== true; // Get current time var now = time(); // Verification is executed before next animation step if (!running[id] || (verifyCallback && !verifyCallback(id))) { running[id] = null; completedCallback && completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, false); return; } // For the current rendering to apply let's update omitted steps in memory. // This is important to bring internal state variables up-to-date with progress in time. if (render) { var droppedFrames = Math.round((now - lastFrame) / (millisecondsPerSecond / desiredFrames)) - 1; for (var j = 0; j < Math.min(droppedFrames, 4); j++) { step(true); dropCounter++; } } // Compute percent value if (duration) { percent = (now - start) / duration; if (percent > 1) { percent = 1; } } // Execute step callback, then... var value = easingMethod ? easingMethod(percent) : percent; if ((stepCallback(value, now, render) === false || percent === 1) && render) { running[id] = null; completedCallback && completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, percent === 1 || duration == null); } else if (render) { lastFrame = now; core.effect.Animate.requestAnimationFrame(step, root); } }; // Mark as running running[id] = true; // Init first step core.effect.Animate.requestAnimationFrame(step, root); // Return unique animation ID return id; } }; })(this); var Scroller; (function() { /** * A pure logic 'component' for 'virtual' scrolling/zooming. */ Scroller = function(callback, options) { this.__callback = callback; this.options = { /** Enable scrolling on x-axis */ scrollingX: true, /** Enable scrolling on y-axis */ scrollingY: true, /** Enable animations for deceleration, snap back, zooming and scrolling */ animating: true, /** duration for animations triggered by scrollTo/zoomTo */ animationDuration: 250, /** Enable bouncing (content can be slowly moved outside and jumps back after releasing) */ bouncing: true, /** Enable locking to the main axis if user moves only slightly on one of them at start */ locking: true, /** Enable pagination mode (switching between full page content panes) */ paging: false, /** Enable snapping of content to a configured pixel grid */ snapping: false, /** Enable zooming of content via API, fingers and mouse wheel */ zooming: false, /** Minimum zoom level */ minZoom: 0.5, /** Maximum zoom level */ maxZoom: 3 }; for (var key in options) { this.options[key] = options[key]; } }; // Easing Equations (c) 2003 Robert Penner, all rights reserved. // Open source under the BSD License. /** * @param pos {Number} position between 0 (start of effect) and 1 (end of effect) **/ var easeOutCubic = function(pos) { return (Math.pow((pos - 1), 3) + 1); }; /** * @param pos {Number} position between 0 (start of effect) and 1 (end of effect) **/ var easeInOutCubic = function(pos) { if ((pos /= 0.5) < 1) { return 0.5 * Math.pow(pos, 3); } return 0.5 * (Math.pow((pos - 2), 3) + 2); }; var members = { /* --------------------------------------------------------------------------- INTERNAL FIELDS :: STATUS --------------------------------------------------------------------------- */ /** {Boolean} Whether only a single finger is used in touch handling */ __isSingleTouch: false, /** {Boolean} Whether a touch event sequence is in progress */ __isTracking: false, /** * {Boolean} Whether a gesture zoom/rotate event is in progress. Activates when * a gesturestart event happens. This has higher priority than dragging. */ __isGesturing: false, /** * {Boolean} Whether the user has moved by such a distance that we have enabled * dragging mode. Hint: It's only enabled after some pixels of movement to * not interrupt with clicks etc. */ __isDragging: false, /** * {Boolean} Not touching and dragging anymore, and smoothly animating the * touch sequence using deceleration. */ __isDecelerating: false, /** * {Boolean} Smoothly animating the currently configured change */ __isAnimating: false, /* --------------------------------------------------------------------------- INTERNAL FIELDS :: DIMENSIONS --------------------------------------------------------------------------- */ /** {Integer} Available outer left position (from document perspective) */ __clientLeft: 0, /** {Integer} Available outer top position (from document perspective) */ __clientTop: 0, /** {Integer} Available outer width */ __clientWidth: 0, /** {Integer} Available outer height */ __clientHeight: 0, /** {Integer} Outer width of content */ __contentWidth: 0, /** {Integer} Outer height of content */ __contentHeight: 0, /** {Integer} Snapping width for content */ __snapWidth: 100, /** {Integer} Snapping height for content */ __snapHeight: 100, /** {Integer} Height to assign to refresh area */ __refreshHeight: null, /** {Boolean} Whether the refresh process is enabled when the event is released now */ __refreshActive: false, /** {Function} Callback to execute on activation. This is for signalling the user about a refresh is about to happen when he release */ __refreshActivate: null, /** {Function} Callback to execute on deactivation. This is for signalling the user about the refresh being cancelled */ __refreshDeactivate: null, /** {Function} Callback to execute to start the actual refresh. Call {@link #refreshFinish} when done */ __refreshStart: null, /** {Number} Zoom level */ __zoomLevel: 1, /** {Number} Scroll position on x-axis */ __scrollLeft: 0, /** {Number} Scroll position on y-axis */ __scrollTop: 0, /** {Integer} Maximum allowed scroll position on x-axis */ __maxScrollLeft: 0, /** {Integer} Maximum allowed scroll position on y-axis */ __maxScrollTop: 0, /* {Number} Scheduled left position (final position when animating) */ __scheduledLeft: 0, /* {Number} Scheduled top position (final position when animating) */ __scheduledTop: 0, /* {Number} Scheduled zoom level (final scale when animating) */ __scheduledZoom: 0, /* --------------------------------------------------------------------------- INTERNAL FIELDS :: LAST POSITIONS --------------------------------------------------------------------------- */ /** {Number} Left position of finger at start */ __lastTouchLeft: null, /** {Number} Top position of finger at start */ __lastTouchTop: null, /** {Date} Timestamp of last move of finger. Used to limit tracking range for deceleration speed. */ __lastTouchMove: null, /** {Array} List of positions, uses three indexes for each state: left, top, timestamp */ __positions: null, /* --------------------------------------------------------------------------- INTERNAL FIELDS :: DECELERATION SUPPORT --------------------------------------------------------------------------- */ /** {Integer} Minimum left scroll position during deceleration */ __minDecelerationScrollLeft: null, /** {Integer} Minimum top scroll position during deceleration */ __minDecelerationScrollTop: null, /** {Integer} Maximum left scroll position during deceleration */ __maxDecelerationScrollLeft: null, /** {Integer} Maximum top scroll position during deceleration */ __maxDecelerationScrollTop: null, /** {Number} Current factor to modify horizontal scroll position with on every step */ __decelerationVelocityX: null, /** {Number} Current factor to modify vertical scroll position with on every step */ __decelerationVelocityY: null, /* --------------------------------------------------------------------------- PUBLIC API --------------------------------------------------------------------------- */ /** * Configures the dimensions of the client (outer) and content (inner) elements. * Requires the available space for the outer element and the outer size of the inner element. * All values which are falsy (null or zero etc.) are ignored and the old value is kept. * * @param clientWidth {Integer ? null} Inner width of outer element * @param clientHeight {Integer ? null} Inner height of outer element * @param contentWidth {Integer ? null} Outer width of inner element * @param contentHeight {Integer ? null} Outer height of inner element */ setDimensions: function(clientWidth, clientHeight, contentWidth, contentHeight) { var self = this; // Only update values which are defined if (clientWidth) { self.__clientWidth = clientWidth; } if (clientHeight) { self.__clientHeight = clientHeight; } if (contentWidth) { self.__contentWidth = contentWidth; } if (contentHeight) { self.__contentHeight = contentHeight; } // Refresh maximums self.__computeScrollMax(); // Refresh scroll position self.scrollTo(self.__scrollLeft, self.__scrollTop, true); }, /** * Sets the client coordinates in relation to the document. * * @param left {Integer ? 0} Left position of outer element * @param top {Integer ? 0} Top position of outer element */ setPosition: function(left, top) { var self = this; self.__clientLeft = left || 0; self.__clientTop = top || 0; }, /** * Configures the snapping (when snapping is active) * * @param width {Integer} Snapping width * @param height {Integer} Snapping height */ setSnapSize: function(width, height) { var self = this; self.__snapWidth = width; self.__snapHeight = height; }, /** * Activates pull-to-refresh. A special zone on the top of the list to start a list refresh whenever * the user event is released during visibility of this zone. This was introduced by some apps on iOS like * the official Twitter client. * * @param height {Integer} Height of pull-to-refresh zone on top of rendered list * @param activateCallback {Function} Callback to execute on activation. This is for signalling the user about a refresh is about to happen when he release. * @param deactivateCallback {Function} Callback to execute on deactivation. This is for signalling the user about the refresh being cancelled. * @param startCallback {Function} Callback to execute to start the real async refresh action. Call {@link #finishPullToRefresh} after finish of refresh. */ activatePullToRefresh: function(height, activateCallback, deactivateCallback, startCallback) { var self = this; self.__refreshHeight = height; self.__refreshActivate = activateCallback; self.__refreshDeactivate = deactivateCallback; self.__refreshStart = startCallback; }, /** * Signalizes that pull-to-refresh is finished. */ finishPullToRefresh: function() { var self = this; self.__refreshActive = false; if (self.__refreshDeactivate) { self.__refreshDeactivate(); } self.scrollTo(self.__scrollLeft, self.__scrollTop, true); }, /** * Returns the scroll position and zooming values * * @return {Map} `left` and `top` scroll position and `zoom` level */ getValues: function() { var self = this; return { left: self.__scrollLeft, top: self.__scrollTop, zoom: self.__zoomLevel }; }, /** * Returns the maximum scroll values * * @return {Map} `left` and `top` maximum scroll values */ getScrollMax: function() { var self = this; return { left: self.__maxScrollLeft, top: self.__maxScrollTop }; }, /** * Zooms to the given level. Supports optional animation. Zooms * the center when no coordinates are given. * * @param level {Number} Level to zoom to * @param animate {Boolean ? false} Whether to use animation * @param originLeft {Number ? null} Zoom in at given left coordinate * @param originTop {Number ? null} Zoom in at given top coordinate */ zoomTo: function(level, animate, originLeft, originTop) { var self = this; if (!self.options.zooming) { throw new Error("Zooming is not enabled!"); } // Stop deceleration if (self.__isDecelerating) { core.effect.Animate.stop(self.__isDecelerating); self.__isDecelerating = false; } var oldLevel = self.__zoomLevel; // Normalize input origin to center of viewport if not defined if (originLeft == null) { originLeft = self.__clientWidth / 2; } if (originTop == null) { originTop = self.__clientHeight / 2; } // Limit level according to configuration level = Math.max(Math.min(level, self.options.maxZoom), self.options.minZoom); // Recompute maximum values while temporary tweaking maximum scroll ranges self.__computeScrollMax(level); // Recompute left and top coordinates based on new zoom level var left = ((originLeft + self.__scrollLeft) * level / oldLevel) - originLeft; var top = ((originTop + self.__scrollTop) * level / oldLevel) - originTop; // Limit x-axis if (left > self.__maxScrollLeft) { left = self.__maxScrollLeft; } else if (left < 0) { left = 0; } // Limit y-axis if (top > self.__maxScrollTop) { top = self.__maxScrollTop; } else if (top < 0) { top = 0; } // Push values out self.__publish(left, top, level, animate); }, /** * Zooms the content by the given factor. * * @param factor {Number} Zoom by given factor * @param animate {Boolean ? false} Whether to use animation * @param originLeft {Number ? 0} Zoom in at given left coordinate * @param originTop {Number ? 0} Zoom in at given top coordinate */ zoomBy: function(factor, animate, originLeft, originTop) { var self = this; self.zoomTo(self.__zoomLevel * factor, animate, originLeft, originTop); }, /** * Scrolls to the given position. Respect limitations and snapping automatically. * * @param left {Number?null} Horizontal scroll position, keeps current if value is null * @param top {Number?null} Vertical scroll position, keeps current if value is null * @param animate {Boolean?false} Whether the scrolling should happen using an animation * @param zoom {Number?null} Zoom level to go to */ scrollTo: function(left, top, animate, zoom) { var self = this; // Stop deceleration if (self.__isDecelerating) { core.effect.Animate.stop(self.__isDecelerating); self.__isDecelerating = false; } // Correct coordinates based on new zoom level if (zoom != null && zoom !== self.__zoomLevel) { if (!self.options.zooming) { throw new Error("Zooming is not enabled!"); } left *= zoom; top *= zoom; // Recompute maximum values while temporary tweaking maximum scroll ranges self.__computeScrollMax(zoom); } else { // Keep zoom when not defined zoom = self.__zoomLevel; } if (!self.options.scrollingX) { left = self.__scrollLeft; } else { if (self.options.paging) { left = Math.round(left / self.__clientWidth) * self.__clientWidth; } else if (self.options.snapping) { left = Math.round(left / self.__snapWidth) * self.__snapWidth; } } if (!self.options.scrollingY) { top = self.__scrollTop; } else { if (self.options.paging) { top = Math.round(top / self.__clientHeight) * self.__clientHeight; } else if (self.options.snapping) { top = Math.round(top / self.__snapHeight) * self.__snapHeight; } } // Limit for allowed ranges left = Math.max(Math.min(self.__maxScrollLeft, left), 0); top = Math.max(Math.min(self.__maxScrollTop, top), 0); // Don't animate when no change detected, still call publish to make sure // that rendered position is really in-sync with internal data if (left === self.__scrollLeft && top === self.__scrollTop) { animate = false; } // Publish new values self.__publish(left, top, zoom, animate); }, /** * Scroll by the given offset * * @param left {Number ? 0} Scroll x-axis by given offset * @param top {Number ? 0} Scroll x-axis by given offset * @param animate {Boolean ? false} Whether to animate the given change */ scrollBy: function(left, top, animate) { var self = this; var startLeft = self.__isAnimating ? self.__scheduledLeft : self.__scrollLeft; var startTop = self.__isAnimating ? self.__scheduledTop : self.__scrollTop; self.scrollTo(startLeft + (left || 0), startTop + (top || 0), animate); }, /* --------------------------------------------------------------------------- EVENT CALLBACKS --------------------------------------------------------------------------- */ /** * Mouse wheel handler for zooming support */ doMouseZoom: function(wheelDelta, timeStamp, pageX, pageY) { var self = this; var change = wheelDelta > 0 ? 0.97 : 1.03; return self.zoomTo(self.__zoomLevel * change, false, pageX - self.__clientLeft, pageY - self.__clientTop); }, /** * Touch start handler for scrolling support */ doTouchStart: function(touches, timeStamp) { // Array-like check is enough here if (touches.length == null) { throw new Error("Invalid touch list: " + touches); } if (timeStamp instanceof Date) { timeStamp = timeStamp.valueOf(); } if (typeof timeStamp !== "number") { throw new Error("Invalid timestamp value: " + timeStamp); } var self = this; // Stop deceleration if (self.__isDecelerating) { core.effect.Animate.stop(self.__isDecelerating); self.__isDecelerating = false; } // Stop animation if (self.__isAnimating) { core.effect.Animate.stop(self.__isAnimating); self.__isAnimating = false; } // Use center point when dealing with two fingers var currentTouchLeft, currentTouchTop; var isSingleTouch = touches.length === 1; if (isSingleTouch) { currentTouchLeft = touches[0].pageX; currentTouchTop = touches[0].pageY; } else { currentTouchLeft = Math.abs(touches[0].pageX + touches[1].pageX) / 2; currentTouchTop = Math.abs(touches[0].pageY + touches[1].pageY) / 2; } // Store initial positions self.__initialTouchLeft = currentTouchLeft; self.__initialTouchTop = currentTouchTop; // Store current zoom level self.__zoomLevelStart = self.__zoomLevel; // Store initial touch positions self.__lastTouchLeft = currentTouchLeft; self.__lastTouchTop = currentTouchTop; // Store initial move time stamp self.__lastTouchMove = timeStamp; // Reset initial scale self.__lastScale = 1; // Reset locking flags self.__enableScrollX = !isSingleTouch && self.options.scrollingX; self.__enableScrollY = !isSingleTouch && self.options.scrollingY; // Reset tracking flag self.__isTracking = true; // Dragging starts directly with two fingers, otherwise lazy with an offset self.__isDragging = !isSingleTouch; // Some features are disabled in multi touch scenarios self.__isSingleTouch = isSingleTouch; // Clearing data structure self.__positions = []; }, /** * Touch move handler for scrolling support */ doTouchMove: function(touches, timeStamp, scale) { // Array-like check is enough here if (touches.length == null) { throw new Error("Invalid touch list: " + touches); } if (timeStamp instanceof Date) { timeStamp = timeStamp.valueOf(); } if (typeof timeStamp !== "number") { throw new Error("Invalid timestamp value: " + timeStamp); } var self = this; // Ignore event when tracking is not enabled (event might be outside of element) if (!self.__isTracking) { return; } var currentTouchLeft, currentTouchTop; // Compute move based around of center of fingers if (touches.length === 2) { currentTouchLeft = Math.abs(touches[0].pageX + touches[1].pageX) / 2; currentTouchTop = Math.abs(touches[0].pageY + touches[1].pageY) / 2; } else { currentTouchLeft = touches[0].pageX; currentTouchTop = touches[0].pageY; } var positions = self.__positions; // Are we already is dragging mode? if (self.__isDragging) { // Compute move distance var moveX = currentTouchLeft - self.__lastTouchLeft; var moveY = currentTouchTop - self.__lastTouchTop; // Read previous scroll position and zooming var scrollLeft = self.__scrollLeft; var scrollTop = self.__scrollTop; var level = self.__zoomLevel; // Work with scaling if (scale != null && self.options.zooming) { var oldLevel = level; // Recompute level based on previous scale and new scale level = level / self.__lastScale * scale; // Limit level according to configuration level = Math.max(Math.min(level, self.options.maxZoom), self.options.minZoom); // Only do further compution when change happened if (oldLevel !== level) { // Compute relative event position to container var currentTouchLeftRel = currentTouchLeft - self.__clientLeft; var currentTouchTopRel = currentTouchTop - self.__clientTop; // Recompute left and top coordinates based on new zoom level scrollLeft = ((currentTouchLeftRel + scrollLeft) * level / oldLevel) - currentTouchLeftRel; scrollTop = ((currentTouchTopRel + scrollTop) * level / oldLevel) - currentTouchTopRel; // Recompute max scroll values self.__computeScrollMax(level); } } if (self.__enableScrollX) { scrollLeft -= moveX; var maxScrollLeft = self.__maxScrollLeft; if (scrollLeft > maxScrollLeft || scrollLeft < 0) { // Slow down on the edges if (self.options.bouncing) { scrollLeft += (moveX / 2); } else if (scrollLeft > maxScrollLeft) { scrollLeft = maxScrollLeft; } else { scrollLeft = 0; } } } // Compute new vertical scroll position if (self.__enableScrollY) { scrollTop -= moveY; var maxScrollTop = self.__maxScrollTop; if (scrollTop > maxScrollTop || scrollTop < 0) { // Slow down on the edges if (self.options.bouncing) { scrollTop += (moveY / 2); // Support pull-to-refresh (only when only y is scrollable) if (!self.__enableScrollX && self.__refreshHeight != null) { if (!self.__refreshActive && scrollTop <= -self.__refreshHeight) { self.__refreshActive = true; if (self.__refreshActivate) { self.__refreshActivate(); } } else if (self.__refreshActive && scrollTop > -self.__refreshHeight) { self.__refreshActive = false; if (self.__refreshDeactivate) { self.__refreshDeactivate(); } } } } else if (scrollTop > maxScrollTop) { scrollTop = maxScrollTop; } else { scrollTop = 0; } } } // Keep list from growing infinitely (holding min 10, max 20 measure points) if (positions.length > 60) { positions.splice(0, 30); } // Track scroll movement for decleration positions.push(scrollLeft, scrollTop, timeStamp); // Sync scroll position self.__publish(scrollLeft, scrollTop, level); // Otherwise figure out whether we are switching into dragging mode now. } else { var minimumTrackingForScroll = self.options.locking ? 3 : 0; var minimumTrackingForDrag = 5; var distanceX = Math.abs(currentTouchLeft - self.__initialTouchLeft); var distanceY = Math.abs(currentTouchTop - self.__initialTouchTop); self.__enableScrollX = self.options.scrollingX && distanceX >= minimumTrackingForScroll; self.__enableScrollY = self.options.scrollingY && distanceY >= minimumTrackingForScroll; positions.push(self.__scrollLeft, self.__scrollTop, timeStamp); self.__isDragging = (self.__enableScrollX || self.__enableScrollY) && (distanceX >= minimumTrackingForDrag || distanceY >= minimumTrackingForDrag); } // Update last touch positions and time stamp for next event self.__lastTouchLeft = currentTouchLeft; self.__lastTouchTop = currentTouchTop; self.__lastTouchMove = timeStamp; self.__lastScale = scale; }, /** * Touch end handler for scrolling support */ doTouchEnd: function(timeStamp) { if (timeStamp instanceof Date) { timeStamp = timeStamp.valueOf(); } if (typeof timeStamp !== "number") { throw new Error("Invalid timestamp value: " + timeStamp); } var self = this; // Ignore event when tracking is not enabled (no touchstart event on element) // This is required as this listener ('touchmove') sits on the document and not on the element itself. if (!self.__isTracking) { return; } // Not touching anymore (when two finger hit the screen there are two touch end events) self.__isTracking = false; // Be sure to reset the dragging flag now. Here we also detect whether // the finger has moved fast enough to switch into a deceleration animation. if (self.__isDragging) { // Reset dragging flag self.__isDragging = false; // Start deceleration // Verify that the last move detected was in some relevant time frame if (self.__isSingleTouch && self.options.animating && (timeStamp - self.__lastTouchMove) <= 100) { // Then figure out what the scroll position was about 100ms ago var positions = self.__positions; var endPos = positions.length - 1; var startPos = endPos; // Move pointer to position measured 100ms ago for (var i = endPos; i > 0 && positions[i] > (self.__lastTouchMove - 100); i -= 3) { startPos = i; } // If start and stop position is identical in a 100ms timeframe, // we cannot compute any useful deceleration. if (startPos !== endPos) { // Compute relative movement between these two points var timeOffset = positions[endPos] - positions[startPos]; var movedLeft = self.__scrollLeft - positions[startPos - 2]; var movedTop = self.__scrollTop - positions[startPos - 1]; // Based on 50ms compute the movement to apply for each render step self.__decelerationVelocityX = movedLeft / timeOffset * (1000 / 60); self.__decelerationVelocityY = movedTop / timeOffset * (1000 / 60); // How much velocity is required to start the deceleration var minVelocityToStartDeceleration = self.options.paging || self.options.snapping ? 4 : 1; // Verify that we have enough velocity to start deceleration if (Math.abs(self.__decelerationVelocityX) > minVelocityToStartDeceleration || Math.abs(self.__decelerationVelocityY) > minVelocityToStartDeceleration) { // Deactivate pull-to-refresh when decelerating if (!self.__refreshActive) { self.__startDeceleration(timeStamp); } } } } } // If this was a slower move it is per default non decelerated, but this // still means that we want snap back to the bounds which is done here. // This is placed outside the condition above to improve edge case stability // e.g. touchend fired without enabled dragging. This should normally do not // have modified the scroll positions or even showed the scrollbars though. if (!self.__isDecelerating) { if (self.__refreshActive && self.__refreshStart) { // Use publish instead of scrollTo to allow scrolling to out of boundary position // We don't need to normalize scrollLeft, zoomLevel, etc. here because we only y-scrolling when pull-to-refresh is enabled self.__publish(self.__scrollLeft, -self.__refreshHeight, self.__zoomLevel, true); if (self.__refreshStart) { self.__refreshStart(); } } else { self.scrollTo(self.__scrollLeft, self.__scrollTop, true, self.__zoomLevel); // Directly signalize deactivation (nothing todo on refresh?) if (self.__refreshActive) { self.__refreshActive = false; if (self.__refreshDeactivate) { self.__refreshDeactivate(); } } } } // Fully cleanup list self.__positions.length = 0; }, /* --------------------------------------------------------------------------- PRIVATE API --------------------------------------------------------------------------- */ /** * Applies the scroll position to the content element * * @param left {Number} Left scroll position * @param top {Number} Top scroll position * @param animate {Boolean?false} Whether animation should be used to move to the new coordinates */ __publish: function(left, top, zoom, animate) { var self = this; // Remember whether we had an animation, then we try to continue based on the current "drive" of the animation var wasAnimating = self.__isAnimating; if (wasAnimating) { core.effect.Animate.stop(wasAnimating); self.__isAnimating = false; } if (animate && self.options.animating) { // Keep scheduled positions for scrollBy/zoomBy functionality self.__scheduledLeft = left; self.__scheduledTop = top; self.__scheduledZoom = zoom; var oldLeft = self.__scrollLeft; var oldTop = self.__scrollTop; var oldZoom = self.__zoomLevel; var diffLeft = left - oldLeft; var diffTop = top - oldTop; var diffZoom = zoom - oldZoom; var step = function(percent, now, render) { if (render) { self.__scrollLeft = oldLeft + (diffLeft * percent); self.__scrollTop = oldTop + (diffTop * percent); self.__zoomLevel = oldZoom + (diffZoom * percent); // Push values out if (self.__callback) { self.__callback(self.__scrollLeft, self.__scrollTop, self.__zoomLevel); } } }; var verify = function(id) { return self.__isAnimating === id; }; var completed = function(renderedFramesPerSecond, animationId, wasFinished) { if (animationId === self.__isAnimating) { self.__isAnimating = false; } if (self.options.zooming) { self.__computeScrollMax(); } }; // When continuing based on previous animation we choose an ease-out animation instead of ease-in-out self.__isAnimating = core.effect.Animate.start(step, verify, completed, self.options.animationDuration, wasAnimating ? easeOutCubic : easeInOutCubic); } else { self.__scheduledLeft = self.__scrollLeft = left; self.__scheduledTop = self.__scrollTop = top; self.__scheduledZoom = self.__zoomLevel = zoom; // Push values out if (self.__callback) { self.__callback(left, top, zoom); } // Fix max scroll ranges if (self.options.zooming) { self.__computeScrollMax(); } } }, /** * Recomputes scroll minimum values based on client dimensions and content dimensions. */ __computeScrollMax: function(zoomLevel) { var self = this; if (zoomLevel == null) { zoomLevel = self.__zoomLevel; } self.__maxScrollLeft = Math.max((self.__contentWidth * zoomLevel) - self.__clientWidth, 0); self.__maxScrollTop = Math.max((self.__contentHeight * zoomLevel) - self.__clientHeight, 0); }, /* --------------------------------------------------------------------------- ANIMATION (DECELERATION) SUPPORT --------------------------------------------------------------------------- */ /** * Called when a touch sequence end and the speed of the finger was high enough * to switch into deceleration mode. */ __startDeceleration: function(timeStamp) { var self = this; if (self.options.paging) { var scrollLeft = Math.max(Math.min(self.__scrollLeft, self.__maxScrollLeft), 0); var scrollTop = Math.max(Math.min(self.__scrollTop, self.__maxScrollTop), 0); var clientWidth = self.__clientWidth; var clientHeight = self.__clientHeight; // We limit deceleration not to the min/max values of the allowed range, but to the size of the visible client area. // Each page should have exactly the size of the client area. self.__minDecelerationScrollLeft = Math.floor(scrollLeft / clientWidth) * clientWidth; self.__minDecelerationScrollTop = Math.floor(scrollTop / clientHeight) * clientHeight; self.__maxDecelerationScrollLeft = Math.ceil(scrollLeft / clientWidth) * clientWidth; self.__maxDecelerationScrollTop = Math.ceil(scrollTop / clientHeight) * clientHeight; } else { self.__minDecelerationScrollLeft = 0; self.__minDecelerationScrollTop = 0; self.__maxDecelerationScrollLeft = self.__maxScrollLeft; self.__maxDecelerationScrollTop = self.__maxScrollTop; } // Wrap class method var step = function(percent, now, render) { self.__stepThroughDeceleration(render); }; // How much velocity is required to keep the deceleration running var minVelocityToKeepDecelerating = self.options.snapping ? 4 : 0.1; // Detect whether it's still worth to continue animating steps // If we are already slow enough to not being user perceivable anymore, we stop the whole process here. var verify = function() { return Math.abs(self.__decelerationVelocityX) >= minVelocityToKeepDecelerating || Math.abs(self.__decelerationVelocityY) >= minVelocityToKeepDecelerating; }; var completed = function(renderedFramesPerSecond, animationId, wasFinished) { self.__isDecelerating = false; // Animate to grid when snapping is active, otherwise just fix out-of-boundary positions self.scrollTo(self.__scrollLeft, self.__scrollTop, self.options.snapping); }; // Start animation and switch on flag self.__isDecelerating = core.effect.Animate.start(step, verify, completed); }, /** * Called on every step of the animation * * @param inMemory {Boolean?false} Whether to not render the current step, but keep it in memory only. Used internally only! */ __stepThroughDeceleration: function(render) { var self = this; // // COMPUTE NEXT SCROLL POSITION // // Add deceleration to scroll position var scrollLeft = self.__scrollLeft + self.__decelerationVelocityX; var scrollTop = self.__scrollTop + self.__decelerationVelocityY; // // HARD LIMIT SCROLL POSITION FOR NON BOUNCING MODE // if (!self.options.bouncing) { var scrollLeftFixed = Math.max(Math.min(self.__maxDecelerationScrollLeft, scrollLeft), self.__minDecelerationScrollLeft); if (scrollLeftFixed !== scrollLeft) { scrollLeft = scrollLeftFixed; self.__decelerationVelocityX = 0; } var scrollTopFixed = Math.max(Math.min(self.__maxDecelerationScrollTop, scrollTop), self.__minDecelerationScrollTop); if (scrollTopFixed !== scrollTop) { scrollTop = scrollTopFixed; self.__decelerationVelocityY = 0; } } // // UPDATE SCROLL POSITION // if (render) { self.__publish(scrollLeft, scrollTop, self.__zoomLevel); } else { self.__scrollLeft = scrollLeft; self.__scrollTop = scrollTop; } // // SLOW DOWN // // Slow down velocity on every iteration if (!self.options.paging) { // This is the factor applied to every iteration of the animation // to slow down the process. This should emulate natural behavior where // objects slow down when the initiator of the movement is removed var frictionFactor = 0.95; self.__decelerationVelocityX *= frictionFactor; self.__decelerationVelocityY *= frictionFactor; } // // BOUNCING SUPPORT // if (self.options.bouncing) { var scrollOutsideX = 0; var scrollOutsideY = 0; // This configures the amount of change applied to deceleration/acceleration when reaching boundaries var penetrationDeceleration = 0.03; var penetrationAcceleration = 0.08; // Check limits if (scrollLeft < self.__minDecelerationScrollLeft) { scrollOutsideX = self.__minDecelerationScrollLeft - scrollLeft; } else if (scrollLeft > self.__maxDecelerationScrollLeft) { scrollOutsideX = self.__maxDecelerationScrollLeft - scrollLeft; } if (scrollTop < self.__minDecelerationScrollTop) { scrollOutsideY = self.__minDecelerationScrollTop - scrollTop; } else if (scrollTop > self.__maxDecelerationScrollTop) { scrollOutsideY = self.__maxDecelerationScrollTop - scrollTop; } // Slow down until slow enough, then flip back to snap position if (scrollOutsideX !== 0) { if (scrollOutsideX * self.__decelerationVelocityX <= 0) { self.__decelerationVelocityX += scrollOutsideX * penetrationDeceleration; } else { self.__decelerationVelocityX = scrollOutsideX * penetrationAcceleration; } } if (scrollOutsideY !== 0) { if (scrollOutsideY * self.__decelerationVelocityY <= 0) { self.__decelerationVelocityY += scrollOutsideY * penetrationDeceleration; } else { self.__decelerationVelocityY = scrollOutsideY * penetrationAcceleration; } } } } }; // Copy over members to prototype for (var key in members) { Scroller.prototype[key] = members[key]; } })(); (function( factory ) { if( typeof define === 'function' && define.amd ) { // AMD. Register as an anonymous module. define( [ 'shoestring' ], factory ); } else if (typeof module === 'object' && module.exports) { // Node/CommonJS module.exports = factory(); } else { // Browser globals factory(); } }(function () { var win = typeof window !== "undefined" ? window : this; var doc = win.document; /** * The shoestring object constructor. * * @param {string,object} prim The selector to find or element to wrap. * @param {object} sec The context in which to match the `prim` selector. * @returns shoestring * @this window */ function shoestring( prim, sec ){ var pType = typeof( prim ), ret = [], sel; // return an empty shoestring object if( !prim ){ return new Shoestring( ret ); } // ready calls if( prim.call ){ return shoestring.ready( prim ); } // handle re-wrapping shoestring objects if( prim.constructor === Shoestring && !sec ){ return prim; } // if string starting with <, make html if( pType === "string" && prim.indexOf( "<" ) === 0 ){ var dfrag = doc.createElement( "div" ); dfrag.innerHTML = prim; // TODO depends on children (circular) return shoestring( dfrag ).children().each(function(){ dfrag.removeChild( this ); }); } // if string, it's a selector, use qsa if( pType === "string" ){ if( sec ){ return shoestring( sec ).find( prim ); } sel = doc.querySelectorAll( prim ); return new Shoestring( sel, prim ); } // array like objects or node lists if( Object.prototype.toString.call( pType ) === '[object Array]' || (win.NodeList && prim instanceof win.NodeList) ){ return new Shoestring( prim, prim ); } // if it's an array, use all the elements if( prim.constructor === Array ){ return new Shoestring( prim, prim ); } // otherwise assume it's an object the we want at an index return new Shoestring( [prim], prim ); } var Shoestring = function( ret, prim ) { this.length = 0; this.selector = prim; shoestring.merge(this, ret); }; // TODO only required for tests Shoestring.prototype.reverse = [].reverse; // For adding element set methods shoestring.fn = Shoestring.prototype; shoestring.Shoestring = Shoestring; // For extending objects // TODO move to separate module when we use prototypes shoestring.extend = function( first, second ){ for( var i in second ){ if( second.hasOwnProperty( i ) ){ first[ i ] = second[ i ]; } } return first; }; // taken directly from jQuery shoestring.merge = function( first, second ) { var len, j, i; len = +second.length, j = 0, i = first.length; for ( ; j < len; j++ ) { first[ i++ ] = second[ j ]; } first.length = i; return first; }; // expose win.shoestring = shoestring; /** * Make an HTTP request to a url. * * **NOTE** the following options are supported: * * - *method* - The HTTP method used with the request. Default: `GET`. * - *data* - Raw object with keys and values to pass with request as query params. Default `null`. * - *headers* - Set of request headers to add. Default `{}`. * - *async* - Whether the opened request is asynchronouse. Default `true`. * - *success* - Callback for successful request and response. Passed the response data. * - *error* - Callback for failed request and response. * - *cancel* - Callback for cancelled request and response. * * @param {string} url The url to request. * @param {object} options The options object, see Notes. * @return shoestring * @this shoestring */ shoestring.ajax = function( url, options ) { var params = "", req = new XMLHttpRequest(), settings, key; settings = shoestring.extend( {}, shoestring.ajax.settings ); if( options ){ shoestring.extend( settings, options ); } if( !url ){ url = settings.url; } if( !req || !url ){ return; } // create parameter string from data object if( settings.data ){ for( key in settings.data ){ if( settings.data.hasOwnProperty( key ) ){ if( params !== "" ){ params += "&"; } params += encodeURIComponent( key ) + "=" + encodeURIComponent( settings.data[key] ); } } } // append params to url for GET requests if( settings.method === "GET" && params ){ url += "?" + params; } req.open( settings.method, url, settings.async ); if( req.setRequestHeader ){ req.setRequestHeader( "X-Requested-With", "XMLHttpRequest" ); // Set 'Content-type' header for POST requests if( settings.method === "POST" && params ){ req.setRequestHeader( "Content-type", "application/x-www-form-urlencoded" ); } for( key in settings.headers ){ if( settings.headers.hasOwnProperty( key ) ){ req.setRequestHeader(key, settings.headers[ key ]); } } } req.onreadystatechange = function () { if( req.readyState === 4 ){ // Trim the whitespace so shoestring('
') works var res = (req.responseText || '').replace(/^\s+|\s+$/g, ''); if( req.status.toString().indexOf( "0" ) === 0 ){ return settings.cancel( res, req.status, req ); } else if ( req.status.toString().match( /^(4|5)/ ) && RegExp.$1 ){ return settings.error( res, req.status, req ); } else if (settings.success) { return settings.success( res, req.status, req ); } } }; if( req.readyState === 4 ){ return req; } // Send request if( settings.method === "POST" && params ){ req.send( params ); } else { req.send(); } return req; }; shoestring.ajax.settings = { success: function(){}, error: function(){}, cancel: function(){}, method: "GET", async: true, data: null, headers: {} }; /** * Helper function wrapping a call to [ajax](ajax.js.html) using the `GET` method. * * @param {string} url The url to GET from. * @param {function} callback Callback to invoke on success. * @return shoestring * @this shoestring */ shoestring.get = function( url, callback ){ return shoestring.ajax( url, { success: callback } ); }; /** * Load the HTML response from `url` into the current set of elements. * * @param {string} url The url to GET from. * @param {function} callback Callback to invoke after HTML is inserted. * @return shoestring * @this shoestring */ shoestring.fn.load = function( url, callback ){ var self = this, args = arguments, intCB = function( data ){ self.each(function(){ shoestring( this ).html( data ); }); if( callback ){ callback.apply( self, args ); } }; shoestring.ajax( url, { success: intCB } ); return this; }; /** * Helper function wrapping a call to [ajax](ajax.js.html) using the `POST` method. * * @param {string} url The url to POST to. * @param {object} data The data to send. * @param {function} callback Callback to invoke on success. * @return shoestring * @this shoestring */ shoestring.post = function( url, data, callback ){ return shoestring.ajax( url, { data: data, method: "POST", success: callback } ); }; /** * Iterates over `shoestring` collections. * * @param {function} callback The callback to be invoked on each element and index * @return shoestring * @this shoestring */ shoestring.fn.each = function( callback ){ return shoestring.each( this, callback ); }; shoestring.each = function( collection, callback ) { var val; for( var i = 0, il = collection.length; i < il; i++ ){ val = callback.call( collection[i], i, collection[i] ); if( val === false ){ break; } } return collection; }; /** * Check for array membership. * * @param {object} needle The thing to find. * @param {object} haystack The thing to find the needle in. * @return {boolean} * @this window */ shoestring.inArray = function( needle, haystack ){ var isin = -1; for( var i = 0, il = haystack.length; i < il; i++ ){ if( haystack.hasOwnProperty( i ) && haystack[ i ] === needle ){ isin = i; } } return isin; }; /** * Bind callbacks to be run when the DOM is "ready". * * @param {function} fn The callback to be run * @return shoestring * @this shoestring */ shoestring.ready = function( fn ){ if( ready && fn ){ fn.call( doc ); } else if( fn ){ readyQueue.push( fn ); } else { runReady(); } return [doc]; }; // TODO necessary? shoestring.fn.ready = function( fn ){ shoestring.ready( fn ); return this; }; // Empty and exec the ready queue var ready = false, readyQueue = [], runReady = function(){ if( !ready ){ while( readyQueue.length ){ readyQueue.shift().call( doc ); } ready = true; } }; // If DOM is already ready at exec time, depends on the browser. // From: https://github.com/mobify/mobifyjs/blob/526841be5509e28fc949038021799e4223479f8d/src/capture.js#L128 if (doc.attachEvent ? doc.readyState === "complete" : doc.readyState !== "loading") { runReady(); } else { doc.addEventListener( "DOMContentLoaded", runReady, false ); doc.addEventListener( "readystatechange", runReady, false ); win.addEventListener( "load", runReady, false ); } /** * Checks the current set of elements against the selector, if one matches return `true`. * * @param {string} selector The selector to check. * @return {boolean} * @this {shoestring} */ shoestring.fn.is = function( selector ){ var ret = false, self = this, parents, check; // assume a dom element if( typeof selector !== "string" ){ // array-like, ie shoestring objects or element arrays if( selector.length && selector[0] ){ check = selector; } else { check = [selector]; } return _checkElements(this, check); } parents = this.parent(); if( !parents.length ){ parents = shoestring( doc ); } parents.each(function( i, e ) { var children; children = e.querySelectorAll( selector ); ret = _checkElements( self, children ); }); return ret; }; function _checkElements(needles, haystack){ var ret = false; needles.each(function() { var j = 0; while( j < haystack.length ){ if( this === haystack[j] ){ ret = true; } j++; } }); return ret; } /** * Get data attached to the first element or set data values on all elements in the current set. * * @param {string} name The data attribute name. * @param {any} value The value assigned to the data attribute. * @return {any|shoestring} * @this shoestring */ shoestring.fn.data = function( name, value ){ if( name !== undefined ){ if( value !== undefined ){ return this.each(function(){ if( !this.shoestringData ){ this.shoestringData = {}; } this.shoestringData[ name ] = value; }); } else { if( this[ 0 ] ) { if( this[ 0 ].shoestringData ) { return this[ 0 ].shoestringData[ name ]; } } } } else { return this[ 0 ] ? this[ 0 ].shoestringData || {} : undefined; } }; /** * Remove data associated with `name` or all the data, for each element in the current set. * * @param {string} name The data attribute name. * @return shoestring * @this shoestring */ shoestring.fn.removeData = function( name ){ return this.each(function(){ if( name !== undefined && this.shoestringData ){ this.shoestringData[ name ] = undefined; delete this.shoestringData[ name ]; } else { this[ 0 ].shoestringData = {}; } }); }; /** * An alias for the `shoestring` constructor. */ if (typeof(win.$) === "undefined") { win.$ = shoestring; } /** * Add a class to each DOM element in the set of elements. * * @param {string} className The name of the class to be added. * @return shoestring * @this shoestring */ shoestring.fn.addClass = function( className ){ var classes = className.replace(/^\s+|\s+$/g, '').split( " " ); return this.each(function(){ for( var i = 0, il = classes.length; i < il; i++ ){ if( this.className !== undefined && (this.className === "" || !this.className.match( new RegExp( "(^|\\s)" + classes[ i ] + "($|\\s)"))) ){ this.className += " " + classes[ i ]; } } }); }; /** * Add elements matching the selector to the current set. * * @param {string} selector The selector for the elements to add from the DOM * @return shoestring * @this shoestring */ shoestring.fn.add = function( selector ){ var ret = []; this.each(function(){ ret.push( this ); }); shoestring( selector ).each(function(){ ret.push( this ); }); return shoestring( ret ); }; /** * Insert an element or HTML string after each element in the current set. * * @param {string|HTMLElement} fragment The HTML or HTMLElement to insert. * @return shoestring * @this shoestring */ shoestring.fn.after = function( fragment ){ if( typeof( fragment ) === "string" || fragment.nodeType !== undefined ){ fragment = shoestring( fragment ); } if( fragment.length > 1 ){ fragment = fragment.reverse(); } return this.each(function( i ){ for( var j = 0, jl = fragment.length; j < jl; j++ ){ var insertEl = i > 0 ? fragment[ j ].cloneNode( true ) : fragment[ j ]; this.parentNode.insertBefore( insertEl, this.nextSibling ); } }); }; /** * Insert an element or HTML string as the last child of each element in the set. * * @param {string|HTMLElement} fragment The HTML or HTMLElement to insert. * @return shoestring * @this shoestring */ shoestring.fn.append = function( fragment ){ if( typeof( fragment ) === "string" || fragment.nodeType !== undefined ){ fragment = shoestring( fragment ); } return this.each(function( i ){ for( var j = 0, jl = fragment.length; j < jl; j++ ){ this.appendChild( i > 0 ? fragment[ j ].cloneNode( true ) : fragment[ j ] ); } }); }; /** * Insert the current set as the last child of the elements matching the selector. * * @param {string} selector The selector after which to append the current set. * @return shoestring * @this shoestring */ shoestring.fn.appendTo = function( selector ){ return this.each(function(){ shoestring( selector ).append( this ); }); }; /** * Get the value of the first element of the set or set the value of all the elements in the set. * * @param {string} name The attribute name. * @param {string} value The new value for the attribute. * @return {shoestring|string|undefined} * @this {shoestring} */ shoestring.fn.attr = function( name, value ){ var nameStr = typeof( name ) === "string"; if( value !== undefined || !nameStr ){ return this.each(function(){ if( nameStr ){ this.setAttribute( name, value ); } else { for( var i in name ){ if( name.hasOwnProperty( i ) ){ this.setAttribute( i, name[ i ] ); } } } }); } else { return this[ 0 ] ? this[ 0 ].getAttribute( name ) : undefined; } }; /** * Insert an element or HTML string before each element in the current set. * * @param {string|HTMLElement} fragment The HTML or HTMLElement to insert. * @return shoestring * @this shoestring */ shoestring.fn.before = function( fragment ){ if( typeof( fragment ) === "string" || fragment.nodeType !== undefined ){ fragment = shoestring( fragment ); } return this.each(function( i ){ for( var j = 0, jl = fragment.length; j < jl; j++ ){ this.parentNode.insertBefore( i > 0 ? fragment[ j ].cloneNode( true ) : fragment[ j ], this ); } }); }; /** * Get the children of the current collection. * @return shoestring * @this shoestring */ shoestring.fn.children = function(){ var ret = [], childs, j; this.each(function(){ childs = this.children; j = -1; while( j++ < childs.length-1 ){ if( shoestring.inArray( childs[ j ], ret ) === -1 ){ ret.push( childs[ j ] ); } } }); return shoestring(ret); }; /** * Clone and return the current set of nodes into a new `shoestring` object. * * @return shoestring * @this shoestring */ shoestring.fn.clone = function() { var ret = []; this.each(function() { ret.push( this.cloneNode( true ) ); }); return shoestring( ret ); }; /** * Find an element matching the selector in the set of the current element and its parents. * * @param {string} selector The selector used to identify the target element. * @return shoestring * @this shoestring */ shoestring.fn.closest = function( selector ){ var ret = []; if( !selector ){ return shoestring( ret ); } this.each(function(){ var element, $self = shoestring( element = this ); if( $self.is(selector) ){ ret.push( this ); return; } while( element.parentElement ) { if( shoestring(element.parentElement).is(selector) ){ ret.push( element.parentElement ); break; } element = element.parentElement; } }); return shoestring( ret ); }; shoestring.cssExceptions = { 'float': [ 'cssFloat' ] }; (function() { var cssExceptions = shoestring.cssExceptions; // marginRight instead of margin-right function convertPropertyName( str ) { return str.replace( /\-([A-Za-z])/g, function ( match, character ) { return character.toUpperCase(); }); } function _getStyle( element, property ) { return win.getComputedStyle( element, null ).getPropertyValue( property ); } var vendorPrefixes = [ '', '-webkit-', '-ms-', '-moz-', '-o-', '-khtml-' ]; /** * Private function for getting the computed style of an element. * * **NOTE** Please use the [css](../css.js.html) method instead. * * @method _getStyle * @param {HTMLElement} element The element we want the style property for. * @param {string} property The css property we want the style for. */ shoestring._getStyle = function( element, property ) { var convert, value, j, k; if( cssExceptions[ property ] ) { for( j = 0, k = cssExceptions[ property ].length; j < k; j++ ) { value = _getStyle( element, cssExceptions[ property ][ j ] ); if( value ) { return value; } } } for( j = 0, k = vendorPrefixes.length; j < k; j++ ) { convert = convertPropertyName( vendorPrefixes[ j ] + property ); // VendorprefixKeyName || key-name value = _getStyle( element, convert ); if( convert !== property ) { value = value || _getStyle( element, property ); } if( vendorPrefixes[ j ] ) { // -vendorprefix-key-name value = value || _getStyle( element, vendorPrefixes[ j ] + property ); } if( value ) { return value; } } return undefined; }; })(); (function() { var cssExceptions = shoestring.cssExceptions; // marginRight instead of margin-right function convertPropertyName( str ) { return str.replace( /\-([A-Za-z])/g, function ( match, character ) { return character.toUpperCase(); }); } /** * Private function for setting the style of an element. * * **NOTE** Please use the [css](../css.js.html) method instead. * * @method _setStyle * @param {HTMLElement} element The element we want to style. * @param {string} property The property being used to style the element. * @param {string} value The css value for the style property. */ shoestring._setStyle = function( element, property, value ) { var convertedProperty = convertPropertyName(property); element.style[ property ] = value; if( convertedProperty !== property ) { element.style[ convertedProperty ] = value; } if( cssExceptions[ property ] ) { for( var j = 0, k = cssExceptions[ property ].length; j -1 ){ ret.push( this ); } } }); return shoestring( ret ); }; /** * Find descendant elements of the current collection. * * @param {string} selector The selector used to find the children * @return shoestring * @this shoestring */ shoestring.fn.find = function( selector ){ var ret = [], finds; this.each(function(){ finds = this.querySelectorAll( selector ); for( var i = 0, il = finds.length; i < il; i++ ){ ret = ret.concat( finds[i] ); } }); return shoestring( ret ); }; /** * Returns the first element of the set wrapped in a new `shoestring` object. * * @return shoestring * @this shoestring */ shoestring.fn.first = function(){ return this.eq( 0 ); }; /** * Returns the raw DOM node at the passed index. * * @param {integer} index The index of the element to wrap and return. * @return {HTMLElement|undefined|array} * @this shoestring */ shoestring.fn.get = function( index ){ // return an array of elements if index is undefined if( index === undefined ){ var elements = []; for( var i = 0; i < this.length; i++ ){ elements.push( this[ i ] ); } return elements; } else { return this[ index ]; } }; /** * Private function for setting/getting the offset property for height/width. * * **NOTE** Please use the [width](width.js.html) or [height](height.js.html) methods instead. * * @param {shoestring} set The set of elements. * @param {string} name The string "height" or "width". * @param {float|undefined} value The value to assign. * @return shoestring * @this window */ shoestring._dimension = function( set, name, value ){ var offsetName; if( value === undefined ){ offsetName = name.replace(/^[a-z]/, function( letter ) { return letter.toUpperCase(); }); return set[ 0 ][ "offset" + offsetName ]; } else { // support integer values as pixels value = typeof value === "string" ? value : value + "px"; return set.each(function(){ this.style[ name ] = value; }); } }; /** * Gets the height value of the first element or sets the height for the whole set. * * @param {float|undefined} value The value to assign. * @return shoestring * @this shoestring */ shoestring.fn.height = function( value ){ return shoestring._dimension( this, "height", value ); }; var set = function( html ){ if( typeof html === "string" || typeof html === "number" ){ return this.each(function(){ this.innerHTML = "" + html; }); } else { var h = ""; if( typeof html.length !== "undefined" ){ for( var i = 0, l = html.length; i < l; i++ ){ h += html[i].outerHTML; } } else { h = html.outerHTML; } return this.each(function(){ this.innerHTML = h; }); } }; /** * Gets or sets the `innerHTML` from all the elements in the set. * * @param {string|undefined} html The html to assign * @return {string|shoestring} * @this shoestring */ shoestring.fn.html = function( html ){ if( typeof html !== "undefined" ){ return set.call( this, html ); } else { // get var pile = ""; this.each(function(){ pile += this.innerHTML; }); return pile; } }; (function() { function _getIndex( set, test ) { var i, result, element; for( i = result = 0; i < set.length; i++ ) { element = set.item ? set.item(i) : set[i]; if( test(element) ){ return result; } // ignore text nodes, etc // NOTE may need to be more permissive if( element.nodeType === 1 ){ result++; } } return -1; } /** * Find the index in the current set for the passed selector. * Without a selector it returns the index of the first node within the array of its siblings. * * @param {string|undefined} selector The selector used to search for the index. * @return {integer} * @this {shoestring} */ shoestring.fn.index = function( selector ){ var self, children; self = this; // no arg? check the children, otherwise check each element that matches if( selector === undefined ){ children = ( ( this[ 0 ] && this[0].parentNode ) || doc.documentElement).childNodes; // check if the element matches the first of the set return _getIndex(children, function( element ) { return self[0] === element; }); } else { // check if the element matches the first selected node from the parent return _getIndex(self, function( element ) { return element === (shoestring( selector, element.parentNode )[ 0 ]); }); } }; })(); /** * Insert the current set after the elements matching the selector. * * @param {string} selector The selector after which to insert the current set. * @return shoestring * @this shoestring */ shoestring.fn.insertAfter = function( selector ){ return this.each(function(){ shoestring( selector ).after( this ); }); }; /** * Insert the current set before the elements matching the selector. * * @param {string} selector The selector before which to insert the current set. * @return shoestring * @this shoestring */ shoestring.fn.insertBefore = function( selector ){ return this.each(function(){ shoestring( selector ).before( this ); }); }; /** * Returns the last element of the set wrapped in a new `shoestring` object. * * @return shoestring * @this shoestring */ shoestring.fn.last = function(){ return this.eq( this.length - 1 ); }; /** * Returns a `shoestring` object with the set of siblings of each element in the original set. * * @return shoestring * @this shoestring */ shoestring.fn.next = function(){ var result = []; // TODO need to implement map this.each(function() { var children, item, found; // get the child nodes for this member of the set children = shoestring( this.parentNode )[0].childNodes; for( var i = 0; i < children.length; i++ ){ item = children.item( i ); // found the item we needed (found) which means current item value is // the next node in the list, as long as it's viable grab it // NOTE may need to be more permissive if( found && item.nodeType === 1 ){ result.push( item ); break; } // find the current item and mark it as found if( item === this ){ found = true; } } }); return shoestring( result ); }; /** * Removes elements from the current set. * * @param {string} selector The selector to use when removing the elements. * @return shoestring * @this shoestring */ shoestring.fn.not = function( selector ){ var ret = []; this.each(function(){ var found = shoestring( selector, this.parentNode ); if( shoestring.inArray(this, found) === -1 ){ ret.push( this ); } }); return shoestring( ret ); }; /** * Returns an object with the `top` and `left` properties corresponging to the first elements offsets. * * @return object * @this shoestring */ shoestring.fn.offset = function(){ return { top: this[ 0 ].offsetTop, left: this[ 0 ].offsetLeft }; }; /** * Returns the set of first parents for each element in the current set. * * @return shoestring * @this shoestring */ shoestring.fn.parent = function(){ var ret = [], parent; this.each(function(){ // no parent node, assume top level // jQuery parent: return the document object for or the parent node if it exists parent = (this === doc.documentElement ? doc : this.parentNode); // if there is a parent and it's not a document fragment if( parent && parent.nodeType !== 11 ){ ret.push( parent ); } }); return shoestring(ret); }; /** * Returns the set of all parents matching the selector if provided for each element in the current set. * * @param {string} selector The selector to check the parents with. * @return shoestring * @this shoestring */ shoestring.fn.parents = function( selector ){ var ret = []; this.each(function(){ var curr = this, match; while( curr.parentElement && !match ){ curr = curr.parentElement; if( selector ){ if( curr === shoestring( selector )[0] ){ match = true; if( shoestring.inArray( curr, ret ) === -1 ){ ret.push( curr ); } } } else { if( shoestring.inArray( curr, ret ) === -1 ){ ret.push( curr ); } } } }); return shoestring(ret); }; /** * Add an HTML string or element before the children of each element in the current set. * * @param {string|HTMLElement} fragment The HTML string or element to add. * @return shoestring * @this shoestring */ shoestring.fn.prepend = function( fragment ){ if( typeof( fragment ) === "string" || fragment.nodeType !== undefined ){ fragment = shoestring( fragment ); } return this.each(function( i ){ for( var j = 0, jl = fragment.length; j < jl; j++ ){ var insertEl = i > 0 ? fragment[ j ].cloneNode( true ) : fragment[ j ]; if ( this.firstChild ){ this.insertBefore( insertEl, this.firstChild ); } else { this.appendChild( insertEl ); } } }); }; /** * Add each element of the current set before the children of the selected elements. * * @param {string} selector The selector for the elements to add the current set to.. * @return shoestring * @this shoestring */ shoestring.fn.prependTo = function( selector ){ return this.each(function(){ shoestring( selector ).prepend( this ); }); }; /** * Returns a `shoestring` object with the set of *one* siblingx before each element in the original set. * * @return shoestring * @this shoestring */ shoestring.fn.prev = function(){ var result = []; // TODO need to implement map this.each(function() { var children, item, found; // get the child nodes for this member of the set children = shoestring( this.parentNode )[0].childNodes; for( var i = children.length -1; i >= 0; i-- ){ item = children.item( i ); // found the item we needed (found) which means current item value is // the next node in the list, as long as it's viable grab it // NOTE may need to be more permissive if( found && item.nodeType === 1 ){ result.push( item ); break; } // find the current item and mark it as found if( item === this ){ found = true; } } }); return shoestring( result ); }; /** * Returns a `shoestring` object with the set of *all* siblings before each element in the original set. * * @return shoestring * @this shoestring */ shoestring.fn.prevAll = function(){ var result = []; this.each(function() { var $previous = shoestring( this ).prev(); while( $previous.length ){ result.push( $previous[0] ); $previous = $previous.prev(); } }); return shoestring( result ); }; // Property normalization, a subset taken from jQuery src shoestring.propFix = { "class": "className", contenteditable: "contentEditable", "for": "htmlFor", readonly: "readOnly", tabindex: "tabIndex" }; /** * Gets the property value from the first element or sets the property value on all elements of the currrent set. * * @param {string} name The property name. * @param {any} value The property value. * @return {any|shoestring} * @this shoestring */ shoestring.fn.prop = function( name, value ){ if( !this[0] ){ return; } name = shoestring.propFix[ name ] || name; if( value !== undefined ){ return this.each(function(){ this[ name ] = value; }); } else { return this[ 0 ][ name ]; } }; /** * Remove an attribute from each element in the current set. * * @param {string} name The name of the attribute. * @return shoestring * @this shoestring */ shoestring.fn.removeAttr = function( name ){ return this.each(function(){ this.removeAttribute( name ); }); }; /** * Remove a class from each DOM element in the set of elements. * * @param {string} className The name of the class to be removed. * @return shoestring * @this shoestring */ shoestring.fn.removeClass = function( cname ){ var classes = cname.replace(/^\s+|\s+$/g, '').split( " " ); return this.each(function(){ var newClassName, regex; for( var i = 0, il = classes.length; i < il; i++ ){ if( this.className !== undefined ){ regex = new RegExp( "(^|\\s)" + classes[ i ] + "($|\\s)", "gmi" ); newClassName = this.className.replace( regex, " " ); this.className = newClassName.replace(/^\s+|\s+$/g, ''); } } }); }; /** * Remove the current set of elements from the DOM. * * @return shoestring * @this shoestring */ shoestring.fn.remove = function(){ return this.each(function(){ if( this.parentNode ) { this.parentNode.removeChild( this ); } }); }; /** * Remove a proprety from each element in the current set. * * @param {string} name The name of the property. * @return shoestring * @this shoestring */ shoestring.fn.removeProp = function( property ){ var name = shoestring.propFix[ property ] || property; return this.each(function(){ this[ name ] = undefined; delete this[ name ]; }); }; /** * Replace each element in the current set with that argument HTML string or HTMLElement. * * @param {string|HTMLElement} fragment The value to assign. * @return shoestring * @this shoestring */ shoestring.fn.replaceWith = function( fragment ){ if( typeof( fragment ) === "string" ){ fragment = shoestring( fragment ); } var ret = []; if( fragment.length > 1 ){ fragment = fragment.reverse(); } this.each(function( i ){ var clone = this.cloneNode( true ), insertEl; ret.push( clone ); // If there is no parentNode, this is pointless, drop it. if( !this.parentNode ){ return; } if( fragment.length === 1 ){ insertEl = i > 0 ? fragment[ 0 ].cloneNode( true ) : fragment[ 0 ]; this.parentNode.replaceChild( insertEl, this ); } else { for( var j = 0, jl = fragment.length; j < jl; j++ ){ insertEl = i > 0 ? fragment[ j ].cloneNode( true ) : fragment[ j ]; this.parentNode.insertBefore( insertEl, this.nextSibling ); } this.parentNode.removeChild( this ); } }); return shoestring( ret ); }; shoestring.inputTypes = [ "text", "hidden", "password", "color", "date", "datetime", // "datetime\-local" matched by datetime "email", "month", "number", "range", "search", "tel", "time", "url", "week" ]; shoestring.inputTypeTest = new RegExp( shoestring.inputTypes.join( "|" ) ); /** * Serialize child input element values into an object. * * @return shoestring * @this shoestring */ shoestring.fn.serialize = function(){ var data = {}; shoestring( "input, select", this ).each(function(){ var type = this.type, name = this.name, value = this.value; if( shoestring.inputTypeTest.test( type ) || ( type === "checkbox" || type === "radio" ) && this.checked ){ data[ name ] = value; } else if( this.nodeName === "SELECT" ){ data[ name ] = this.options[ this.selectedIndex ].nodeValue; } }); return data; }; /** * Get all of the sibling elements for each element in the current set. * * @return shoestring * @this shoestring */ shoestring.fn.siblings = function(){ if( !this.length ) { return shoestring( [] ); } var sibs = [], el = this[ 0 ].parentNode.firstChild; do { if( el.nodeType === 1 && el !== this[ 0 ] ) { sibs.push( el ); } el = el.nextSibling; } while( el ); return shoestring( sibs ); }; var getText = function( elem ){ var node, ret = "", i = 0, nodeType = elem.nodeType; if ( !nodeType ) { // If no nodeType, this is expected to be an array while ( (node = elem[i++]) ) { // Do not traverse comment nodes ret += getText( node ); } } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { // Use textContent for elements // innerText usage removed for consistency of new lines (jQuery #11153) if ( typeof elem.textContent === "string" ) { return elem.textContent; } else { // Traverse its children for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { ret += getText( elem ); } } } else if ( nodeType === 3 || nodeType === 4 ) { return elem.nodeValue; } // Do not include comment or processing instruction nodes return ret; }; /** * Recursively retrieve the text content of the each element in the current set. * * @return shoestring * @this shoestring */ shoestring.fn.text = function() { return getText( this ); }; /** * Get the value of the first element or set the value of all elements in the current set. * * @param {string} value The value to set. * @return shoestring * @this shoestring */ shoestring.fn.val = function( value ){ var el; if( value !== undefined ){ return this.each(function(){ if( this.tagName === "SELECT" ){ var optionSet, option, options = this.options, values = [], i = options.length, newIndex; values[0] = value; while ( i-- ) { option = options[ i ]; if ( (option.selected = shoestring.inArray( option.value, values ) >= 0) ) { optionSet = true; newIndex = i; } } // force browsers to behave consistently when non-matching value is set if ( !optionSet ) { this.selectedIndex = -1; } else { this.selectedIndex = newIndex; } } else { this.value = value; } }); } else { el = this[0]; if( el.tagName === "SELECT" ){ if( el.selectedIndex < 0 ){ return ""; } return el.options[ el.selectedIndex ].value; } else { return el.value; } } }; /** * Gets the width value of the first element or sets the width for the whole set. * * @param {float|undefined} value The value to assign. * @return shoestring * @this shoestring */ shoestring.fn.width = function( value ){ return shoestring._dimension( this, "width", value ); }; /** * Wraps the child elements in the provided HTML. * * @param {string} html The wrapping HTML. * @return shoestring * @this shoestring */ shoestring.fn.wrapInner = function( html ){ return this.each(function(){ var inH = this.innerHTML; this.innerHTML = ""; shoestring( this ).append( shoestring( html ).html( inH ) ); }); }; function initEventCache( el, evt ) { if ( !el.shoestringData ) { el.shoestringData = {}; } if ( !el.shoestringData.events ) { el.shoestringData.events = {}; } if ( !el.shoestringData.loop ) { el.shoestringData.loop = {}; } if ( !el.shoestringData.events[ evt ] ) { el.shoestringData.events[ evt ] = []; } } function addToEventCache( el, evt, eventInfo ) { var obj = {}; obj.isCustomEvent = eventInfo.isCustomEvent; obj.callback = eventInfo.callfunc; obj.originalCallback = eventInfo.originalCallback; obj.namespace = eventInfo.namespace; el.shoestringData.events[ evt ].push( obj ); if( eventInfo.customEventLoop ) { el.shoestringData.loop[ evt ] = eventInfo.customEventLoop; } } /** * Bind a callback to an event for the currrent set of elements. * * @param {string} evt The event(s) to watch for. * @param {object,function} data Data to be included with each event or the callback. * @param {function} originalCallback Callback to be invoked when data is define.d. * @return shoestring * @this shoestring */ shoestring.fn.bind = function( evt, data, originalCallback ){ if( typeof data === "function" ){ originalCallback = data; data = null; } var evts = evt.split( " " ); // NOTE the `triggeredElement` is purely for custom events from IE function encasedCallback( e, namespace, triggeredElement ){ var result; if( e._namespace && e._namespace !== namespace ) { return; } e.data = data; e.namespace = e._namespace; var returnTrue = function(){ return true; }; e.isDefaultPrevented = function(){ return false; }; var originalPreventDefault = e.preventDefault; var preventDefaultConstructor = function(){ if( originalPreventDefault ) { return function(){ e.isDefaultPrevented = returnTrue; originalPreventDefault.call(e); }; } else { return function(){ e.isDefaultPrevented = returnTrue; e.returnValue = false; }; } }; // thanks https://github.com/jonathantneal/EventListener e.target = triggeredElement || e.target || e.srcElement; e.preventDefault = preventDefaultConstructor(); e.stopPropagation = e.stopPropagation || function () { e.cancelBubble = true; }; result = originalCallback.apply(this, [ e ].concat( e._args ) ); if( result === false ){ e.preventDefault(); e.stopPropagation(); } return result; } return this.each(function(){ var domEventCallback, customEventCallback, customEventLoop, oEl = this; for( var i = 0, il = evts.length; i < il; i++ ){ var split = evts[ i ].split( "." ), evt = split[ 0 ], namespace = split.length > 0 ? split[ 1 ] : null; domEventCallback = function( originalEvent ) { if( oEl.ssEventTrigger ) { originalEvent._namespace = oEl.ssEventTrigger._namespace; originalEvent._args = oEl.ssEventTrigger._args; oEl.ssEventTrigger = null; } return encasedCallback.call( oEl, originalEvent, namespace ); }; customEventCallback = null; customEventLoop = null; initEventCache( this, evt ); this.addEventListener( evt, domEventCallback, false ); addToEventCache( this, evt, { callfunc: customEventCallback || domEventCallback, isCustomEvent: !!customEventCallback, customEventLoop: customEventLoop, originalCallback: originalCallback, namespace: namespace }); } }); }; shoestring.fn.on = shoestring.fn.bind; /** * Unbind a previous bound callback for an event. * * @param {string} event The event(s) the callback was bound to.. * @param {function} callback Callback to unbind. * @return shoestring * @this shoestring */ shoestring.fn.unbind = function( event, callback ){ var evts = event ? event.split( " " ) : []; return this.each(function(){ if( !this.shoestringData || !this.shoestringData.events ) { return; } if( !evts.length ) { unbindAll.call( this ); } else { var split, evt, namespace; for( var i = 0, il = evts.length; i < il; i++ ){ split = evts[ i ].split( "." ), evt = split[ 0 ], namespace = split.length > 0 ? split[ 1 ] : null; if( evt ) { unbind.call( this, evt, namespace, callback ); } else { unbindAll.call( this, namespace, callback ); } } } }); }; function unbind( evt, namespace, callback ) { var bound = this.shoestringData.events[ evt ]; if( !(bound && bound.length) ) { return; } var matched = [], j, jl; for( j = 0, jl = bound.length; j < jl; j++ ) { if( !namespace || namespace === bound[ j ].namespace ) { if( callback === undefined || callback === bound[ j ].originalCallback ) { this.removeEventListener( evt, bound[ j ].callback, false ); matched.push( j ); } } } for( j = 0, jl = matched.length; j < jl; j++ ) { this.shoestringData.events[ evt ].splice( j, 1 ); } } function unbindAll( namespace, callback ) { for( var evtKey in this.shoestringData.events ) { unbind.call( this, evtKey, namespace, callback ); } } shoestring.fn.off = shoestring.fn.unbind; /** * Bind a callback to an event for the currrent set of elements, unbind after one occurence. * * @param {string} event The event(s) to watch for. * @param {function} callback Callback to invoke on the event. * @return shoestring * @this shoestring */ shoestring.fn.one = function( event, callback ){ var evts = event.split( " " ); return this.each(function(){ var thisevt, cbs = {}, $t = shoestring( this ); for( var i = 0, il = evts.length; i < il; i++ ){ thisevt = evts[ i ]; cbs[ thisevt ] = function( e ){ var $t = shoestring( this ); for( var j in cbs ) { $t.unbind( j, cbs[ j ] ); } return callback.apply( this, [ e ].concat( e._args ) ); }; $t.bind( thisevt, cbs[ thisevt ] ); } }); }; /** * Trigger an event on the first element in the set, no bubbling, no defaults. * * @param {string} event The event(s) to trigger. * @param {object} args Arguments to append to callback invocations. * @return shoestring * @this shoestring */ shoestring.fn.triggerHandler = function( event, args ){ var e = event.split( " " )[ 0 ], el = this[ 0 ], ret; // See this.fireEvent( 'on' + evts[ i ], document.createEventObject() ); instead of click() etc in trigger. if( doc.createEvent && el.shoestringData && el.shoestringData.events && el.shoestringData.events[ e ] ){ var bindings = el.shoestringData.events[ e ]; for (var i in bindings ){ if( bindings.hasOwnProperty( i ) ){ event = doc.createEvent( "Event" ); event.initEvent( e, true, true ); event._args = args; args.unshift( event ); ret = bindings[ i ].originalCallback.apply( event.target, args ); } } } return ret; }; /** * Trigger an event on each of the DOM elements in the current set. * * @param {string} event The event(s) to trigger. * @param {object} args Arguments to append to callback invocations. * @return shoestring * @this shoestring */ shoestring.fn.trigger = function( event, args ){ var evts = event.split( " " ); return this.each(function(){ var split, evt, namespace; for( var i = 0, il = evts.length; i < il; i++ ){ split = evts[ i ].split( "." ), evt = split[ 0 ], namespace = split.length > 0 ? split[ 1 ] : null; if( evt === "click" ){ if( this.tagName === "INPUT" && this.type === "checkbox" && this.click ){ this.click(); return false; } } if( doc.createEvent ){ var event = doc.createEvent( "Event" ); event.initEvent( evt, true, true ); event._args = args; event._namespace = namespace; this.dispatchEvent( event ); } } }); }; return shoestring; })); if( !window.jQuery ){ window.jQuery = window.jQuery || window.shoestring; } (function(exports){ /** * The Akamai component namespace contains the {@link Akamai.Viewer}, {@link * Akamai.Spin360}, {@link Akamai.Carousel}, and {@link Akamai.Zoom} components. * The {@link Akamai.Viewer} component manages the other components depending on * the state of the markup it is provided, start there for more. * * @example On-ready automatic initialization with jQuery * $(function(){ * $( "[data-akamai-viewer]" ).each(function(i, element){ * var viewer = Akamai.Viewer( element ); * ... * // example API use, stoping autoration of first {@link Akamai.Spin360} * viewer.getCarousels()[0].getSpin360s()[0].stopSpin(); * }) * }) * * @namespace Akamai */ exports.Akamai = exports.Akamai || {}; window.componentNamespace = "Akamai"; })(typeof exports === 'undefined'? window : exports); (function( window, $ ) { var $window, $doc; $window = $(window); $doc = $( document.documentElement ); var ns = window.componentNamespace = window.componentNamespace || "FG"; window[ns] = window[ns] || {}; Function.prototype.bind = Function.prototype.bind || function( context ) { var self = this; return function() { self.apply( context, arguments ); }; }; var Tau = window[ns].Tau = function( element, options ) { var startIndex, reducedStepSize; this.element = element; this.options = options || {}; this.$element = $( element ); this.$initial = this.$element.find( "img" ); this.$loading = this.$element.find( ".loading" ); this.index = 0; // frame count by order of precendence // 1. initial frames when they are specified explicitly // 2. the data attribute on the initial image // 3. the configured number of frames this.frames = this.$initial.length > 1 ? this.$initial.length : parseInt( this.$initial.attr("data-frames"), 10 ) || this.options.frames; // grab the user specified step size for when the browser is less-abled reducedStepSize = parseInt( this.$initial.attr("data-reduced-step-size"), 10 ) || 4; // TODO sort out a better qualification for the full set of images? this.stepSize = window.requestAnimationFrame ? 1 : reducedStepSize; // grab the user specified auto start delay this.autoRotateStartDelay = (this.options.autoplay || {}).delay || parseInt( this.$initial.attr("data-auto-rotate-delay"), 10 ) || Tau.autoRotateStartDelay; this.mouseMoveBinding = this.rotateEvent.bind(this); this.touchMoveBinding = this.rotateEvent.bind(this); this.path = new Tau.Path(); // make sure the initial image stays visible after enhance this.$initial.first().addClass( "focused" ); // hide all other images this.$element.addClass( "tau-enhanced" ); // create a rendering spot to force decoding in IE and prevent blinking this.$render = $( "
" ) .css( "position", "absolute" ) .css( "left", "0" ) .css( "top", "0" ) .prependTo( this.element ); if( this.options.canvas !== false ){ this.canvas = $( "").prependTo( this.element )[0]; if(this.canvas.getContext ){ this.canvasCtx = this.canvas.getContext("2d"); this.$element.addClass( "tau-canvas" ); $(window).bind("resize", function(){ clearTimeout(this.canvasResizeTimeout); this.canvasResizeTimeout = setTimeout(this.renderCanvas.bind(this), 100); }.bind(this)); } } if( this.options.controls ){ this.options.controls.text = this.options.controls.text || { play: "Spin Object", left: "Rotate Left", right: "Rotate Right" }; this.createControls(); } // create the rest of the images this.createImages(); // set the initial index and image if( this.options.autoplay && this.options.autoplay.enabled ){ // start the automatic rotation this.autostartTimeout = setTimeout(this.autoRotate.bind(this), this.autoRotateStartDelay); } // setup the event bindings for touch drag and mouse drag rotation this.bind(); }; Tau.autoRotateTraversalTime = 4500; Tau.autoRotateStartDelay = 100; Tau.verticalScrollRatio = 4; // Tau.decelTimeStep = Tau.autoRotateDelay / 2; // Tau.decel = Tau.decelTimeStep / 8; Tau.maxVelocity = 60; Tau.prototype.createControls = function(){ this.$controls = $("
"); if(this.options.controls.play){ this.$controls.append(this.controlAnchorMarkup("play")); } if(this.options.controls.arrows){ this.$controls .prepend(this.controlAnchorMarkup("left")) .append(this.controlAnchorMarkup("right")); } this.$controls.bind("mousedown touchstart", this.onControlDown.bind(this)); this.$controls.bind("mouseup", this.onControlUp.bind(this)); // prevent link clicks from bubbling this.$controls.bind("click", function(event){ if( $(event.target).is("a") ){ event.preventDefault(); } }); this.$element.append(this.$controls); }; Tau.prototype.controlAnchorMarkup = function(name){ var text = this.options.controls.text[name]; return "" + text + ""; }; Tau.prototype.onControlDown = function(event){ var $link = $(event.target).closest("a"); switch($link.attr("data-tau-controls")){ case "left": this.$element.addClass("control-left-down"); this.stopAutoRotate(); this.autoRotate(); break; case "right": this.$element.addClass("control-right-down"); this.stopAutoRotate(); this.autoRotate(true); break; } }; Tau.prototype.onControlUp = function(event){ var $link = $(event.target).closest("a"); switch($link.attr("data-tau-controls")){ case "left": case "right": this.$element.removeClass("control-left-down"); this.$element.removeClass("control-right-down"); this.stopAutoRotate(); break; case "play": if( this.autoInterval ){ this.stopAutoRotate(); } else { this.autoRotate(); } break; } }; Tau.prototype.change = function( delta ) { this.goto( this.options.reverse ? this.index - delta : this.index + delta ); }; Tau.prototype.goto = function( index ) { var $next, normalizedIndex, imageCount = this.$images.length; index = index % imageCount; // stay within the bounds of the array normalizedIndex = (imageCount + index) % imageCount; // set the next image that's going to be shown/focused $next = this.$images.eq( normalizedIndex ); // skip this action if the desired image isn't loaded yet // TODO do something fancier here instead of just throwing up hands if( !$next[0].tauImageLoaded ) { this.showLoading(); return false; } // hide any image that happens to be visible (initial image when canvas) if( this.$current ) { this.$current.removeClass( "focused" ); } else { this.$images.removeClass( "focused" ); } // record the current focused image and make it visible this.$current = $next; // record the updated index only after advancing is possible this.index = normalizedIndex; if( this.canvasCtx ) { return this.renderCanvas(); } else { // show the new focused image this.$current.addClass( "focused" ); return true; } }; Tau.prototype.renderCanvas = function() { var $img = this.$current; var img = $img[0]; var width = img.naturalWidth; var height = img.naturalHeight; var parentWidth = this.element.clientWidth; var calcHeight = (parentWidth/width) * height; if(!width || !height || !img.complete){ return false; } if( this.canvas.width !== parentWidth || this.canvas.height !== calcHeight || (parentWidth && calcHeight) ) { this.canvas.width = parentWidth; this.canvas.height = calcHeight; } this.canvasCtx.drawImage(img, 0, 0, parentWidth, calcHeight); return true; }; // TODO transplant the attributes from the initial image Tau.prototype.createImages = function() { var src, frames, html, $new, boundImageLoaded; // if there are no image elements, raise an exception if( this.$initial.length < 1 ){ throw new Error( "At least one image required" ); } this.loadedCount = 0; // if there is only one image element, assume it's a template if( this.$initial.length == 1 ) { this.markImageLoaded( this.$initial[0] ); src = this.options.template || this.$initial.attr( "data-src-template" ); var imgs = []; for( var i = this.stepSize + 1; i <= this.frames; i+= this.stepSize ) { html = ""; $new = $( html ); imgs.push($new); } $.each(imgs, function(i, e){ var $img = $(e); $img.bind("load error", function(e){ this.imageLoaded(i, e.target, e); }.bind(this)); this.$element.append( $img ); this.$render.append( $img.html() ); }.bind(this)); // take all the child images and use them as frames of the rotation this.$images = this.$element.children().filter( "img" ); this.$current = this.$images; this.goto(0); } else { // take all the child images and use them as frames of the rotation this.$images = this.$element.children().filter( "img" ); this.$images.each(function(i, e){ // if the image height is greater than zero we assume the image is loaded // otherwise we bind to onload and pray that we win the race if( $(e).height() > 0 ){ this.imageLoaded( i, e ); } else { $(e).bind("load error", function(event){ this.imageLoaded( i, event.target, event ); }.bind(this)); } }.bind(this)); } }; Tau.prototype.imageLoaded = function( index, element, event ) { var initTriggered = false; this.markImageLoaded( element ); // if the isn't going to play automatically and the first image is // loaded make sure to render it if( this.$element.find("img")[0] == element && (!event || event.type !== "error") && (!this.options.autoplay || !this.options.autoplay.enabled) ){ this.goto(0); this.$element.trigger("tau.init"); initTriggered = true; } this.loadedCount++; if( this.loadedCount >= this.frames - 1) { this.hideLoading(); if(!initTriggered) { this.$element.trigger("tau.init"); this.initialized = true; } } }; Tau.prototype.markImageLoaded = function( element ) { element.tauImageLoaded = true; }; Tau.prototype.bind = function() { this.$element.bind( "mousedown touchstart", this.track.bind(this) ); }; Tau.prototype.autoRotate = function( right ) { // already rotating if( this.autoInterval ) { return; } this.$element.addClass("spinning"); // move once initially this.change( right ? -1 : 1 ); // move after the interval this.autoInterval = setInterval(function() { this.change( right ? -1 : 1 ); }.bind(this), this.autoRotateDelay() * this.stepSize); this.$element.trigger( "tau.auto-rotate-start" ); }; Tau.prototype.autoRotateDelay = function(){ return (this.options.interval || Tau.autoRotateTraversalTime) / this.frames; }; Tau.prototype.stopAutoRotate = function() { clearInterval( this.autoInterval ); clearInterval( this.autostartTimeout ); this.$element.removeClass("spinning"); this.autoInterval = undefined; this.$element.trigger( "tau.auto-rotate-stop" ); }; Tau.prototype.track = function( event ) { var point; // ignore tracking on control clicks if( $(event.target).closest(".tau-controls").length ){ return; } // prevent dragging behavior for mousedown if( event.type === "mousedown" ){ event.preventDefault(); } if( event.type === "touchstart" ) { this.$element.trigger("tau.touch-tracking-start"); } else { this.$element.trigger("tau.mouse-tracking-start"); } if( this.tracking ) { return; } $doc.one( "mouseup", this.release.bind(this) ); $doc.one( "touchend", this.release.bind(this) ); this.tracking = true; // clean out the path since we'll need a new one for decel this.path.reset(); // show the cursor as grabbing this.cursorGrab(); // By default the number of pixels required to move the carousel by one // frame is the ratio of the tau element width to the number of frames. That // is, by default the user should be able to see the full rotation by moving // their input device from one side of the tau element to the other. var defaultThreshold = this.$element[0].clientWidth / this.frames ; // divide the default by the sensitivity. If the senstivity is greater than // 1 it will require less effort (smaller distance) to advance the rotation // by a single slide. If the sensitivity is less than 1 it will require more // effort this.rotateThreshold = defaultThreshold / (this.options.sensitivity || 1); // record the x for threshold calculations point = this.getPoint( event ); this.downX = point.x; this.downY = point.y; this.downIndex = this.index; $doc.bind( "mousemove", this.mouseMoveBinding ); $doc.bind( "touchmove", this.touchMoveBinding ); }; Tau.prototype.slow = function() { // if the path gets broken during the decel just stop if( !this.path.isSufficient() ) { this.clearSlowInterval(); return; } this.rotate({ x: this.path.last().x + this.velocity, y: this.path.last().y }); if( this.velocity > 0 ){ this.velocity = this.velocity - this.decelVal(); if( this.velocity <= 0 ){ this.clearSlowInterval(); } } else { this.velocity = this.velocity + this.decelVal(); if( this.velocity >= 0 ){ this.clearSlowInterval(); } } }; Tau.prototype.decelVal = function(){ return this.decelTimeStep() / 8; }; Tau.prototype.clearSlowInterval = function() { clearInterval(this.slowInterval); this.velocity = 0; this.slowInterval = undefined; }; Tau.prototype.decel = function() { var velocity, sign; // if we don't have two points of mouse or touch tracking this won't work if( !this.path.isSufficient() ) { return; } // determine the starting velocity based on the traced path velocity = this.path.velocity( this.decelTimeStep() ); // borrowed from http://stackoverflow.com/questions/7624920/number-sign-in-javascript sign = velocity > 0 ? 1 : velocity < 0 ? -1 : 0; // keep a lid on how fast the rotation spins out if( Math.abs(velocity) > Tau.maxVelocity ){ velocity = sign * Tau.maxVelocity; } this.velocity = velocity; this.slowInterval = setInterval(this.slow.bind(this), this.decelTimeStep()); }; Tau.prototype.decelTimeStep = function(){ return this.autoRotateDelay() / 2; }; Tau.prototype.release = function( event ) { if( $(event.target).closest(".tau-controls").length ){ return; } if( !this.tracking ){ return; } if( event.type === "touchend" ) { this.$element.trigger("tau.touch-tracking-stop"); } else { this.$element.trigger("tau.mouse-tracking-stop"); } this.decel(); this.cursorRelease(); // TODO sort out why shoestring borks when unbinding with a string split list $doc.unbind( "mousemove", this.mouseMoveBinding ); $doc.unbind( "touchmove", this.touchMoveBinding ); this.tracking = false; }; Tau.prototype.cursorGrab = function() { $doc.addClass( "grabbing" ); }; Tau.prototype.cursorRelease = function() { $doc.removeClass( "grabbing" ); }; Tau.prototype.showLoading = function() { this.$loading.attr( "style" , "display: block" ); }; Tau.prototype.hideLoading = function() { this.$loading.attr( "style" , "display: none" ); }; Tau.prototype.getPoint = function( event ) { var touch = event.touches || (event.originalEvent && event.originalEvent.touches); if( touch ){ return { x: touch[0].pageX, y: touch[0].pageY }; } return { x: event.pageX || event.clientX, y: event.pageY || event.clientY }; }; Tau.prototype.rotateEvent = function( event ) { // NOTE it might be better to prevent when the rotation returns anything BUT false // so that slow drags still get the scroll prevented if( this.rotate(this.getPoint(event)) ){ event.preventDefault(); } }; Tau.prototype.rotate = function( point ) { var deltaX, deltaY; deltaX = point.x - this.downX; deltaY = point.y - this.downY; // if the movement on the Y dominates X then skip and allow scroll if( Math.abs(deltaY) / Math.abs(deltaX) >= Tau.verticalScrollRatio ) { return false; } // since we're rotating record the point for decel this.path.record( point ); // NOTE to reverse the spin direction add the delta/thresh to the downIndex if( Math.abs(deltaX) >= this.rotateThreshold ) { // NOTE works better on mousedown, here allows autorotate to continue this.stopAutoRotate(); var index; if( this.options.reverse ) { index = this.downIndex + Math.round(deltaX / this.rotateThreshold); } else { index = this.downIndex - Math.round(deltaX / this.rotateThreshold); } this.goto( index ); return true; } }; })(this, jQuery); (function( window, $ ) { var ns = window.componentNamespace = window.componentNamespace || "FG"; window[ns] = window[ns] || {}; // IE 8 Date.now = Date.now || function now() { return new Date().getTime(); }; var Path = window[ns].Tau.Path = function() { this.reset(); }; Path.prototype.isSufficient = function() { return !!this.prevPoint && this.prevPrevPoint; }; Path.prototype.distance = function() { return this.prevPoint.x - this.prevPrevPoint.x; }; Path.prototype.duration = function() { return this.prevTime - this.prevPrevTime; }; // TODO sort out variable names Path.prototype.record = function( point ) { this.prevPrevTime = this.prevTime; this.prevPrevPoint = this.prevPoint; // record the most recent drag point for decel on release this.prevTime = Date.now(); this.prevPoint = point; }; Path.prototype.velocity = function( timeStep ) { var distance, time; distance = this.distance(); time = this.duration(); return distance / ( time / timeStep ); }; Path.prototype.reset = function() { this.prevPoint = undefined; this.prevTime = undefined; this.prevPrevTime = undefined; this.prevPrevPoint = undefined; }; Path.prototype.last = function() { return this.prevPoint; }; })(this, jQuery); (function(exports, $){ exports.Akamai = exports.Akamai || {}; var Util = exports.Akamai.Util = {}; Util.preflight = function(context, element, options, name){ name = name || context.constructor.name; context._$el = $(element); if( !context._$el.length ){ throw new Error( name + " component requires element" ); } // If the element has already been instantiated with this component, skip if( context._$el.data(name) ) { return context._$el.data(name); } // Store the instance for access and to prevent double init above context._$el.data( name, context ); // make sure the element has the data attribute for CSS that keys off it context._$el.attr(context.constructor._dataAttr, true); context._options = Util.options(context.constructor.defaultOptions || {}, options || {}, context._$el, name); // retain the options decided at instantiation for reseting the options // when the breakpoint options don't apply (are "backed out") context._originalOptions = Util.extend(true, {}, context._options); // map all the child component events to the wrapper component events Util.mapEvents(context); return false; }; Util.component = function(name, async, init){ if( !init ){ init = async; async = false; } var constr = function(element, options){ // do the preflight var existing = Util.preflight(this, element, options, name); // return on double init if( existing ){ this._trigger("init"); return existing; } // call the init code init.apply(this, arguments); // trigger the init event if( !async ) { this._trigger("init"); } }; constr.name = name; constr._dataAttr = `data-akamai-${name.toLowerCase()}`; // static constructor for many possible objects constr.createMany = Util.createMany(constr); // component prefixed event trigger constr.prototype._trigger = Util.trigger(name); return constr; }; Util.mapEvents = function(context){ var mapping = context.constructor._componentEventMapping || {}; for( var event in mapping) { if( mapping.hasOwnProperty(event) ){ var mappedTo = mapping[event].to || mapping[event]; var $el = mapping[event].selector ? context._$el.find(mapping[event].selector) : undefined; Util.mapEvent(context, event, mappedTo, $el); } } }; Util.mapEvent = function(context, from, to, $el){ ($el || context._$el).bind(from, function(){ context._trigger(to); }.bind(context)); }; Util.createMany = function(constructor, selector){ selector = selector || `[${constructor._dataAttr}]`; return function(element, options){ var $comps = $(element).find( selector ); // sigh, shoestring needs map var objects = []; $comps.each(function(i, el){ objects.push(new constructor(el, options)); }); return objects; }; }; /** * Helper for building assertions into methods based on property requirements * @private * @static * @param {prop} prop - the property required for the function to operate correctly * @param {fn} function - the function definition */ Util.propRequired = function(prop, msg, fn){ return function(){ var args = arguments; if( !this[prop] ){ throw new Error( msg ); } return fn.apply(this, args); }; }; Util.trigger = function(componentName){ componentName = componentName.toLowerCase(); return function(event) { var fullEvent = "akamai-" + componentName + "-" + event; // NOTE we assume here that if jQuery is present we will only want to trigger // with jQuery and that we are using the same DOM lib across the library this._$el.trigger(fullEvent); }; }; Util.camelToKabob = function(string){ return string.replace(/([A-Z]+)/g, function(m){ return "-" + m.toLowerCase(); }); }; Util.options = function(defaults, options, $element, name){ var dataAttrOptions = {}; // make sure to make a new copy of the defaults so we don't mess with the original var cloneDefaults = Util.extend(true, {}, defaults); if( $element && name ) { dataAttrOptions = Util.getDataAttrOptions($element, defaults, "data-akamai-" + name.toLowerCase()); } // use the following precedence, prefering settings further to the right // defaults -> options -> datattributes var finalOptions = Util.extend(true, Util.extend(true, cloneDefaults, dataAttrOptions), options); // push the final options down to the dom element so that CSS that keys off // of the attributes can apply when JS config is used if( $element && name ){ Util.setDataAttrOptions($element, defaults, finalOptions, name); } return finalOptions; }; Util.getDataAttrOptions = function($element, defaults, prefix){ var dataAttrOptions = {}; for( var prop in defaults ) { if( defaults.hasOwnProperty(prop) ){ var dashedProp = Util.camelToKabob(prop); var currentDataProp = prefix + "-" + dashedProp; if( /object/.test(typeof defaults[prop]) && !(defaults[prop] instanceof Array)){ var subDefaults = defaults[prop]; var newPrefix = currentDataProp; var subConfig = Util.getDataAttrOptions($element, subDefaults, newPrefix); dataAttrOptions[prop] = subConfig; } else { var value = $element.attr(currentDataProp); value = Util.coerceAttrVal(value); if( value !== undefined && value !== null ){ // set the config dataAttrOptions[prop] = value; } } } } return dataAttrOptions; }; Util.setDataAttrOptions = function($element, defaults, options, name){ Util.setDataAttrOptionsPrefix($element, defaults, options, "data-akamai-" + name.toLowerCase()); }; Util.setDataAttrOptionsPrefix = function($element, defaults, options, prefix){ defaults = defaults || {}; for( var prop in options ) { if( options.hasOwnProperty(prop) && prop !== "breakpoints" ){ var dashedProp = Util.camelToKabob(prop); var currentDataProp = prefix + "-" + dashedProp; if( /object/.test(typeof options[prop]) ){ var subOptions = options[prop]; var newPrefix = currentDataProp; Util.setDataAttrOptionsPrefix($element, defaults[prop], subOptions, newPrefix); } else { // if the $element had the attribute already or the property exists // as one of the defaults then we can write it to the element. if( $element.attr(currentDataProp) || defaults[prop] !== undefined ) { $element.attr(currentDataProp, options[prop]); } } } } }; Util.coerceAttrVal = function(value){ if( value === "true" ) { return true; } if( value === "false" ) { return false; } if( /^[0-9]+$/.test(value) ) { return parseInt(value, 10); } if( /^[0-9]+.[0-9]+$/.test(value) ) { return parseFloat(value, 10); } return value; }; // Due to jQuery, but with less argument handling Util.extend = function( deep, target, options ) { var name, src, copy, copyIsArray, clone; // Only deal with non-null/undefined values if ( options != null ) { // Extend the base object for ( name in options ) { src = target[ name ]; copy = options[ name ]; // Prevent never-ending loop if ( target === copy ) { continue; } // Recurse if we're merging plain objects or arrays if ( deep && copy && Util.isPlainObject( copy ) ) { clone = src && Util.isPlainObject( src ) ? src : {}; // Never move original objects, clone them target[ name ] = Util.extend( deep, clone, copy ); // Don't bring in undefined values } else if ( copy !== undefined ) { target[ name ] = copy; } } } // Return the modified object return target; }; // Due to jQuery Util.isPlainObject = function( obj ) { var proto, Ctor; // taken from https://github.com/jupiter/simple-mock/pull/3/files if ( !obj || Object.prototype.toString.call(obj) !== "[object Object]" ) { return false; } return true; }; // Due to jQuery var rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g; Util.trim = function( text ) { return text == null ? "" : ( text + "" ).replace( rtrim, "" ); }; Util.map = function(array, fn){ return Util.reduce(array, function(acc, val, i){ acc.push(fn(val, i)); return acc; }, []); }; Util.reduce = function(array, fn, acc){ for(var i = 0; i < array.length; i++){ acc = fn(acc, array[i], i); } return acc; }; Util.log = function(msg, type){ type = type || "log"; if( console ){ console[type](msg); } }; Util.mapJSONVals = function(obj, callback, key){ var result = {}; if(obj.map){ return obj.map(function(o){ return Util.mapJSONVals(o, callback); }); } if(["string", "number", "boolean"].indexOf( typeof obj ) >= 0 ){ return callback(obj, key); } for( var prop in obj ){ if(obj.hasOwnProperty(prop)){ result[prop] = Util.mapJSONVals(obj[prop], callback, prop); } } return result; }; Util.escapeJSONVals = function(obj){ return Util.mapJSONVals(obj, function(val){ if(typeof val === "string" ) { return escapeHTML(val); } return val; }); }; // https://github.com/janl/mustache.js/blob/23beb3a8805c9a857e3ea777431481599fab503e/mustache.js#L60 var entityMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=' }; function escapeHTML(string) { return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) { return entityMap[s]; }); } })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(exports, $){ var Advanceable = { extension: { next: function(callback){ this.goto(this.getIndex() + 1, function(){ if( callback ) { callback(); }; this._trigger("next"); }.bind(this)); }, previous: function(callback){ this.goto(this.getIndex() - 1, function(){ if( callback ) { callback(); }; this._trigger("previous"); }.bind(this)); } }, extend: function(constr) { if(!constr.prototype.goto || !constr.prototype.getIndex) { throw new Error("Advanceable mixin requires `goto` and `getIndex` methods"); } $.extend(constr.prototype, this.extension); } }; exports.Akamai = exports.Akamai || {}; exports.Akamai.Advanceable = Advanceable; })(typeof exports === 'undefined'? window : exports, this.jQuery); // TODO this should be handled in an image component (function(exports, $){ var Sourceable = { extension: { _srcArray: function(url, options, includeW ){ var policy = options.policy || options.thumbnail && options.thumbnail.policy; var widthParam = options.widthParam; return options.widths.map(function(w){ var src = url + (url.indexOf("?") === -1 ? "?" : "&") + widthParam + "=" + w; if( policy ){ src += "&impolicy=" + policy; } //note src width for srcset (eg ' 500w') if( includeW ){ src += " " + w + "w"; } return src; }); }, _srcset: function(url, options){ return this._srcArray( url, options, true ).join(", "); }, _fallbackSrc: function(url, options){ return this._srcArray( url, options )[ 0 ]; }, _largestSrc: function(url, options){ return this._srcArray( url, options ).pop(); } }, extendStatic: function(constr) { Akamai.Util.extend(false, constr, this.extension); } }; exports.Akamai = exports.Akamai || {}; exports.Akamai.Sourceable = Sourceable; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function( w, undefined ){ // requestAnimationFrame pfill var raf = (function(){ return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function( callback ){ w.setTimeout(callback, 1000 / 60); }; })(); /* toss scrolls and element with easing // elem is the element to scroll // options hash: * left is the desired horizontal scroll. Default is "+0". For relative distances, pass a string with "+" or "-" in front. * top is the desired vertical scroll. Default is "+0". For relative distances, pass a string with "+" or "-" in front. * duration is the number of milliseconds the throw will take. Default is 100. * easing is an optional custom easing function. Default is w.toss.easing. Must follow the easing function signature */ w.toss = function( elem, options ){ toss.tossing( elem, false ); var i = 0, sLeft = elem.scrollLeft, sTop = elem.scrollTop, // Toss defaults op = { top: "+0", left: "+0", duration: 200, easing: toss.easing, finished: function() {} }, endLeft, endTop; // Mixin based on predefined defaults if( options ){ for( var j in op ){ if( options[ j ] !== undefined ){ op[ j ] = options[ j ]; } } } // Convert relative values to ints // First the left val if( typeof op.left === "string" ){ op.left = parseFloat( op.left ); endLeft = op.left + sLeft; } else { endLeft = op.left; op.left = op.left - sLeft; } // Then the top val if( typeof op.top === "string" ){ op.top = parseFloat( op.top ); endTop = op.top + sTop; } else { endTop = op.top; op.top = op.top - sTop; } toss.tossing( elem, true ); var startTime = new Date().getTime(); var endTime = startTime + op.duration; var run = function(){ var curTime = new Date().getTime(); // if tossing is suddenly not true, return the callback if( !toss.tossing( elem ) ){ if( op.finished ){ op.finished(); } } // if the time is still less than the end of duration, keep scrolling else if( curTime < endTime ){ i = ( ( curTime - startTime ) / op.duration ) * op.duration; elem.scrollLeft = op.easing( i, sLeft, op.left, op.duration ); elem.scrollTop = op.easing( i, sTop, op.top, op.duration ); return raf( run ); } // if time is up, else{ elem.scrollLeft = endLeft; elem.scrollTop = endTop; if( op.finished ){ op.finished(); } toss.tossing( elem, false ); } }; raf( run ); // Return the values, post-mixin, with end values specified return { top: endTop, left: endLeft, duration: op.duration, easing: op.easing }; }; // tossing object keeps track of currently tossing elements. true during a programatic scroll var tossingElems = {}; toss.tossing = function( elem, state ){ if( state !== undefined ){ tossingElems[ elem ] = state; } return tossingElems[ elem ]; }; // Easing can use any of Robert Penner's equations (http://www.robertpenner.com/easing_terms_of_use.html). By default, toss includes ease-out-cubic // arguments: t = current iteration, b = initial value, c = end value, d = total iterations // use w.toss.easing to provide a custom function externally, or pass an easing function as a callback to the toss method toss.easing = function (t, b, c, d) { return c*((t=t/d-1)*t*t + 1) + b; }; //retain old api toss.toss = toss; })( this ); ;(function( w, $ ){ var pluginName = "snapper"; $.fn[ pluginName ] = function(optionsOrMethod){ var pluginArgs = arguments; var scrollListening = true; // css snap points feature test. // even if this test passes, several behaviors will still be polyfilled, such as snapping after resize, and animated advancing of slides with anchor links or next/prev links var testProp = "scroll-snap-type"; // test latest spec first. then fallback older var snapSupported = w.CSS && w.CSS.supports && (w.CSS.supports(testProp, "x mandatory") || w.CSS.supports(testProp, "mandatory") || w.CSS.supports("-webkit-" + testProp, "mandatory") || w.CSS.supports("-ms-" + testProp, "mandatory") ); // get the snapper_item elements whose left offsets fall within the scroll pane. Returns a wrapped array. function itemsAtOffset( elem, offset ){ var $childNodes = $( elem ).find( "." + pluginName + "_item" ); var containWidth = $( elem ).width(); var activeItems = []; $childNodes.each(function( i ){ if( this.offsetLeft >= offset - 5 && this.offsetLeft < offset + containWidth - 5 ){ activeItems.push( this ); } }); return $( activeItems ); } function outerWidth( $elem ){ return $elem.width() + parseFloat( $elem.css( "margin-left" ) ) + parseFloat( $elem.css( "margin-right" ) ); } function outerHeight( $elem ){ return $elem.height() + parseFloat( $elem.css( "margin-bottom" ) ) + parseFloat( $elem.css( "margin-top" ) ); } // snapEvent dispatches the "snapper.snap" event. // The snapper_item elements with left offsets that are inside the scroll viewport are listed in an array in the second callback argument's activeSlides property. // use like this: $( ".snapper" ).bind( "snapper.snap", function( event, data ){ console.log( data.activeSlides ); } ); function snapEvent( elem, x, prefix ){ prefix = prefix ? prefix + "-" : ""; var activeSlides = itemsAtOffset( elem, x ); $( elem ).trigger( pluginName + "." + prefix + "snap", { activeSlides: activeSlides } ); } // optional: include toss() in your page to get a smooth scroll, otherwise it'll just jump to the slide function goto( elem, x, nothrow, callback ){ scrollListening = false; snapEvent( elem, x ); var after = function(){ elem.scrollLeft = x; $(elem).closest( "." + pluginName ).removeClass( pluginName + "-looping" ); $( elem ).trigger( pluginName + ".after-goto", { activeSlides: itemsAtOffset( elem, x ) }); if( callback ){ callback(); }; snapEvent( elem, x, "after" ); scrollListening = true; }; // backport to old toss for compat if( !w.toss && w.overthrow ){ w.toss = w.overthrow.toss; } if( typeof w.toss !== "undefined" && !nothrow ){ w.toss( elem, { left: x, finished: after }); } else { elem.scrollLeft = x; after(); } } var result, innerResult; // Loop through snapper elements and enhance/bind events result = this.each(function(){ if( innerResult !== undefined ){ return; } var self = this; var $self = $( self ); var addNextPrev = $self.is( "[data-" + pluginName + "-nextprev]" ); var autoTimeout; var $slider = $( "." + pluginName + "_pane", self ); var enhancedClass = pluginName + "-enhanced"; var $itemsContain = $slider.find( "." + pluginName + "_items" ); var $items = $itemsContain.children(); $items.addClass( pluginName + "_item" ); var numItems = $items.length; var $nav = $( "." + pluginName + "_nav", self ); var navSelectedClass = pluginName + "_nav_item-selected"; var useDeepLinking = $self.attr( "data-snapper-deeplinking" ) !== "false"; if( typeof optionsOrMethod === "string" ){ var args = Array.prototype.slice.call(pluginArgs, 1); var index; var itemWidth = ( $itemsContain.width() / numItems ); switch(optionsOrMethod) { case "goto": index = args[0] % numItems; // width / items * index to make sure it goes offset = itemWidth * index; goto( $slider[ 0 ], offset, false, function(){ // snap the scroll to the right position snapScroll(); // invoke the callback if it was supplied if( typeof args[1] === "function" ){ args[1](); } }); break; case "getIndex": // NOTE make the scroll left value large enough to overcome // subpixel widths innerResult = Math.floor(($slider[ 0 ].scrollLeft + 1)/ itemWidth); break; case "updateWidths": updateWidths(); break; } return; } // avoid double enhance activities if( $self.attr("data-" + pluginName + "-enhanced") ) { return; } // NOTE all state manipulation has to come after method invocation to // avoid monkeying with the DOM when it's unwarranted var $navInner = $nav.find( "." + pluginName + "_nav_inner" ); if( !$navInner.length ){ $navInner = $( '
' ).append( $nav.children() ).appendTo( $nav ); } // give the pane a tabindex for arrow key handling $slider.attr("tabindex", "0"); function getAutoplayInterval() { var autoTiming = $self.attr( "data-autoplay" ) || $self.attr( "data-snapper-autoplay" ); var parseError = false; if( autoTiming ) { try { autoTiming = parseInt(autoTiming, 10); } catch(e) { parseError = true; } // if NaN or there was an error throw an exception if( !autoTiming || parseError ) { var msg = "Snapper: `data-autoplay` must have an natural number value."; throw new Error(msg); } } return autoTiming; } // this function updates the widths of the items within the slider, and their container. // It factors in margins and converts those to values that make sense when all items are placed in a long row function updateWidths(){ var itemsContainStyle = $itemsContain.attr( "style" ); $itemsContain.attr( "style", "" ); var itemStyle = $items.eq(0).attr( "style" ); $items.eq(0).attr( "style", "" ); var sliderWidth = $slider.width(); var itemWidth = $items.eq(0).width(); var computed = w.getComputedStyle( $items[ 0 ], null ); var itemLeftMargin = parseFloat( computed.getPropertyValue( "margin-left" ) ); var itemRightMargin = parseFloat( computed.getPropertyValue( "margin-right" ) ); var outerItemWidth = itemWidth + itemLeftMargin + itemRightMargin; $items.eq(0).attr( "style", itemStyle ); $itemsContain.attr( "style", itemsContainStyle ); var parentWidth = numItems / Math.round(sliderWidth / outerItemWidth) * 100; var iPercentWidth = itemWidth / sliderWidth * 100; var iPercentRightMargin = itemRightMargin / sliderWidth * 100; var iPercentLeftMargin = itemLeftMargin / sliderWidth * 100; var outerPercentWidth = iPercentWidth + iPercentLeftMargin + iPercentRightMargin; var percentAsWidth = iPercentWidth / outerPercentWidth; var percentAsRightMargin = iPercentRightMargin / outerPercentWidth; var percentAsLeftMargin = iPercentLeftMargin / outerPercentWidth; $itemsContain.css( "width", parentWidth + "%"); $items.css( "width", 100 / numItems * percentAsWidth + "%" ); $items.css( "margin-left", 100 / numItems * percentAsLeftMargin + "%" ); $items.css( "margin-right", 100 / numItems * percentAsRightMargin + "%" ); } updateWidths(); $( self ).addClass( enhancedClass ); // if the nextprev option is set, add the nextprev nav if( addNextPrev ){ var $nextprev = $( '' ); var $nextprevContain = $( ".snapper_nextprev_contain", self ); if( !$nextprevContain.length ){ $nextprevContain = $( self ); } $nextprev.appendTo( $nextprevContain ); } // This click binding will allow deep-linking to slides without causing the page to scroll to the carousel container // this also supports click handling for generated next/prev links $( "a", this ).bind( "click", function( e ){ clearTimeout(autoTimeout); var slideID = $( this ).attr( "href" ); if( $( this ).is( ".snapper_nextprev_next" ) ){ e.preventDefault(); return arrowNavigate( true ); } else if( $( this ).is( ".snapper_nextprev_prev" ) ){ e.preventDefault(); return arrowNavigate( false ); } // internal links to slides else if( slideID.indexOf( "#" ) === 0 && slideID.length > 1 ){ e.preventDefault(); var $slide = $( slideID, self ); if( $slide.length ){ goto( $slider[ 0 ], $slide[ 0 ].offsetLeft ); if( useDeepLinking && "replaceState" in w.history ){ w.history.replaceState( {}, document.title, slideID ); } } } }); // arrow key bindings for next/prev $( this ) .bind( "keydown", function( e ){ if( e.keyCode === 37 || e.keyCode === 38 ){ clearTimeout(autoTimeout); e.preventDefault(); e.stopImmediatePropagation(); arrowNavigate( false ); } if( e.keyCode === 39 || e.keyCode === 40 ){ clearTimeout(autoTimeout); e.preventDefault(); e.stopImmediatePropagation(); arrowNavigate( true ); } } ); var snapScrollCancelled = false; // snap to nearest slide. Useful after a scroll stops, for polyfilling snap points function snapScroll(){ if(isTouched){ snapScrollCancelled = true; return; } var currScroll = $slider[ 0 ].scrollLeft; var width = $itemsContain.width(); var itemWidth = $items[ 1 ] ? $items[ 1 ].offsetLeft : outerWidth( $items.eq( 0 ) ); var roundedScroll = Math.round(currScroll/itemWidth)*itemWidth; var maxScroll = width - $slider.width(); if( roundedScroll > maxScroll ){ roundedScroll = maxScroll; } if( currScroll !== roundedScroll ){ if( snapSupported ){ snapEvent( $slider[ 0 ], roundedScroll ); snapEvent( $slider[ 0 ], roundedScroll, "after" ); } else { goto( $slider[ 0 ], roundedScroll ); } } else { goto( $slider[ 0 ], roundedScroll ); } snapScrollCancelled = false; } // retain snapping on resize (necessary even in scroll-snap supporting browsers currently, unfortunately) var startSlide; var afterResize; function snapStay(){ var currScroll = $slider[ 0 ].scrollLeft; var numItems = $items.length; var width = $itemsContain.width(); if( startSlide === undefined ){ startSlide = Math.round( currScroll / width * numItems ); } if( afterResize ){ clearTimeout( afterResize ); } afterResize = setTimeout( function(){ updateWidths(); goto( $slider[ 0 ], $items[ startSlide ].offsetLeft, true ); startSlide = afterResize = undefined; }, 50 ); } $( w ).bind( "resize", snapStay ); // next/prev links or arrows should loop back to the other end when an extreme is reached function arrowNavigate( forward ){ var currScroll = $slider[ 0 ].scrollLeft; var width = $itemsContain.width(); var itemWidth = outerWidth( $slider ); var maxScroll = width - itemWidth - 5; if( forward ){ if( currScroll >= maxScroll ){ $self.addClass( pluginName + "-looping" ); return first(); } else { return next(); } } else { if( currScroll === 0 ){ $self.addClass( pluginName + "-looping" ); return last(); } else { return prev(); } } } // advance slide one full scrollpane's width forward function next(){ goto( $slider[ 0 ], $slider[ 0 ].scrollLeft + ( $itemsContain.width() / numItems ), false, function(){ $slider.trigger( pluginName + ".after-next" ); }); } // advance slide one full scrollpane's width backwards function prev(){ goto( $slider[ 0 ], $slider[ 0 ].scrollLeft - ( $itemsContain.width() / numItems ), false, function(){ $slider.trigger( pluginName + ".after-prev" ); }); } // go to first slide function first(){ goto( $slider[ 0 ], 0 ); } // go to last slide function last(){ goto( $slider[ 0 ], $itemsContain.width() - $slider.width() ); } // update thumbnail state on pane scroll if( $nav.length ){ // function for scrolling to the xy of the active thumbnail function scrollNav(elem, x, y){ if( typeof w.toss !== "undefined" ){ w.toss( elem, { left: x, top:y }); } else { elem.scrollLeft = x; elem.scrollTop = y; } } var lastActiveItem; function activeItem( force ){ var currTime = new Date().getTime(); if( !force && lastActiveItem && currTime - lastActiveItem < 200 ){ return; } lastActiveItem = currTime; var currScroll = $slider[ 0 ].scrollLeft; var width = outerWidth( $itemsContain ); var navWidth = outerWidth( $nav ); var navHeight = outerHeight( $nav ); var activeIndex = Math.round( currScroll / width * numItems ) || 0; var childs = $nav.find( "a" ).removeClass( navSelectedClass ); var activeChild = childs.eq( activeIndex ).addClass( navSelectedClass ); var thumbX = activeChild[ 0 ].offsetLeft - (navWidth/2); var thumbY = activeChild[ 0 ].offsetTop - (navHeight/2); scrollNav( $navInner[ 0 ], thumbX, thumbY ); } // set active item on init activeItem(); $slider.bind( "scroll", activeItem ); } // apply snapping after scroll, in browsers that don't support CSS scroll-snap var scrollStop; var scrolling; var lastScroll = 0; $slider.bind( "scroll", function(e){ lastScroll = new Date().getTime(); scrolling = true; }); setInterval(function(){ if( scrolling && lastScroll <= new Date().getTime() - 150) { snapScroll(); if( activeItem ){ activeItem( true ); } scrolling = false; } }, 150); var isTouched = false; // if a touch event is fired on the snapper we know the user is trying to // interact with it and we should disable the auto play $slider.bind("touchstart", function(){ clearTimeout(autoTimeout); isTouched = true; }); $slider.bind("touchend", function(){ isTouched = false; if(snapScrollCancelled && !scrolling){ snapScroll(); scrolling = false; } }); // if the `data-autoplay` attribute is assigned a natural number value // use it to make the slides cycle until there is a user interaction function autoplay( autoTiming ) { if( autoTiming ){ // autoTimeout is cleared in each user interaction binding autoTimeout = setTimeout(function(){ var timeout = getAutoplayInterval(); if( timeout ) { arrowNavigate(true); autoplay( timeout ); } }, autoTiming); } } autoplay( getAutoplayInterval() ); $self.attr("data-" + pluginName + "-enhanced", true); }); return (innerResult !== undefined ? innerResult : result); }; }( this, jQuery )); ;(function( w ){ var enlarge = function(){ var $ = w.jQuery; var pluginName = "enlarge"; $.fn[ pluginName ] = function( options ){ var pluginArgs = arguments; // options var o = $(this).data("options") || { button: true, hoverZoomWithoutClick: true, delay: 300, flyout: { width: 200, height: 200 }, placement: "inline", magnification: 3 }; if( typeof options !== "string" ) { // extend with passed options o = $.extend( o, options ); $(this).data("options", o); } var internalResult; var result = this.each(function(){ var $element = $(this); var self = this; var testimg = w.document.createElement( "img" ); var srcsetSupported = "srcset" in testimg; var srcsetSizesSupported = srcsetSupported && "sizes" in testimg; var $anchor = $( this ).find( "a" ); if( !$anchor.length ){ throw new Error(pluginName + ": requires an anchor element with `href` for the enlarged image source"); } // find image within container var initialImg = $element.find( "img" )[ 0 ]; var targetImg = initialImg; var imgOriginalSrc = targetImg.src; var srcset = $( targetImg ).attr( "srcset" ); var imgOriginalSizes = $( targetImg ).attr( "sizes" ); var imgZoomSrc = $anchor[ 0 ].href; var initialText = $anchor[ 0 ].innerText; var zoomClass = pluginName + "-zoomed"; var delayClass = pluginName + "-delay"; var $contain = $( targetImg ).closest( ".enlarge_contain" ); var $zoomContain = $contain; var $parentPane = $( targetImg ).closest( ".enlarge_pane" ) || $element; var $zoomParent = $(this).data("zoomParent") || $parentPane; $(this).data("zoomParent", $zoomParent); var zoomed = $element.data("zoomed") || false; $element.data("zoomed", zoomed); $element.data("lockedZoom", $element.data("lockedZoom") || false); var lockZoomClass = pluginName + "-locked"; if( !$contain.length ){ throw new Error(pluginName + ": requires an element above the image marked with the class `enlarge_contain`"); } // this allows for methods and changing options in subsequent calls to the plugin if( typeof options === "string" ) { var args = Array.prototype.slice.call(pluginArgs, 1); switch(options){ case "in": if( !$element.data("zoomed") ){ standardToggleZoom(); } break; case "out": if( $element.data("zoomed") ){ standardToggleZoom(); } break; case "isZoomed": internalResult = $element.data("zoomed"); break; case "updateOptions": $element.data( "updateOptions" )( args[ 0 ] ); break; } return; } // to toggle between inline and flyout modes, we change the elements that // the targetImg, zoomParent, and zoomContain vars refer to function updatePlacement(){ if( o.placement === "inline" ){ targetImg = initialImg; $zoomParent = $parentPane; $element.data("zoomParent", $zoomParent); $zoomContain = $contain; } else { targetImg = $flyout.find( "img" )[ 0 ]; $zoomParent = $zoomContain = $flyout; $element.data("zoomParent", $zoomParent); } } // this positions the loupe or side flyout element either according to mouse/touch coordinates // or the sides of the viewer specified function positionFlyout(){ // set flyout width and height $flyout.css({ "width": o.flyout.width + "px", "height": o.flyout.height + "px", top: "", left: "", "margin-left": "", "margin-top": "" }); // set negative left or right value to match width var flyoutSide = o.placement.match( /left|right/ ); if( flyoutSide ){ $flyout.css( flyoutSide[ 0 ], -o.flyout.width + "px" ); } // if loupe mode, center offset var loupe = o.placement.match( /loupe/ ); if( loupe ) { // loupe $flyout.css({ "margin-left": ( -o.flyout.width / 2 ) + "px", "margin-top": ( -o.flyout.height / 2 ) + "px" }); } // add class to specify positioning spot for static css to apply $flyout[ 0 ].className = $flyout[ 0 ].className.replace( /enlarge_flyout\-[^$\s]+/, ' ' ); $flyout.addClass( "enlarge_flyout-" + o.placement ); } function disable(){ if(o.disabled){ $element.addClass("enlarge_disabled"); } else { $element.removeClass("enlarge_disabled"); } } disable(); // this allows for subsequent calls to the plugin to pass an updateOptions method and object, // which will pass through to the existing viewer on that element $element.data( "updateOptions", function( opts ){ o = $.extend( o, opts ); $(this).data("options", o); updatePlacement(); positionFlyout(); hoverEnabled = o.hoverZoomWithoutClick; if( o.image && o.image.sizes ){ imgOriginalSizes = o.image.sizes; toggleImgSrc(); } disable(); if( o.disabled && $element.data("zoomed") ) { standardToggleZoom(); } }); // loader div holds a new image while its new source is loading // we insert this into the dom so that srcset/sizes can calculate a best source function addLoader(){ $contain.append( '' ); } // zoom state toggle boolean function toggleZoomState(){ zoomed = !$element.data("zoomed"); $element.data("zoomed", zoomed); } // toggle the image source bigger or smaller // ideally, this toggles the sizes attribute and allows the browser to select a new source from srcset // if srcset isn't supported or sizes attribute is not provided, the link href is used for the larger source function toggleImgSrc(after){ after = after || function(){}; if( !zoomed ){ targetImg.sizes = imgOriginalSizes; if( !srcsetSizesSupported ){ targetImg.src = imgOriginalSrc; } after(); } else { // if the zooming is disabled do not replace with the larger source // NOTE we don't prevent switching to the original source because we // always want to allow the plugin to back out of the zoomed state // when disabled if( o.disabled ) { after(); return false; } var zoomimg = new Image(); zoomimg.className = "enlarge_img-loading"; zoomimg.onload = function(){ targetImg.sizes = zoomimg.sizes; if( !srcsetSizesSupported || !srcset ){ targetImg.src = imgZoomSrc; } $( zoomimg ).remove(); after(); }; zoomimg.sizes = imgZoomWidth() + "px"; if( !srcsetSizesSupported || !srcset ){ zoomimg.src = imgZoomSrc; } else if (srcset) { zoomimg.srcset = srcset; } $( zoomimg ).insertBefore( targetImg ); } } // scroll to the center of the zoomed image function scrollToCenter(){ var pw = $zoomContain.width(); var ph = $zoomContain.height(); var w = targetImg.offsetWidth; var h = targetImg.offsetHeight; $zoomContain[ 0 ].scrollLeft = ( w / 2 ) - ( pw / 2 ); $zoomContain[ 0 ].scrollTop = ( h / 2 ) - ( ph / 2 ); } // lock zoom mode allows for scrolling around normally without a cursor-follow behavior function toggleLockZoom(){ if( !$element.data("lockedZoom") ){ // NOTE we allow the image to zoom out if functionality gets disabled // when it's in a zoomed state if(o.disabled) { return false; } $parentPane.add( $zoomParent ).addClass( lockZoomClass ); $element.data("lockedZoom", lockedZoom = true); $zoomContain.attr( "tabindex", "0" ); $zoomContain[ 0 ].focus(); } else { $parentPane.add( $zoomParent ).removeClass( lockZoomClass ); $element.data("lockedZoom", lockedZoom = false); $zoomContain.removeAttr( "tabindex" ); } } function imgZoomWidth(){ return $parentPane[0].offsetWidth * o.magnification; } // toggle magnification of image function toggleImgZoom(){ if( $element.data("zoomed") ){ // NOTE we allow the image to zoom out if functionality gets disabled // when it's in a zoomed state if(o.disabled) { return false; } if( o.placement === "inline" ){ $contain.add( $parentPane ).css( { "width": $parentPane[0].offsetWidth + "px", "height": parseFloat( getComputedStyle( $parentPane[0] ).height ) + "px" } ); } $zoomParent.addClass( zoomClass ); $( targetImg ).css( "width", imgZoomWidth() + "px" ); $(self).trigger( pluginName + ".after-zoom-in"); } else{ $zoomParent.removeClass( zoomClass ); if( o.placement === "inline" ){ $contain.add( $parentPane ).css( { "width": "", "height": "" } ); } $( targetImg ).css( "width", "" ); $(self).trigger( pluginName + ".after-zoom-out"); } } function forceInlineMode(){ var oldO = o.placement; if( oldO !== "inline" ){ function resetPlacement(){ o.placement = oldO; updatePlacement(); $(self).unbind( pluginName + ".after-zoom-out", resetPlacement ); } $(self).bind( pluginName + ".after-zoom-out", resetPlacement ); o.placement = "inline"; updatePlacement(); } } // lock zoom mode toggle function standardToggleZoom(){ // NOTE if the current is zoomed out and it's disabled prevent toggling if(o.disabled && !$element.data("zoomed")) { return false; } toggleZoomState(); toggleImgSrc(function(){ toggleLockZoom(); toggleImgZoom(); scrollToCenter(); }); } var trackingOn; var trackingTimer; var mouseEntered; var touchStarted; var hoverEnabled = o.hoverZoomWithoutClick; var lastMouseMove; // mouseenter or touchstart handler for dragging image function startTrackingDelay(e){ if( e.type === "touchstart" ){ touchStarted = true; } if( touchStarted && e.type === "mouseenter" || e.type === "mouseenter" && !touchStarted && !hoverEnabled || $element.data("lockedZoom") || mouseEntered ){ return; } mouseEntered = true; $contain.addClass( delayClass ); trackingTimer = setTimeout( function(){ $contain.removeClass( delayClass ); toggleZoomState(); toggleImgSrc(function(){ toggleImgZoom(); trackingOn = true; if (lastMouseMove) { scrollWithMouse(lastMouseMove); } else { scrollWithMouse(e); } }); }, o.delay ); } // mouseleave or touchend after a drag function stopTrackingDelay( e ){ $contain.removeClass( delayClass ); clearTimeout( trackingTimer ); trackingOn = false; if( o.hoverZoomWithoutClick === false && !touchStarted ){ hoverEnabled = false; } if( touchStarted && e.type === "mouseleave" ){ touchStarted = false; } } // mousemove or touch-drag image placement function scrollWithMouse( e ){ // if tracking's not on yet, ignore. This allows the delay to work if( trackingOn ){ // if the move was touch-started, and the event is mousemove, it can be ignored // (mouse events fire along with touch events and we just want the touch) if( touchStarted && e.type === "mousemove" ){ return; } // normalize ev to touch or mouse var ev = e.touches ? e.touches[ 0 ] : e; e.preventDefault(); var x = ev.clientX - $contain[ 0 ].getBoundingClientRect().left; var y = ev.clientY - $contain[ 0 ].getBoundingClientRect().top; if( o.placement.match( /loupe/ ) ) { // offset the loupe a little differently for touch so that it's not directly beneath a finger var mLeft = ( e.touches ? -o.flyout.width / 1.3 : -o.flyout.width / 2 ) + "px"; var mTop = ( e.touches ? -o.flyout.height / 1.3 : -o.flyout.height / 2 ) + "px"; requestAnimationFrame(function(){ $flyout.css( { top: y + "px", left: x + "px", "margin-left": mLeft, "margin-top": mTop } ); }); } var containWidth = $contain[ 0 ].offsetWidth; var containHeight = $contain[ 0 ].offsetHeight; var containScrollWidth = targetImg.offsetWidth; var containScrollHeight = targetImg.offsetHeight; var zoomContainWidth = $zoomContain[ 0 ].offsetWidth; var zoomContainHeight = $zoomContain[ 0 ].offsetHeight; var widthFactor = containWidth / zoomContainWidth; var heightFactor = containHeight / zoomContainHeight; $zoomContain[ 0 ].scrollLeft = (( x / containWidth ) * ( containScrollWidth - zoomContainWidth )); $zoomContain[0].scrollLeft += ((x / containWidth) - 0.5) * zoomContainWidth; $zoomContain[ 0 ].scrollTop = (( y / containHeight ) * ( containScrollHeight - zoomContainHeight )) ; $zoomContain[0].scrollTop += ((y / containHeight) - 0.5) * zoomContainHeight; } else { lastMouseMove = e; } } // add flyout for flyout and loupe modes // flyout is always present, yet hidden when inline mode is active var $flyout = $( '
' ).append( $contain.clone() ); $flyout.insertAfter( $parentPane ); // add loader div addLoader(); updatePlacement(); positionFlyout(); // clicking the magnify anchor toggles lock-zoom mode $anchor .bind( "keydown", function(e){ if( e.keyCode === 13 || e.keyCode === 32 ){ forceInlineMode(); } // spacebar triggers click too if( e.keyCode === 32 ){ e.preventDefault(); // don't scroll the new focused area $( this ).trigger( "click" ); } }) .bind( "click", function( e ){ e.preventDefault(); standardToggleZoom(); }); // on resize, if in lock zoom mode, un zoom $( w ) .bind( "resize", function( e ){ if( $element.data("lockedZoom") ){ standardToggleZoom(); } }); // on click-out on the page, if in locked zoom mode, zoom out $( w.document ) .bind( "mouseup", function( e ){ if( $element.data("lockedZoom") && !$( e.target ).closest( $parentPane ).length ){ standardToggleZoom(); } }); // mouse hover and touch-drag gestures for a cursor-tracked zoom behavior $( initialImg ) .bind( "mouseenter touchstart", startTrackingDelay ) .bind( "mousemove touchmove", scrollWithMouse ) .bind( "mouseleave touchend", function( e ){ mouseEntered = false; if( zoomed && !$element.data("lockedZoom") ){ toggleZoomState(); toggleImgSrc(function(){ toggleImgZoom(); }); } stopTrackingDelay( e ); }) // tapping the image should trigger a lock zoom // click will not fire after a touch-drag so it works as a tap for our needs here .bind( "click", function( e ){ e.preventDefault(); // if the click was started with a touchstart event, // and placement is inline // toggle the locked zoom mode if( touchStarted && o.placement === "inline" ){ standardToggleZoom(); } if( o.hoverZoomWithoutClick === false && !touchStarted ){ hoverEnabled = !hoverEnabled; if( hoverEnabled ){ $( this ).trigger( "mouseenter" ); } else { $( this ).trigger( "mouseleave" ); } } } ); // keyboard handling for arrows in zoom mode $(this).bind( "keydown keyup", function( e ){ if( e.keyCode === 37 || e.keyCode === 38 || e.keyCode === 39 || e.keyCode === 40 ){ e.stopImmediatePropagation(); if( !$element.data("lockedZoom") ){ e.preventDefault(); } } else if( e.type === "keyup" && $(this).data("lockedZoom") && e.keyCode === 27 ){ //esc or backspace closes zoom standardToggleZoom(); $anchor[0].focus(); e.stopImmediatePropagation(); } }); // on scroll, zoom out $parentPane.bind( "scroll", function(){ if( $element.data("zoomed") ){ toggleZoomState(); toggleImgSrc(function(){ if( $element.data("lockedZoom") ){ toggleLockZoom(); } toggleImgZoom(); }); } }); }); return internalResult !== undefined ? internalResult : result; }; }; if( typeof module !== "undefined" ){ module.exports = enlarge; } else { enlarge(); } }( typeof global !== "undefined" ? global : this )); (function(exports){ var Image = { defaultConfig: { widths: [ "320", "640", "800", "1024", "2048", "5000" ], sizes: "100vw", policy: undefined, widthParam: "imwidth" } }; exports.Akamai = exports.Akamai || {}; exports.Akamai.Image = Image; })(typeof exports === 'undefined'? window : exports); (function(exports, $){ /** * 360 degree viewer * @class * @alias Akamai.Spin360 * @param {HTMLElement} element - the DOM element representing the component markup * @param {Object} options - configuration options */ var Spin360 = Akamai.Util.component("Spin360", function(element, options){ this._comp = new Akamai.Tau(element, this._options); }); // srcset stuff Akamai.Sourceable.extendStatic(Spin360); // Used in preflight to "rename" events based on the child component events Spin360._componentEventMapping = { "tau.auto-rotate-start": "play", "tau.auto-rotate-stop": "pause" }; Spin360.prototype._updateOptions = function(options){ if( !options ) { this._options = this._originalOptions; } this._options = Akamai.Util.extend(true, this._options, options); // push the final options down to the dom element so that CSS that keys off // of the attributes can apply when JS config is used Akamai.Util.setDataAttrOptions(this._$el, Spin360.defaultOptions, this._options, "Spin360"); // TODO actually update the options }; Spin360._renderImg = function(url, options){ return ` `; }; Spin360.render = function(json, options){ var finalOptions = Akamai.Util.options(Spin360.defaultOptions, options); // TODO fix the string problem in shoestring return Akamai.Util.trim(`
${Akamai.Util .map(json.urls, function(url) { return Spin360._renderImg(url, finalOptions); }) .join("\n") }
`); }; /** * Defines the global default options for all Spin360s on the page * @static * @property {Boolean} autoplay.enabled - Enable autoplay (default: false) * @property {Integer} autoplay.delay - Delay in milliseconds after initialization before spinning begins (default: 1000) * @property {Boolean} controls.arrows - Render controls to spin left and right (default: false) * @property {Boolean} controls.play - Render controls to enable and disable automatic spinning (default: false) * @property {String} controls.text.left - Left rotation control title and text (default: "Rotate Left") * @property {String} controls.text.right - Right rotation control title and text (default: "Rotate Right") * @property {String} controls.text.play - Spin control title and text (default: "Spin Object") * @property {Array} images.widths - list of available widths for an image (to be combined with image.widthParam), Default: ["320","640","800","1024","2048","5000"] * @property {String} images.sizes - value for image sizes attribute. Default is set dynamically to viewer width when rendered with JS, and updated dynamically. Values: `100vw`, `200px`, `(min-width:1000px) 500px, 100vw`. * @property {String} images.policy - query param value for policy, appended to &policy= per image url when specified. Values: `foo`. Default: undefined. * @property {String} images.widthParam - query string name to use for setting each url width. Default urls will be ?imwidth=320 for example. Values: `imwidth` (default), `w`, `width`, etc. * @property {Integer} interval - The full rotation interval in milliseconds, determines physics, (default: 3000) * @property {Boolean} reverse - Reverse the direction of the spin (default: false) * @property {Float} sensitivity - The speed at which the object rotates relative to user input (default: 1). At the default value of `1` the object will complete a full 360 rotation when you drag across the entire width of the spin360 component. To require less effort to complete a rotation, change this value to a higher number. For example, setting `sensitivity: 2` would complete a full 360 rotation by dragging halfway (ex. from the center to the edge). */ Spin360.defaultOptions = { autoplay: { enabled: false, delay: 1000 }, controls: { arrows: false, play: false, text: { left: "Rotate Left", right: "Rotate Right", play: "Spin Object" } }, // unsupported, the number of frame images to create using the configured // template frames: 72, images: Akamai.Image.defaultConfig, interval: 3000, reverse: false, sensitivity: 1, // unsupported, template used to generate urls when only one image is // present in the 360 viewer template: undefined }; /** * Goto to a particular frame of the spining image * @method * @param {Integer} index - the frame to advance to * @param {Function?} callback - callback invoked after the action has completed in the DOM * @returns {undefined} */ Spin360.prototype.goto = function(index, callback){ this._comp.goto(index); // NOTE goto must come before the callback because the callback will be // used to trigger the `next` and `previous` events. The order should be // maintained consistently as `goto` -> `next`/`previous` this._trigger("goto"); if(callback){ callback(); } }; /** * Return the current frame index * @method * @returns {Integer} */ Spin360.prototype.getIndex = function(){ return this._comp.index; }; // Extend Carousel with Advanceable interface Akamai.Advanceable.extend(Spin360); /** * Go to the next frame * @method * @param {Function?} callback - callback invoked after the action has completed in the DOM * @returns {undefined} */ Spin360.prototype.next; // required for JSDocs /** * Go to the previous frame * @method * @param {Function?} callback - callback invoked after the action has completed in the DOM * @returns {undefined} */ Spin360.prototype.previous; // required for JSDocs /** * Begin the automatic rotation of the images * @todo support passing in an interval or speed? * @method * @fires Akamai.Spin360#akamai-spin360-play * @returns {undefined} */ Spin360.prototype.play = function(){ this._comp.autoRotate(); }; /** * Stop the automatic rotation of the images * @method * @fires Akamai.Spin360#akamai-spin360-pause * @returns {undefined} */ Spin360.prototype.pause = function(){ this._comp.stopAutoRotate(); }; Spin360.prototype.getElement = function(){ return this._$el[0]; }; Spin360.States = { Playing: 0, Paused: 1 }; /** * Return the current state of the spin360 * @example Spin360 states * Akamai.Spin360.States = { * Playing: 0, * Paused: 1 * }; * * @example Conditioning on states * if( spin360.getState() === Akamai.Spin360.States.Paused ) { * spin360.play() * } * @method * @returns {Akamai.Spin360.State} */ Spin360.prototype.getState = function(){ // TODO expose using method in Tau return !!this._comp.autoInterval ? Spin360.States.Playing : Spin360.States.Paused; }; /** * Triggered when the spin360 has stoped automatically spinning * {@link Akamai.Spin360#pause}. * * @event Akamai.Spin360#akamai-spin360-stop-spin */ /** * Triggered when the spin360 has started automatically spinning * {@link Akamai.Spin360#play}. * * @event Akamai.Spin360#akamai-spin360-start-spin */ /** * Triggered when initialization finishes * {@link Akamai.Spin360}. * * @event Akamai.Spin360#akamai-spin360-init */ exports.Akamai = exports.Akamai || {}; exports.Akamai.Spin360 = Spin360; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(){ function MagnifierImpl(element, options){ throw new Error('Not allowed to instantiate MagnifierImpl'); } MagnifierImpl.prototype.updateOptions = function(options){ throw new Error('magnifier updateOptions not implemented'); }; MagnifierImpl.prototype.zoomIn = function(){ throw new Error('magnifier zoomIn not implemented'); }; MagnifierImpl.prototype.zoomOut = function(){ throw new Error('magnifier zoomOut not implemented'); }; MagnifierImpl.prototype.isMagnified = function(){ throw new Error('magnifier isMagnified not implemented'); }; MagnifierImpl.prototype.toggleZoom = function(){ throw new Error('magnifier toggleZoom not implemented'); }; MagnifierImpl.prototype.render = function(options, dataAttr, fallbackSrc, largestSrc, srcSet){ throw new Error('magnifier render not implemented'); }; var ex = (typeof exports === 'undefined') ? window : exports; ex.Akamai = ex.Akamai || {}; ex.Akamai.MagnifierImpl = MagnifierImpl; })(); (function(){ function MagnifierImplEnlarge(element, options){ this._$el = $(element); this._$el.enlarge(options); } MagnifierImplEnlarge.prototype = Object.create(Akamai.MagnifierImpl.prototype); MagnifierImplEnlarge.prototype.constructor = MagnifierImplEnlarge; MagnifierImplEnlarge.prototype.updateOptions = function(options){ this._$el.enlarge( "updateOptions", options ); }; MagnifierImplEnlarge.prototype.zoomIn = function(){ this._$el.enlarge( "in" ); }; MagnifierImplEnlarge.prototype.zoomOut = function(){ this._$el.enlarge( "out" ); }; MagnifierImplEnlarge.prototype.isMagnified = function(){ return this._$el.enlarge( "isZoomed" ); }; MagnifierImplEnlarge.prototype.toggleZoom = function(){ if(this.isMagnified()){ this.zoomOut(); } else { this.zoomIn(); } }; MagnifierImplEnlarge.render = function(options, dataAttr, fallbackSrc, largestSrc, srcSet){ return `
${options.buttonText}
`; }; var ex = (typeof exports === 'undefined') ? window : exports; ex.Akamai = ex.Akamai || {}; ex.Akamai.MagnifierImplEnlarge = MagnifierImplEnlarge; })(); (function(){ function ClickTracker(element, handler){ var self = this; self.element = element; self.handler = handler; this.reset(); } ClickTracker.prototype.onDown = function(e){ if (e.target == this.element) { if (this.isDown) { this.reset(); } else { this.down.x = e.clientX; this.down.y = e.clientY; this.down.timestamp = new Date(); this.isDown = true; } } }; ClickTracker.prototype.onMove = function(){ this.isMoved = true; }; ClickTracker.prototype.onUp = function(e){ if (this.isDown) { this.up.x = e.clientX; this.up.y = e.clientY; this.up.timestamp = new Date(); if (!this.isMoved && (this.down.x === this.up.x && this.down.y === this.up.y && (this.up.timestamp.getTime() - this.down.timestamp.getTime() <= 500))) { this.handler(); } } this.reset(); }; ClickTracker.prototype.reset = function(x, y){ this.down = { x: -Infinity, y: -Infinity, timestamp: new Date(0) }; this.isDown = false; this.isMoved = false; this.up = { x: Infinity, y: Infinity, timestamp: new Date() }; }; function MagnifierImplScroller(element, options){ this._$el = $(element); this._options = options; this._container = this._$el.get(0); this._content = this._$el.children().get(0); this._imgCast = $(this._content).find('div').get(0); this._zoomInBtn = this._$el.find("a.scroller-zoom-in").get(0); this._zoomOutBtn = this._$el.find("a.scroller-zoom-out").get(0); this._magnification = 1; var self = this; // Initialize Scroller this.scroller = new Scroller(this._renderer(), { zooming: options.enabled, minZoom: 1, maxZoom: options.magnification, animationDuration: options.animationDuration }); var rect = this._container.getBoundingClientRect(); this.scroller.setPosition(rect.left + this._container.clientLeft, rect.top + this._container.clientTop); this._installEventHandlers(); this._updateButtonStates(); setTimeout(function(){ self._onResize(); }); } MagnifierImplScroller.prototype = Object.create(Akamai.MagnifierImpl.prototype); MagnifierImplScroller.prototype.constructor = MagnifierImplScroller; MagnifierImplScroller.prototype.updateOptions = function(options){ var rect = this._container.getBoundingClientRect(); this.scroller.setPosition(rect.left + this._container.clientLeft, rect.top + this._container.clientTop); this._onResize(); this._zoomBy(1 / this._options.magnification, false); }; MagnifierImplScroller.prototype.zoomIn = function(){ this._zoomBy(this._options.incrementalZoomFactor); }; MagnifierImplScroller.prototype.cyclicZoom = function(){ var self = this; if (this._magnification >= this._options.magnification) { this._zoomBy(1 / this._options.magnification); } else { this.zoomIn(); } }; MagnifierImplScroller.prototype.zoomOut = function(){ this._zoomBy(1 / this._options.incrementalZoomFactor); }; MagnifierImplScroller.prototype.isMagnified = function(){ return this._magnification > 1; }; MagnifierImplScroller.prototype.toggleZoom = function(){ throw new Error('zoom toggle behavior is undefined in scroller mode'); }; MagnifierImplScroller.render = function(options, dataAttr, fallbackSrc, largestSrc, srcSet){ return `
${options.zoomInBtnText} ${options.zoomOutBtnText}
`; }; MagnifierImplScroller.prototype._zoomBy = function(magnification, animate){ var self = this, magnification = Number(magnification); if (typeof(animate) === "undefined") { animate = this._options.animateZoom; } if (magnification === 1 || // if we are magnifying by 1 OR (this._magnification === 1 && magnification < 1) || // fully zoomed out and still attempting a zoom out OR (this._magnification === this._options.magnification && magnification > 1)) { // fully zoomed in and still attempting a zoom in return; } this.scroller.zoomTo(this._magnification * magnification, this._options.animateZoom); setTimeout(function(){ self._onZoom(); }, this._options.animationDuration); } MagnifierImplScroller.prototype._onZoom = function(){ var oldMagnification = this._magnification; this._magnification = Number(this.scroller.getValues().zoom.toFixed(2)); this._updateButtonStates(); if (oldMagnification > this._magnification) { this._$el.trigger("scroller.after-zoom-out"); } else if (oldMagnification < this._magnification) { this._$el.trigger("scroller.after-zoom-in"); } }; MagnifierImplScroller.prototype._renderer = function(){ var docStyle = document.documentElement.style, self = this; var engine; if (window.opera && Object.prototype.toString.call(opera) === '[object Opera]') { engine = 'presto'; } else if ('MozAppearance' in docStyle) { engine = 'gecko'; } else if ('WebkitAppearance' in docStyle) { engine = 'webkit'; } else if (typeof navigator.cpuClass === 'string') { engine = 'trident'; } var vendorPrefix = { trident: 'ms', gecko: 'Moz', webkit: 'Webkit', presto: 'O' }[engine]; var helperElem = document.createElement("div"); var undef; var perspectiveProperty = vendorPrefix + "Perspective"; var transformProperty = vendorPrefix + "Transform"; if (helperElem.style[perspectiveProperty] !== undef) { return function(left, top, zoom) { self._content.style[transformProperty] = 'translate3d(' + (-left) + 'px,' + (-top) + 'px,0) scale(' + zoom + ')'; }; } else if (helperElem.style[transformProperty] !== undef) { return function(left, top, zoom) { self._content.style[transformProperty] = 'translate(' + (-left) + 'px,' + (-top) + 'px) scale(' + zoom + ')'; }; } else { return function(left, top, zoom) { self._content.style.marginLeft = left ? (-left/zoom) + 'px' : ''; self._content.style.marginTop = top ? (-top/zoom) + 'px' : ''; self._content.style.zoom = zoom || ''; }; } }; MagnifierImplScroller.prototype._onResize = function(){ this.scroller.setDimensions(this._container.clientWidth, this._container.clientHeight, this._container.clientWidth, this._container.clientHeight); }; MagnifierImplScroller.prototype._installEventHandlers = function(){ var self = this, clickTracker = new ClickTracker(this._imgCast, function(){ self.cyclicZoom(); }); window.addEventListener("resize", function(e){ self._onResize(e); }, false); this._zoomInBtn.addEventListener("click", function(e) { e.preventDefault(); self.zoomIn(); }, false); this._zoomOutBtn.addEventListener("click", function(e) { e.preventDefault(); self.zoomOut(); }, false); if ('ontouchstart' in window) { self._container.addEventListener("touchstart", function(e) { // Don't react if initial down happens on one of the zoom buttons if (e.touches[0].target == self._$el.find('a.scroller-zoom-in').get(0) || e.touches[0].target == self._$el.find('a.scroller-zoom-out').get(0)) { return; } // Don't react if initial down happens on a form element if (e.touches[0] && e.touches[0].target && e.touches[0].target.tagName.match(/input|textarea|select/i)) { return; } self.scroller.doTouchStart(e.touches, e.timeStamp); for (var i = 0; i < e.touches.length; i++) { clickTracker.onDown(e.touches[i]); } e.preventDefault(); }, false); document.addEventListener("touchmove", function(e) { self.scroller.doTouchMove(e.touches, e.timeStamp, e.scale); clickTracker.onMove(); }, false); document.addEventListener("touchend", function(e) { self.scroller.doTouchEnd(e.timeStamp); // handle any zoom that may have occured self._onZoom(); for (var i = 0; i < e.changedTouches.length; i++) { clickTracker.onUp(e.changedTouches[i]); } }, false); document.addEventListener("touchcancel", function(e) { self.scroller.doTouchEnd(e.timeStamp); // handle any zoom that may have occured self._onZoom(); for (var i = 0; i < e.changedTouches.length; i++) { clickTracker.onUp(e.changedTouches[i]); } }, false); } else { self._container.addEventListener("mousedown", function(e) { if (e.target.tagName.match(/input|textarea|select/i)) { return; } if (self.scroller.__clientWidth === 0) { self._onResize(); } self.scroller.doTouchStart([{ pageX: e.pageX, pageY: e.pageY }], e.timeStamp); clickTracker.onDown(e); }, false); document.addEventListener("mousemove", function(e) { if (!clickTracker.isDown) { return; } self.scroller.doTouchMove([{ pageX: e.pageX, pageY: e.pageY }], e.timeStamp); clickTracker.onMove(); }, false); document.addEventListener("mouseup", function(e) { if (!clickTracker.isDown) { return; } self.scroller.doTouchEnd(e.timeStamp); clickTracker.onUp(e); }, false); // self._container.addEventListener(navigator.userAgent.indexOf("Firefox") > -1 ? "DOMMouseScroll" : "mousewheel", function(e) { // e.preventDefault(); // self.scroller.doMouseZoom(e.detail ? (e.detail * -120) : e.wheelDelta, e.timeStamp, e.pageX, e.pageY); // }, false); } }; MagnifierImplScroller.prototype._updateButtonStates = function(){ if (this._magnification === 1) { $(this._$el).addClass('scroller-zoom-out-max'); $(this._$el).removeClass('scroller-zoom-in-max'); } else if (this._magnification === this._options.magnification) { $(this._$el).addClass('scroller-zoom-in-max'); $(this._$el).removeClass('scroller-zoom-out-max'); } else { $(this._$el).removeClass('scroller-zoom-in-max'); $(this._$el).removeClass('scroller-zoom-out-max'); } }; var ex = (typeof exports === 'undefined') ? window : exports; ex.Akamai = ex.Akamai || {}; ex.Akamai.MagnifierImplScroller = MagnifierImplScroller; })(); (function(exports, $){ /** * Image magnifier * @class * @alias Akamai.Magnifier * @param {HTMLElement} element - the DOM element representing the component markup * @param {Object} options - configuration options */ var Magnifier = Akamai.Util.component("Magnifier", function(element, options){ // compat with enlarge `disabled` option this._options.disabled = !this._options.enabled; if (this._options.mode === Magnifier.MODE_ANIMATED_ZOOM) { this._impl = new Akamai.MagnifierImplScroller(element, options); } else { this._impl = new Akamai.MagnifierImplEnlarge(element, options); } }); Magnifier.prototype._updateOptions = function(options){ if( !options ) { this._options = this._originalOptions; } this._options = Akamai.Util.extend(true, this._options, options); // compat with enlarge `disabled` option this._options.disabled = !this._options.enabled; // push the final options down to the dom element so that CSS that keys off // of the attributes can apply when JS config is used Akamai.Util.setDataAttrOptions(this._$el, Magnifier.defaultOptions, this._options, "Magnifier"); this._impl.updateOptions(this._options); }; // Used in preflight to "rename" events based on the child component events Magnifier._componentEventMapping = { "enlarge.after-zoom-in": "in", "enlarge.after-zoom-out": "out", "scroller.after-zoom-in": "in", "scroller.after-zoom-out": "out" }; Magnifier.MODE_HOVER_ZOOM = 'hoverzoom'; Magnifier.MODE_ANIMATED_ZOOM = 'animatedzoom'; /** * Defines the global default options for all Magnifiers on the page * @static * @property {String} mode - Set what mode to run the magnifier in. There are two modes available: `hoverzoom` and `animatedzoom`. The `hoverzoom` mode provides the ability to magnify and pan the image by just hovering your mouse over the image. It also provides a `flyout` option where a clip of the zoomed in image is displayed on a separate widget floating somewhere around (configurable) the image. The `animatedzoom` mode only does inline magnification but provides smooth animation between magnification levels, it also allows for incremental zooming. * @property {Boolean} button - Whether to show a button for toggling magnification (default: true) * @property {Boolean} enabled - Enabled/disable magnification (default: true, breakpoints supported) * @property {Integer} magnification - The scale factor to magnify the image: `2`, `3` (default), `4`, `4.5`, etc * @property {Array} image.widths - List of available widths for an image (to be combined with image.widthParam) - (default: ["320","640","800","1024","2048","5000"]) * @property {String} image.sizes - Value for image sizes attribute. Default is set dynamically to viewer width when rendered with JS, and updated dynamically. Values: `100vw` (default), `200px`, `(min-width:1000px) 500px, 100vw` - (default: `100vw`, breakpoints supported) * @property {String} image.policy - Query param value for policy, appended to &policy= per image url when specified. Values: `foo`. (default: undefined) * @property {Integer} delay - Only applicable in `hoverzoom` mode. The time delay in milliseconds between mouse hover and magnification (default: 300, breakpoints supported) * @property {String} buttonText - Only applicable in `hoverzoom` mode. Text for the zoom button. Also used for its title attribute. (default: "Toggle Image Magnification") * @property {Integer} flyout.width - Only applicable in `hoverzoom` mode. Width of the flyout image (default: 200) * @property {Integer} flyout.height - Only applicable in `hoverzoom` mode. Height of the flyout image (default: 200) * @property {Boolean} hoverZoomWithoutClick - Only applicable in `hoverzoom` mode. Zoom starts on mouse hover with no click needed (default: true; false will require a click to hover-zoom) * @property {String} placement - Only applicable in `hoverzoom` mode. Placement of the magnified image: `inline` , `flyoutloupe`, `flyouttopleft`,`flyoutbottomleft` ,`flyouttopright` and `flyoutbottomright` - (default: inline, breakpoins supported) * @property {Float} incrementalZoomFactor - Only applicable in `animatedzoom` mode. A number by which to incrementally zoom up until the specified `magnification`, default `3`. For example a `magnification` of `4` and an `incrementalZoomFactor` of `2` will zoom the image in `2` steps * @property {Boolean} animateZoom - Only applicable in `animatedzoom` mode. Animates the magnification process. Default `true` * @property {Integer} animationDuration - Only applicable in `animatedzoom` mode. If `animateZoom` is true, this specifies the length of the animation in milliseconds. Default `250` * @property {String} zoomInBtnText - Only applicable in `animatedzoom` mode. Hover text to display on the zoom in button. Default `Zoom In` * @property {String} zoomOutBtnText - Only applicable in `animatedzoom` mode. Hover text to display on the zoom out button. Default `Zoom Out` */ Magnifier.defaultOptions = { // general options mode: Magnifier.MODE_HOVER_ZOOM, button: true, enabled: true, magnification: 3, // image options image: Akamai.Image.defaultConfig, // hoverzoom options delay: 300, buttonText: "Toggle Image Magnification", flyout: { width: 200, height: 200 }, hoverZoomWithoutClick: true, placement: "inline", // animatedzoom options incrementalZoomFactor: 3, animateZoom: true, animationDuration: 250, zoomInBtnText: 'Zoom In', zoomOutBtnText: 'Zoom Out' }; // srcset stuff Akamai.Sourceable.extendStatic(Magnifier); Magnifier.render = function(json, options){ var finalOptions = Akamai.Util.options(Magnifier.defaultOptions, options); var dataAttr = Magnifier._dataAttr; var fallbackSrc = Magnifier._fallbackSrc(json.url, finalOptions.image); var largestSrc = Magnifier._largestSrc(json.url, finalOptions.image); var srcSet = Magnifier._srcset(json.url, finalOptions.image) var sizes = finalOptions.image.sizes; var impl = (finalOptions.mode === Magnifier.MODE_ANIMATED_ZOOM) ? Akamai.MagnifierImplScroller : Akamai.MagnifierImplEnlarge; return impl.render(finalOptions, dataAttr, fallbackSrc, largestSrc, srcSet, sizes); }; /** * Enter magnifier mode * @method * @fires Akamai.Magnifier#akamai-magnifier-in * @returns {undefined} */ Magnifier.prototype.in = function(){ this._impl.zoomIn(); }; /** * Exit magnifier mode * @method * @fires Akamai.Magnifier#akamai-magnifier-out * @returns {undefined} */ Magnifier.prototype.out = function(){ this._impl.zoomOut(); }; /** * Return the current state of the magnifier * @method * @returns {Boolean} */ Magnifier.prototype.isMagnified = function(){ return this._impl.isMagnified(); }; /** * Toggle the state of the magnifier * @method * @fires Akamai.Magnifier#akamai-magnifier-in * @fires Akamai.Magnifier#akamai-magnifier-out * @returns {undefined} */ Magnifier.prototype.toggle = function(){ this._impl.toggleZoom(); }; /** * Triggered when the magnifier has completed it transition to * a new index due to user interaction or a call to {@link Akamai.Carouse#in}. * * @event Akamai.Magnifier#akamai-magnifier-in */ /** * Triggered when the magnifier has completed it transition to * a new index due to user interaction or a call to {@link Akamai.Carouse#out}. * * @event Akamai.Magnifier#akamai-magnifier-out */ /** * Triggered when initialization finishes * {@link Akamai.Magnifier}. * * @event Akamai.Magnifier#akamai-magnifier-init */ exports.Akamai = exports.Akamai || {}; exports.Akamai.Magnifier = Magnifier; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(exports, $){ var VideoImpl = Akamai.Util.component("VideoImpl", function(element, options){ throw new Error('Not allowed to instantiate VideoImpl'); }); VideoImpl.render = function(json, options){ throw new Error('VideoImpl.render not implemented'); }; exports.Akamai = exports.Akamai || {}; exports.Akamai.VideoImpl = VideoImpl; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(exports, $){ var VideoImplPristine = Akamai.Util.component("VideoImplPristine", function(element, options){ }); VideoImplPristine.prototype = Object.create(Akamai.VideoImpl.prototype); VideoImplPristine.prototype.constructor = VideoImplPristine; VideoImplPristine.render = function(json, options){ var finalOptions = Akamai.Util.options(Akamai.Video.defaultOptions, options); var url = json.url; var poster = json.poster || ""; var mime = (json.mime) ? 'type="' + json.mime + '"' : ''; var attrs = [ finalOptions.loop ? "loop" : "", finalOptions.autoplay ? "autoplay" : "", finalOptions.controls ? "controls" : "", finalOptions.muted ? "muted" : "", "playsinline" ]; return Akamai.Util.trim(`
`); }; exports.Akamai = exports.Akamai || {}; exports.Akamai.VideoImplPristine = VideoImplPristine; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(exports, $){ var VideoImplIm = Akamai.Util.component("VideoImplIm", function(element, options){ var finalOptions = Akamai.Util.options(Akamai.Video.defaultOptions, options); VideoImplIm._validateOptions(finalOptions); }); VideoImplIm.prototype = Object.create(Akamai.VideoImpl.prototype); VideoImplIm.prototype.constructor = VideoImplIm; VideoImplIm._validateOptions = function(options){ if (!Array.isArray(options.sizes) || options.sizes.length !== 3) { throw new Error('Akamai.Video sizes must have three entries'); } if (options.sizes.some(function (size) { return isNaN(size); })) { throw new Error('Akamai.VideoImplIm all sizes must be numbers'); } } VideoImplIm.render = function(json, options){ var finalOptions = Akamai.Util.options(Akamai.Video.defaultOptions, options); VideoImplIm._validateOptions(finalOptions); var url = json.url; var poster = json.poster || ""; var width; var viewPortWidth = window.innerWidth; if (viewPortWidth < 992) { width = finalOptions.sizes[0]; } else if (viewPortWidth < 1200) { width = finalOptions.sizes[1]; } else { width = finalOptions.sizes[2]; } var attrs = [ finalOptions.loop ? "loop" : "", finalOptions.autoplay ? "autoplay" : "", finalOptions.controls ? "controls" : "", finalOptions.muted ? "muted" : "", "playsinline" ]; var joiner = url.indexOf('?') === -1 ? '?' : '&'; return Akamai.Util.trim(`
`); }; exports.Akamai = exports.Akamai || {}; exports.Akamai.VideoImplIm = VideoImplIm; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(exports, $){ /** * Video component * @class * @alias Akamai.Video * @param {HTMLElement} element - the DOM element representing the component markup * @param {Object} options - configuration options */ var Video = Akamai.Util.component("Video", function(element, options){ // TODO this._comp = this._$el.length && undefined; this._$videoElement = this._$el.find("video"); this._videoElement = this._$videoElement[0]; if( !this._videoElement ){ throw new Error( "Akamai.Video requires a child HTML Video element" ); } if( !this._videoElement.play || !this._videoElement.pause ){ this._unsupportedAPI = true; this._unsupported(); return; } // get the initial state (could be autoplaying on render) this._setState(this._videoElement.paused ? Video.States.Paused : Video.States.Playing); // state bindings this._$el .bind("akamai-video-play", function(){ this._setState(Video.States.Playing); }.bind(this)) .bind("akamai-video-pause", function(){ this._setState(Video.States.Paused); }.bind(this)); this._createPlayButton(); }); Video.MODE_IM = 'im'; Video.MODE_PRISTINE = 'pristine'; Video._componentEventMapping = { "play": { to: "play", selector: "video" }, "pause": { to: "pause", selector: "video" }, "seeked": { to: "seek", selector: "video" } }; Video.prototype._updateOptions = function(options){ if( !options ) { this._options = this._originalOptions; } this._options = Akamai.Util.extend(true, this._options, options); // push the final options down to the dom element so that CSS that keys off // of the attributes can apply when JS config is used Akamai.Util.setDataAttrOptions(this._$el, Video.defaultOptions, this._options, "Video"); // TODO see Magnifier for example }; /** * Defines the global default options for all Spin360s on the page * @static * @property {Boolean} autoplay - Automatically play the video on load (default: false) * @property {Boolean} controls - Display the video controls (default: true) * @property {Boolean} loop - Restart the video when it reaches the end (default: false) * @property {Boolean} muted - Mute the video (default: true) * @property {String} mode - What video component to use. `im` will let you use any size pristine video and will automatically request & generate the right size when the page is loaded. `pristine` just passes through the original video into the video player and this is what will always play on the users device. Default: `pristine` * @property {Array} sizes - Video sizes (widths) to use for different screen widths. Defaults: [1920 (large screens), 1280 (tablet), 854 (mobile), ] */ Video.defaultOptions = { autoplay: false, controls: true, loop: false, muted: true, mode: Video.MODE_PRISTINE, sizes: [854, 1280, 1920] }; Video.render = function(json, options){ var impl = (options && options.mode === Video.MODE_IM) ? Akamai.VideoImplIm : Akamai.VideoImplPristine; return impl.render(json, options); }; Video.States = { Playing: 0, Paused: 1 }; Video.prototype._unsupported = function(){ if( this._unsupportedAPI ){ Akamai.Util.log("Video: video API not supported", 'error'); } return this._unsupportedAPI; }; /** * Play the video, idempotent * @method * @fires Akamai.Video#akamai-video-play * @returns {undefined} */ Video.prototype.play = function(){ if( this._unsupported() ) { return; } this._videoElement.play(); }; /** * Pause the video * @method * @fires Akamai.Video#akamai-video-pause * @returns {undefined} */ Video.prototype.pause = function(){ if( this._unsupported() ) { return; } this._videoElement.pause(); }; /** * Return the current state of the video * @example Video states * Akamai.Video.States = { * Playing: 0, * Paused: 1 * }; * * @example Conditioning on states * if( video.getState() === Akamai.Video.States.Paused ) { * video.play() * } * @method * @returns {Akamai.Video.State} */ Video.prototype.getState = function(){ return this._state; }; Video.prototype._setState = function(value){ this._state = value; }; /** * Seek to the input percentage. * @method * @fires Akamai.Video#akamai-video-seek * @param {Integer} percent - value between 0 and 100 percent for seeking * @returns {undefined} */ Video.prototype.seek = function(percent){ if( percent < 0 || 100 < percent ) { throw new Error( "seek takes a an integer between 0 and 100" ); } var newTime = (percent / 100) * (this._videoElement.duration || 1); this._videoElement.currentTime = newTime; }; /** * Set whether the video should loop or not * @method * @param {Boolean} value - The value true or false * @returns {undefined} */ Video.prototype.setLoop = function(value){ this._videoElement.loop = value; }; Video.prototype.getElement = function(){ return this._$el[0]; }; // Borrowed from https://codepen.io/chrisnager/pen/jPrJgQ Video.prototype._createPlayButton = function(){ var videoPlayButton; var videoWrapper = this._$el[0]; var video = this._videoElement; if(this._options.autoplay){ return; } // TODO move to render? videoWrapper.insertAdjacentHTML('beforeend', ` `); video.removeAttribute('controls'); videoPlayButton = this._$el.find('svg')[0]; videoPlayButton.addEventListener('click', function(){ video.play(); videoPlayButton.classList.add('is-hidden'); if( this._options.controls ) { video.setAttribute('controls', 'controls'); } }.bind(this)); }; /** * Triggered when the video has been played. See {@link Akamai.Video#play}. * * @event Akamai.Video#akamai-video-play */ /** * Triggered when the video has been paused. See {@link Akamai.Video#pause}. * * @event Akamai.Video#akamai-video-pause */ /** * Triggered when the video has seeked to a position. See {@link Akamai.Video#seek}. * * @event Akamai.Video#akamai-video-seek */ /** * Triggered when initialization finishes * {@link Akamai.Video}. * * @event Akamai.Video#akamai-video-init */ exports.Akamai = exports.Akamai || {}; exports.Akamai.Video = Video; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(exports, $){ /** * Image carousel * @class * @alias Akamai.Carousel * @param {HTMLElement} element - the DOM element representing the component markup * @param {Object} options - configuration options */ var Carousel = Akamai.Util.component("Carousel", function(element, options){ this._spins = Akamai.Spin360.createMany(this._$el[0], this._options.spin360); this._magnifiers = Akamai.Magnifier.createMany(this._$el[0], this._options.magnifier); this._videos = Akamai.Video.createMany(this._$el[0], this._options.video); this._$el.snapper(this._options); this._setAspectRatio(); this._bindAspectAttributes(); if( this._options.slideshow.autostart ){ setTimeout(function(){ this.startSlideshow(); }.bind(this), this._options.slideshow.interval); } // NOTE !! the following two bindings must happen in order, the // autoplayables binding relies on the attribut // add item attrs and maintain the active item attributes to determine the // active item state this._activeItemAttributes(); this._$el.bind( "akamai-carousel-goto", this._activeItemAttributes.bind(this)); // TODO pause autoplay videos that are not visible this._handleAutoplayables(); this._$el.bind( "akamai-carousel-goto", this._handleAutoplayables.bind(this)); this._$el .bind( "tau.touch-tracking-start", function(){ this._$el.find(".snapper_pane").addClass("no-scroll"); }.bind(this)) .bind( "tau.touch-tracking-stop", function(){ this._$el.find(".snapper_pane").removeClass("no-scroll"); }.bind(this)); // when child components are doing things, stop the carousel from // automatically advancing this._$el.bind( this.constructor._stopSlideshowEvents.join(" "), this.stopSlideshow.bind(this)); }); Carousel._stopSlideshowEvents = [ "akamai-magnifier-in", "akamai-magnifier-out", "akamai-spin360-goto", "akamai-video-play", "akamai-video-seek" ]; // clearly there should be an autoplayable interface Carousel.prototype._handleAutoplayables = function(){ var isParentActive = function(comp){ return !!$(comp.getElement()) .closest("[" + this.constructor.activeItemAttr + "]") .length; }.bind(this); this._videos.concat(this._spins).forEach(function(comp){ if( isParentActive(comp) && comp._carouselWasPlaying ) { comp.play(); } else { if( comp.getState() == comp.constructor.States.Playing ){ comp._carouselWasPlaying = true; comp.pause(); } else { comp._carouselWasPlaying = false; } } }.bind(this)); }; // Unique counter for IDs Carousel.counter = 0; Carousel.prototype._updateOptions = function(options){ if( !options ) { this._options = this._originalOptions; } // update the current options this._options = Akamai.Util.extend(true, this._options, options); // update the options for each of the subcomponents var update = function(comp, name){ comp._updateOptions(this._options[name]); }.bind(this); // push the final options down to the dom element so that CSS that keys off // of the attributes can apply when JS config is used Akamai.Util.setDataAttrOptions(this._$el, Carousel.defaultOptions, this._options, "Carousel"); this._$el.snapper( "updateOptions", options ); this._spins.forEach(function(c){ update(c, "spin360"); }); this._magnifiers.forEach(function(c){ update(c, "magnifier"); }); this._videos.forEach(function(c){ update(c, "video"); }); this._setAspectRatio(); }; // Used in preflight to "rename" events based on the child component events Carousel._componentEventMapping = { "snapper.after-snap": "goto", "snapper.snap": "snap", "snapper.after-next": "next", "snapper.after-prev": "previous" }; /** * Defines the global default options for all Carousels on the page * @static * @property {Boolean} arrows - Show carousel controls - (default: true) * @property {Number} aspectratio - Specify a percentage-based height for the carousel, relative to the width. Values: `false`, `100`, `45.6`, `78` - (default `false`, breakpoints supported) * @property {Integer} slideshow.interval - Time in milliseconds between slide advances - (default: 4000, breakpoints supported) * @property {Integer} slideshow.autostart - Start the slideshow on instantiation - (default: false) * @property {String} thumbnail.placement - Placement relative to the slide container: `left`, `right`, `bottom` (default: `bottom`, breakpoints supported) * @property {String} thumbnail.type - Type of thumbnail: `dots`, `none`, `images` - (default: `images`, breakpoints supported) * @property {String} thumbnail.policy - String to use for query parameter, ex: "&policy=" (default: undefined) * @property {String} thumbnail.sizes - Sizes attribute value to use if thumbnail policy is set. Values: `300px`, `200px`, `(min-width:1000px) 300px, 100px` - (default: `300px`) * @property {Object} images - Defaults to the Magnifier image option's settings */ Carousel.defaultOptions = { arrows: true, aspectratio: false, slideshow: { interval: 4000, autostart: false }, thumbnail: { placement: "bottom", type: "images", policy: undefined, sizes: "300px" }, images: Akamai.Image.defaultConfig }; Carousel.renderMapping = { image: Akamai.Magnifier, spin360: Akamai.Spin360, video: Akamai.Video }; Carousel._typeMapping = { image: "magnifier" }; Carousel._uniqueItemId = function(item, i) { return "akamai-carousel-" + Carousel.counter + "-" + item.type + "-" + i; }; // srcset stuff Akamai.Sourceable.extendStatic(Carousel); Carousel._renderItem = function(item, i, options) { var mappedType = Carousel._typeMapping[item.type] || item.type; if( !Carousel.renderMapping[item.type] ){ throw new Error(` item type ${item.type} at index ${i} must be 'image', 'spin360', or 'video' `); } return `
${Carousel.renderMapping[item.type].render(item, options[mappedType])}
`; }; Carousel._renderThumbnailAnchor = function(item, data, i, options) { var thumbUrl = ""; var altText = item.alt || ""; // use the video poster, the canonical url, or the first in a sequence if( item.type === 'video' ){ thumbUrl = item.poster || data.reduce(function(acc, i){ return acc || Carousel._thumbUrl(i); }, ""); } else { thumbUrl = Carousel._thumbUrl(item); } // TODO this sucks options.images.policy = options.thumbnail.policy; var src = Carousel._fallbackSrc( thumbUrl, options.images ); var srcset = Carousel._srcset( thumbUrl, options.images ); var sizes = options.images.sizes; // if there's a thumbnail policy, the thumbnails will be fresh image requests, // so they should have better sizes attribute values if( options.thumbnail.policy ){ sizes = options.thumbnail.sizes; } var attrs = [ `${Carousel._dataAttr}-thumb-type="${item.type}"`, item.type == "video" && !item.poster ? "${Carousel._dataAttr}-thumb-noposter" : "" ]; return ` ${altText} `; }; Carousel._thumbUrl = function(item){ return item.type === 'video' ? item.poster : item.url || (item.urls && item.urls[0]); }; Carousel._renderThumbnails = function(data, options) { if(data.length <= 1 ){ return ""; }; return `
${Akamai.Util.map(data, function(item, i){ return Carousel._renderThumbnailAnchor(item, data, i, options); }).join("\n")}
`; }; Carousel.prototype._setAspectAttributes = function( $el ){ var orientationAttr = `${Carousel._dataAttr}-aspectratio-orientation`; if( $el.height() > $el.width() ) { $el.attr( orientationAttr, "portrait" ); } else if( $el.height() < $el.width() ) { $el.attr( orientationAttr, "landscape" ); } else { if ($el.parent().height() >= $el.parent().width()) { $el.attr( orientationAttr, "landscape" ); } else { $el.attr( orientationAttr, "portrait" ); } } }; Carousel.prototype._setAspectRatio = function(){ var value = this._options.aspectratio || 0; var $items = this._$el.find( ".snapper_item" ); $items.css( "padding-top", (value / $items.length) + "%" ); }; Carousel.prototype._bindAspectAttributes = function(){ if( this._options.aspectratio === false ){ return; } var value = this._options.aspectratio || 0; var self = this; var $items = this._$el.find( ".snapper_item" ); $items.each(function(){ var loadBound; var $item = $(this); var $loadable = $item.find( "img, video" ).eq(0); $loadable.bind("load loadedmetadata", loadBinding = function(){ clearTimeout(loadBound); if( $item.is(`[${Akamai.Spin360._dataAttr}]`) ){ // TODO it's not always a canvas, sometimes it's a collection of // images based on settings self._setAspectAttributes( $item.find( "canvas" ) ); } else{ self._setAspectAttributes( $loadable ); } }); loadBound = setTimeout(loadBinding, 5000); }); $items.eq(0).find( "img,video" ).eq(0).bind( "load loadedmetadata", function(){ self._trigger( "first-media-load" ); }); }; Carousel.render = function(data, options){ options = options || {}; // establish the extended default options for the carousel var carouselOptions = Akamai.Util.options(Carousel.defaultOptions, options); var templateAttrs = [ `${Carousel._dataAttr}`, `${Carousel._dataAttr}-item-count="${(data || []).length}"`, `data-snapper-deeplinking="false"`, carouselOptions.arrows ? "data-snapper-nextprev" : "" ]; Carousel.counter++; // TODO can we do something about the `enlarge_pane` class here, seems it // could be conditioned on at least one regular image type in the json // TODO options should dictate `data-snapper-nextprev` return Akamai.Util.trim(`
${Akamai.Util.map(data, function(item, i){ return Carousel._renderItem(item, i, carouselOptions); }).join("\n")}
${Carousel._renderThumbnails(data, carouselOptions)}
`); }; /** * Instantiate Carousels by looking for children matching * [data-akamai-carousel] in `element` param * @method * @static * @param {HTMLElement} element - the element to search in for * @returns {Akamai.Carousel[]} */ /** * @method * @returns {Akamai.Spin360[]} */ Carousel.prototype.getSpin360s = function(){ return this._spins; }; /** * @method * @returns {Akamai.Magnifier[]} */ Carousel.prototype.getMagnifiers = function(){ return this._magnifiers; }; /** * @method * @returns {Akamai.Video[]} */ Carousel.prototype.getVideos = function(){ return this._videos; }; /** * Go to a particular slide. * * Note that the state of the DOM on the page and thus the index of the * carousel will not be up-to-date until the {@link * Akamai.Carousel#akamai-carousel-goto} event has been fired. That is, * calling this method and the calling `getIndex` will not necessarily result * in an updated index value. Either bind to the event or supply a callback. * * @method * @fires Akamai.Carousel#akamai-carousel-goto * @param {Integer} index - The zero-based slide index to go to * @param {Function?} callback - callback invoked after the action has completed in the DOM * @returns {undefined} */ Carousel.prototype.goto = function(index, callback){ this._$el.snapper( "goto", index, callback ); }; Carousel._itemAttr = `${Carousel._dataAttr}-item`; Carousel._activeItemAttr = `${Carousel._itemAttr}-active`; /** * Add an attribute to all carousel items, * and additionally maintain an active attribute on the active carousel item * * @method * @returns {undefined} */ Carousel.prototype._activeItemAttributes = function(){ var attrAllItems = this.constructor._itemAttr; var attrActiveItem = this.constructor._activeItemAttr; this._$el .find( ".snapper_item" ) .attr( attrAllItems, true ) .removeAttr( attrActiveItem ) .eq( this.getIndex() ).attr( attrActiveItem, true ); }; /** * Return the current slide index * @method * @returns {undefined} */ Carousel.prototype.getIndex = function(){ return this._$el.snapper( "getIndex" ); }; // Extend Carousel with Advanceabl interface Akamai.Advanceable.extend(Carousel); /** * Advance to the next item * @method * @fires Akamai.Carousel#akamai-carousel-next * @param {Function?} callback - callback invoked after the action has completed in the DOM * @returns {undefined} */ Carousel.prototype.next; // required for JSDocs, implementation in Advanceable /** * Retreat to the previous item * @method * @fires Akamai.Carousel#akamai-carousel-previous * @param {Function?} callback - callback invoked after the action has completed in the DOM * @returns {undefined} */ Carousel.prototype.previous; // required for JSDocs, implementation in Advanceable /** * Start automatic advancement of the carousel items * @method * @fires Akamai.Carousel#akamai-carousel-start-slideshow * @returns {undefined} */ Carousel.prototype.startSlideshow = function(){ this._$el.one("mousedown touchstart", this.stopSlideshow.bind(this)); this.next(function(){ this._trigger("start-slideshow"); this._slideshowTimer = setTimeout(function(){ this.startSlideshow(); // TODO remove || when default options are added }.bind(this), this._options.slideshow.interval ); }.bind(this)); }; /** * Start automatic advancement of the carousel items * @method * @fires Akamai.Carousel#akamai-carousel-stop-slideshow * @returns {undefined} */ Carousel.prototype.stopSlideshow = function(){ clearTimeout(this._slideshowTimer); this._slideshowTimer = undefined; this._trigger("stop-slideshow"); }; /** * Triggered when the carousel has completed it transition to * a new index due to user interaction or a call to {@link Akamai.Carousel#goto}. * * @event Akamai.Carousel#akamai-carousel-goto */ /** * Triggered when the carousel has completed it transition to * a new index due to user interaction or a call to {@link Akamai.Carousel#next}. * * @event Akamai.Carousel#akamai-carousel-next */ /** * Triggered when the carousel has completed it transition to * a new index due to user interaction or a call to {@link Akamai.Carousel#previous}. * * @event Akamai.Carousel#akamai-carousel-previous */ /** * Triggered when the carousel has started the slide show due to a call to * {@link Akamai.Carousel#startSlideshow}. * * @event Akamai.Carousel#akamai-carousel-start-slideshow */ /** * Triggered when the carousel has stoped the slide show due to a call to * {@link Akamai.Carousel#stopSlideshow}. * * @event Akamai.Carousel#akamai-carousel-stop-slideshow */ /** * Triggered when initialization finishes * {@link Akamai.Carousel}. * * @event Akamai.Carousel#akamai-carousel-init */ Carousel.prototype.refresh = function(){ this._$el.snapper("updateWidths"); }; exports.Akamai = exports.Akamai || {}; exports.Akamai.Carousel = Carousel; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(exports, $){ /** * Image Fullscreen * @class * @alias Akamai.Fullscreen * @param {HTMLElement} element - the DOM element representing the component markup * @param {Object} options - configuration options */ // TODO JSDocs // TODO Tests var Fullscreen = Akamai.Util.component("Fullscreen", function(element, options){ if( this._options.enabled ) { this._init(); } }); Fullscreen.prototype._init = function(){ this._$fullscreen = this._$el; // parent container does not allow widths to be set (Firefox, fullscreen), so we’ll adjust the children this.addButton(); this._fullscreenApiKeys = Fullscreen._keys(); if( this._options.native ) { this._useNativeApi = !!this._fullscreenApiKeys; } else { this._useNativeApi = false; } // Fullscreen API is disabled and the fallback behavior is active this._fullscreenFallbackEnabled = false; this._$placeholder = $( "
" ); this._isFullscreen = false; this.addEvents(); }; Fullscreen.classes = { btn: "akamai-fullscreen-btn", btnContainer: "akamai-fullscreen-btncontainer", enterBtn: "akamai-fullscreen-btn-enter", exitBtn: "akamai-fullscreen-btn-exit", active: "akamai-fullscreen-active", fallback: "akamai-fullscreen-fallback" }; Fullscreen.attr = { width: "data-akamai-fullscreen-width", enterBtn: "data-akamai-fullscreen-btn", exitBtn: "data-akamai-fullscreen-exit-btn" }; Fullscreen.defaultOptions = { enabled: false, native: false }; Fullscreen.prototype.addButton = function(){ this._$widthAdjust = this._$fullscreen.children().filter(".focused"); if( !this._$widthAdjust.length ) { this._$widthAdjust = this._$fullscreen .children() .eq(0); } if( this._$widthAdjust.attr(Fullscreen.attr.width) === null ){ this._$widthAdjust.attr( Fullscreen.attr.width, "" ); } if( this._$widthAdjust.find("[" + Fullscreen.attr.enterBtn + "]").length ){ return; } // buttons parent this._$buttonContainer = this._$widthAdjust; this._$buttonContainer.addClass( Fullscreen.classes.btnContainer ); this._$buttonContainer.append(this.render()); }; Fullscreen.prototype.addEvents = function() { this._$fullscreen.on( "click", function( e ) { var $target = $( e.target ); if( !$target.is("[" + Fullscreen.attr.enterBtn + "]") ){ return; } this.enter(); e.preventDefault(); }.bind( this )); this._$fullscreen.on( "click", function( e ) { var $target = $( e.target ); if( !$target.is( "[" + Fullscreen.attr.exitBtn + "]" ) ){ return; } this.exit(); e.preventDefault(); }.bind( this )); if( this._useNativeApi ) { document.addEventListener( this._fullscreenApiKeys.onchange, function() { // exiting fullscreen using native method (ESC or menu option) if( !document[ this._fullscreenApiKeys.element ] ) { this._exit(); } }.bind( this ), false ); } // ESC to close $( document ).on( "keydown", function( e ) { var code = e.keyCode || e.which; if( code === 27 ) { this._exit(); } }.bind( this )); }; Fullscreen.prototype.render = function() { return Akamai.Util.trim(` `); }; Fullscreen._keyLookup = [ { enter: "requestFullscreen", exit: "exitFullscreen", element: "fullscreenElement", onchange: "fullscreenchange" }, { enter: "webkitRequestFullscreen", exit: "webkitExitFullscreen", element: "webkitFullscreenElement", onchange: "webkitfullscreenchange" }, { enter: "webkitRequestFullScreen", exit: "webkitCancelFullScreen", element: "webkitCurrentFullScreenElement", onchange: "webkitfullscreenchange" }, { enter: "mozRequestFullScreen", exit: "mozCancelFullScreen", element: "mozFullScreenElement", onchange: "mozfullscreenchange" }, { enter: "msRequestFullscreen", exit: "msExitFullscreen", element: "msFullscreenElement", onchange: "MSFullscreenChange" } ]; Fullscreen._keys = function( el ) { var keys = Fullscreen._keyLookup; var el = document.body; for( var j = 0, k = keys.length; j < k; j++ ) { if( keys[ j ].enter in el ) { return keys[ j ]; } } }; Fullscreen.prototype._maximizePlacement = function(){ this._fullscreenFallbackEnabled = true; this._$fullscreen.addClass( Fullscreen.classes.fallback ); this._$placeholder.insertAfter( this._$fullscreen ); this._$fullscreen.appendTo( document.body ); }; Fullscreen.prototype._restorePlacement = function(){ if( !this._fullscreenFallbackEnabled ) { return; } this._fullscreenFallbackEnabled = false; this._$fullscreen.removeClass( Fullscreen.classes.fallback ); this._$fullscreen.insertAfter( this._$placeholder ); this._$placeholder.remove(); }; Fullscreen.prototype._adjustWidth = function() { this._$fullscreen.css( "width", "auto !important" ); // wish this could go into the change event above, but alas the dimensions don’t measure right var widthSmall = this._$fullscreen.width(); var heightSmall = this._$fullscreen.height(); var viewportWidth = this._useNativeApi ? window.screen.width : Math.max( document.documentElement.clientWidth, window.innerWidth || 0 ); var viewportHeight = this._useNativeApi ? window.screen.height : Math.max( document.documentElement.clientHeight, window.innerHeight || 0 ); // calculate the maximum width we can use to fill the viewportHeight var widthBig = widthSmall * viewportHeight / heightSmall; // if the width is bigger than the maximum, just set to 100% (won’t fill the entire height, but that’s ok) if( widthBig > viewportWidth ) { widthBig = "100%"; } this._$widthAdjust.width( widthBig ); }; Fullscreen.prototype._revertWidth = function() { this._$widthAdjust.css( "width", "" ); }; /** * Enter full screen mode * @method * @returns {undefined} */ Fullscreen.prototype.enter = function(){ var activeIndex = this._$fullscreen[0].shoestringData.Viewer._carousels[0].getIndex(); this._$fullscreen.addClass( Fullscreen.classes.active ); if( this._useNativeApi ) { this._$fullscreen[ 0 ][ this._fullscreenApiKeys.enter ](); this._adjustWidth(); } else { this._adjustWidth(); this._maximizePlacement(); } this._isFullscreen = true; this._$fullscreen[0].shoestringData.Viewer._carousels[0].goto(activeIndex); this._trigger("enter"); }; Fullscreen.prototype._exit = function(){ var activeIndex = this._$fullscreen[0].shoestringData.Viewer._carousels[0].getIndex(); this._$fullscreen.removeClass( Fullscreen.classes.active ); if( !this._useNativeApi ) { this._restorePlacement(); } this._revertWidth(); this._isFullscreen = false; this._$fullscreen[0].shoestringData.Viewer._carousels[0].goto(activeIndex); this._trigger("exit"); }; /** * Exits full screen mode * @method * @returns {undefined} */ Fullscreen.prototype.exit = function(){ if( this._useNativeApi ) { // note some close behavior happens in the onchange event handler above document[ this._fullscreenApiKeys.exit ](); } this._exit(); }; Fullscreen.prototype.isFullscreen = function(){ return this._isFullscreen; }; exports.Akamai = exports.Akamai || {}; exports.Akamai.Fullscreen = Fullscreen; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(exports, $){ /** * Akamai Viewer omni-component * @class * @alias Akamai.Viewer * @param {HTMLElement} element - the DOM element representing the component markup * @param {Object} options - configuration options * * @example Instatiation * var element = document.querySelector( "[data-akamai-viewer]" ); * var viewer = Akamai.Viewer( element ); */ var Viewer = Akamai.Util.component("Viewer", true, function(element, options){ // try to update the carousel and magnifier sizes options to the width of // the viewer for more accurate srcset selection this._updateSizesOptions(true); this._onResizeComplete(this._updateSizesOptions.bind(this)); // move all the flattened component options (spin, zoom, video) onto the carousel this._options.carousel = Viewer._extendCarouselOptions(this._options); this._withData(function(data){ if( data ) { // check all of the urls agains the hostname and whitelist this._checkJSONUrls(data); // prevent XSS / injection attacks by escaping string values data = Akamai.Util.escapeJSONVals(data); // store escaped data for later reference this._options.items.data = data; } this._tagMapping = {}; // TODO if the element is empty and/or options has `items` render into element if( this._$el.children().length == 0 && data ) { this._tagMapping = this.constructor._tagSplit(data, this._options.items); this._$el.append(this.constructor.render(data, this._options, true)); } // intantiate all child carousels this._carousels = Akamai.Carousel.createMany(element, this._options.carousel); this._fullscreen = new Akamai.Fullscreen(element, this._options.fullscreen); // on enter and exit of fullscreen resolve the breakpoints and update options this._$el.bind( "akamai-fullscreen-enter akamai-fullscreen-exit", function(){ this._updateOptions(this._resolveBreakpointOptions()); }.bind(this)); // use the breakpoints to set match media listeners this._setBreakpoints(); // have to trigger init explicitly for async constructors this._trigger("init"); }.bind(this), function(msg){ throw new Error(msg); }); }); Viewer.prototype._updateSizesOptions = function(localUpdateOnly){ // update sizes option to something more specific if possible var elWidth = this._$el.width(); if( elWidth && elWidth > 0 ){ elWidth += "px"; this._options = Akamai.Util.extend(true, this._options, { carousel: { images: { sizes: elWidth } } }); } if( !localUpdateOnly ){ this._updateOptions(this._options); } }; Viewer.prototype._withData = function(after, fail){ if(!this._options.items.uri){ if( this._exceedsLimit(this._options.items.data, fail)) { return; } after(this._options.items.data); } else if (typeof(this._options.items.uri) !== 'undefined' && this._options.items.uri.length > 0) { $.get(this._options.items.uri, function(data){ // make sure the data gets sorted out regardless of `get` impl data = (typeof data === "string" ? JSON.parse(data) : data); if( this._exceedsLimit(data.items, fail) ){ return; } after(data.items); }.bind(this)); } }; // value in kibibytes Viewer.prototype._exceedsLimit = function(data, fail){ // TODO some tests don't provide data if( !data ) { return false; } // 1 unicode character = 4 bytes // length = # of chars // kibibyte = 1024 bytes // # of chars * 4 / 1024 var kb = JSON.stringify(data).length * 4 / 1024; if( kb > this._options.items.limit ) { fail = fail || function(){}; fail(`JSON data size exceeds ${this._options.items.limit} KiB. Limit can be configured with items.limit Akamai.Viewer options.`); return true; } return false; }; Viewer.prototype._setBreakpoints = function(){ this._breakpoints = this._reduceBreakpoints(this._options.breakpoints || {}); this._fullscreenBreakpoints = this._reduceBreakpoints(this._options.fullscreenBreakpoints || {}); this._bindBreakpoints(); }; Viewer.prototype._reduceBreakpoints = function(breakpoints){ var bps = []; for( var bp in breakpoints ){ if(breakpoints.hasOwnProperty(bp)){ try { bps.push(parseInt(bp, 10)); } catch(e){ Akamai.Util.log(e, 'error'); } } } bps.sort(function(a, b){ return b < a;}); return bps; }; Viewer.prototype._bindBreakpoints = function(){ [this._breakpoints, this._fullscreenBreakpoints].forEach(function(bps){ // bind using the minwidth and the next breakpoint as the maxwidth // adds a range from 0 to the first breakpoint and from the last // breakpoint to a very large number if(!bps.length) { return; } for( var i = -1; i < bps.length; i++ ){ this._bindMatchMedia(bps[i], bps[i+1]); } }.bind(this)); }; // bind a callback to run after resize completes Viewer.prototype._onResizeComplete = function( callback ){ var cbtimer; var self = this; window.addEventListener( "resize", function(){ clearTimeout( cbtimer ); cbtimer = setTimeout( callback, 500 ); }); }; Viewer.prototype._bindMatchMedia = function(minWidth, maxWidth){ minWidth = minWidth || 0; // large number so we can use one media query template maxWidth = maxWidth || 1000000000; if( window.matchMedia ){ // bind using the minwidth and maxwidth so we get the events at the boundaries // so we can asses which set of options applies. var query = `(min-width: ${minWidth}px) and (max-width: ${maxWidth}px)`; // create a media list to bind to var initList = window.matchMedia(query); if(initList && initList.addListener) { // handle a match on instantiation this._mediaMatch(initList, minWidth, true); // bind for later changes in whether the media query matches initList.addListener(function(list){ this._mediaMatch(list, minWidth); }.bind(this)); } } }; Viewer.prototype._mediaMatch = function(list, minWidth, ignoreDefault){ // If there is a match for the breakpoint (we're in the bp range) // Then set the options based on that breakpoint // Else if there is not a match and the client width is below the // breakpoint that's being disabled, then use the original options if(list.matches){ var resolvedOptions = this._resolveBreakpointOptions(minWidth); this._updateOptions(resolvedOptions); } }; // TODO a ton of duplication with bindMatchmedia Viewer.prototype._getCurrentMinWidth = function(bps){ // bind using the minwidth and the next breakpoint as the maxwidth // adds a range from 0 to the first breakpoint and from the last // breakpoint to a very large number for( var i = -1; i < bps.length; i++ ){ minWidth = bps[i] || 0; maxWidth = bps[i+1] || 100000000; if( window.matchMedia ){ // bind using the minwidth and maxwidth so we get the events at the boundaries // so we can asses which set of options applies. var query = `(min-width: ${minWidth}px) and (max-width: ${maxWidth}px)`; // create a media list to bind to var initList = window.matchMedia(query); if(initList && initList.matches) { return minWidth; } } } return false; }; Viewer.prototype._resolveBreakpointOptions = function(minWidth){ // TODO bind on the reduce callback was not working var self = this; var bps, bpConfig; if( this._fullscreen.isFullscreen() ) { bps = this._fullscreenBreakpoints; bpConfig = self._options.fullscreenBreakpoints; } else { bps = this._breakpoints; bpConfig = self._options.breakpoints; } if(!minWidth){ minWidth = self._getCurrentMinWidth(bps); } // There may be no matching breakpoints when this method is called to // resolve the current options. If that's the case then we need to default // to the top level options if(!bps.length){ return self._originalOptions; } return bps.reduce(function(acc, bp){ var cloned = Akamai.Util.extend(true, {}, acc); var ptions; if(bp <= minWidth){ cloned = Akamai.Util.extend(true, cloned, bpConfig[bp]); } return cloned; }, Akamai.Util.extend(true, {}, this._originalOptions)); }; Viewer._extendCarouselOptions = function(options){ // clone the carousel options for the given breakpoint var clonedCarousel = Akamai.Util.extend(true, {}, options.carousel || {}); // TODO shared code with constructor // extend the cloned options with the breakpoint options so that all the // child component config is attached to the carousel config (also happens // in the constructor ) return Akamai.Util.extend(true, clonedCarousel, { magnifier: options.magnifier, spin360: options.spin360, video: options.video, fullscreen: options.fullscreen }); }; Viewer.prototype._updateOptions = function(options){ this._options = Akamai.Util.extend(true, this._options, options); // extend the carousel options for the given breakpoint var carouselOptions = Viewer._extendCarouselOptions(options); // push the final options down to the dom element so that CSS that keys off // of the attributes can apply when JS config is used Akamai.Util.setDataAttrOptions(this._$el, Viewer.defaultOptions, options, "Viewer"); // pass the new options down to all child carousels this._carousels.forEach(function(comp){ comp._updateOptions(carouselOptions); }.bind(this)); }; Viewer.prototype._checkJSONUrls = function(obj){ var hostnames = this._options.items.hostnames; return Akamai.Util.mapJSONVals(obj,function(val, key){ if(key === "url" && !this._urlHostnameMatch(val)){ throw new Error(`The URL \`${val}\` does not match this page's hostname or the whitelist defined in Akamai.Viewer option \`hostnames\` which is: ${hostnames.length ? hostnames.join("\n") : "No hostnames"} `); } return val; }.bind(this)); }; Viewer.prototype._urlHostnameMatch = function(url){ var parser = document.createElement('a'); parser.href = url; return parser.hostname === "" || parser.hostname === window.location.hostname || this._options.items.hostnames.indexOf(parser.hostname) >= 0; }; /** * Defines the global default options for all Viewers on the page * @static * @property {Object} breakpoints - configuration changes for child components at breakpoints (no default) * @property {Object} fullscreenBreakpoints - configuration changes for child components at breakpoints when in fullscreen mode (no default) * @property {Object[]} items.data - array of items from the Akamai JSON (default: undefined) * @property {String} items.defaultTag - (default: "akamai-untagged") * @property {String[]} items.hostnames - whitelist of URL hostnames to check for in JSON, (default: empty array) * @property {Integer} items.limit - size limit of JSON data in kibibytes (default: 100) * @property {String} items.renderAll - (default: false) * @property {String[]} items.tags - Set of tags to match against the Akamai JSON data (default: undefined) * @property {String} items.uri - URI at which to retrieve the Akamai JSON (default: undefined) * @property {Object} carousel - child {@link Akamai.Carousel} options * @property {Object} magnifier - child {@link Akamai.Magnifier} options * @property {Object} spin360 - child {@link Akamai.Spin360} options * @property {Object} video - child {@link Akamai.Video} options * @property {Object} fullscreen - child {@link Akamai.Fullscreen} options */ Viewer.defaultOptions = { breakpoints: {}, items: { data: undefined, defaultTag: "akamai-untagged", hostnames: [], limit: 100, renderAll: false, tags: undefined, uri: undefined }, carousel: Akamai.Carousel.defaultOptions, magnifier: Akamai.Magnifier.defaultOptions, spin360: Akamai.Spin360.defaultOptions, video: Akamai.Video.defaultOptions, fullscreen: Akamai.Fullscreen.defaultOptions }; Viewer._tagSplit = function(data, options){ var items = options; // set all items without a tag to the default data = data.map(function(item){ item.tags = (item.tags && item.tags.length) ? item.tags : [items.defaultTag]; return item; }); // if the tags option was set, filter items out that don't match if( items.tags ) { data = items.data.filter(function(item){ return item.tags.reduce(function(acc, tag){ return acc || items.tags.indexOf(tag) >= 0; }, false); }); } // from the filtered get the first (may be default tag) var def = data[0].tags[0]; // create a mapping from tags to items in the set var tagMapping = data.reduce(function(acc, item){ (item.tags || []).forEach(function(tag){ acc[tag] = acc[tag] || []; acc[tag].push(item); }); return acc; }, {}); // set the first (default) object to the first tag tagMapping[Viewer._firstRenderTag] = tagMapping[def]; return tagMapping; }; Viewer._firstRenderTag = "akamai-first-render"; Viewer._tagAttr = `${Viewer._dataAttr}-tag`; Viewer._renderTag = function(data, options){ // get the first item and it's tag // NOTE this assumes that the data has been normalized to have a default tag var tag = data[0].tags[0]; // TODO shoestring doesn't treat html correctly unless the leading `<` has // no whitepsace in front of it: // shoestring/issues/94 return Akamai.Util.trim(`
${Akamai.Carousel.render(data, options)}
`); }; /** * Takes standard options including an `items` attribute and renders HTML * that conforms to the component expectations * @method * @static * @params {Object[]} json - Akamai JSON data * @params {Object} options - options object with an `items` attribute, see default options * @params {Boolean} options.items - configuration for how the items should be * rendered, see {@link Akamai.Viewer.defaultOptions} default options items sub-configuration * @returns { String } */ Viewer.render = function(json, options, unwrapped){ // NOTE we do not extend the default options here because it has no bearing // on the rendering of the Viewer markup or child markup. This is contrast // to the carousel which does extend the passed options with defaults so the // markup can make use of them var data = Viewer._tagSplit(json, options.items); var carouselOptions = Viewer._extendCarouselOptions(options); var childMarkup; if( options.items.renderAll ) { childMarkup = data.map(function(acc, datum){ return Viewer._renderTag(datum, carouselOptions); }).join(" "); } else { childMarkup = Viewer._renderTag(data[Viewer._firstRenderTag], carouselOptions); } var wrapped = `
${childMarkup}
`; return unwrapped ? childMarkup : wrapped ; }; /** * Accessor for child carousel components. * @method * @returns { Akamai.Carousel[] } */ Viewer.prototype.getCarousels = function(){ return this._carousels; }; /** * Set the visible carousel based on the tag. Note the method activity does * not complete until all the images from the relevant viewer have loaded, use * the `akamai-viewer-switch-tag` event * @method * @fires Akamai.Viewer#akamai-viewer-switch-tag * @params {String} tag - The tag corresponding to the desired carousel * @returns {undefined} */ Viewer.prototype.switchTag = function(tag){ var tags = this.getTags(); // if the passed tag isn't in the data set return early and log an error in // the console if( tags.indexOf(tag) == -1){ Akamai.Util.log(`tag: ${tag} is not present in the data for this viewer`, 'error'); return; } var selector = `[${this.constructor._tagAttr}="${tag}"]`; // try to find an existing element with the tag var $taggedViewer = this._$el.find(selector); // if there's no element append the newly rendered tag markup if( !$taggedViewer.length ){ // make sure the config takes into account the current breakpoint var options = this._resolveBreakpointOptions(); var carouselOptions = Viewer._extendCarouselOptions(options); // create the markup that will be inserted var $markup = $(this.constructor._renderTag(this._tagMapping[tag], carouselOptions, true)); var $imgs = $markup.find("img"); var imgCount = $imgs.length; var loaded = 0; // hide the new carousel initially $markup.css("display", "none"); $imgs.bind("load", function(){ if( ++loaded !== imgCount ) { return; } // TODO namespace? $imgs.unbind("load"); var carousels = Akamai.Carousel.createMany($markup[0], carouselOptions); carousels.forEach(function(c){ // TODO figure out why the carousel constructor doesn't apply the // active index class we want on instantiation, likely due to the fact // that the carousel is hidden so the "getIndex" calc is broken // mark the carousel item as active c.goto(c.getIndex()); }); // create and store the new carousels (should be one) this._carousels = this._carousels.concat(carousels); this._showViewer($markup, carousels); }.bind(this)); // append the new markup to the existing viewer this._$el.append($markup); } else { this._showViewer($taggedViewer); } }; // TODO should be handled in CSS Viewer.prototype._showViewer = function($viewer, carousels){ this._$el .find(`[${this.constructor._tagAttr}]`) .css("display", "none") .removeClass("focused"); $viewer .css("display", "block") .addClass("focused"); if( carousels ){ carousels.map(function(c) { c.refresh(); }); } // TODO the placement here seems arbitrary, probably belongs in `_showViewer` // TODO also sucks to be so tightly coupled this._fullscreen.addButton(); this._trigger( "switch-tag" ); }; /** * Accessor for tags derived from Akamai JSON data * @method * @returns { String[] } */ Viewer.prototype.getTags = function(){ if( this._tags ) { return this._tags; }; var tags = []; // map and store all the carousel tags for(var tag in this._tagMapping){ if(this._tagMapping.hasOwnProperty(tag) && tag !== Viewer._firstRenderTag ) { // otherwise grab the list of tags tags.push(tag); } } return this._tags = tags; }; /** * Triggered when the viewer switches tag views. This includes waiting for * images to load for carousels dedicated to previously unviewed tags. * {@link Akamai.Viewer#switchTag}. * * @event Akamai.Viewer#akamai-viewer-switch-tag */ /** * Triggered when initialization finishes * {@link Akamai.Viewer}. * * @event Akamai.Viewer#akamai-viewer-init */ exports.Akamai = exports.Akamai || {}; exports.Akamai.Viewer = Viewer; })(typeof exports === 'undefined'? window : exports, this.jQuery); (function(exports, $){ $.fn.akamaiViewer = function(options){ this.each(function(i, element){ new Akamai.Viewer(element, options); }); }; })(typeof exports === 'undefined'? window : exports, this.jQuery);