PageSourceSearch

https://www.eventeny.com/js/Services/BatchScroller.js?v=1790189618

js eventeny.com collected 2026-09-24 08:56:16 UTC 12,778 bytes, 283 lines download raw bytes

1/**
2 * @file BatchScroller.js
3 * @description Batched infinite scroll for the ticket/add-on card grids
4 * (dashboard/events/event/tickets/ticket/index.php). Rendering every ticket/add-on eagerly
5 * used to instantiate thousands of web components at once and reliably crashed the page on
6 * mobile (out-of-memory). This keeps the UX as continuous infinite scroll while guaranteeing
7 * only a bounded number of <application-card-layout> instances ever exist in the DOM at once.
8 *
9 * Cards are tracked in fixed-size batches (not individually) -- a handful of tracked
10 * boundaries instead of hundreds of individually-observed cards. At most 2 batches are ever
11 * mounted; every other batch is represented by a single placeholder <div> spanning the full
12 * grid width (grid-column:1/-1, so this holds regardless of column count -- single-column
13 * add-on grid or multi-column auto-fill ticket grid), sized to that batch's actual
14 * last-measured height (not estimated), so scroll position and page height stay stable. A
15 * single shared IntersectionObserver (rootMargin 800px, double the 400px already used by
16 * ApplicationCardLayout's cover-image observer, since building a batch is costlier than
17 * fetching one image) watches both the trailing "load more" sentinel and every currently-
18 * unmounted batch's placeholder; mounting a batch always re-enforces the 2-batch cap,
19 * demoting whichever mounted batch is now furthest away.
20 *
21 * pause()/resume() force-mount (then re-window) every batch, used around a drag-reorder
22 * session (see the card-drag-start/card-drag-end events dispatched by DraggableCardList.js)
23 * so the drag's sibling-position math always sees the complete, real list. resume() rebuilds
24 * batches from the DOM's current order rather than the pre-pause grouping, since the drag may
25 * have moved cards across old batch boundaries.
26 *
27 * Loaded as a plain classic script (includeJS), not an ES module -- its consumer,
28 * tickets-ticket-2018-06-27.js, is itself a classic script and can't use `import`. Depends on
29 * buildApplicationCard() from CardBuilder.js (must be included first).
30 */
31
32class BatchScroller {
33    /**
34     * @param {Object} opts
35     * @param {HTMLElement} opts.containerEl - the <card-list> (manage mode) or plain
36     *   <div class="not-card-list"> (view-only mode) holding the cards.
37     * @param {'ticket'|'addon'} opts.cardType
38     * @param {boolean} opts.canManage
39     * @param {number} opts.batchSize
40     * @param {Object[]} opts.data - ordered array of card data (ticketCardData/addonCardData,
41     *   already filtered to whatever subset should currently be shown).
42     * @param {(el: HTMLElement) => void} [opts.onCardBuilt] - called once per newly-built card
43     *   element, before it's inserted -- used by the page script to apply current
44     *   selected/other transient state before the card is visible.
45     */
46    constructor(opts) {
47        this.containerEl = opts.containerEl;
48        this.cardType = opts.cardType;
49        this.canManage = opts.canManage;
50        this.batchSize = opts.batchSize;
51        this.onCardBuilt = opts.onCardBuilt || function () {};
52        this.cardListEl = this.containerEl.tagName === 'CARD-LIST' ? this.containerEl : null;
53
54        this.paused = false;
55        this.mountedIndices = new Set();
56        this._observer = null;
57
58        this._buildSentinel();
59        this.setData(opts.data, { adoptExisting: true });
60
61        if (this.cardListEl) {
62            this.cardListEl.addEventListener('card-drag-start', () => this.pause());
63            this.cardListEl.addEventListener('card-drag-end', () => this.resume());
64        }
65    }
66
67    /**
68     * Replaces the working dataset (e.g. after a filter/sort change) and restarts batching
69     * from the top. Pass { adoptExisting: true } only on construction, when batch 0's cards
70     * may already be present in the DOM from server-side rendering.
71     */
72    setData(data, { adoptExisting = false } = {}) {
73        this._teardownBatches();
74        this.data = data;
75        this.batches = [];
76        for (let i = 0; i < data.length; i += this.batchSize) {
77            this.batches.push({
78                index: this.batches.length,
79                items: data.slice(i, i + this.batchSize),
80                mounted: false,
81                elements: [],
82                placeholderEl: null,
83            });
84        }
85        this.mountedIndices = new Set();
86
87        if (adoptExisting) {
88            const existing = Array.from(
89                this.containerEl.querySelectorAll('application-card-layout[slot="application-card"]')
90            );
91            if (existing.length > 0 && this.batches.length > 0) {
92                this.batches[0].mounted = true;
93                this.batches[0].elements = existing;
94                this.mountedIndices.add(0);
95            }
96        } else {
97            // Filter/sort changed: nothing from the previous dataset is trustworthy to keep
98            // mounted (ids/order may differ), so start clean and scroll back to the top.
99            this.containerEl.querySelectorAll('application-card-layout[slot="application-card"]').forEach((el) => el.remove());
100            this.containerEl.querySelectorAll('.batch-scroller-placeholder').forEach((el) => el.remove());
101            if (this.batches.length > 0) this._mount(0);
102        }
103
104        this._repositionSentinel();
105        this._observeSentinel();
106    }
107
108    /** Total number of cards currently represented (mounted or not). */
109    get length() {
110        return this.data.length;
111    }
112
113    /**
114     * Force-mounts every batch (e.g. for a drag-reorder session that needs the complete,
115     * real sibling list) and suspends the 2-batch cap until resume().
116     */
117    pause() {
118        if (this.paused) return;
119        this.paused = true;
120        this.batches.forEach((batch) => this._mount(batch.index, { skipEnforce: true }));
121    }
122
123    /** Re-enables the 2-batch cap and prunes back down to it around the last-focused batch. */
124    resume() {
125        if (!this.paused) return;
126        this.paused = false;
127
128        // Every card is currently a real, mounted element (pause() mounted them all).
129        // Whatever ran while paused (drag, revert) may have moved cards across what used to
130        // be batch boundaries, so rebuild batches from their actual current DOM order instead
131        // of trusting the pre-pause grouping.
132        const orderedEls = [...this.containerEl.querySelectorAll('application-card-layout[slot="application-card"]')];
133        const byId = new Map(this.data.map((item) => [String(item.id), item]));
134        this.data = orderedEls.map((el) => byId.get(el.getAttribute('application-id'))).filter(Boolean);
135
136        this.batches = [];
137        for (let i = 0; i < orderedEls.length; i += this.batchSize) {
138            this.batches.push({
139                index: this.batches.length,
140                items: this.data.slice(i, i + this.batchSize),
141                mounted: true,
142                elements: orderedEls.slice(i, i + this.batchSize),
143                placeholderEl: null,
144            });
145        }
146        this.mountedIndices = new Set(this.batches.map((b) => b.index));
147
148        this._enforceWindow(0);
149        // A full reorder re-appends every card, which leaves the sentinel behind wherever it
150        // happened to sit -- it must stay the last child, both as the "load more" trigger and
151        // as the insertion anchor for future batch mounts.
152        this._repositionSentinel();
153    }
154
155    _buildSentinel() {
156        this.sentinel = document.createElement('div');
157        this.sentinel.className = 'batch-scroller-sentinel';
158        this.sentinel.setAttribute('slot', 'application-card');
159        this.sentinel.style.cssText = 'grid-column:1/-1;height:1px;pointer-events:none;';
160    }
161
162    _repositionSentinel() {
163        this.containerEl.appendChild(this.sentinel);
164    }
165
166    _getObserver() {
167        if (!this._observer) {
168            this._observer = new IntersectionObserver((entries) => {
169                entries.forEach((entry) => {
170                    if (!entry.isIntersecting) return;
171                    if (entry.target === this.sentinel) {
172                        this._loadNext();
173                    } else {
174                        const idx = Number(entry.target.dataset.batchIndex);
175                        this._observer.unobserve(entry.target);
176                        this._mount(idx);
177                    }
178                });
179            }, { rootMargin: '800px 0px' });
180        }
181        return this._observer;
182    }
183
184    _observeSentinel() {
185        this._getObserver().observe(this.sentinel);
186    }
187
188    // Advances from the highest batch ever reached, not "first unmounted by array order" --
189    // a demoted earlier batch is unmounted too, and picking that one up here would re-insert
190    // it at its old spot near the top instead of loading new content at the bottom.
191    _loadNext() {
192        const highestMounted = this.mountedIndices.size ? Math.max(...this.mountedIndices) : -1;
193        const next = this.batches[highestMounted + 1];
194        if (next && !next.mounted) this._mount(next.index);
195    }
196
197    _mount(index, { skipEnforce = false } = {}) {
198        const batch = this.batches[index];
199        if (!batch) return;
200
201        if (!batch.mounted) {
202            const observer = this._getObserver();
203            if (batch.placeholderEl) observer.unobserve(batch.placeholderEl);
204
205            const cards = batch.items.map((data) => buildApplicationCard(data, this.cardType, this.canManage));
206
207            const anchor = batch.placeholderEl || this.sentinel;
208            cards.forEach((card) => this.containerEl.insertBefore(card, anchor));
209            if (batch.placeholderEl) {
210                batch.placeholderEl.remove();
211                batch.placeholderEl = null;
212            }
213
214            // Only safe to touch component state (e.g. onCardBuilt setting `.selected`) after
215            // insertion, since connectedCallback (which builds the internal shadow-DOM nodes
216            // those setters reach into) runs synchronously on insert.
217            cards.forEach((card) => this.onCardBuilt(card));
218
219            batch.elements = cards;
220            batch.mounted = true;
221
222            // customElements.whenDefined guards against a real race: if the page reloads
223            // already scrolled down, the IntersectionObserver's mandatory initial notification
224            // can fire before <card-list> has upgraded to DraggableCardList, so
225            // registerApplicationCards wouldn't exist on it yet -- same pattern
226            // DraggableCardList.js itself uses for application-card-layout.
227            if (this.cardListEl) {
228                customElements.whenDefined('card-list').then(() => this.cardListEl.registerApplicationCards(cards));
229            }
230        }
231
232        this.mountedIndices.add(index);
233        if (!skipEnforce && !this.paused) this._enforceWindow(index);
234    }
235
236    // Keeps only the 2 mounted batches closest to focusIndex (including focusIndex itself),
237    // demoting the rest -- this is what actually caps DOM size regardless of total card count.
238    _enforceWindow(focusIndex) {
239        const mounted = [...this.mountedIndices].sort(
240            (a, b) => Math.abs(a - focusIndex) - Math.abs(b - focusIndex)
241        );
242        const keep = new Set(mounted.slice(0, 2));
243        mounted.forEach((idx) => {
244            if (!keep.has(idx)) this._demote(idx);
245        });
246        this.mountedIndices = keep;
247    }
248
249    _demote(index) {
250        const batch = this.batches[index];
251        if (!batch || !batch.mounted || batch.elements.length === 0) return;
252
253        const first = batch.elements[0];
254        const last = batch.elements[batch.elements.length - 1];
255        const top = first.getBoundingClientRect().top;
256        const bottom = last.getBoundingClientRect().bottom;
257        const height = Math.max(1, Math.round(bottom - top));
258
259        const placeholder = document.createElement('div');
260        placeholder.className = 'batch-scroller-placeholder';
261        placeholder.dataset.batchIndex = String(index);
262        placeholder.setAttribute('slot', 'application-card');
263        placeholder.style.cssText = `grid-column:1/-1;height:${height}px;`;
264
265        this.containerEl.insertBefore(placeholder, first);
266        batch.elements.forEach((el) => el.remove());
267
268        batch.elements = [];
269        batch.mounted = false;
270        batch.placeholderEl = placeholder;
271
272        this._getObserver().observe(placeholder);
273    }
274
275    _teardownBatches() {
276        if (!this.batches) return;
277        if (this._observer) {
278            this._observer.disconnect();
279            this._observer = null;
280        }
281        this.containerEl.querySelectorAll('.batch-scroller-placeholder').forEach((el) => el.remove());
282    }
283}

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.