PageSourceSearch

https://porta-muehle.de/wp-content/plugins/secondary-product-image…oduct-image-for-woocommerce.js?m=1790005360

js porta-muehle.de collected 2026-10-10 02:14:54 UTC 12,416 bytes, 472 lines download raw bytes

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.