/** * Free single-image gallery sync. * * When a variation is selected (via swatch, native select, or any theme * control — we bind WooCommerce's own found_variation/reset_data events, not our * swatch clicks), swap the MAIN product-gallery image to that variation's image, * and restore the original on reset/deselect. * * This is a FALLBACK for galleries WooCommerce core does not swap itself. When * core's own wc_variations_image_update has claimed the image (it marks every * attribute it touches with data-o_), this helper stays hands-off for * both swap and restore so core's native reset semantics win. * * SCOPE BOUNDARY (the Pro feature manages + swaps a MULTI-image gallery and * drives zoom/lightbox): this ONLY swaps the single primary/featured image and * its link. It never touches the other thumbnails, never re-inits * zoom/flexslider/photoswipe, and never manages a per-variation gallery set. * ZERO imports from src/frontend/extension/. * * @package B3VariationSwatches */ const VARIATION_FORM_SELECTOR = "form.variations_form"; export interface WooVariationImage { src?: string; srcset?: string; sizes?: string; full_src?: string; alt?: string; title?: string; } interface ImageSnapshot { src: string; srcset: string; sizes: string; alt: string; title: string; dataSrc: string | null; dataSrcset: string | null; href: string | null; largeImage: string | null; } const snapshots = new WeakMap(); /** * WooCommerce's own image swap (wc_variations_image_update) stores the * pre-swap value of every attribute it touches in `data-o_` and restores * from there on reset_data. Its handler is bound directly on the form, so it * runs BEFORE our delegated body listener for the same event. When that marker * is present core owns the image: it has already swapped it, and it will * already have restored it by the time our reset_data handler runs. Snapshotting * or restoring in that state would capture the variation image as "original" * and re-apply it after core's reset (the 1.1.0 "Clear keeps the variation * image" regression). */ export const isWooManagedImage = (img: HTMLImageElement): boolean => img.hasAttribute("data-o_src"); /** * Original lazy-loader `data-srcset` for core-managed images. Core mirrors the * swap into `data-src` but never into `data-srcset`, so a lazy loader that * re-hydrates `srcset` from it would reapply the product's responsive set over * the variation image. We keep that one auxiliary attribute in step without * touching any attribute core owns. Core never writes `data-srcset`, so the * value seen on first sync is the genuine original. */ const lazySrcsetOriginals = new WeakMap(); /** * Responsive candidate set for a variation: its own srcset, or its plain src * as a single candidate when it has none. Falling back to the current src (not * leaving the attribute untouched) is what prevents a previous variation's or * the product's srcset from being displayed for this variation. */ const variationSrcset = (image: WooVariationImage): string => image.srcset && image.srcset !== "" ? image.srcset : (image.src as string); const syncLazySrcsetForWoo = ( img: HTMLImageElement, image: WooVariationImage, ): void => { if (!img.hasAttribute("data-srcset")) return; if (!lazySrcsetOriginals.has(img)) { lazySrcsetOriginals.set(img, img.getAttribute("data-srcset") || ""); } img.setAttribute("data-srcset", variationSrcset(image)); }; const restoreLazySrcsetForWoo = (img: HTMLImageElement): void => { const original = lazySrcsetOriginals.get(img); if (original === undefined || !img.hasAttribute("data-srcset")) return; img.setAttribute("data-srcset", original); }; /** Whether the sync is enabled (default ON when the setting is absent). */ export const isGalleryImageSyncEnabled = (): boolean => { const settings = typeof b3WvsData !== "undefined" && b3WvsData?.settings ? b3WvsData.settings : null; return settings?.gallery_image_sync !== false; }; /** Resolve the primary gallery image for a variation form's product. */ export const findPrimaryImage = ( form: HTMLElement, ): HTMLImageElement | null => { const container: ParentNode = form.closest(".product") || form.closest("[data-product_id]") || form.closest(".single-product") || form.ownerDocument?.body || document.body; const gallery = (container as Element).querySelector?.(".woocommerce-product-gallery") || container; return ( gallery.querySelector( ".woocommerce-product-gallery__image:first-child img", ) || gallery.querySelector("img.wp-post-image") || gallery.querySelector( ".woocommerce-product-gallery__wrapper img", ) || gallery.querySelector("img") ); }; const takeSnapshot = (img: HTMLImageElement): void => { if (snapshots.has(img)) return; // Never treat an image core already swapped as the original. if (isWooManagedImage(img)) return; const link = img.closest("a"); snapshots.set(img, { src: img.getAttribute("src") || "", srcset: img.getAttribute("srcset") || "", sizes: img.getAttribute("sizes") || "", alt: img.getAttribute("alt") || "", title: img.getAttribute("title") || "", dataSrc: img.getAttribute("data-src"), dataSrcset: img.getAttribute("data-srcset"), href: link ? link.getAttribute("href") : null, largeImage: img.getAttribute("data-large_image"), }); }; /** Swap the image to the variation's image (snapshotting the original first). */ export const applyVariationImage = ( img: HTMLImageElement, image: WooVariationImage | undefined, ): void => { if (!image || !image.src) return; if (isWooManagedImage(img)) { // Core swapped this image first and will reset it itself. Drop any // snapshot we may hold from before core claimed the image so a later // reset_data cannot re-apply a stale variation image over core's reset. snapshots.delete(img); syncLazySrcsetForWoo(img, image); return; } // If nothing has been snapshotted yet but the image already shows this // variation, someone else mutated it before us; there is no trustworthy // original to capture, so leave ownership with them. if (!snapshots.has(img) && img.getAttribute("src") === image.src) return; takeSnapshot(img); const snap = snapshots.get(img); const setOrRemove = (name: string, value: string | undefined): void => { if (typeof value === "string" && value !== "") { img.setAttribute(name, value); } else { img.removeAttribute(name); } }; // Every attribute is written for THIS variation: nothing from a previously // selected variation may survive when this one lacks a value. img.setAttribute("src", image.src); const srcset = variationSrcset(image); img.setAttribute("srcset", srcset); setOrRemove("sizes", image.sizes); // WooCommerce may emit an empty variation alt even when the product image // already has a useful accessible description. Use useful variation text, // otherwise fall back to the product's own alt (not a prior variation's). if (typeof image.alt === "string" && image.alt.trim()) { img.setAttribute("alt", image.alt); } else if (snap) { img.setAttribute("alt", snap.alt); } if (typeof image.title === "string") { img.setAttribute("title", image.title); } else if (snap) { setOrRemove("title", snap.title); } // Defeat lazy-load plugins that own data-src/data-srcset by writing the swap // there too (only when those attributes already exist). if (img.hasAttribute("data-src")) img.setAttribute("data-src", image.src); if (img.hasAttribute("data-srcset")) img.setAttribute("data-srcset", srcset); const link = img.closest("a"); if (link && image.full_src) link.setAttribute("href", image.full_src); if (image.full_src) img.setAttribute("data-large_image", image.full_src); }; /** Restore the image to its pre-swap snapshot (no-op if never swapped). */ export const restoreVariationImage = (img: HTMLImageElement): void => { if (isWooManagedImage(img)) { // Core has already restored its own originals (its form-bound reset_data // handler ran before this delegated one). Do not overwrite them; only put // back the auxiliary lazy-loader attribute core does not know about. snapshots.delete(img); restoreLazySrcsetForWoo(img); return; } const snap = snapshots.get(img); if (!snap) return; const restore = (name: string, value: string): void => { if (value) { img.setAttribute(name, value); } else { img.removeAttribute(name); } }; img.setAttribute("src", snap.src); restore("srcset", snap.srcset); restore("sizes", snap.sizes); img.setAttribute("alt", snap.alt); restore("title", snap.title); if (snap.dataSrc !== null) img.setAttribute("data-src", snap.dataSrc); if (snap.dataSrcset !== null) img.setAttribute("data-srcset", snap.dataSrcset); const link = img.closest("a"); if (link && snap.href !== null) link.setAttribute("href", snap.href); if (snap.largeImage !== null) { img.setAttribute("data-large_image", snap.largeImage); } else { img.removeAttribute("data-large_image"); } }; let listenersBound = false; /** * Bind the WooCommerce found_variation/reset_data listeners (idempotent). Safe * to call from multiple init points; only the first call binds. */ export const initGalleryImageSync = (): void => { if (listenersBound) return; const jq = typeof window !== "undefined" ? (window as { jQuery?: unknown }).jQuery : undefined; if (typeof jq !== "function") return; listenersBound = true; const $ = jq as (el: unknown) => { on: ( events: string, selector: string, handler: (this: HTMLElement, event: unknown, payload?: unknown) => void, ) => void; }; $(document.body).on( "found_variation", VARIATION_FORM_SELECTOR, function (this: HTMLElement, _event: unknown, payload?: unknown) { if (!isGalleryImageSyncEnabled()) return; const variation = payload as { image?: WooVariationImage } | undefined; const img = findPrimaryImage(this); if (!img) return; if (!variation || !variation.image || !variation.image.src) { // A variation without its own image must show the product image, not // the previously selected variation's image (core does the same via // wc_variations_image_reset). restoreVariationImage(img); return; } applyVariationImage(img, variation.image); }, ); $(document.body).on( "reset_data", VARIATION_FORM_SELECTOR, function (this: HTMLElement) { const img = findPrimaryImage(this); if (img) restoreVariationImage(img); }, ); };