PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.4
WooCommerce v10.9.4
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / assets / js / admin / variation-gallery.js
woocommerce / assets / js / admin Last commit date
api-keys.js 5 years ago api-keys.min.js 2 years ago backbone-modal.js 2 years ago backbone-modal.min.js 2 years ago marketplace-suggestions.js 10 months ago marketplace-suggestions.min.js 10 months ago meta-boxes-coupon.js 1 year ago meta-boxes-coupon.min.js 1 year ago meta-boxes-order.js 1 month ago meta-boxes-order.min.js 1 month ago meta-boxes-product-variation.js 1 year ago meta-boxes-product-variation.min.js 1 year ago meta-boxes-product.js 1 month ago meta-boxes-product.min.js 1 month ago meta-boxes.js 4 months ago meta-boxes.min.js 4 months ago network-orders.js 8 years ago network-orders.min.js 2 years ago order-attribution-admin.js 2 years ago order-attribution-admin.min.js 2 years ago product-editor.js 11 months ago product-editor.min.js 11 months ago product-ordering.js 3 months ago product-ordering.min.js 3 months ago quick-edit.js 1 year ago quick-edit.min.js 1 year ago reports.js 1 year ago reports.min.js 1 year ago settings-views-html-settings-tax.js 1 year ago settings-views-html-settings-tax.min.js 1 year ago settings.js 1 year ago settings.min.js 1 year ago system-status.js 3 months ago system-status.min.js 3 months ago term-ordering.js 3 months ago term-ordering.min.js 3 months ago users.js 5 years ago users.min.js 2 years ago variation-gallery.js 1 month ago variation-gallery.min.js 1 month ago wc-brands-enhanced-select.js 1 year ago wc-brands-enhanced-select.min.js 1 year ago wc-clipboard.js 5 years ago wc-clipboard.min.js 5 years ago wc-customer-stock-notifications.js 10 months ago wc-customer-stock-notifications.min.js 10 months ago wc-enhanced-select.js 2 years ago wc-enhanced-select.min.js 2 years ago wc-orders.js 3 years ago wc-orders.min.js 2 years ago wc-product-export.js 1 year ago wc-product-export.min.js 1 year ago wc-product-import.js 3 years ago wc-product-import.min.js 2 years ago wc-recent-reviews-widget-async.js 4 months ago wc-recent-reviews-widget-async.min.js 4 months ago wc-setup.js 5 years ago wc-setup.min.js 2 years ago wc-shipping-classes.js 1 year ago wc-shipping-classes.min.js 1 year ago wc-shipping-providers.js 3 months ago wc-shipping-providers.min.js 3 months ago wc-shipping-zone-methods.js 5 months ago wc-shipping-zone-methods.min.js 5 months ago wc-shipping-zones.js 1 year ago wc-shipping-zones.min.js 1 year ago wc-status-widget-async.js 4 months ago wc-status-widget-async.min.js 4 months ago wc-status-widget.js 1 year ago wc-status-widget.min.js 1 year ago woocommerce_admin.js 1 month ago woocommerce_admin.min.js 1 month ago
variation-gallery.js
948 lines
1 /* global jQuery, wp, wcVariationGalleryL10n */
2
3 /*
4 * Variation gallery (classic admin).
5 *
6 * Embedded inside the variation meta box on the classic Edit Product screen.
7 */
8
9 jQuery( function ( $ ) {
10 'use strict';
11
12 const SELECTORS = {
13 productOptionsRoot: '#variable_product_options',
14 productData: '#woocommerce-product-data',
15 field: '.wc-variation-gallery-field',
16 fieldThumbList: '.wc-variation-gallery-field__thumbs',
17 fieldHero: '.wc-variation-gallery-field__hero',
18 fieldHeroImg: '.wc-variation-gallery-field__hero-img',
19 fieldHeroBroken: '.wc-variation-gallery-field__hero-broken',
20 fieldHeroEmptyCta: '.wc-variation-gallery-field__empty-cta',
21 fieldHint: '.wc-variation-gallery-field__hint',
22 fieldCount: '.wc-variation-gallery-field__count',
23 fieldImageIdsInput: '.wc-variation-gallery-image-ids',
24 thumb: '.wc-variation-gallery-thumb',
25 thumbButton: '.wc-variation-gallery-thumb__button',
26 thumbRemove: '.wc-variation-gallery-thumb__remove',
27 manageTrigger: '.wc-variation-gallery-manage',
28 replaceTrigger: '.wc-variation-gallery-replace',
29 primaryBadge: '[data-primary-badge]',
30 missingFileLabel: '.screen-reader-text[data-missing-file-label]',
31 // Legacy variation-row fields that we keep in sync.
32 variationRow: '.woocommerce_variation',
33 legacyInput: '.upload_image_id',
34 legacyButton: '.upload_image_button',
35 };
36
37 const CLASSES = {
38 isEmpty: 'is-empty',
39 isActive: 'is-active',
40 isBroken: 'is-broken',
41 fieldHeroImg: 'wc-variation-gallery-field__hero-img',
42 fieldHeroBroken: 'wc-variation-gallery-field__hero-broken',
43 };
44
45 const l10n = window.wcVariationGalleryL10n || {};
46 const a11y = ( wp && wp.a11y ) || null;
47
48 /**
49 * Speak a message via wp.a11y.speak when available. No-op otherwise.
50 *
51 * @param {string} message
52 */
53 const announce = ( message ) => {
54 if ( message && a11y && typeof a11y.speak === 'function' ) {
55 a11y.speak( message );
56 }
57 };
58
59 /**
60 * Pick the URL of the largest reasonable preview for the hero slot.
61 * Prefers `medium`, then `full`, then the raw attachment URL.
62 *
63 * @param {{ sizes?: Object, url?: string }} attachmentJson
64 * @return {string}
65 */
66 const pickAttachmentDisplayUrl = ( attachmentJson ) => {
67 const sizes = attachmentJson.sizes || {};
68 const preferred = sizes.medium || sizes.full;
69 return ( preferred && preferred.url ) || attachmentJson.url || '';
70 };
71
72 /**
73 * Pick the thumbnail URL for use in small previews (gallery thumbs and
74 * the legacy inline preview slot). Falls back to the raw URL when no
75 * thumbnail variant is registered for this attachment.
76 *
77 * @param {{ sizes?: Object, url?: string }} attachmentJson
78 * @return {string}
79 */
80 const pickAttachmentThumbnailUrl = ( attachmentJson ) => {
81 const sizes = attachmentJson.sizes || {};
82 return (
83 ( sizes.thumbnail && sizes.thumbnail.url ) ||
84 attachmentJson.url ||
85 ''
86 );
87 };
88
89 /**
90 * Map an image count to the i18n template key used to label the field.
91 *
92 * @param {number} count
93 * @return {string}
94 */
95 const getCountTemplateKey = ( count ) => {
96 if ( count === 0 ) {
97 return 'countZero';
98 }
99 if ( count === 1 ) {
100 return 'countSingular';
101 }
102 return 'countPlural';
103 };
104
105 /**
106 * Remove all hero-state overlays.
107 *
108 * @param {jQuery} $hero
109 */
110 const clearHeroOverlays = ( $hero ) => {
111 $hero.find( SELECTORS.fieldHeroEmptyCta ).remove();
112 $hero.find( SELECTORS.fieldHeroBroken ).remove();
113 $hero.find( SELECTORS.missingFileLabel ).remove();
114 };
115
116 /**
117 * Return the existing hero <img> if one is present, or create and
118 * prepend a fresh one.
119 *
120 * @param {jQuery} $hero
121 * @return {jQuery}
122 */
123 const getOrCreateHeroImage = ( $hero ) => {
124 const $existing = $hero.find( SELECTORS.fieldHeroImg );
125 if ( $existing.length ) {
126 return $existing;
127 }
128
129 const $img = $( '<img />' )
130 .addClass( CLASSES.fieldHeroImg )
131 .attr( 'loading', 'lazy' )
132 .attr( 'decoding', 'async' );
133 $hero.prepend( $img );
134 return $img;
135 };
136
137 const variationGallery = {
138 /** @type {wp.media.frames.MediaFrame|null} */
139 manageFrame: null,
140 /** @type {wp.media.frames.MediaFrame|null} */
141 replaceFrame: null,
142 /** @type {jQuery|null} */
143 activeField: null,
144 /** @type {number[]} */
145 activePreloadIds: [],
146 /** @type {number|null} */
147 activeIndexForReplace: null,
148 wpMediaPostId: wp.media.model.settings.post.id,
149
150 init() {
151 const $root = $( SELECTORS.productOptionsRoot );
152
153 $root.on(
154 'click',
155 SELECTORS.manageTrigger,
156 this.onManage.bind( this )
157 );
158 $root.on(
159 'click',
160 SELECTORS.replaceTrigger,
161 this.onReplace.bind( this )
162 );
163 $root.on(
164 'click',
165 SELECTORS.thumbButton,
166 this.onThumbClick.bind( this )
167 );
168 $root.on(
169 'click',
170 SELECTORS.thumbRemove,
171 this.onRemoveClick.bind( this )
172 );
173
174 // The meta box re-fires these events when variation rows are paginated
175 // in or appended after a save, so re-initialize sortables each time.
176 $root.on(
177 'woocommerce_variations_added',
178 this.initializeSortables.bind( this )
179 );
180 $( SELECTORS.productData ).on(
181 'woocommerce_variations_loaded',
182 this.initializeSortables.bind( this )
183 );
184
185 this.initializeSortables();
186 },
187
188 initializeSortables() {
189 $( SELECTORS.fieldThumbList ).each( function () {
190 const $list = $( this );
191 const $field = $list.closest( SELECTORS.field );
192
193 variationGallery.updateFromDom( $field );
194
195 if ( $list.data( 'wc-variation-gallery-sortable' ) ) {
196 return;
197 }
198
199 $list.sortable( {
200 items: 'li' + SELECTORS.thumb,
201 cancel: SELECTORS.thumbRemove,
202 cursor: 'grabbing',
203 scrollSensitivity: 40,
204 forcePlaceholderSize: true,
205 helper: 'clone',
206 opacity: 0.65,
207 placeholder: 'wc-metabox-sortable-placeholder',
208 start( _event, ui ) {
209 ui.item.addClass( 'is-dragging' );
210 $list.addClass( 'is-sorting' );
211 },
212 stop( _event, ui ) {
213 ui.item.removeClass( 'is-dragging' );
214 $list.removeClass( 'is-sorting' );
215 },
216 update() {
217 const wasPrimary =
218 variationGallery.getActiveAttachmentId( $field );
219 variationGallery.setActiveIndex( $field, 0 );
220 variationGallery.syncField( $field );
221
222 const isPrimary =
223 variationGallery.getActiveAttachmentId( $field );
224 announce(
225 wasPrimary !== isPrimary
226 ? l10n.announcePrimary
227 : l10n.announceReorder
228 );
229 },
230 } );
231
232 $list.data( 'wc-variation-gallery-sortable', true );
233 } );
234 },
235
236 /**
237 * Click on a thumbnail: surface that image in the hero slot.
238 *
239 * Does not change the gallery order or the primary image.
240 *
241 * @param {jQuery.Event} event
242 */
243 onThumbClick( event ) {
244 const $button = $( event.currentTarget );
245 const $thumb = $button.closest( SELECTORS.thumb );
246 const $field = $thumb.closest( SELECTORS.field );
247 const index = $thumb.index();
248
249 event.preventDefault();
250 this.setActiveIndex( $field, index );
251 },
252
253 /**
254 * Click on a thumbnail's remove button: drop that image from the
255 * gallery.
256 *
257 * @param {jQuery.Event} event
258 */
259 onRemoveClick( event ) {
260 event.preventDefault();
261 event.stopPropagation();
262
263 const $trigger = $( event.currentTarget );
264 const $thumb = $trigger.closest( SELECTORS.thumb );
265 const $field = $thumb.closest( SELECTORS.field );
266 const removedIndex = $thumb.index();
267 const currentActive = this.getActiveIndex( $field );
268 const ids = this.getFieldIds( $field );
269
270 ids.splice( removedIndex, 1 );
271
272 let nextActive;
273 if ( removedIndex < currentActive ) {
274 nextActive = currentActive - 1;
275 } else if ( removedIndex === currentActive ) {
276 nextActive = Math.min( removedIndex, ids.length - 1 );
277 } else {
278 nextActive = currentActive;
279 }
280
281 this.writeGallery( $field, ids, Math.max( 0, nextActive ) );
282 announce( l10n.announceRemoved );
283 },
284
285 /**
286 * "Manage" button: open the WP media frame in multi-select mode,
287 * preselect the variation's current gallery, and rewrite the
288 * gallery from whatever the merchant selects.
289 *
290 * @param {jQuery.Event} event
291 */
292 onManage( event ) {
293 const $trigger = $( event.currentTarget );
294 const $field = $trigger.closest( SELECTORS.field );
295 const variationId = $field.data( 'variationId' );
296
297 event.preventDefault();
298
299 // Scope newly-uploaded attachments to this variation post.
300 wp.media.model.settings.post.id = variationId;
301
302 // Update per-open state read by the cached frame's handlers.
303 this.activeField = $field;
304 this.activePreloadIds = this.getFieldIds( $field );
305
306 if ( ! this.manageFrame ) {
307 this.manageFrame = wp.media( {
308 title: l10n.manageTitle,
309 library: { type: 'image' },
310 button: { text: l10n.manageButton },
311 multiple: 'add',
312 } );
313
314 this.manageFrame.on( 'open', () =>
315 this.preloadFrameSelection(
316 this.manageFrame,
317 this.activePreloadIds
318 )
319 );
320 this.manageFrame.on( 'select', () =>
321 this.onManageSelect( this.manageFrame, this.activeField )
322 );
323 this.manageFrame.on( 'close', () => this.restoreMediaPostId() );
324 }
325
326 this.manageFrame.open();
327 },
328
329 /**
330 * "Replace" button on the hero slot: open the WP media frame in
331 * single-select mode and swap the currently-active gallery slot
332 * with the chosen attachment.
333 *
334 * @param {jQuery.Event} event
335 */
336 onReplace( event ) {
337 const $trigger = $( event.currentTarget );
338 const $field = $trigger.closest( SELECTORS.field );
339 const variationId = $field.data( 'variationId' );
340
341 event.preventDefault();
342
343 wp.media.model.settings.post.id = variationId;
344
345 this.activeField = $field;
346 this.activeIndexForReplace = this.getActiveIndex( $field );
347
348 if ( ! this.replaceFrame ) {
349 this.replaceFrame = wp.media( {
350 title: l10n.replaceTitle,
351 library: { type: 'image' },
352 button: { text: l10n.replaceButton },
353 multiple: false,
354 } );
355
356 this.replaceFrame.on( 'select', () =>
357 this.onReplaceSelect( this.replaceFrame, this.activeField )
358 );
359 this.replaceFrame.on( 'close', () =>
360 this.restoreMediaPostId()
361 );
362 }
363
364 this.replaceFrame.open();
365 },
366
367 /**
368 * When the manage frame opens, populate its selection with the
369 * variation's current gallery.
370 *
371 * @param {wp.media.frames.MediaFrame} frame
372 * @param {number[]} currentIds
373 */
374 preloadFrameSelection( frame, currentIds ) {
375 if ( ! frame ) {
376 return;
377 }
378
379 const selection = frame.state().get( 'selection' );
380 if ( ! selection ) {
381 return;
382 }
383
384 selection.reset();
385
386 currentIds.forEach( ( id ) => {
387 const attachment = wp.media.attachment( id );
388 attachment.fetch();
389 selection.add( attachment );
390 } );
391 },
392
393 /**
394 * Manage frame "select" handler: read attachments out of the
395 * frame's selection model and rewrite the gallery to match.
396 *
397 * @param {wp.media.frames.MediaFrame} frame
398 * @param {jQuery} $field
399 */
400 onManageSelect( frame, $field ) {
401 const selection = frame.state().get( 'selection' );
402 const nextIds = [];
403
404 selection.each( ( attachment ) => {
405 const json = attachment.toJSON();
406 if ( json.id ) {
407 nextIds.push( Number( json.id ) );
408 }
409 } );
410
411 this.writeGallery( $field, nextIds );
412 announce( l10n.announceUpdated );
413 this.restoreMediaPostId();
414 },
415
416 /**
417 * Replace frame "select" handler: swap the attachment at the
418 * cached active index with the chosen one and keep that slot
419 * surfaced as the hero image.
420 *
421 * @param {wp.media.frames.MediaFrame} frame
422 * @param {jQuery} $field
423 */
424 onReplaceSelect( frame, $field ) {
425 const selection = frame.state().get( 'selection' );
426 const attachment = selection.first();
427
428 if ( ! attachment ) {
429 return;
430 }
431
432 const newId = Number( attachment.toJSON().id );
433 const index = this.activeIndexForReplace;
434 const ids = this.getFieldIds( $field );
435
436 if ( index === null || index < 0 || index >= ids.length ) {
437 return;
438 }
439
440 ids[ index ] = newId;
441 this.writeGallery( $field, ids, index );
442 announce( l10n.announceReplaced );
443 this.restoreMediaPostId();
444 },
445
446 /**
447 * Replace the field's gallery with the given ID list, dedupe,
448 * re-render thumbs, surface the active slot, and sync the field.
449 *
450 * @param {jQuery} $field
451 * @param {number[]} nextIds
452 * @param {number} [activeIndex=0]
453 */
454 writeGallery( $field, nextIds, activeIndex = 0 ) {
455 const uniqueIds = Array.from( new Set( nextIds ) ).filter(
456 ( id ) => Number.isInteger( id ) && id > 0
457 );
458
459 this.rebuildThumbs( $field, uniqueIds );
460 this.setActiveIndex(
461 $field,
462 Math.min( activeIndex, Math.max( uniqueIds.length - 1, 0 ) )
463 );
464 this.syncField( $field );
465 },
466
467 /**
468 * Read the cached thumbnail/hero `src` for an attachment from the
469 * existing field DOM. Server-rendered <img> tags carry valid URLs
470 * even for attachments that haven't been loaded into wp.media's
471 * client cache (e.g. migrator-imported images), so prefer the DOM
472 * over `wp.media.attachment(id).attributes`.
473 *
474 * @param {jQuery} $field
475 * @param {number} id
476 * @return {string}
477 */
478 getCachedAttachmentUrl( $field, id ) {
479 const $existingThumb = $field.find(
480 SELECTORS.thumb +
481 '[data-attachment_id="' +
482 id +
483 '"] ' +
484 SELECTORS.thumbButton +
485 ' img'
486 );
487
488 if ( $existingThumb.length ) {
489 return $existingThumb.attr( 'src' ) || '';
490 }
491
492 const $existingHero = $field.find(
493 SELECTORS.fieldHeroImg + '[data-id="' + id + '"]'
494 );
495
496 if ( $existingHero.length ) {
497 return $existingHero.attr( 'src' ) || '';
498 }
499
500 return '';
501 },
502
503 /**
504 * Resolve a usable URL for the given attachment, preferring the
505 * server-rendered DOM and falling back to the media-frame cache
506 * (which is hydrated for attachments the merchant just selected
507 * via wp.media).
508 *
509 * @param {jQuery} $field
510 * @param {number} id
511 * @param {(json: Object) => string} pick
512 * @return {string}
513 */
514 resolveAttachmentUrl( $field, id, pick ) {
515 const cached = this.getCachedAttachmentUrl( $field, id );
516 if ( cached ) {
517 return cached;
518 }
519
520 const attachment = wp.media.attachment( id );
521 return pick( attachment.attributes || {} );
522 },
523
524 /**
525 * Re-render the thumbnail list from scratch for the given IDs.
526 * Caller is responsible for ensuring the IDs are unique and
527 * non-empty before this is invoked.
528 *
529 * @param {jQuery} $field
530 * @param {number[]} ids
531 */
532 rebuildThumbs( $field, ids ) {
533 const $list = $field.find( SELECTORS.fieldThumbList );
534 const urls = ids.map( ( id ) =>
535 this.resolveAttachmentUrl(
536 $field,
537 id,
538 pickAttachmentThumbnailUrl
539 )
540 );
541
542 $list.empty();
543
544 ids.forEach( ( id, index ) => {
545 $list.append(
546 this.buildThumbMarkup( id, urls[ index ], index === 0 )
547 );
548 } );
549
550 if ( $list.data( 'wc-variation-gallery-sortable' ) ) {
551 $list.sortable( 'refresh' );
552 }
553 },
554
555 /**
556 * Build the markup for a single thumbnail list item.
557 *
558 * Renders a "missing file" placeholder when no thumbnail
559 * URL is available.
560 *
561 * @param {number} id
562 * @param {string} thumbnailUrl
563 * @param {boolean} isActive
564 * @return {jQuery}
565 */
566 buildThumbMarkup( id, thumbnailUrl, isActive ) {
567 const labelTemplate = l10n.thumbLabel || 'Show gallery image %d';
568 const label = labelTemplate.replace( '%d', id );
569 const $li = $( '<li></li>' )
570 .addClass( 'wc-variation-gallery-thumb' )
571 .toggleClass( CLASSES.isActive, isActive )
572 .attr( 'data-attachment_id', id );
573 const $button = $( '<button type="button"></button>' )
574 .addClass( 'wc-variation-gallery-thumb__button' )
575 .attr( 'aria-label', label );
576 const $remove = $( '<button type="button"></button>' )
577 .addClass( 'wc-variation-gallery-thumb__remove' )
578 .attr( 'aria-label', l10n.removeLabel || 'Remove image' )
579 .append(
580 $( '<span></span>' )
581 .addClass( 'dashicons dashicons-no-alt' )
582 .attr( 'aria-hidden', 'true' )
583 );
584
585 if ( thumbnailUrl ) {
586 const $img = $( '<img />' )
587 .attr( 'src', thumbnailUrl )
588 .attr( 'alt', '' );
589 $button.append( $img );
590 return $li.append( $button, $remove );
591 }
592
593 $li.addClass( CLASSES.isBroken );
594
595 const $brokenIcon = $( '<span></span>' ).addClass(
596 'dashicons dashicons-format-image'
597 );
598 const $brokenWrapper = $( '<span></span>' )
599 .addClass( 'wc-variation-gallery-thumb__broken' )
600 .attr( 'aria-hidden', 'true' )
601 .append( $brokenIcon );
602 const $srLabel = $( '<span></span>' )
603 .addClass( 'screen-reader-text' )
604 .text( l10n.missingFileLabel || 'Attachment file missing' );
605
606 $button.append( $brokenWrapper, $srLabel );
607 return $li.append( $button, $remove );
608 },
609
610 /**
611 * Surface the slot at `index` in the hero area and mark its thumb
612 * as active. Falls back to the empty state if there are no images.
613 *
614 * @param {jQuery} $field
615 * @param {number} index
616 */
617 setActiveIndex( $field, index ) {
618 const ids = this.getFieldIds( $field );
619
620 if ( ! ids.length ) {
621 this.setHeroEmpty( $field );
622 return;
623 }
624
625 const safeIndex = Math.max( 0, Math.min( index, ids.length - 1 ) );
626 const activeId = ids[ safeIndex ];
627
628 $field
629 .find( SELECTORS.fieldHero )
630 .attr( 'data-active-index', safeIndex );
631
632 this.setHeroImage( $field, activeId, safeIndex === 0 );
633
634 $field.find( SELECTORS.thumb ).removeClass( CLASSES.isActive );
635 $field
636 .find(
637 SELECTORS.thumb + '[data-attachment_id="' + activeId + '"]'
638 )
639 .addClass( CLASSES.isActive );
640 },
641
642 /**
643 * @param {jQuery} $field
644 * @return {number}
645 */
646 getActiveIndex( $field ) {
647 return Number(
648 $field
649 .find( SELECTORS.fieldHero )
650 .attr( 'data-active-index' ) || 0
651 );
652 },
653
654 /**
655 * @param {jQuery} $field
656 * @return {number}
657 */
658 getActiveAttachmentId( $field ) {
659 const ids = this.getFieldIds( $field );
660
661 if ( ! ids.length ) {
662 return 0;
663 }
664
665 return ids[ this.getActiveIndex( $field ) ] || ids[ 0 ];
666 },
667
668 /**
669 * Render the given attachment in the hero slot. Falls through to
670 * the missing-file state if the attachment record has no usable
671 * URL (e.g. the underlying file has been deleted).
672 *
673 * @param {jQuery} $field
674 * @param {number} attachmentId
675 * @param {boolean} isPrimary
676 */
677 setHeroImage( $field, attachmentId, isPrimary ) {
678 const $hero = $field.find( SELECTORS.fieldHero );
679 const url = this.resolveAttachmentUrl(
680 $field,
681 attachmentId,
682 pickAttachmentDisplayUrl
683 );
684
685 if ( ! url ) {
686 this.setHeroMissingFile( $field, isPrimary );
687 return;
688 }
689
690 clearHeroOverlays( $hero );
691
692 const $img = getOrCreateHeroImage( $hero );
693 $img.attr( 'src', url )
694 .attr( 'data-id', attachmentId )
695 .attr( 'alt', '' );
696
697 this.ensureHeroControls( $hero, isPrimary );
698 },
699
700 /**
701 * Render the "attachment file is missing" placeholder in the hero
702 * slot. Used when an attachment row exists but the underlying file
703 * has been deleted or is otherwise unreachable.
704 *
705 * @param {jQuery} $field
706 * @param {boolean} isPrimary
707 */
708 setHeroMissingFile( $field, isPrimary ) {
709 const $hero = $field.find( SELECTORS.fieldHero );
710
711 $hero.find( SELECTORS.fieldHeroEmptyCta ).remove();
712 $hero.find( SELECTORS.fieldHeroImg ).remove();
713
714 if ( ! $hero.find( SELECTORS.fieldHeroBroken ).length ) {
715 const $brokenIcon = $( '<span></span>' ).addClass(
716 'dashicons dashicons-format-image'
717 );
718 const $brokenWrapper = $( '<span></span>' )
719 .addClass( CLASSES.fieldHeroBroken )
720 .attr( 'aria-hidden', 'true' )
721 .append( $brokenIcon );
722 const $srLabel = $( '<span></span>' )
723 .addClass( 'screen-reader-text' )
724 .attr( 'data-missing-file-label', 'true' )
725 .text( l10n.missingFileLabel || 'Attachment file missing' );
726
727 $hero.prepend( $brokenWrapper, $srLabel );
728 }
729
730 this.ensureHeroControls( $hero, isPrimary );
731 },
732
733 /**
734 * Ensure the primary-image badge and the Replace button are
735 * present in the hero slot. Idempotent.
736 *
737 * @param {jQuery} $hero
738 * @param {boolean} isPrimary
739 */
740 ensureHeroControls( $hero, isPrimary ) {
741 if ( ! $hero.find( SELECTORS.primaryBadge ).length ) {
742 const $badgeIcon = $( '<span></span>' ).addClass(
743 'dashicons dashicons-star-filled'
744 );
745 const badgeLabel = document.createTextNode(
746 ' ' + ( l10n.primaryLabel || 'Primary' )
747 );
748 const $badge = $( '<span></span>' )
749 .addClass( 'wc-variation-gallery-field__badge' )
750 .attr( 'data-primary-badge', '' )
751 .attr( 'aria-hidden', 'true' )
752 .append( $badgeIcon )
753 .append( badgeLabel );
754
755 $hero.append( $badge );
756 }
757
758 $hero.find( SELECTORS.primaryBadge ).toggle( Boolean( isPrimary ) );
759
760 if ( ! $hero.find( SELECTORS.replaceTrigger ).length ) {
761 const $replace = $( '<button type="button"></button>' )
762 .addClass( 'button wc-variation-gallery-replace' )
763 .text( l10n.replaceLabel || 'Replace' );
764
765 $hero.append( $replace );
766 }
767 },
768
769 /**
770 * Render the empty-gallery state in the hero slot: a single CTA
771 * that opens the WP media frame.
772 *
773 * @param {jQuery} $field
774 */
775 setHeroEmpty( $field ) {
776 const $hero = $field.find( SELECTORS.fieldHero );
777
778 const $ctaIcon = $( '<span></span>' )
779 .addClass( 'dashicons dashicons-plus-alt2' )
780 .attr( 'aria-hidden', 'true' );
781 const ctaLabel = document.createTextNode(
782 ' ' + ( l10n.emptyCtaLabel || 'Add variation images' )
783 );
784 const $cta = $( '<button type="button"></button>' )
785 .addClass(
786 'wc-variation-gallery-field__empty-cta wc-variation-gallery-manage'
787 )
788 .append( $ctaIcon )
789 .append( ctaLabel );
790
791 $hero.empty().attr( 'data-active-index', 0 ).append( $cta );
792 $field.addClass( CLASSES.isEmpty );
793 },
794
795 /**
796 * @param {jQuery} $field
797 * @return {number[]}
798 */
799 getFieldIds( $field ) {
800 return $field
801 .find( SELECTORS.thumb )
802 .map( function () {
803 return Number( $( this ).attr( 'data-attachment_id' ) );
804 } )
805 .get()
806 .filter( ( id ) => Number.isInteger( id ) && id > 0 );
807 },
808
809 /**
810 * Persist current DOM state back into the hidden form input and
811 * the legacy single-image slot, then refresh the count label.
812 *
813 * @param {jQuery} $field
814 */
815 syncField( $field ) {
816 const ids = this.getFieldIds( $field );
817 const primaryId = ids[ 0 ] || '';
818
819 $field
820 .find( SELECTORS.fieldImageIdsInput )
821 .val( ids.join( ',' ) )
822 .trigger( 'change' );
823
824 this.syncLegacyImageSlot( $field, primaryId );
825 this.updateFromDom( $field, ids.length );
826 },
827
828 /**
829 * Keep the existing single-image variation field (`upload_image_id`,
830 * its upload-image button, and the inline preview) in sync with the
831 * gallery's primary image. This lets the existing variation save
832 * path persist the featured image without changes.
833 *
834 * @param {jQuery} $field
835 * @param {number|string} primaryId Attachment ID, or empty string when no primary is set.
836 */
837 syncLegacyImageSlot( $field, primaryId ) {
838 const $row = $field.closest( SELECTORS.variationRow );
839
840 this.updateLegacyInput( $row, primaryId );
841 this.updateLegacyButton( $row, primaryId );
842 this.updateLegacyPreview( $row, primaryId );
843 },
844
845 /**
846 * Mirror the gallery's primary image into the hidden
847 * `upload_image_id[ loop ]` input that the variation save path
848 * already reads.
849 *
850 * @param {jQuery} $row
851 * @param {number|string} primaryId
852 */
853 updateLegacyInput( $row, primaryId ) {
854 const $input = $row.find( SELECTORS.legacyInput );
855 if ( ! $input.length ) {
856 return;
857 }
858 $input.val( primaryId ).trigger( 'change' );
859 },
860
861 /**
862 * Toggle the upload-image button's "remove" affordance based on
863 * whether a primary image is currently set.
864 *
865 * @param {jQuery} $row
866 * @param {number|string} primaryId
867 */
868 updateLegacyButton( $row, primaryId ) {
869 const $button = $row.find( SELECTORS.legacyButton );
870 if ( ! $button.length ) {
871 return;
872 }
873 $button.toggleClass( 'remove', Boolean( primaryId ) );
874 },
875
876 /**
877 * Update the inline thumbnail preview inside the upload-image
878 * button. Stashes the original placeholder src on the first call
879 * so it can be restored when the merchant clears the gallery.
880 *
881 * @param {jQuery} $row
882 * @param {number|string} primaryId
883 */
884 updateLegacyPreview( $row, primaryId ) {
885 const $preview = $row
886 .find( SELECTORS.legacyButton )
887 .find( 'img' )
888 .first();
889 if ( ! $preview.length ) {
890 return;
891 }
892
893 // Stash the placeholder src on first call so we can restore it later.
894 if ( ! $preview.attr( 'data-placeholder-src' ) ) {
895 $preview.attr(
896 'data-placeholder-src',
897 $preview.attr( 'src' ) || ''
898 );
899 }
900
901 if ( ! primaryId ) {
902 $preview.attr(
903 'src',
904 $preview.attr( 'data-placeholder-src' ) || ''
905 );
906 return;
907 }
908
909 const $field = $row.find( SELECTORS.field );
910 const url = this.resolveAttachmentUrl(
911 $field,
912 primaryId,
913 pickAttachmentThumbnailUrl
914 );
915 if ( url ) {
916 $preview.attr( 'src', url );
917 }
918 },
919
920 /**
921 * Refresh the count label, the empty-state class, and the hint
922 * visibility from the field's current image count. Pass an
923 * explicit count to skip the DOM lookup.
924 *
925 * @param {jQuery} $field
926 * @param {number|null} [precomputedCount=null]
927 */
928 updateFromDom( $field, precomputedCount = null ) {
929 const count =
930 precomputedCount === null
931 ? this.getFieldIds( $field ).length
932 : precomputedCount;
933 const template = l10n[ getCountTemplateKey( count ) ] || '%d';
934 const label = template.replace( '%d', count );
935
936 $field.toggleClass( CLASSES.isEmpty, count === 0 );
937 $field.find( SELECTORS.fieldCount ).text( label );
938 $field.find( SELECTORS.fieldHint ).prop( 'hidden', count === 0 );
939 },
940
941 restoreMediaPostId() {
942 wp.media.model.settings.post.id = this.wpMediaPostId;
943 },
944 };
945
946 variationGallery.init();
947 } );
948