1/** 2 * Secondary Product Image for WooCommerce 3 * 4 * Reveals the secondary images once a hover-capable pointer is present, and 5 * matches each overlay to the box of the product image it covers. Themes crop 6 * product images to their own ratio (Blocksy uses 4:3, others 1:1 or natural), 7 * so the overlay is measured from the primary image rather than assumed. 8 */ 9( function () { 10 'use strict'; 11 12 var config = window.wpzoomWcSpi || {}; 13 var i18n = config.i18n || {}; 14 15 var CONTAINER = '.wpzoom-secondary-image-container'; 16 var ITEM = '.wpzoom-wc-spi-has-enabled'; 17 var IMAGE = '.wpzoom-wc-spi-secondary-img'; 18 19 var hoverable = window.matchMedia && window.matchMedia( '(hover: hover) and (pointer: fine)' ).matches; 20 21 if ( ! hoverable && 'yes' !== config.touch ) { 22 return; 23 } 24 25 var products = new WeakMap(); 26 var ready = false; 27 var hovered = null; 28 29 /* --------------------------------------------------------------------- * 30 * Product 31 * --------------------------------------------------------------------- */ 32 33 function setup( item ) { 34 35 var container = item.querySelector( CONTAINER ); 36 var primary = item.querySelector( 'img:not(.wpzoom-wc-spi-secondary-img)' ); 37 38 if ( ! container || ! primary ) { 39 return null; 40 } 41 42 var product = { 43 item: item, 44 container: container, 45 primary: primary, 46 images: Array.prototype.slice.call( container.querySelectorAll( IMAGE ) ), 47 controls: container.querySelector( '.wpzoom-wc-spi-controls' ),
48 index: 0, 49 timer: null 50 }; 51 52 // Buttons rendered inside the product link would be interactive content 53 // nested in an anchor, so they are moved up to the product itself. 54 if ( product.controls ) { 55 item.appendChild( product.controls ); 56 product.controls.hidden = false; 57 label( product.controls ); 58 product.controls.addEventListener( 'click', function ( event ) { 59 onControlClick( event, product ); 60 } ); 61 } 62 63 product.measure = function () { 64 measure( product ); 65 }; 66 67 if ( window.ResizeObserver ) { 68 new ResizeObserver( product.measure ).observe( item ); 69 } 70 71 if ( ! primary.complete ) { 72 primary.addEventListener( 'load', product.measure, { once: true } ); 73 } 74 75 products.set( item, product ); 76 77 return product; 78 } 79 80 function label( controls ) { 81 var labels = { 82 prev: i18n.previous, 83 next: i18n.next, 84 lightbox: i18n.expand 85 }; 86 87 controls.querySelectorAll( '[data-wpzoom-action]' ).forEach( function ( button ) { 88 var text = labels[ button.getAttribute( 'data-wpzoom-action' ) ]; 89 90 if ( text ) { 91 button.setAttribute( 'aria-label', text ); 92 button.setAttribute( 'title', text ); 93 } 94 } ); 95 } 96 97 /** 98 * Size the overlay to the product image it covers. 99 * 100 * Measuring again on every hover is what keeps it correct after a viewport 101 * change, a late web font, or a grid rebuilt by AJAX filters. 102 */ 103 function measure( product ) { 104 105 var box = product.primary.getBoundingClientRect(); 106 107 if ( ! box.width || ! box.height ) { 108 return; 109 } 110 111 var itemBox = product.item.getBoundingClientRect(); 112 113 // Offsets are relative to the item's padding box, which is what an 114 // absolutely positioned child is placed against. 115 var top = ( box.top - itemBox.top - product.item.clientTop ) + 'px'; 116 var left = ( box.left - itemBox.left - product.item.clientLeft ) + 'px'; 117 118 [ product.container, product.controls ].forEach( function ( element ) { 119 if ( ! element ) { 120 return; 121 } 122 123 element.style.top = top; 124 element.style.left = left; 125 element.style.width = box.width + 'px'; 126 element.style.height = box.height + 'px'; 127 } ); 128 129 var primaryStyle = window.getComputedStyle( product.primary ); 130 131 product.images.forEach( function ( image ) { 132 // `fill` would stretch an image whose ratio differs from the primary. 133 image.style.objectFit = 'fill' === primaryStyle.objectFit ? 'cover' : primaryStyle.objectFit; 134 image.style.objectPosition = primaryStyle.objectPosition; 135 } ); 136 } 137 138 /** 139 * Load the images that were held back from the initial page load. 140 */ 141 function hydrate( product ) { 142 143 product.images.forEach( function ( image ) { 144 var src = image.getAttribute( 'data-wpzoom-src' ); 145 146 if ( ! src ) { 147 return; 148 } 149 150 var srcset = image.getAttribute( 'data-wpzoom-srcset' ); 151 152 if ( srcset ) { 153 image.srcset = srcset; 154 } 155 156 image.src = src; 157 image.removeAttribute( 'data-wpzoom-src' ); 158 image.removeAttribute( 'data-wpzoom-srcset' ); 159 } ); 160 } 161 162 function show( product, index ) { 163 164 var total = product.images.length; 165 166 if ( ! total ) { 167 return; 168 } 169 170 product.index = ( index + total ) % total; 171 172 product.images.forEach( function ( image, position ) { 173 image.classList.toggle( 'is-active', position === product.index ); 174 } ); 175 } 176 177 function startRotating( product ) { 178 179 var interval = parseInt( product.container.getAttribute( 'data-interval' ), 10 ); 180 181 if ( product.images.length < 2 || ! interval ) { 182 return; 183 } 184 185 stopRotating( product ); 186 187 product.timer = window.setInterval( function () { 188 show( product, product.index + 1 ); 189 }, interval ); 190 } 191 192 function stopRotating( product ) { 193 194 if ( product.timer ) { 195 window.clearInterval( product.timer ); 196 product.timer = null; 197 } 198 } 199 200 function onControlClick( event, product ) { 201 202 var button = event.target.closest( '[data-wpzoom-action]' ); 203 204 if ( ! button ) { 205 return; 206 } 207 208 // The controls sit on top of the product link. 209 event.preventDefault(); 210 event.stopPropagation(); 211 212 var action = button.getAttribute( 'data-wpzoom-action' ); 213 214 if ( 'prev' === action ) { 215 stopRotating( product ); 216 show( product, product.index - 1 ); 217 } else if ( 'next' === action ) { 218 stopRotating( product ); 219 show( product, product.index + 1 ); 220 } else if ( 'lightbox' === action ) { 221 stopRotating( product ); 222 lightbox.open( product, button ); 223 } 224 } 225 226 function enter( item ) { 227 228 var product = products.get( item ) || setup( item ); 229 230 if ( ! product ) { 231 return; 232 } 233 234 hydrate( product ); 235 measure( product ); 236 237 if ( 'rotate' === product.container.getAttribute( 'data-mode' ) ) { 238 startRotating( product ); 239 } 240 } 241 242 function leave( item ) { 243 244 var product = products.get( item ); 245 246 if ( ! product ) { 247 return; 248 } 249 250 stopRotating( product ); 251 show( product, 0 ); 252 } 253 254 /* --------------------------------------------------------------------- * 255 * Lightbox 256 * --------------------------------------------------------------------- */ 257 258 var lightbox = { 259 260 element: null, 261 sources: [], 262 index: 0, 263 opener: null, 264 265 build: function () { 266 267 if ( this.element ) { 268 return this.element; 269 } 270 271 var root = document.createElement( 'div' ); 272 273 root.className = 'wpzoom-wc-spi-lightbox'; 274 root.setAttribute( 'role', 'dialog' ); 275 root.setAttribute( 'aria-modal', 'true' ); 276 root.hidden = true; 277 278 root.innerHTML = 279 '<div class="wpzoom-wc-spi-lightbox-backdrop" data-wpzoom-close></div>' + 280 '<div class="wpzoom-wc-spi-lightbox-frame">' + 281 '<img class="wpzoom-wc-spi-lightbox-image" alt="" />' + 282 '<p class="wpzoom-wc-spi-lightbox-counter" aria-live="polite"></p>' + 283 '</div>' + 284 '<button type="button" class="wpzoom-wc-spi-lightbox-close" data-wpzoom-close></button>' + 285 '<button type="button" class="wpzoom-wc-spi-lightbox-prev"></button>' + 286 '<button type="button" class="wpzoom-wc-spi-lightbox-next"></button>'; 287 288 this.element = root; 289 this.image = root.querySelector( '.wpzoom-wc-spi-lightbox-image' ); 290 this.counter = root.querySelector( '.wpzoom-wc-spi-lightbox-counter' ); 291 this.closeButton = root.querySelector( '.wpzoom-wc-spi-lightbox-close' ); 292 this.prevButton = root.querySelector( '.wpzoom-wc-spi-lightbox-prev' ); 293 this.nextButton = root.querySelector( '.wpzoom-wc-spi-lightbox-next' ); 294 295 this.closeButton.setAttribute( 'aria-label', i18n.close || 'Close' ); 296 this.prevButton.setAttribute( 'aria-label', i18n.previous || 'Previous' ); 297 this.nextButton.setAttribute( 'aria-label', i18n.next || 'Next' ); 298 299 var self = this; 300 301 root.addEventListener( 'click', function ( event ) { 302 if ( event.target.hasAttribute( 'data-wpzoom-close' ) ) { 303 self.close(); 304 } else if ( event.target === self.prevButton ) { 305 self.go( self.index - 1 ); 306 } else if ( event.target === self.nextButton ) { 307 self.go( self.index + 1 ); 308 } 309 } ); 310 311 document.body.appendChild( root ); 312 313 return root; 314 }, 315 316 open: function ( product, opener ) { 317 318 this.sources = product.images 319 .map( function ( image ) { 320 return image.getAttribute( 'data-wpzoom-full' ) || image.currentSrc || image.src; 321 } ) 322 .filter( Boolean ); 323 324 if ( ! this.sources.length ) { 325 return; 326 } 327 328 this.build(); 329 this.opener = opener; 330 this.item = product.item; 331 332 // The pointer has usually left the product by the time the lightbox is 333 // closed. Keeping the item marked as active leaves the controls visible, 334 // so focus has somewhere to return to. 335 this.item.classList.add( 'wpzoom-wc-spi-active' ); 336 337 this.element.hidden = false;
338 document.documentElement.classList.add( 'wpzoom-wc-spi-lightbox-open' ); 339 340 var single = this.sources.length < 2; 341 342 this.prevButton.hidden = single; 343 this.nextButton.hidden = single; 344 345 this.go( product.index ); 346 this.closeButton.focus(); 347 348 document.addEventListener( 'keydown', this.onKeydown ); 349 }, 350 351 go: function ( index ) { 352 353 var total = this.sources.length; 354 355 this.index = ( index + total ) % total; 356 this.image.src = this.sources[ this.index ]; 357 358 var template = i18n.counter || 'Image %1$s of %2$s'; 359 360 this.counter.textContent = template 361 .replace( '%1$s', this.index + 1 ) 362 .replace( '%2$s', total ); 363 }, 364 365 close: function () { 366 367 if ( ! this.element || this.element.hidden ) { 368 return; 369 } 370 371 this.element.hidden = true; 372 document.documentElement.classList.remove( 'wpzoom-wc-spi-lightbox-open' ); 373 document.removeEventListener( 'keydown', this.onKeydown ); 374 375 var opener = this.opener; 376 377 this.opener = null; 378 379 if ( opener ) { 380 // Flush pending style changes so the button counts as visible, and 381 // therefore focusable, at this point. 382 void opener.offsetWidth; 383 384 opener.focus(); 385 386 // A hidden button can't take focus; fall back to the product link 387 // rather than dropping the shopper back at the top of the document. 388 if ( document.activeElement !== opener ) { 389 var item = opener.closest( ITEM ); 390 var link = item && item.querySelector( 'a[href]' ); 391 392 if ( link ) { 393 link.focus(); 394 } 395 } 396 } 397 398 if ( this.item ) { 399 this.item.classList.remove( 'wpzoom-wc-spi-active' ); 400 this.item = null; 401 } 402 }, 403 404 onKeydown: function ( event ) { 405 406 if ( 'Escape' === event.key ) { 407 lightbox.close(); 408 } else if ( 'ArrowLeft' === event.key ) { 409 lightbox.go( lightbox.index - 1 ); 410 } else if ( 'ArrowRight' === event.key ) { 411 lightbox.go( lightbox.index + 1 ); 412 } else if ( 'Tab' === event.key ) { 413 // Keep focus inside the dialog. 414 var focusable = [ lightbox.closeButton, lightbox.prevButton, lightbox.nextButton ].filter( function ( button ) { 415 return ! button.hidden; 416 } ); 417 418 var position = focusable.indexOf( document.activeElement ); 419 var next = event.shiftKey ? position - 1 : position + 1; 420 421 event.preventDefault(); 422 focusable[ ( next + focusable.length ) % focusable.length ].focus(); 423 } 424 } 425 }; 426 427 /* --------------------------------------------------------------------- * 428 * Wiring 429 * --------------------------------------------------------------------- */ 430 431 // Waiting for the first pointer movement keeps the images out of the initial 432 // page load, and gives them time to decode before the first product is hovered. 433 document.addEventListener( 434 'pointerover', 435 function ( event ) { 436 437 if ( ! ready ) { 438 ready = true; 439 document.documentElement.classList.add( 'wpzoom-wc-spi-ready' ); 440 document.querySelectorAll( ITEM ).forEach( function ( item ) { 441 var product = products.get( item ) || setup( item ); 442 443 if ( product ) { 444 measure( product ); 445 } 446 } ); 447 } 448 449 if ( ! event.target.closest ) { 450 return; 451 } 452 453 var item = event.target.closest( ITEM ); 454 455 // pointerover repeats for every child, so only act on a new product. 456 // Tracking null too means leaving the grid and coming back re-measures. 457 if ( item !== hovered ) { 458 459 if ( hovered ) { 460 leave( hovered ); 461 } 462 463 hovered = item; 464 465 if ( item ) { 466 enter( item ); 467 } 468 } 469 }, 470 { passive: true } 471 ); 472} )();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.