@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.
- package/dist/src/app-root.js +4 -0
- package/dist/src/app-root.js.map +1 -1
- package/dist/src/collection-browser.d.ts +41 -0
- package/dist/src/collection-browser.js +129 -36
- package/dist/src/collection-browser.js.map +1 -1
- package/dist/src/tiles/item-image.d.ts +9 -1
- package/dist/src/tiles/item-image.js +22 -2
- package/dist/src/tiles/item-image.js.map +1 -1
- package/dist/src/tiles/tile-dispatcher.js +18 -5
- package/dist/src/tiles/tile-dispatcher.js.map +1 -1
- package/dist/test/tiles/tile-dispatcher.test.js +14 -0
- package/dist/test/tiles/tile-dispatcher.test.js.map +1 -1
- package/package.json +3 -3
- package/src/app-root.ts +3 -0
- package/src/collection-browser.ts +147 -35
- package/src/tiles/item-image.ts +28 -1
- package/src/tiles/tile-dispatcher.ts +19 -5
- package/test/tiles/tile-dispatcher.test.ts +25 -2
|
@@ -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
|
|
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.
|
|
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.
|
|
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,
|
|
2135
|
-
const lastVisibleCellIndex =
|
|
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
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
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.
|
|
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
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
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
|
/**
|
package/src/tiles/item-image.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
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(
|
|
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
|