@internetarchive/collection-browser 4.3.2-rc-webdev-8334.2 → 4.3.2-rc-webdev-8334.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -421,7 +421,7 @@ export class CollectionBrowser
421
421
  const model = this.dataSource.getTileModelAt(index);
422
422
  /**
423
423
  * If we encounter a model we don't have yet and we're not in the middle of an
424
- * automated scroll, fetch the page and just return undefined.
424
+ * automated scroll, schedule a fetch for the missing page and return undefined.
425
425
  * The datasource will be updated once the page is loaded and the cell will be rendered.
426
426
  *
427
427
  * We disable it during the automated scroll since we don't want to fetch pages for intervening cells the
@@ -429,11 +429,61 @@ export class CollectionBrowser
429
429
  */
430
430
  if (!model && !this.isScrollingToCell && this.dataSource.queryInitialized) {
431
431
  const pageNumber = Math.floor(index / this.pageSize) + 1;
432
- this.dataSource.fetchPage(pageNumber);
432
+ this.scheduleDeferredPageFetch(pageNumber);
433
433
  }
434
434
  return model;
435
435
  }
436
436
 
437
+ /**
438
+ * Debounce delay for page fetches initiated by new cells becoming visible.
439
+ * Tuned so quick scrolling through unloaded regions doesn't send rapid-fire
440
+ * search requests for every page we pass through, but to still feel responsive
441
+ * when the scroll ends.
442
+ */
443
+ private static readonly DEFERRED_FETCH_DELAY_MS = 150;
444
+
445
+ private deferredFetchTimer = 0;
446
+
447
+ /**
448
+ * Schedules a fetch for the given page, debounced to ensure we don't
449
+ * rapid-fire fetches while scrolling through pages quickly.
450
+ *
451
+ * If there's no pending fetch timer yet, it will fire a fetch immediately.
452
+ * Otherwise, it will reset any existing timer. In either case, a deferred
453
+ * fetch for the visible pages is scheduled after a brief delay to account
454
+ * for whatever pages we land on after scrolling.
455
+ */
456
+ private scheduleDeferredPageFetch(pageNumber: number): void {
457
+ if (!this.deferredFetchTimer) {
458
+ this.dataSource.fetchPage(pageNumber);
459
+ } else {
460
+ window.clearTimeout(this.deferredFetchTimer);
461
+ }
462
+
463
+ this.deferredFetchTimer = window.setTimeout(() => {
464
+ this.deferredFetchTimer = 0;
465
+ this.fetchVisiblePages();
466
+ }, CollectionBrowser.DEFERRED_FETCH_DELAY_MS);
467
+ }
468
+
469
+ /**
470
+ * Fetch each currently-visible page whose first cell still has no
471
+ * loaded model.
472
+ */
473
+ private fetchVisiblePages(): void {
474
+ const visibleIndices = this.infiniteScroller?.getVisibleCellIndices() ?? [];
475
+ const visiblePages = new Set(
476
+ visibleIndices.map(i => Math.floor(i / this.pageSize) + 1),
477
+ );
478
+
479
+ for (const page of visiblePages) {
480
+ const firstCellOfPage = (page - 1) * this.pageSize;
481
+ if (!this.dataSource.getTileModelAt(firstCellOfPage)) {
482
+ this.dataSource.fetchPage(page);
483
+ }
484
+ }
485
+ }
486
+
437
487
  // this is the total number of tiles we expect if
438
488
  // the data returned is a full page worth
439
489
  // this is useful for putting in placeholders for the expected number of tiles
@@ -866,6 +916,8 @@ export class CollectionBrowser
866
916
  class=${this.infiniteScrollerClasses}
867
917
  itemCount=${this.placeholderType ? 0 : nothing}
868
918
  ariaLandmarkLabel="Search results"
919
+ .estimatedCellHeight=${this.estimatedTileHeight}
920
+ .minBufferMarginCells=${this.pageSize}
869
921
  .cellProvider=${this}
870
922
  .placeholderCellTemplate=${this.placeholderCellTemplate}
871
923
  @scrollThresholdReached=${this.scrollThresholdReached}
@@ -887,6 +939,25 @@ export class CollectionBrowser
887
939
  });
888
940
  }
889
941
 
942
+ /**
943
+ * Best-effort hint of how tall a single rendered tile is, by display mode.
944
+ * The scroller uses this to better estimate the size its initial scroll
945
+ * spacer and buffer position before real cell heights are measured.
946
+ * Should roughly match the placeholder heights since the initial render
947
+ * of a new page generally shows placeholders only anyway.
948
+ */
949
+ private get estimatedTileHeight(): number {
950
+ switch (this.displayMode) {
951
+ case 'list-detail':
952
+ return 80;
953
+ case 'list-compact':
954
+ return 45;
955
+ case 'grid':
956
+ default:
957
+ return 225;
958
+ }
959
+ }
960
+
890
961
  /**
891
962
  * Template for the sort & filtering bar that appears atop the search results.
892
963
  */
@@ -1098,7 +1169,7 @@ export class CollectionBrowser
1098
1169
  if ((this.currentPage ?? 1) > 1) {
1099
1170
  this.goToPage(1);
1100
1171
  }
1101
- this.currentPage = 1;
1172
+ this.setCurrentPage(1);
1102
1173
  }
1103
1174
 
1104
1175
  /**
@@ -1871,6 +1942,11 @@ export class CollectionBrowser
1871
1942
  window.removeEventListener('popstate', this.boundNavigationHandler);
1872
1943
  }
1873
1944
 
1945
+ if (this.deferredFetchTimer) {
1946
+ window.clearTimeout(this.deferredFetchTimer);
1947
+ this.deferredFetchTimer = 0;
1948
+ }
1949
+
1874
1950
  this.leftColIntersectionObserver?.disconnect();
1875
1951
  this.facetsIntersectionObserver?.disconnect();
1876
1952
  window.removeEventListener('resize', this.updateLeftColumnHeight);
@@ -2123,27 +2199,45 @@ export class CollectionBrowser
2123
2199
  private visibleCellsChanged(
2124
2200
  e: CustomEvent<{ visibleCellIndices: number[] }>,
2125
2201
  ) {
2202
+ this.updateVisiblePage(e.detail.visibleCellIndices);
2203
+ }
2204
+
2205
+ /**
2206
+ * Recomputes the current page from the given set of visible cell indices
2207
+ * and emits `visiblePageChanged` if the page actually changed.
2208
+ */
2209
+ private updateVisiblePage(visibleCellIndices: number[]): void {
2126
2210
  if (this.isScrollingToCell) return;
2127
- const { visibleCellIndices } = e.detail;
2128
2211
  if (visibleCellIndices.length === 0) return;
2129
2212
 
2213
+ // The indices aren't necessarily sorted, so sort them here to ensure our
2214
+ // calculations below find the right cell/page.
2215
+ const sorted = [...visibleCellIndices].sort((a, b) => a - b);
2216
+
2130
2217
  // For page determination, do not count more than a single page of visible cells,
2131
2218
  // since otherwise patrons using very tall screens will be treated as one page
2132
2219
  // further than they actually are.
2133
2220
  const lastIndexWithinCurrentPage =
2134
- Math.min(this.pageSize, visibleCellIndices.length) - 1;
2135
- const lastVisibleCellIndex = visibleCellIndices[lastIndexWithinCurrentPage];
2221
+ Math.min(this.pageSize, sorted.length) - 1;
2222
+ const lastVisibleCellIndex = sorted[lastIndexWithinCurrentPage];
2136
2223
  const lastVisibleCellPage =
2137
2224
  Math.floor(lastVisibleCellIndex / this.pageSize) + 1;
2138
- if (this.currentPage !== lastVisibleCellPage) {
2139
- this.currentPage = lastVisibleCellPage;
2140
- }
2141
- const event = new CustomEvent('visiblePageChanged', {
2142
- detail: {
2143
- pageNumber: lastVisibleCellPage,
2144
- },
2145
- });
2146
- this.dispatchEvent(event);
2225
+
2226
+ this.setCurrentPage(lastVisibleCellPage);
2227
+ }
2228
+
2229
+ /**
2230
+ * Sets the current page number and emits a `visiblePageChanged`
2231
+ * event if the new page differs from the previous one.
2232
+ */
2233
+ private setCurrentPage(pageNumber: number): void {
2234
+ if (this.currentPage === pageNumber) return;
2235
+ this.currentPage = pageNumber;
2236
+ this.dispatchEvent(
2237
+ new CustomEvent('visiblePageChanged', {
2238
+ detail: { pageNumber },
2239
+ }),
2240
+ );
2147
2241
  }
2148
2242
 
2149
2243
  // we only want to scroll on the very first query change
@@ -2243,10 +2337,10 @@ export class CollectionBrowser
2243
2337
  this.selectedCreatorFilter = restorationState.selectedCreatorFilter ?? null;
2244
2338
  this.selectedFacets = restorationState.selectedFacets;
2245
2339
  if (!this.suppressURLQuery) this.baseQuery = restorationState.baseQuery;
2246
- this.currentPage = restorationState.currentPage ?? 1;
2340
+ this.setCurrentPage(restorationState.currentPage ?? 1);
2247
2341
  this.minSelectedDate = restorationState.minSelectedDate;
2248
2342
  this.maxSelectedDate = restorationState.maxSelectedDate;
2249
- if (this.currentPage > 1) {
2343
+ if (this.currentPage && this.currentPage > 1) {
2250
2344
  this.goToPage(this.currentPage);
2251
2345
  }
2252
2346
  }
@@ -2323,25 +2417,43 @@ export class CollectionBrowser
2323
2417
  });
2324
2418
  }
2325
2419
 
2326
- private scrollToPage(pageNumber: number): Promise<void> {
2327
- return new Promise(resolve => {
2328
- const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
2329
- // without this setTimeout, Safari just pauses until the `fetchPage` is complete
2330
- // then scrolls to the cell
2331
- setTimeout(() => {
2332
- this.isScrollingToCell = true;
2333
- this.infiniteScroller?.scrollToCell(cellIndexToScrollTo, true);
2334
- // This timeout is to give the scroll animation time to finish
2335
- // then updating the infinite scroller once we're done scrolling
2336
- // There's no scroll animation completion callback so we're
2337
- // giving it 0.5s to finish.
2338
- setTimeout(() => {
2339
- this.isScrollingToCell = false;
2340
- this.infiniteScroller?.refreshAllVisibleCells();
2341
- resolve();
2342
- }, 500);
2343
- }, 0);
2420
+ private async scrollToPage(pageNumber: number): Promise<void> {
2421
+ const cellIndexToScrollTo = this.pageSize * (pageNumber - 1);
2422
+
2423
+ // Wait for the infinite scroller be rendered before proceeding
2424
+ let waitAttempts = 0;
2425
+ while (!this.infiniteScroller && waitAttempts < 20) {
2426
+ await this.updateComplete;
2427
+ waitAttempts++;
2428
+ }
2429
+ if (!this.infiniteScroller) return;
2430
+
2431
+ // The scroller have its default `itemCount=0`, so propagate our estimated
2432
+ // tile count before jumping to the desired page.
2433
+ if (this.infiniteScroller.itemCount < this.estimatedTileCount) {
2434
+ this.infiniteScroller.itemCount = this.estimatedTileCount;
2435
+ await this.updateComplete;
2436
+ }
2437
+
2438
+ // Without this setTimeout(0), Safari just pauses until the `fetchPage`
2439
+ // is complete then scrolls to the cell.
2440
+ await new Promise<void>(resolve => {
2441
+ setTimeout(resolve, 0);
2344
2442
  });
2443
+
2444
+ this.isScrollingToCell = true;
2445
+ const scrolled = await this.infiniteScroller.scrollToCell(
2446
+ cellIndexToScrollTo,
2447
+ true,
2448
+ );
2449
+ this.isScrollingToCell = false;
2450
+ this.infiniteScroller.refreshAllVisibleCells();
2451
+
2452
+ // After we finish scrolling, recompute the visible page from the new state
2453
+ // so that it doesn't fall out of sync.
2454
+ if (scrolled) {
2455
+ this.updateVisiblePage(this.infiniteScroller.getVisibleCellIndices());
2456
+ }
2345
2457
  }
2346
2458
 
2347
2459
  /**
@@ -1,4 +1,11 @@
1
- import { css, CSSResultGroup, html, LitElement, nothing } from 'lit';
1
+ import {
2
+ css,
3
+ CSSResultGroup,
4
+ html,
5
+ LitElement,
6
+ nothing,
7
+ PropertyValues,
8
+ } from 'lit';
2
9
  import { customElement, property, query, state } from 'lit/decorators.js';
3
10
  import { ClassInfo, classMap } from 'lit/directives/class-map.js';
4
11
 
@@ -12,6 +19,14 @@ import { searchIcon } from '../assets/img/icons/mediatype/search';
12
19
 
13
20
  @customElement('item-image')
14
21
  export class ItemImage extends LitElement {
22
+ /**
23
+ * Map to cache which identifiers have waveform-style thumbnails, so that
24
+ * they can have their waveform styling applied immediately, rather than
25
+ * waiting for the image content to load before applying it (which can
26
+ * cause noticeable flicker when such tiles refresh).
27
+ */
28
+ private static readonly waveformByIdentifier = new Map<string, boolean>();
29
+
15
30
  @property({ type: Object }) model?: TileModel;
16
31
 
17
32
  @property({ type: String }) baseImageUrl?: string;
@@ -30,6 +45,15 @@ export class ItemImage extends LitElement {
30
45
 
31
46
  @query('img') private baseImage!: HTMLImageElement;
32
47
 
48
+ protected willUpdate(changed: PropertyValues): void {
49
+ if (changed.has('model')) {
50
+ // If this identifier is known to have a waveform image, then set isWaveform upfront
51
+ const identifier = this.model?.identifier;
52
+ this.isWaveform =
53
+ ItemImage.waveformByIdentifier.get(identifier as string) === true;
54
+ }
55
+ }
56
+
33
57
  render() {
34
58
  return html`
35
59
  <div class=${classMap(this.itemBaseClass)}>${this.imageTemplate}</div>
@@ -149,6 +173,9 @@ export class ItemImage extends LitElement {
149
173
  this.baseImage.naturalWidth / this.baseImage.naturalHeight === 4
150
174
  ) {
151
175
  this.isWaveform = true;
176
+ if (this.model?.identifier) {
177
+ ItemImage.waveformByIdentifier.set(this.model.identifier, true);
178
+ }
152
179
  }
153
180
  }
154
181
 
@@ -176,11 +176,25 @@ export class TileDispatcher
176
176
  // Use the server-specified href if available.
177
177
  // Otherwise, construct a details page URL from the item identifier.
178
178
  if (this.model.href) {
179
- // Defensive decode: %3A (encoded colon) in a URL is an unambiguous sign the
180
- // target URL was incorrectly percent-encoded decode it before use.
181
- const href = /%3A/i.test(this.model.href)
182
- ? decodeURIComponent(this.model.href)
183
- : this.model.href;
179
+ // Backstop for legacy Wayback Machine URLs where the target URL was
180
+ // erroneously percent-encoded by encodeURIComponent (the root cause in
181
+ // tile-display-value-provider is fixed, but old stored data may still
182
+ // carry encoded URLs). Decode only the target-URL segment after the
183
+ // /web/{timestamp}/ prefix — decoding the full href risks corrupting the
184
+ // Wayback prefix or intentional encoding elsewhere in the path.
185
+ // %3A%2F%2F (encoded "://") is an unambiguous indicator the entire URL
186
+ // was over-encoded; legitimate paths with %3A never appear next to %2F%2F.
187
+ const href = this.model.href.replace(
188
+ /(\/web\/\d+\/)(.+)/,
189
+ (_, prefix, target) => {
190
+ if (!/%3A%2F%2F/i.test(target)) return `${prefix}${target}`;
191
+ try {
192
+ return `${prefix}${decodeURIComponent(target)}`;
193
+ } catch {
194
+ return `${prefix}${target}`;
195
+ }
196
+ },
197
+ );
184
198
  return `${this.baseNavigationUrl}${href}`;
185
199
  }
186
200
 
@@ -121,7 +121,9 @@ describe('Tile Dispatcher', () => {
121
121
  ></tile-dispatcher>
122
122
  `);
123
123
 
124
- const tileLink = el.shadowRoot?.querySelector('a[href]') as HTMLAnchorElement;
124
+ const tileLink = el.shadowRoot?.querySelector(
125
+ 'a[href]',
126
+ ) as HTMLAnchorElement;
125
127
  expect(tileLink).to.exist;
126
128
  expect(tileLink.getAttribute('href')).to.equal(
127
129
  'https://web.archive.org/web/20180613065659/http://www.sankei.com/',
@@ -139,13 +141,34 @@ describe('Tile Dispatcher', () => {
139
141
  ></tile-dispatcher>
140
142
  `);
141
143
 
142
- const tileLink = el.shadowRoot?.querySelector('a[href]') as HTMLAnchorElement;
144
+ const tileLink = el.shadowRoot?.querySelector(
145
+ 'a[href]',
146
+ ) as HTMLAnchorElement;
143
147
  expect(tileLink).to.exist;
144
148
  expect(tileLink.getAttribute('href')).to.equal(
145
149
  'https://web.archive.org/web/20180613065659/http://www.sankei.com/',
146
150
  );
147
151
  });
148
152
 
153
+ it('should not decode %3A in Wayback URL path when not an encoded scheme', async () => {
154
+ // %3A in the path (e.g. /path/%3Asomething) is not a sign of over-encoding;
155
+ // only %3A%2F%2F (encoded "://") is.
156
+ const href =
157
+ 'https://web.archive.org/web/20180613065659/https://example.com/path/%3Asomething';
158
+ const el = await fixture<TileDispatcher>(html`
159
+ <tile-dispatcher
160
+ .model=${{ identifier: 'foo', href }}
161
+ .baseNavigationUrl=${''}
162
+ ></tile-dispatcher>
163
+ `);
164
+
165
+ const tileLink = el.shadowRoot?.querySelector(
166
+ 'a[href]',
167
+ ) as HTMLAnchorElement;
168
+ expect(tileLink).to.exist;
169
+ expect(tileLink.getAttribute('href')).to.equal(href);
170
+ });
171
+
149
172
  it('should toggle model checked state when manage check clicked', async () => {
150
173
  const el = await fixture<TileDispatcher>(html`
151
174
  <tile-dispatcher