PageSourceSearch

https://poppsee.com/wp-content/themes/poppsee_theme/customization/js/variant_manager.js?v=1791442013

js poppsee.com collected 2026-10-08 18:04:59 UTC 59,378 bytes, 1,233 lines download raw bytes

1/**
2 * Variant Manager — Design Studio
3 * Ticket #9486478256: Artist product variations upon saving initial products
4 *
5 * Manages the variant selection panel, per-variant canvas resizing,
6 * design capture, background submission, and retry logic.
7 */
8var VariantManager = (function ($) {
9    'use strict';
10
11    /* ------------------------------------------------------------------ */
12    /*  State                                                              */
13    /* ------------------------------------------------------------------ */
14    var variants         = [];   // array of variant objects
15    var activeIndex      = -1;   // currently-selected variant index
16    var designData       = {};   // variantId -> { thumbnail, additional_data, custom_style_value, background_color }
17    var selectedIds      = {};   // variantId -> true once that size is SAVED (2026-09-23: the box reports saves, it is not a control)
18    var lastSavedVid     = null; // variation_id of the most recently saved design
19    // The capture currently running, or null. variantScreenshot() reads the
20    // LIVE #designcanvas, so a size switch must wait for it rather than race
21    // it -- racing banks one size's artwork against another, which is how 36
22    // live products came to share a print file (see saveCurrentVariantDesign).
23    var pendingCapture   = null;
24    var productId        = '';
25    // Other surfaces this design could also be published onto, as answered
26    // by poppsee_offerable_surfaces. Empty until the panel asks, and empty
27    // for a design with no artwork id.
28    var offerableSurfaces = [];
29    var catId            = '';
30    var postId           = '';
31    var userId           = '';
32    var productTitle     = '';
33    // One label, one place. The button reads "Publish" because the artist is
34    // publishing sizes of a product, not filing a submission.
35    var SUBMIT_LABEL     = 'Publish';
36    var panelOpen        = false;
37    var isProcessing     = false;
38
39    /* ------------------------------------------------------------------ */
40    /*  Initialisation                                                     */
41    /* ------------------------------------------------------------------ */
42    /**
43     * Drop every scrap of the PREVIOUS product's rendered design.
44     *
45     * These hidden fields are written from #previewImg inside an AJAX success
46     * handler, so until that has run for the NEW product they still hold the
47     * previous product's values. saveCurrentVariantDesign() reads them as
48     *
49     *     $('#post_thumbnail').val() || $('#previewImg').attr('src') || ''
50     *
51     * and a stale value is truthy, so it WINS. Publish a product, pick another
52     * without reloading the page, save a variant, and it was saved with the old
53     * product's artwork (reported 2026-08-22). That is silent data corruption on
54     * a live listing, which is far worse than an error.
55     *
56     * Clearing at init() makes the stale read impossible: init() runs on every
57     * product switch, so from here the value must be freshly generated or it is
58     * empty, and empty is caught by the capture-complete gate rather than
59     * shipped.
60     */
61    function clearCarriedOverDesignState() {
62        // Every field the save path reads, including the numbered siblings that
63        // are written in the same breath as the primary one.
64        ['#post_thumbnail', '#post_thumbnail1', '#post_thumbnail2', '#post_thumbnail3',
65         '#additional_data', '#custom_style_value'].forEach(function (sel) {
66            var $el = $(sel);
67            if ($el.length) { $el.val(''); }
68        });
69        // The preview itself is the source those fields are copied from, so it
70        // has to go too, or the very next capture re-seeds them from the old
71        // product.
72        $('#previewImg').attr('src', '');
73        if (typeof window.previewImgB64 !== 'undefined') { window.previewImgB64 = ''; }
74    }
75
76    function init(opts) {
77        // Reset state for re-initialization (e.g. artist selects a new product)
78        variants     = [];
79        activeIndex  = -1;
80        designData   = {};
81        selectedIds  = {};
82        lastSavedVid = null;
83        isProcessing = false;
84        panelOpen    = false;
85        $('#variantPanel').html('').removeClass('open');
86        $('.vp-toggle-btn').removeClass('visible');
87        setVariantsRow(false, 'Select a product first');
88        clearCarriedOverDesignState();
89
90        productId    = opts.productId    || '';
91        catId        = opts.catId        || '';
92        postId       = opts.postId       || '';
93        userId       = opts.userId       || '';
94        productTitle = opts.productTitle || '';
95        var autoOpen = !!opts.autoOpen;
96        // The size already loaded in the canvas, when the caller knows it
97        // (editing an existing product). Pre-selects that row once the list
98        // arrives, instead of leaving every size looking untouched.
99        var activeVariationId = opts.variationId || '';
100
101        if (!productId) return;
102
103        // Fetch available variations for the product
104        $.ajax({
105            url: admin_url,
106            type: 'POST',
107            dataType: 'json',
108            data: {
109                action: 'get_product_variations_for_panel',
110                product_id: productId
111            },
112            success: function (res) {
113                if (res && res.success && res.data && res.data.length > 0) {
114                    variants = res.data;
115                    buildPanel();
116                    $('.vp-toggle-btn').addClass('visible');
117                    // The left-rail row is driven from the same moment as the
118                    // existing toggle, so the two can never disagree about
119                    // whether variants are available.
120                    setVariantsRow(true, variants.length + (variants.length === 1 ? ' size ready' : ' sizes ready'));
121                    if (autoOpen) openPanel();
122                    if (activeVariationId) {
123                        markVariantActive(findVariantIndex(activeVariationId));
124                    }
125                    console.log('[VariantManager] Loaded ' + variants.length + ' variants');
126                } else {
127                    console.log('[VariantManager] Product has no variants — panel hidden');
128                    setVariantsRow(false, 'This product has no variants');
129                }
130            },
131            error: function () {
132                console.log('[VariantManager] Failed to fetch variants');
133                setVariantsRow(false, 'Could not load variants');
134            }
135        });
136    }
137
138    /* ------------------------------------------------------------------ */
139    /*  Build the side-panel HTML                                          */
140    /* ------------------------------------------------------------------ */
141    function buildPanel() {
142        var html = '';
143        html += '<div class="vp-header">';
144        html += '  <h3>Choose your size variants</h3>';
145        html += '  <button class="vp-close" id="vpClose" title="Hide this panel and keep designing">&times;</button>';
146        html += '</div>';
147
148        // Which size the studio is editing right now, and the fact that the
149        // studio IS still editable. Reported 2026-08-22: once you reach the
150        // variants step nothing signals that the left-hand tools still work, so
151        // it reads as a dead end. The behaviour was already right -- pick a
152        // size, edit it, Save Current Variant -- it was simply never stated.
153        html += '<div class="vp-context" id="vpContext">';
154        html += '  <div class="vp-context-line" id="vpContextLine">Pick a size below to start designing it.</div>';
155        html += '</div>';
156
157        // The product title is inherited from the base product and was never
158        // editable here, so showing a greyed-out box only added noise. It still
159        // rides along on submit via `productTitle`.
160
161        // progress
162        html += '<div class="vp-progress">';
163        html += '  <div class="vp-progress-bar"><div class="vp-progress-fill" id="vpProgressFill"></div></div>';
164        html += '  <div class="vp-progress-text" id="vpProgressText">0 of ' + variants.length + ' selected &middot; 0 designed</div>';
165        html += '</div>';
166
167        // list — ONE row per distinct size (the server groups all same-size
168        // color/frame combinations behind each row; they ride along on submit)
169        html += '<ul class="vp-list" id="vpList">';
170        for (var i = 0; i < variants.length; i++) {
171            var v = variants[i];
172            html += '<li class="vp-item" data-index="' + i + '" data-variation-id="' + v.variation_id + '">';
173            html += '  <div class="vp-check" title="Ticks itself once this size is saved" aria-hidden="true"><i class="fa fa-check"></i></div>';
174            html += '  <div class="vp-info">';
175            html += '    <div class="vp-name">' + escHtml(v.label) + '</div>';
176            if (v.sublabel) {
177                html += '    <div class="vp-sublabel">' + escHtml(v.sublabel) + '</div>';
178            }
179            html += '    <div class="vp-dims">' + escHtml(v.dimensions) + '</div>';
180            html += '    <div class="vp-status pending" id="vpStatus' + i + '">Pending</div>';
181            html += '  </div>';
182            html += '  <div class="vp-thumb" id="vpThumb' + i + '"></div>';
183            html += '</li>';
184        }
185        html += '</ul>';
186
187        // Other surfaces this same artwork can go on. Empty until the server
188        // answers, and absent entirely for a design with no artwork id -- a
189        // draft-loaded design has none, and offering surfaces for it would
190        // publish products that nothing can ever link together.
191        html += '<div class="vp-surfaces" id="vpSurfaces"></div>';
192
193        // actions
194        html += '<div class="vp-actions">';
195        html += '  <button class="vp-btn vp-btn-primary" id="vpSubmitAll" disabled>' + SUBMIT_LABEL + '</button>';
196        html += '  <div class="vp-submit-hint" id="vpSubmitHint"></div>';
197        html += '  <button class="vp-btn vp-btn-secondary" id="vpSaveCurrent" disabled>Save Current Variant</button>';
198        html += '</div>';
199
200        $('#variantPanel').html(html);
201        bindPanelEvents();
202        updateProgress();
203        loadOfferableSurfaces();
204    }
205
206    /* ------------------------------------------------------------------ */
207    /*  Event bindings                                                     */
208    /* ------------------------------------------------------------------ */
209    function bindPanelEvents() {
210        $('#vpClose').on('click', function () {
211            closePanel();
212        });
213
214        // No handler for .vp-check on purpose. Chris, 2026-09-23: the boxes
215        // "should be not clickable and they should check themselves when they
216        // are saved". A click anywhere on the row, the tick included, opens
217        // that size for editing -- which is what an artist means by it.
218        $('#vpList').on('click', '.vp-item', function () {
219            var idx = parseInt($(this).data('index'), 10);
220            selectVariant(idx);
221        });
222
223        $('#vpSaveCurrent').on('click', function () {
224            saveCurrentVariantDesign();
225        });
226
227        $('#vpSubmitAll').on('click', function () {
228            submitAllVariants();
229        });
230
231        $('#vpList').on('click', '.vp-retry', function (e) {
232            e.stopPropagation();
233            var idx = parseInt($(this).closest('.vp-item').data('index'), 10);
234            retryVariant(idx);
235        });
236    }
237
238    /* ------------------------------------------------------------------ */
239    /*  The left-rail Variants row                                         */
240    /* ------------------------------------------------------------------ */
241
242    /**
243     * Enable or disable the rail row, and say WHY in its subtitle.
244     *
245     * The row is always present, never hidden: a control that disappears
246     * cannot teach an artist that it exists, which is the discoverability
247     * problem this row was added to solve. So it stays put and explains
248     * itself instead.
249     */
250    function setVariantsRow(enabled, subtitle) {
251        var $row = $('#spVariantsRow');
252        if (!$row.length) return;          // studio markup predates this row
253        $row.prop('disabled', !enabled).attr('aria-disabled', enabled ? 'false' : 'true');
254        if (subtitle) $('#spVariantsRowSub').text(subtitle);
255        if (!enabled) $row.removeClass('is-open');
256    }
257
258    /** Mirror the drawer's state onto the row, so "open" is visible from the rail. */
259    function markVariantsRowOpen(open) {
260        $('#spVariantsRow').toggleClass('is-open', !!open);
261    }
262
263    /* ------------------------------------------------------------------ */
264    /*  Panel open / close                                                 */
265    /* ------------------------------------------------------------------ */
266    function togglePanel() {
267        if (panelOpen) closePanel();
268        else openPanel();
269    }
270    function openPanel() {
271        $('#variantPanel').addClass('open');
272        // The panel is a fixed 380px drawer. Without this the studio simply
273        // sits UNDERNEATH it, which is a large part of why the step felt like a
274        // separate screen rather than a sidebar you design alongside.
275        $('body').addClass('vp-panel-open');
276        markVariantsRowOpen(true);
277        panelOpen = true;
278    }
279    function closePanel() {
280        markVariantsRowOpen(false);
281        $('#variantPanel').removeClass('open');
282        $('body').removeClass('vp-panel-open');
283        panelOpen = false;
284    }
285
286    /** Update the "what am I editing" line. Safe before the panel is built. */
287    function setContextLine(html) {
288        var el = document.getElementById('vpContextLine');
289        if (el) { el.innerHTML = html; }
290    }
291
292    /* ------------------------------------------------------------------ */
293    /*  Other surfaces this artwork can be published onto                  */
294    /* ------------------------------------------------------------------ */
295
296    /**
297     * Ask the server which surfaces sell something the shape the artist is
298     * working at, and offer them.
299     *
300     * NOTHING IS OFFERED WITHOUT AN ARTWORK ID. The whole point is that the
301     * products end up linked, and the id is best effort: the studio knows it
302     * for a design built in this session and not for one loaded from a draft.
303     * Publishing extra surfaces that nothing can ever link together would be
304     * worse than not offering, because the artist would believe it worked.
305     */
306    function loadOfferableSurfaces() {
307        var $box = $('#vpSurfaces');
308        if (!$box.length) { return; }
309        if (!window.poppseeLastArtId) {
310            $box.empty();
311            return;
312        }
313        var active = activeIndex >= 0 ? variants[activeIndex] : variants[0];
314        $.ajax({
315            type: 'post',
316            url: (window.ajaxurl || (window.wc_add_to_cart_params || {}).ajax_url),
317            data: {
318                action: 'poppsee_offerable_surfaces',
319                product_id: productId,
320                size_label: active ? (active.size_label || active.label || '') : '',
321                _ajax_nonce: (window.poppseeAjax || {}).nonce || ''
322            },
323            success: function (res) {
324                offerableSurfaces = (res && res.success && res.data) ? res.data : [];
325                renderOfferableSurfaces();
326            },
327            error: function () {
328                // A surface list that cannot be fetched is simply not offered.
329                // The ordinary single-surface publish must never be blocked by
330                // it, so this fails quiet rather than loud.
331                offerableSurfaces = [];
332                renderOfferableSurfaces();
333            }
334        });
335    }
336
337    function renderOfferableSurfaces() {
338        var $box = $('#vpSurfaces');
339        if (!$box.length) { return; }
340        if (!offerableSurfaces.length) {
341            $box.empty();
342            return;
343        }
344        var html = '<div class="vp-surfaces-head">Also publish this design on</div>';
345        html += '<ul class="vp-surface-list">';
346        offerableSurfaces.forEach(function (s, i) {
347            html += '<li class="vp-surface">'
348                 +  '<label><input type="checkbox" class="vp-surface-cb" data-surface="' + s.id + '" data-index="' + i + '"> '
349                 +  escHtml(s.name)
350                 +  ' <span class="vp-surface-sizes">' + s.matching_sizes + ' size'
351                 +  (s.matching_sizes === 1 ? '' : 's') + ' fit</span>'
352                 +  '</label></li>';
353        });
354        html += '</ul>';
355        // Said plainly, because it is the part an artist would otherwise
356        // discover by waiting: each surface is a separate render pass.
357        html += '<div class="vp-surfaces-note">Each one is rendered against its own template, so this takes longer than a single publish.</div>';
358        $box.html(html);
359    }
360
361    /** The surfaces the artist ticked, as the plan module wants them. */
362    function selectedSurfaces() {
363        var out = [];
364        $('#vpSurfaces .vp-surface-cb:checked').each(function () {
365            var s = offerableSurfaces[parseInt($(this).data('index'), 10)];
366            if (s) { out.push(s); }
367        });
368        return out;
369    }
370
371    /* ------------------------------------------------------------------ */
372    /*  Select a variant — resize canvas and load its template image       */
373    /* ------------------------------------------------------------------ */
374    /**
375     * Point the canvas at ONE variation of ONE product and load its template.
376     *
377     * Extracted unchanged from selectVariant() so the same path can be aimed
378     * at another PRODUCT, which is what multi-surface publish needs: the
379     * endpoint is literally called select_different_product_action and has
380     * always taken both ids -- only the caller assumed one product.
381     *
382     * `restoreFrom` is a callback rather than a value so it is evaluated at
383     * the same moment the original code evaluated it, after the response
384     * lands. For an ordinary size switch it returns that size's own saved
385     * design; for a cross-surface render it returns the SOURCE design, which
386     * is how the artwork comes to sit on the new surface's template.
387     *
388     * `onDone` fires once the canvas holds the loaded variation.
389     */
390    function loadVariationIntoCanvas(pid, vid, label, restoreFrom, onDone) {
391        // Load variation into the canvas via existing AJAX endpoint
392        var frmdata = new FormData();
393        frmdata.append('product_id', pid);
394        frmdata.append('variation_id', vid);
395        frmdata.append('action', 'select_different_product_action');
396
397        $.ajax({
398            url: admin_url,
399            method: 'post',
400            processData: false,
401            contentType: false,
402            cache: false,
403            data: frmdata,
404            success: function (response) {
405                try {
406                    var res = (typeof response === 'string') ? JSON.parse(response) : response;
407
408                    // Update the hidden variation_id fields
409                    $('.variation_id').val(vid);
410                    $('#currVariationID').text(vid);
411
412                    // Resize canvas with existing updateBoundingBox logic
413                    var canvasHeight  = res.pallet_height;
414                    var canvasWidth   = res.pallet_width;
415                    var previewW      = res.preview_widthDS;
416                    var previewH      = res.preview_heightDS;
417                    var mwwTW         = res.mww_template_width;
418                    var mwwTH         = res.mww_template_height;
419                    var mwwSW         = res.mww_sewn_product_width;
420                    var mwwSH         = res.mww_sewn_product_height;
421                    var mwwSZW        = res.mww_safeZone_product_width;
422                    var mwwSZH        = res.mww_safeZone_product_height;
423                    var catIdVal      = $('#currCatID').text();
424
425                    // Switching size must also update the PRINT TEMPLATE spans.
426                    // Only changeBaseProduct() ever wrote these, so they kept
427                    // the first-loaded variation's values for the whole
428                    // session. activePreview() sizes #imgcan from them, so
429                    // every later size was composited at the FIRST size's
430                    // template — and when the two differ in ORIENTATION the
431                    // art lands in a wrong-shaped box.
432                    //
433                    // Seen on Area Rugs, whose sizes are both different and
434                    // differently oriented (10'x8' -> 11904x9600 landscape,
435                    // 5'x7' -> 9300x12900 portrait): the saved design was
436                    // exactly right (105.92% wide, 100.9% tall, centred) while
437                    // the published image showed the artwork squashed into the
438                    // top third. Products whose sizes share one template
439                    // (most of the catalogue) were unaffected, which is why
440                    // this stayed invisible.
441                    if (mwwTW) { $('#currTemplateWidth').text(mwwTW); }
442                    if (mwwTH) { $('#currTemplateHeight').text(mwwTH); }
443
444                    // And the SEWN dimensions, which are what actually size the
445                    // capture. activePreview() does:
446                    //
447                    //   let modTemplWidth  = mwwSewnWidth;
448                    //   let modTemplHeight = mwwSewnHeight;
449                    //   if (modTemplWidth == 0 && ...) { modTemplWidth = $('#currTemplateWidth').text(); }
450                    //
451                    // so #currTemplateWidth is only the FALLBACK, used when the
452                    // sewn globals are 0. Those globals were set once for the
453                    // product's default variation and never updated on a size
454                    // switch, so every other size was captured at the default
455                    // size — a landscape 3'x2' rug published as a 9000x12600
456                    // portrait image with the artwork squashed into a corner.
457                    //
458                    // Measured on prod: spans 5550x3750 (correct) while
459                    // mwwSewnWidth/Height were still 9000x12600. Setting them
460                    // to the size's own values produced a correct 5550x3750
461                    // landscape render with full-bleed artwork (product 72982).
462                    //
463                    // Only products whose sizes differ in template are affected
464                    // — 23 of 42 — which is why the first size of each product
465                    // always looked right and the rest did not.
466                    if (mwwSW) { window.mwwSewnWidth  = mwwSW; }
467                    if (mwwSH) { window.mwwSewnHeight = mwwSH; }
468
469                    // Determine shape type
470                    var shapeType = 'square';
471                    if (canvasWidth && canvasHeight) {
472                        var w = parseFloat(canvasWidth);
473                        var h = parseFloat(canvasHeight);
474                        if (w > h) shapeType = 'rectangleH';
475                        else if (h > w) shapeType = 'rectangleV';
476                    }
477
478                    if (typeof updateBoundingBox === 'function') {
479                        updateBoundingBox(canvasWidth, canvasHeight, previewW, previewH, shapeType, catIdVal,
480                            mwwTW, mwwTH, mwwSW, mwwSH, mwwSZW, mwwSZH, vid);
481                    }
482
483                    // Load the variation image as product SVG if available
484                    if (res.imgarray && res.imgarray.length > 0) {
485                        var imgUrl = res.imgarray[0];
486                        var previewBlock = $('.block_img.img0');
487                        if (previewBlock.length) {
488                            previewBlock.find('img').attr('src', imgUrl);
489                        }
490                    }
491
492                    // Restore this size's design: what was SAVED if it has ever
493                    // been saved, otherwise the working canvas it was left in.
494                    var restoreHtml = restoreFrom ? restoreFrom() : '';
495                    if (restoreHtml) {
496                        $('#designcanvas').html(restoreHtml);
497                    }
498
499                    // Handle safe-zone display
500                    if (typeof showSafeAreas === 'function') {
501                        var safeVal = $('#safeZoneSet').length ? $('#safeZoneSet').text() : 'N';
502                        showSafeAreas(safeVal);
503                    }
504
505                    if (label) { showToast('Switched to: ' + label, 'info'); }
506
507                    // The canvas now holds this variation on its own template.
508                    // Called INSIDE the try so a loader that threw never
509                    // reports success to a caller that is about to capture.
510                    if (typeof onDone === 'function') { onDone(true); }
511                } catch (e) {
512                    console.log('[VariantManager] Error parsing variant response', e);
513                    showToast('Error loading variant', 'error');
514                    // A caller waiting on this must hear about it, or a
515                    // cross-surface run would hang on a canvas that never
516                    // loaded.
517                    if (typeof onDone === 'function') { onDone(false); }
518                }
519            },
520            error: function () {
521                showToast('Failed to load variant data', 'error');
522                if (typeof onDone === 'function') { onDone(false); }
523            }
524        });
525    }
526
527    function selectVariant(idx) {
528        if (idx < 0 || idx >= variants.length) return;
529        if (isProcessing) return;
530
531        // A save is still reading the canvas. Wait for it rather than swapping
532        // the canvas underneath it: variantScreenshot() renders whatever
533        // #designcanvas holds at that moment, so switching mid-capture banks
534        // this size's artwork against the one being saved. The artist is not
535        // blocked -- the click is honoured as soon as the capture lands.
536        if (pendingCapture) {
537            var want = idx;
538            pendingCapture.then(function () { selectVariant(want); });
539            return;
540        }
541
542        // Save current design first if we already had one active
543        if (activeIndex >= 0 && activeIndex !== idx) {
544            captureCurrentDesign(activeIndex);
545        }
546
547        activeIndex = idx;
548        var v = variants[idx];
549
550        // UI highlight
551        $('#vpList .vp-item').removeClass('active');
552        $('#vpList .vp-item[data-index="' + idx + '"]').addClass('active');
553        updateStatus(idx, 'Designing...', 'active');
554
555        // Name the size the left-hand tools now affect. Without this the artist
556        // cannot tell whether adding artwork applies to this size, to all of
557        // them, or to nothing -- which is what made the step feel like a dead
558        // end even though editing worked the whole time.
559        setContextLine(
560            'Editing <strong>' + escHtml(v.label) + '</strong> &mdash; use the tools on the left to add '
561            + 'artwork or text, then <strong>Save Current Variant</strong>.'
562        );
563
564        // Enable save button
565        $('#vpSaveCurrent').prop('disabled', false);
566
567        loadVariationIntoCanvas(
568            productId,
569            v.variation_id,
570            v.label,
571            function () { return designHtmlFor(designData[v.variation_id]); },
572            null
573        );
574    }
575
576    /**
577     * Point the panel at the size the canvas is ALREADY showing.
578     *
579     * selectVariant() exists to *change* the canvas, so it re-fetches the
580     * variation and re-runs updateBoundingBox. When the studio has just loaded
581     * a variation itself — editing an existing product, where loadDraft() has
582     * restored the artwork into #designcanvas — that round trip would fight the
583     * restore for no gain. This only updates panel state.
584     */
585    function markVariantActive(idx) {
586        if (idx < 0 || idx >= variants.length) return;
587
588        activeIndex = idx;
589        var v = variants[idx];
590        var $item = $('#vpList .vp-item[data-index="' + idx + '"]');
591
592        $('#vpList .vp-item').removeClass('active');
593        $item.addClass('active');
594        updateStatus(idx, 'Designing...', 'active');
595
596        // Opening a size does NOT tick it. The tick means "saved" now, and a
597        // size that has only been opened has nothing banked against it.
598
599        setContextLine(
600            'Editing <strong>' + escHtml(v.label) + '</strong> &mdash; use the tools on the left to change '
601            + 'the artwork or text, then <strong>Save Current Variant</strong>.'
602        );
603
604        $('#vpSaveCurrent').prop('disabled', false);
605        updateProgress();
606    }
607
608    /**
609     * Which snapshot a size comes back as.
610     *
611     * `savedHtml` is frozen by a save and only a later save replaces it.
612     * `canvasHtml` is the working copy, rewritten every time the artist leaves
613     * the size. Before this existed they were the same field, so moving a
614     * design to suit another size's proportions and coming back showed the
615     * moved version -- the saved one was gone. Chris, 2026-09-23: it must
616     * "always show what was saved when you return".
617     */
618    function designHtmlFor(rec) {
619        if (!rec) return '';
620        return rec.savedHtml || rec.canvasHtml || '';
621    }
622
623    /* ------------------------------------------------------------------ */
624    /*  Capture the current canvas design for the active variant           */
625    /* ------------------------------------------------------------------ */
626    function captureCurrentDesign(idx) {
627        if (idx < 0 || idx >= variants.length) return;
628        var v = variants[idx];
629        var canvas = document.getElementById('designcanvas');
630        if (!canvas) return;
631
632        designData[v.variation_id] = designData[v.variation_id] || {};
633        designData[v.variation_id].canvasHtml = canvas.innerHTML;
634    }
635
636    /* ------------------------------------------------------------------ */
637    /*  Save current variant design (capture screenshot + data)            */
638    /* ------------------------------------------------------------------ */
639    function saveCurrentVariantDesign() {
640        if (activeIndex < 0) return;
641
642        var v = variants[activeIndex];
643        var idx = activeIndex;
644
645        // Freeze the artwork NOW, at the moment the artist pressed save, and
646        // tick the row for it. Everything after this is the screenshot
647        // pipeline, which the artist should not have to watch. Chris,
648        // 2026-09-23: "saving should happen in the background without having
649        // to wait." If the capture fails, markVariantFailed() below takes the
650        // tick back, so an optimistic tick can never outlive a failed save.
651        designData[v.variation_id] = designData[v.variation_id] || {};
652        designData[v.variation_id].savedHtml = document.getElementById('designcanvas').innerHTML;
653        // Count it as saved straight away, so the tick, the progress line and
654        // the Publish button all say the same thing the instant the artist
655        // presses save. A failed capture takes all three back together
656        // (markVariantUnsaved), so they can never disagree.
657        designData[v.variation_id].saved = true;
658        markVariantComplete(idx);
659        updateStatus(idx, 'Saving...', 'active');
660
661        // Use the existing saveasimg flow to generate the thumbnail / additional_data
662        // We wrap it in a try-catch since saveasimg returns a Promise
663        try {
664            // Clear the capture targets BEFORE rendering this size.
665            //
666            // These fields persist across sizes. When a size's preview cannot
667            // be generated (no print template, so buildpsdimg returns
668            // psd_template_missing) they still hold the PREVIOUS size's image,
669            // and the capture below banks that instead of failing — so the
670            // size publishes silently carrying another size's print file.
671            //
672            // Measured on the live catalogue: 36 products across 13 lines
673            // shared a print file with a different size, including Cloth
674            // Napkins 22" and 10", Wall Tapestries 36"x26" and 60"x51", and
675            // Area Rugs 2'x3' and 3'x2' (different orientations). Only the
676            // very first size of a product ever failed loudly, because it had
677            // nothing to inherit.
678            //
679            // Blanking them first makes the existing `if (!thumbVal)` guard
680            // below do its job: refuse the save rather than bank the wrong
681            // artwork. A visible "could not capture" beats a wrong print file.
682            $('#post_thumbnail').val('');
683            $('#additional_data').val('');
684            $('#previewImg').attr('src', '');
685
686            // Trigger the existing design capture pipeline
687            if (typeof productDesign === 'function') {
688                productDesign();
689            }
690
691            // Use variantScreenshot to capture. The promise is parked in
692            // pendingCapture so a size switch can wait for it (selectVariant),
693            // and cleared in both outcomes below so a failure cannot wedge the
694            // panel into queueing every later click forever.
695            if (typeof variantScreenshot === 'function') {
696                pendingCapture = variantScreenshot().then(function () {
697                    // Now capture values from the hidden fields
698                    var thumbVal    = $('#post_thumbnail').val() || $('#previewImg').attr('src') || '';
699                    var addDataVal  = $('#additional_data').val() || $('#previewImg').attr('src') || '';
700                    var styleVal    = $('#custom_style_value').val() || '';
701                    var bgColor     = $('#background_color_val2').length ? $('#background_color_val2').val() : '';
702
703                    // Nothing to save means the capture did not produce anything
704                    // for THIS product. Refuse rather than bank an empty design,
705                    // which would publish a listing with no artwork. Saving
706                    // something wrong quietly is the failure we are removing
707                    // here; failing loudly is the point.
708                    if (!thumbVal) {
709                        // Take the optimistic tick back: this size is NOT saved.
710                        markVariantUnsaved(idx);
711                        updateStatus(idx, 'Not saved', 'error');
712                        showToast('Could not capture the design for ' + v.label + '. Try again.', 'error');
713                        $('#vpSaveCurrent').prop('disabled', false);
714                        return;
715                    }
716
717                    designData[v.variation_id] = designData[v.variation_id] || {};
718                    designData[v.variation_id].post_thumbnail      = thumbVal;
719                    designData[v.variation_id].additional_data      = addDataVal;
720                    designData[v.variation_id].custom_style_value   = styleVal;
721                    designData[v.variation_id].background_color_val2 = bgColor;
722                    designData[v.variation_id].canvasHtml           = document.getElementById('designcanvas').innerHTML;
723                    designData[v.variation_id].saved                = true;
724
725                    // Update thumbnail preview in panel
726                    if (thumbVal) {
727                        $('#vpThumb' + idx).html('<img src="' + thumbVal + '" />');
728                    }
729
730                    pendingCapture = null;
731                    markVariantComplete(idx);
732                    showToast(v.label + ' design saved', 'success');
733                    $('#vpSaveCurrent').prop('disabled', false);
734                }).catch(function () {
735                    pendingCapture = null;
736                    markVariantUnsaved(idx);
737                    updateStatus(idx, 'Save failed', 'error');
738                    showToast('Failed to capture design', 'error');
739                    $('#vpSaveCurrent').prop('disabled', false);
740                });
741            } else {
742                // Fallback: just capture the raw canvas HTML
743                var canvas = document.getElementById('designcanvas');
744                designData[v.variation_id] = designData[v.variation_id] || {};
745                designData[v.variation_id].canvasHtml        = canvas ? canvas.innerHTML : '';
746                designData[v.variation_id].post_thumbnail    = $('#previewImg').attr('src') || '';
747                designData[v.variation_id].additional_data   = $('#previewImg').attr('src') || '';
748                designData[v.variation_id].custom_style_value = '';
749                designData[v.variation_id].background_color_val2 = '';
750                designData[v.variation_id].saved             = true;
751                markVariantComplete(idx);
752                showToast(v.label + ' design saved (basic)', 'success');
753                $('#vpSaveCurrent').prop('disabled', false);
754            }
755        } catch (e) {
756            console.log('[VariantManager] Error saving variant design', e);
757            pendingCapture = null;
758            markVariantUnsaved(idx);
759            updateStatus(idx, 'Save failed', 'error');
760            showToast('Error saving variant design', 'error');
761            $('#vpSaveCurrent').prop('disabled', false);
762        }
763    }
764
765    /* ------------------------------------------------------------------ */
766    /*  Un-tick — the save the tick promised did not land                  */
767    /* ------------------------------------------------------------------ */
768    function markVariantUnsaved(idx) {
769        if (idx < 0 || idx >= variants.length) return;
770        var vid = variants[idx].variation_id;
771        delete selectedIds[vid];
772        if (designData[vid]) {
773            designData[vid].saved = false;
774            delete designData[vid].savedHtml;
775        }
776        if (lastSavedVid === vid) lastSavedVid = null;
777        $('#vpList .vp-item[data-index="' + idx + '"]').removeClass('selected completed');
778        updateProgress();
779    }
780
781    /* ------------------------------------------------------------------ */
782    /*  Mark variant complete / update progress                            */
783    /* ------------------------------------------------------------------ */
784    function markVariantComplete(idx) {
785        var $item = $('#vpList .vp-item[data-index="' + idx + '"]');
786        $item.addClass('completed').removeClass('failed');
787        // The tick IS the save. Nothing else sets it and nothing but a failed
788        // capture takes it back, so what an artist sees ticked is exactly what
789        // publishing will send.
790        lastSavedVid = variants[idx].variation_id;
791        selectedIds[lastSavedVid] = true;
792        $item.addClass('selected');
793        updateStatus(idx, 'Complete', 'done');
794        updateProgress();
795    }
796
797    function markVariantFailed(idx, msg) {
798        var $item = $('#vpList .vp-item[data-index="' + idx + '"]');
799        $item.addClass('failed').removeClass('completed');
800        updateStatus(idx, msg || 'Failed', 'error');
801
802        // Add retry button
803        var $status = $('#vpStatus' + idx);
804        if (!$status.find('.vp-retry').length) {
805            $status.after('<button class="vp-retry">Retry</button>');
806        }
807        updateProgress();
808    }
809
810    function updateStatus(idx, text, cls) {
811        var $el = $('#vpStatus' + idx);
812        $el.text(text).removeClass('pending active done error').addClass(cls);
813    }
814
815    function updateProgress() {
816        var designed = 0, selected = 0;
817        for (var i = 0; i < variants.length; i++) {
818            var vid = variants[i].variation_id;
819            if (designData[vid] && designData[vid].saved) designed++;
820            if (selectedIds[vid]) selected++;
821        }
822        var pct = variants.length > 0 ? Math.round((designed / variants.length) * 100) : 0;
823        $('#vpProgressFill').css('width', pct + '%');
824        $('#vpProgressText').text(designed + ' of ' + variants.length + ' saved');
825
826        // Publishing sends the saved sizes, so one save is the whole gate.
827        var canSubmit = designed >= 1;
828        $('#vpSubmitAll').prop('disabled', !canSubmit);
829        var hint = '';
830        if (!canSubmit) {
831            hint = 'Save a size ("Save Current Variant") to publish it';
832        }
833        $('#vpSubmitHint').text(hint).toggle(hint !== '');
834    }
835
836    /* ------------------------------------------------------------------ */
837    /*  Submit — enqueue a background job and close immediately            */
838    /* ------------------------------------------------------------------ */
839
840    // Refresh the AJAX nonce (long studio sessions outlive the original one).
841    function refreshNonce() {
842        return $.post(admin_url, { action: 'poppsee_refresh_nonce' }).then(function (res) {
843            if (res && res.success && res.data && res.data.nonce) {
844                window.poppseeSec = window.poppseeSec || {};
845                poppseeSec.nonce = res.data.nonce;
846                return res.data.nonce;
847            }
848            return null;
849        });
850    }
851
852    // POST the payload; on a 403 (stale nonce) refresh the token and retry once.
853    function postSubmission(payload, onDone, onFail, retried) {
854        payload.nonce = (window.poppseeSec && poppseeSec.nonce) || '';
855        $.ajax({
856            url: admin_url,
857            type: 'POST',
858            data: payload,
859            dataType: 'json',
860            timeout: 30000,
861            success: function (res) {
862                if (res && res.success) onDone(res);
863                else onFail(res && res.data && res.data.message ? res.data.message : 'Submission failed');
864            },
865            error: function (xhr, status, err) {
866                if (xhr && xhr.status === 403 && !retried) {
867                    refreshNonce().then(function (nonce) {
868                        if (nonce) postSubmission(payload, onDone, onFail, true);
869                        else onFail('Your session expired — please reload the page and try again.');
870                    });
871                    return;
872                }
873                // PHP drops $_POST wholesale when the body exceeds
874                // post_max_size, so admin-ajax answers a bare "0" with HTTP
875                // 400/413 and jQuery reports a JSON parse error. Surfacing
876                // that as "Network error" told the artist nothing — name the
877                // real problem instead.
878                if (xhr && (xhr.status === 413 || (xhr.status === 400 && String(xhr.responseText).trim() === '0'))) {
879                    onFail('That submission was too large for the server to accept. '
880                         + 'Submit fewer sizes at once, or re-save your design and try again.');
881                    return;
882                }
883                if (status === 'timeout') {
884                    onFail('The server took too long to respond. Your designs may still be processing — '
885                         + 'check your dashboard notifications before resubmitting.');
886                    return;
887                }
888                onFail('Network error: ' + (err || status));
889            }
890        });
891    }
892
893    // Upload the heavy design blobs ONE PER REQUEST and collect their keys.
894    // Doing this before the submit keeps every request small and constant, so
895    // the body can't cross post_max_size no matter how many designs the artist
896    // used. Sequential on purpose: parallel uploads of multi-MB base64 blobs
897    // just trade one limit for another.
898    // Slice size. The server's hard ceiling is post_max_size (8M) and it applies
899    // to the WHOLE encoded body, so this leaves generous room for the other
900    // fields plus form-encoding overhead (base64 roughly doubles once escaped).
901    // Pieces are cut from the base64 body, so this must stay a multiple of 4.
902    var CHUNK_CHARS = 1500000;
903
904    function randomStageId() {
905        var abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', s = '';
906        for (var i = 0; i < 20; i++) { s += abc.charAt(Math.floor(Math.random() * abc.length)); }
907        return s;
908    }
909
910    // Base64 of the UTF-8 bytes. The design travels encoded because prod's
911    // MalCare firewall reads canvas_html as an attack: the studio's own
912    // onmouseleave= handlers 403'd Lisa's second piece on every Publish
913    // (2026-09-16). Base64 carries no markup, spaces or SQL, so no rule can
914    // match. See tests/stage-chunks-survive-the-firewall.test.js.
915    function utf8ToBase64(str) {
916        var bytes = new TextEncoder().encode(str);
917        var out = '';
918        for (var i = 0; i < bytes.length; i += 0x8000) {
919            out += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
920        }
921        return btoa(out);
922    }
923
924    // Upload the designs one at a time, and each design in slices. A single
925    // high-resolution design can exceed post_max_size on its own, so slicing —
926    // not just separating — is what removes the size ceiling entirely.
927    function stageDesigns(designs, onReady, onFail) {
928        var keys = [];
929
930        function uploadDesign(i, done, fail) {
931            var body    = utf8ToBase64(JSON.stringify(designs[i]));
932            var total   = Math.max(1, Math.ceil(body.length / CHUNK_CHARS));
933            var stageId = randomStageId();
934
935            function sendChunk(idx) {
936                var label = 'Uploading design ' + (i + 1) + ' of ' + designs.length
937                          + (total > 1 ? ' (' + (idx + 1) + '/' + total + ')' : '') + '…';
938                $('#vpSubmitAll').text(label);
939                $.ajax({
940                    url: admin_url,
941                    type: 'POST',
942                    dataType: 'json',
943                    timeout: 120000,
944                    data: {
945                        action:      'poppsee_stage_variant_design',
946                        nonce:       (window.poppseeSec && poppseeSec.nonce) || '',
947                        stage_id:    stageId,
948                        chunk_index: idx,
949                        chunk_total: total,
950                        enc:         'b64',
951                        chunk:       body.substr(idx * CHUNK_CHARS, CHUNK_CHARS)
952                    },
953                    success: function (res) {
954                        if (!res || !res.success) {
955                            fail((res && res.data && res.data.message) ? res.data.message
956                                 : 'Could not upload design ' + (i + 1));
957                            return;
958                        }
959                        if (idx < total - 1) { sendChunk(idx + 1); return; }
960                        if (res.data && res.data.design_key) { done(res.data.design_key); }
961                        else { fail('The server did not confirm design ' + (i + 1)); }
962                    },
963                    error: function (xhr, status) {
964                        fail('Could not upload design ' + (i + 1)
965                             + (status ? ' (' + status + ')' : '') + '. Please try again.');
966                    }
967                });
968            }
969            sendChunk(0);
970        }
971
972        function next(i) {
973            if (i >= designs.length) { onReady(keys); return; }
974            uploadDesign(i, function (key) { keys.push(key); next(i + 1); }, onFail);
975        }
976        next(0);
977    }
978
979    function submitAllVariants() {
980        if (isProcessing) return;
981
982        // Use locked init title — panel field is read-only
983        var title = productTitle
984                 || $('#TemplateForm input[name="title"]').val()
985                 || 'Untitled Product';
986        var desc  = $('#TemplateForm textarea[name="content"]').val() || '';
987
988        // Build the payload from the SAVED sizes. Until 2026-09-23 the tick
989        // was a control, so a ticked size with no design of its own published
990        // using a "donor" — the active size's save, else the most recent one.
991        // The tick now reports a save instead, which makes that borrowing both
992        // unreachable and undesirable: every published size carries the
993        // artwork the artist actually approved at that size's proportions.
994        // Each row still carries ALL same-size variation ids, so every
995        // colour/frame combination of that size goes with it.
996
997        // De-duplicate heavy design data. Multiple checked sizes reusing the
998        // same design would otherwise embed the SAME base64 screenshot once
999        // PER size and blow past the server's post_max_size (8M): PHP then
1000        // silently drops $_POST and admin-ajax answers "0" with HTTP 400.
1001        // Send each unique design ONCE in `designs`; each size references it
1002        // by index. Server rehydrates before queuing, so the job is unchanged.
1003        var designs = [];
1004        var designIndexByVid = {};
1005        function internDesign(srcVid, d) {
1006            if (designIndexByVid[srcVid] !== undefined) { return designIndexByVid[srcVid]; }
1007            var idx = designs.length;
1008            designs.push({
1009                post_thumbnail:        d.post_thumbnail || '',
1010                additional_data:       d.additional_data || '',
1011                custom_style_value:    d.custom_style_value || '',
1012                background_color_val2: d.background_color_val2 || '',
1013                // Hidden layers must never reach the server: the job queue
1014                // rehydrates canvas_html, and display:none is only a client
1015                // rendering fact. Strip from this COPY only — the live canvas
1016                // and drafts keep hidden layers so the artist gets them back.
1017                canvas_html:           (window.LayersPanel
1018                                          ? window.LayersPanel.stripHidden(d.canvasHtml || '')
1019                                          : (d.canvasHtml || ''))
1020            });
1021            designIndexByVid[srcVid] = idx;
1022            return idx;
1023        }
1024
1025        var variationsPayload = [];
1026        for (var i = 0; i < variants.length; i++) {
1027            var v = variants[i];
1028            var own = designData[v.variation_id];
1029            if (!own || !own.saved) {
1030                continue;
1031            }
1032            variationsPayload.push({
1033                variation_id:  v.variation_id,
1034                variation_ids: v.variation_ids || [v.variation_id],
1035                size_label:    v.size_label || v.label || '',
1036                design_index:  internDesign(v.variation_id, own)
1037            });
1038        }
1039
1040        if (variationsPayload.length === 0) {
1041            showToast('Save at least one size before publishing.', 'error');
1042            return;
1043        }
1044
1045        if (!confirm('Publish ' + variationsPayload.length + ' saved size(s)?\n\nTitle: ' + title)) {
1046            return;
1047        }
1048
1049        isProcessing = true;
1050        $('#vpSubmitAll').prop('disabled', true).text('Uploading designs…');
1051        showToast('Uploading your designs…', 'info');
1052
1053        function finish(msg, ok, res) {
1054            isProcessing = false;
1055            $('#vpSubmitAll').prop('disabled', false).text(SUBMIT_LABEL);
1056            if (ok) {
1057                for (var j = 0; j < variationsPayload.length; j++) {
1058                    var matchIdx = findVariantIndex(variationsPayload[j].variation_id);
1059                    if (matchIdx >= 0) updateStatus(matchIdx, 'Queued', 'done');
1060                }
1061                closePanel();
1062                showToast((res && res.data && res.data.message)
1063                    ? res.data.message
1064                    : 'Designs queued! You\'ll get a notification when they\'re done.', 'success');
1065            } else {
1066                openPanel();
1067                showToast(msg, 'error');
1068                updateProgress();
1069            }
1070        }
1071
1072        // Stage the heavy blobs first, then submit a payload that carries only
1073        // their keys. The panel stays open through the upload so the artist can
1074        // see progress — the old code closed instantly and then had to re-open
1075        // itself on failure.
1076        stageDesigns(designs, function (designKeys) {
1077            $('#vpSubmitAll').text('Submitting…');
1078            postSubmission({
1079                action:      'submit_artist_product_variations',
1080                user_id:     userId,
1081                cat_id:      catId,
1082                post_id:     postId,
1083                title:       title,
1084                description: desc,
1085                product_id:  productId,
1086            // Best effort: present for a design built in this session, absent
1087            // for one loaded from a draft. Absent simply means no surface picker.
1088            artwork_id:  (window.poppseeLastArtId || ''),
1089                variations:  JSON.stringify(variationsPayload),
1090                design_keys: JSON.stringify(designKeys)
1091            }, function (res) {
1092                finish('', true, res);
1093            }, function (msg) {
1094                finish(msg, false);
1095            });
1096        }, function (msg) {
1097            finish(msg, false);
1098        });
1099    }
1100
1101    /* ------------------------------------------------------------------ */
1102    /*  Retry a single failed variant                                      */
1103    /* ------------------------------------------------------------------ */
1104    function retryVariant(idx) {
1105        if (isProcessing) return;
1106        var v = variants[idx];
1107        var d = designData[v.variation_id];
1108
1109        if (!d || !d.saved) {
1110            showToast('Design this variant first before retrying.', 'error');
1111            return;
1112        }
1113
1114        updateStatus(idx, 'Retrying...', 'active');
1115        $('#vpList .vp-item[data-index="' + idx + '"]').removeClass('failed');
1116        $('#vpList .vp-item[data-index="' + idx + '"]').find('.vp-retry').remove();
1117
1118        var title = productTitle
1119                 || $('#TemplateForm input[name="title"]').val()
1120                 || 'Untitled Product';
1121        var desc  = $('#TemplateForm textarea[name="content"]').val() || '';
1122
1123        var singlePayload = [{
1124            variation_id:          v.variation_id,
1125            variation_ids:         v.variation_ids || [v.variation_id],
1126            size_label:            v.size_label || v.label || '',
1127            post_thumbnail:        d.post_thumbnail || '',
1128            additional_data:       d.additional_data || '',
1129            custom_style_value:    d.custom_style_value || '',
1130            background_color_val2: d.background_color_val2 || '',
1131            // Hidden layers must never reach the server: the job queue
1132            // rehydrates canvas_html, and display:none is only a client
1133            // rendering fact. Strip from this COPY only — the live canvas
1134            // and drafts keep hidden layers so the artist gets them back.
1135            canvas_html:           (window.LayersPanel
1136                                      ? window.LayersPanel.stripHidden(d.canvasHtml || '')
1137                                      : (d.canvasHtml || ''))
1138        }];
1139
1140        postSubmission({
1141            action:      'submit_artist_product_variations',
1142            user_id:     userId,
1143            cat_id:      catId,
1144            post_id:     postId,
1145            title:       title,
1146            description: desc,
1147            product_id:  productId,
1148            // Best effort: present for a design built in this session, absent
1149            // for one loaded from a draft. Absent simply means no surface picker.
1150            artwork_id:  (window.poppseeLastArtId || ''),
1151            variations:  JSON.stringify(singlePayload)
1152        }, function () {
1153            updateStatus(idx, 'Queued', 'done');
1154            showToast(v.label + ' re-queued — you\'ll be notified when it\'s done.', 'success');
1155        }, function (msg) {
1156            markVariantFailed(idx, msg || 'Server error');
1157            showToast(v.label + ' retry failed.', 'error');
1158        });
1159    }
1160
1161    /* ------------------------------------------------------------------ */
1162    /*  Helpers                                                            */
1163    /* ------------------------------------------------------------------ */
1164    // A row is ONE size, standing in for every same-size color/frame
1165    // combination (see get_product_variations_for_panel). `variation_id` is
1166    // only the representative of that group, so an id handed to us from
1167    // elsewhere in the studio — e.g. the variation an artist is editing — is
1168    // just as likely to be a non-representative member. Match on the whole
1169    // group or the lookup silently misses on any product with combinations.
1170    function findVariantIndex(variationId) {
1171        var needle = String(variationId);
1172        for (var i = 0; i < variants.length; i++) {
1173            if (String(variants[i].variation_id) === needle) return i;
1174            var ids = variants[i].variation_ids || [];
1175            for (var j = 0; j < ids.length; j++) {
1176                if (String(ids[j]) === needle) return i;
1177            }
1178        }
1179        return -1;
1180    }
1181
1182    function escHtml(str) {
1183        if (!str) return '';
1184        return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1185    }
1186
1187    function showToast(msg, type) {
1188        var $toast = $('#vpToast');
1189        if (!$toast.length) {
1190            $('body').append('<div class="vp-toast" id="vpToast"></div>');
1191            $toast = $('#vpToast');
1192        }
1193        $toast.text(msg).removeClass('show error success').addClass(type || '').addClass('show');
1194        clearTimeout($toast.data('timer'));
1195        $toast.data('timer', setTimeout(function () {
1196            $toast.removeClass('show');
1197        }, 4500));
1198    }
1199
1200    function showProcessingOverlay(show) {
1201        var $ov = $('#vpProcessingOverlay');
1202        if (!$ov.length) {
1203            $('body').append(
1204                '<div class="vp-processing-overlay" id="vpProcessingOverlay">' +
1205                '  <div class="vp-processing-box">' +
1206                '    <div class="vp-spinner"></div>' +
1207                '    <h4>Processing Variants</h4>' +
1208                '    <p>Submitting your designs to the server. You will be notified by email when complete.</p>' +
1209                '  </div>' +
1210                '</div>'
1211            );
1212            $ov = $('#vpProcessingOverlay');
1213        }
1214        if (show) $ov.addClass('show');
1215        else $ov.removeClass('show');
1216    }
1217
1218    /* ------------------------------------------------------------------ */
1219    /*  Public API                                                         */
1220    /* ------------------------------------------------------------------ */
1221    return {
1222        init:            init,
1223        openPanel:       openPanel,
1224        closePanel:      closePanel,
1225        togglePanel:     togglePanel,
1226        selectVariant:   selectVariant,
1227        getDesignData:   function () { return designData; },
1228        getVariants:     function () { return variants; },
1229        getActiveIndex:  function () { return activeIndex; },
1230        isOpen:          function () { return panelOpen; }
1231    };
1232
1233})(jQuery);

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.