@brftech/filex-core 0.43.0 → 0.43.2

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.
Files changed (31) hide show
  1. package/dist/{ArchiveViewer-BqU761Wk.js → ArchiveViewer-Bn-pwgX8.js} +2 -2
  2. package/dist/{ArchiveViewer-BqU761Wk.js.map → ArchiveViewer-Bn-pwgX8.js.map} +1 -1
  3. package/dist/{CsvViewer-ByHVK5ga.js → CsvViewer-I0AceHh8.js} +2 -2
  4. package/dist/{CsvViewer-ByHVK5ga.js.map → CsvViewer-I0AceHh8.js.map} +1 -1
  5. package/dist/{DrawioViewer-Bgrj8-zG.js → DrawioViewer-B2vl10hY.js} +2 -2
  6. package/dist/{DrawioViewer-Bgrj8-zG.js.map → DrawioViewer-B2vl10hY.js.map} +1 -1
  7. package/dist/{EpubViewer-DJwUKlHW.js → EpubViewer-L3rxrx5i.js} +2 -2
  8. package/dist/{EpubViewer-DJwUKlHW.js.map → EpubViewer-L3rxrx5i.js.map} +1 -1
  9. package/dist/{IpynbViewer-Bz0eJ4NS.js → IpynbViewer-Do8n0RzJ.js} +2 -2
  10. package/dist/{IpynbViewer-Bz0eJ4NS.js.map → IpynbViewer-Do8n0RzJ.js.map} +1 -1
  11. package/dist/{MermaidViewer-CEDTD_pp.js → MermaidViewer-0JqH6k0f.js} +2 -2
  12. package/dist/{MermaidViewer-CEDTD_pp.js.map → MermaidViewer-0JqH6k0f.js.map} +1 -1
  13. package/dist/{PsdViewer-CZcxUpZO.js → PsdViewer-BnI-NDr8.js} +2 -2
  14. package/dist/{PsdViewer-CZcxUpZO.js.map → PsdViewer-BnI-NDr8.js.map} +1 -1
  15. package/dist/{TiffViewer-BwI62CbQ.js → TiffViewer-CrLDXI_f.js} +2 -2
  16. package/dist/{TiffViewer-BwI62CbQ.js.map → TiffViewer-CrLDXI_f.js.map} +1 -1
  17. package/dist/{Viewer3D-DoeZcuMm.js → Viewer3D-CpB1K0-0.js} +2 -2
  18. package/dist/{Viewer3D-DoeZcuMm.js.map → Viewer3D-CpB1K0-0.js.map} +1 -1
  19. package/dist/filex-core.js +1 -1
  20. package/dist/filex-core.umd.cjs +49 -49
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-DuWnGuv5.js → index-D9_EBsqF.js} +6970 -6905
  23. package/dist/index-D9_EBsqF.js.map +1 -0
  24. package/package.json +1 -1
  25. package/src/components/GalleryView.vue +24 -30
  26. package/src/components/GridView.vue +33 -36
  27. package/src/components/ListView.vue +15 -11
  28. package/src/components/ThumbTile.vue +58 -0
  29. package/src/composables/useThumbs.ts +53 -21
  30. package/src/lib/nearViewport.ts +77 -0
  31. package/dist/index-DuWnGuv5.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.43.0",
3
+ "version": "0.43.2",
4
4
  "description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
5
5
  "type": "module",
6
6
  "main": "./dist/filex-core.umd.cjs",
@@ -25,6 +25,7 @@ import {
25
25
  type FilePreview,
26
26
  } from '../lib/filePreview'; /* gorunum:v1-preview */
27
27
  import StarButton from './StarButton.vue';
28
+ import ThumbTile from './ThumbTile.vue';
28
29
  import { applyDragGhost } from '../lib/dragGhost';
29
30
  import { parentDirOf } from '../lib/listing'; /* the folder a row sits in — one split rule for every view */
30
31
  import {
@@ -358,41 +359,34 @@ function metaFor(n: FileNode): string {
358
359
  v-html="fileIconTile(n)"
359
360
  ></span>
360
361
  </template>
361
- <!-- draggable="false" for the same reason as GridView: HTML5 image
362
- drag adds a 'Files' MIME to dataTransfer, which would trip the
363
- parent's upload handler on internal drags. -->
364
- <img
365
- v-else-if="thumbOf(n)"
366
- :src="thumbOf(n)!"
362
+ <!-- ⚠⚠ Through ThumbTile, as in GridView: read in this template, each
363
+ thumbnail that arrived re-rendered every tile of the folder. The
364
+ tile draws the <img> (draggable="false", same reason as GridView)
365
+ and the play badge over a video frame; its slot until then. -->
366
+ <ThumbTile
367
+ v-else
368
+ :node="n"
369
+ :src-of="thumbOf"
370
+ :video-badge="drawsAsVideo(n)"
367
371
  :alt="n.basename"
368
372
  :class="{ 'fe-thumb--page': drawsAsPage(n) /* gorunum:v1-preview — crop a page from its TOP */ }"
369
- loading="lazy"
370
- draggable="false"
371
- />
372
- <!-- ikon:emoji — see GridView: the folder shape survives, the padlock
373
- is cut out of it, and the name the 🔒 never had is on the span. -->
374
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
375
- <span
376
- v-else-if="isEncryptedFolder(n)"
377
- class="fe-gal__icon fe-gal__icon--svg"
378
- role="img"
379
- :aria-label="t('e2e.badge')"
380
- v-html="encryptedFolderTile()"
381
- ></span>
382
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
383
- <span v-else class="fe-gal__icon fe-gal__icon--svg" v-html="fileIconTile(n)"></span>
373
+ >
374
+ <!-- ikon:emoji — see GridView: the folder shape survives, the padlock
375
+ is cut out of it, and the name the 🔒 never had is on the span. -->
376
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
377
+ <span
378
+ v-if="isEncryptedFolder(n)"
379
+ class="fe-gal__icon fe-gal__icon--svg"
380
+ role="img"
381
+ :aria-label="t('e2e.badge')"
382
+ v-html="encryptedFolderTile()"
383
+ ></span>
384
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
385
+ <span v-else class="fe-gal__icon fe-gal__icon--svg" v-html="fileIconTile(n)"></span>
386
+ </ThumbTile>
384
387
  <!-- Same star chip as the grid, same component, same rule: painted
385
388
  when starred, on hover/focus otherwise. It sits above .fe-gal__meta
386
389
  (which is aria-hidden and covers the tile's foot on hover). -->
387
- <!-- gorunum:v1-preview — a frame lifted out of a video is, on a card,
388
- indistinguishable from a photograph. The badge is the difference,
389
- and it is drawn only over a real frame: a video that fell back to
390
- its type tile already says what it is. -->
391
- <span
392
- v-if="!previewKind(n) && thumbOf(n) && drawsAsVideo(n)"
393
- class="fe-thumb__play"
394
- aria-hidden="true"
395
- ></span>
396
390
  <!-- issue #26 — the card's checkbox, the one click that selects: the
397
391
  corner opposite the star, on the same reveal rule plus "anything
398
392
  is selected" (styles/base.css .fe-item-check). -->
@@ -38,6 +38,7 @@ import {
38
38
  } from '../lib/dateGroups'; /* gruplama */
39
39
  import { useSortStore, type ListingOrder } from '../lib/sortOrder'; /* gruplama */
40
40
  import StarButton from './StarButton.vue';
41
+ import ThumbTile from './ThumbTile.vue';
41
42
  import { snippetSegments } from '../lib/snippet'; /* bul:s3 */
42
43
  import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
43
44
  import { contentDir } from '../lib/direction';
@@ -229,7 +230,8 @@ function headingBefore(n: FileNode): string | null {
229
230
 
230
231
 
231
232
  // Prefer the authenticated resolver when the host wired one; otherwise fall
232
- // back to the raw URL (legacy same-origin behavior).
233
+ // back to the raw URL (legacy same-origin behavior). ⚠ Called by ThumbTile,
234
+ // not by this template — see the note on the tile in the markup.
233
235
  function thumbOf(n: FileNode): string | null {
234
236
  return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
235
237
  }
@@ -512,48 +514,43 @@ function snippetTitle(snippet: string): string {
512
514
  v-html="fileIconTile(n)"
513
515
  ></span>
514
516
  </template>
515
- <!--
516
- draggable="false" — HTML5 image drag dataTransfer adds 'Files'
517
- MIME, which trips the parent's upload handler; without this
518
- the user dragging an item with a thumbnail re-uploads it.
519
- Parent card stays draggable=true so internal move works.
520
- -->
521
- <img
522
- v-else-if="thumbOf(n)"
523
- :src="thumbOf(n)!"
517
+ <!-- ⚠⚠ The thumbnail is read inside ThumbTile, never here: read in
518
+ this template, each thumbnail that arrived re-rendered every card
519
+ of the folder (see ThumbTile). The tile draws the <img> once there
520
+ is a picture — draggable="false", because an image drag puts a
521
+ 'Files' MIME on the dataTransfer and the parent's upload handler
522
+ re-uploads the file being moved — and its slot until then.
523
+ gorunum:v1-preview — the play badge goes over a real video frame
524
+ only: a video that fell back to its type tile already says what
525
+ it is. -->
526
+ <ThumbTile
527
+ v-else
528
+ :node="n"
529
+ :src-of="thumbOf"
530
+ :video-badge="drawsAsVideo(n)"
524
531
  :alt="n.basename"
525
532
  :class="{ 'fe-thumb--page': drawsAsPage(n) /* gorunum:v1-preview — crop a page from its TOP */ }"
526
- loading="lazy"
527
- draggable="false"
528
- />
529
- <!-- ikon:emoji — an encrypted folder is still a FOLDER, so it keeps the
530
- folder's own shape and colour with the padlock cut out of it; the
531
- 🔒 it replaces said "locked" and nothing else. One definition,
532
- in lib/fileIcons, for all three views. -->
533
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
534
- <span
535
- v-else-if="isEncryptedFolder(n)"
536
- class="fe-grid__icon fe-grid__icon--svg"
537
- role="img"
538
- :aria-label="t('e2e.badge')"
539
- v-html="encryptedFolderTile()"
540
- ></span>
541
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
542
- <span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconTile(n)"></span>
533
+ >
534
+ <!-- ikon:emoji — an encrypted folder is still a FOLDER, so it keeps the
535
+ folder's own shape and colour with the padlock cut out of it; the
536
+ 🔒 it replaces said "locked" and nothing else. One definition,
537
+ in lib/fileIcons, for all three views. -->
538
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
539
+ <span
540
+ v-if="isEncryptedFolder(n)"
541
+ class="fe-grid__icon fe-grid__icon--svg"
542
+ role="img"
543
+ :aria-label="t('e2e.badge')"
544
+ v-html="encryptedFolderTile()"
545
+ ></span>
546
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
547
+ <span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconTile(n)"></span>
548
+ </ThumbTile>
543
549
  <!-- Star, ON the tile. A hover-only affordance would be invisible to
544
550
  the person looking for what they starred, so the chip is always
545
551
  painted once the file IS starred and only appears on hover/focus
546
552
  otherwise (see .fe-grid__star in styles/base.css). @click.stop so
547
553
  starring never doubles as opening the card. -->
548
- <!-- gorunum:v1-preview — a frame lifted out of a video is, on a card,
549
- indistinguishable from a photograph. The badge is the difference,
550
- and it is drawn only over a real frame: a video that fell back to
551
- its type tile already says what it is. -->
552
- <span
553
- v-if="!previewKind(n) && thumbOf(n) && drawsAsVideo(n)"
554
- class="fe-thumb__play"
555
- aria-hidden="true"
556
- ></span>
557
554
  <div v-if="canStar(n)" class="fe-grid__star" @click.stop @dblclick.stop>
558
555
  <StarButton
559
556
  :starred="!!starredIds?.has(n.id!)"
@@ -65,6 +65,7 @@ import {
65
65
  } from '../lib/viewPrefs'; /* tablo:t1 — per-folder columns */
66
66
  import DataTable, { type DataColumn } from './DataTable.vue';
67
67
  import StarButton from './StarButton.vue';
68
+ import ThumbTile from './ThumbTile.vue';
68
69
 
69
70
  const props = defineProps<{
70
71
  files: FileNode[];
@@ -786,21 +787,24 @@ const tableGroups = computed(() =>
786
787
  v-html="encryptedFolderTile()"
787
788
  ></span>
788
789
  <!-- tablo:t1 — the real thumbnail, at the tile's own size so the row
789
- height does not move. `draggable="false"`: dragging an <img> puts a
790
- 'Files' MIME on the dataTransfer and the parent's upload handler
791
- re-uploads the thing you were only moving. -->
792
- <img
793
- v-else-if="thumbOf(row)"
790
+ height does not move. ⚠⚠ Through ThumbTile, never read in this
791
+ template: here, each thumbnail that arrived re-rendered every row of
792
+ the table. The tile draws the <img> (`draggable="false"`: dragging
793
+ an <img> puts a 'Files' MIME on the dataTransfer and the parent's
794
+ upload handler re-uploads the thing you were only moving) and the
795
+ type tile until then. -->
796
+ <ThumbTile
797
+ v-else
798
+ :node="row"
799
+ :src-of="thumbOf"
794
800
  class="fe-list__icon fe-list__icon--img"
795
801
  :class="{ 'fe-thumb--page': drawsAsPage(row) }"
796
- :src="thumbOf(row)!"
797
802
  alt=""
798
803
  aria-hidden="true"
799
- loading="lazy"
800
- draggable="false"
801
- />
802
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
803
- <span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconTile(row)"></span>
804
+ >
805
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
806
+ <span class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconTile(row)"></span>
807
+ </ThumbTile>
804
808
  <div class="fe-list__name-wrap">
805
809
  <span class="fe-list__name" :title="nameTitle(row)">
806
810
  <!-- ⚠ RTL: `<bdi>` — the name is the person's text, not the
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ThumbTile — one file's thumbnail, or whatever the view draws instead.
4
+ *
5
+ * ⚠⚠ Why a component. A thumbnail's object URL arrives later, and whatever
6
+ * read it is woken when it does. Read in a folder view's own template, that was
7
+ * the whole view: every arrival re-rendered every row (see useThumbs). Read
8
+ * here, it is this tile.
9
+ *
10
+ * It also asks late. The resolver starts the fetch, and it is only called once
11
+ * the tile is near the viewport (lib/nearViewport), so opening a folder no
12
+ * longer downloads the thumbnails of tiles nobody has scrolled to.
13
+ *
14
+ * The <img> takes the attributes the view gives the tile (class, alt, aria-*);
15
+ * the default slot is the fallback — the view's type tile — drawn until the
16
+ * picture is there, and for good when there is none.
17
+ *
18
+ * ⚠ No `loading="lazy"`: the picture is already only handed over near the
19
+ * viewport, and it is an object URL in memory, so lazy would defer nothing but
20
+ * the decode — and an <img> that has not loaded yet when useThumbs revokes its
21
+ * URL (its oldest, past the cache's cap) would come up broken.
22
+ */
23
+ import { computed, getCurrentInstance, onBeforeUnmount, onMounted, ref } from 'vue';
24
+ import type { FileNode } from '../types/FileNode';
25
+ import { whenNearViewport } from '../lib/nearViewport';
26
+
27
+ defineOptions({ inheritAttrs: false });
28
+
29
+ const props = defineProps<{
30
+ node: FileNode;
31
+ /** The view's resolver: the object URL to draw, or null (not yet, or none). */
32
+ srcOf: (n: FileNode) => string | null;
33
+ /** Draw the play badge over the picture — a frame lifted out of a video is,
34
+ * on a card, indistinguishable from a photograph. */
35
+ videoBadge?: boolean;
36
+ }>();
37
+
38
+ const near = ref(false);
39
+ let stopWaiting: (() => void) | null = null;
40
+
41
+ onMounted(() => {
42
+ /* The tile draws no box of its own (it is the picture, or the fallback), so
43
+ it watches the box it sits in — the view's thumbnail cell. */
44
+ const anchor = getCurrentInstance()?.subTree.el as Node | null | undefined;
45
+ stopWaiting = whenNearViewport(anchor?.parentElement, () => {
46
+ near.value = true;
47
+ });
48
+ });
49
+ onBeforeUnmount(() => stopWaiting?.());
50
+
51
+ const src = computed(() => (near.value ? props.srcOf(props.node) : null));
52
+ </script>
53
+
54
+ <template>
55
+ <img v-if="src" v-bind="$attrs" :src="src" draggable="false" />
56
+ <slot v-else />
57
+ <span v-if="src && videoBadge" class="fe-thumb__play" aria-hidden="true"></span>
58
+ </template>
@@ -1,4 +1,4 @@
1
- // useThumbs — authenticated thumbnail loader for the grid view.
1
+ // useThumbs — authenticated thumbnail loader for the folder views.
2
2
  //
3
3
  // The backend emits `thumb_url` as a ROOT-RELATIVE path ("/api/files/thumb/{id}").
4
4
  // A plain `<img src>` only works for the native same-origin SPA: an embedded
@@ -6,9 +6,24 @@
6
6
  // and, even with the URL fixed, `<img>` cannot carry the bearer header a
7
7
  // proxied host (fishapp PWA) requires. So thumbs are fetched through the same
8
8
  // auth machinery as every API call (headers + credentials), cached as object
9
- // URLs, and handed to the grid; a failed fetch falls back to the file icon.
9
+ // URLs, and handed to the views; a failed fetch falls back to the file icon.
10
+ //
11
+ // ⚠⚠ An arrival wakes ONE tile. The object URLs used to live in one reactive
12
+ // record that was REPLACED as each thumbnail arrived, so every arrival woke
13
+ // everything that had read any thumbnail — the whole folder view, which read
14
+ // them all. Field report: a folder of 344 files (~240 thumbnails) re-rendered
15
+ // itself ~240 times in a few seconds; a fast Mac lost ~4 s, a slower Windows PC
16
+ // froze Edge for ~30 s and ran a Chrome tab out of memory. The cache is now a
17
+ // reactive Map read per key, and the views read it through ThumbTile (one
18
+ // component per tile), so an arrival re-renders the tile that shows it.
19
+ //
20
+ // ⚠ Keyed by picture, not by URL. `thumb_url` carries a signature whose expiry
21
+ // moves every hour; keyed by URL, the first listing after the hour turned
22
+ // fetched every thumbnail of the folder again. The key is the URL's path plus
23
+ // the file's `last_modified`: a rotated signature is the same picture, an edited
24
+ // file is not.
10
25
 
11
- import { ref } from 'vue';
26
+ import { shallowReactive, toRaw } from 'vue';
12
27
  import type { FileNode } from '../types/FileNode';
13
28
 
14
29
  interface ThumbApiSlice {
@@ -20,10 +35,22 @@ interface ThumbApiSlice {
20
35
  * small (~KBs) so this is generous while still bounding a long session. */
21
36
  const MAX_CACHED = 500;
22
37
 
38
+ /** The cache key for a node's thumbnail — null when it has none. */
39
+ function thumbKey(n: FileNode): string | null {
40
+ const raw = n.thumb_url;
41
+ if (!raw) return null;
42
+ const q = raw.indexOf('?');
43
+ return `${q === -1 ? raw : raw.slice(0, q)}|${n.last_modified ?? ''}`;
44
+ }
45
+
23
46
  export function useThumbs(apiBase: string | undefined, api: ThumbApiSlice) {
24
- const urls = ref<Record<string, string>>({});
47
+ const cache = shallowReactive(new Map<string, string>());
25
48
  const order: string[] = [];
49
+ /** Signed URLs that failed. Per URL, not per key: 404 (thumb evicted
50
+ * server-side) or an auth hiccup is not retried within the session, but a
51
+ * freshly signed URL for the same picture may be tried once. */
26
52
  const failed = new Set<string>();
53
+ /** Keys being fetched — one request per picture however many URLs name it. */
27
54
  const pending = new Set<string>();
28
55
 
29
56
  function resolveUrl(raw: string): string {
@@ -32,19 +59,21 @@ export function useThumbs(apiBase: string | undefined, api: ThumbApiSlice) {
32
59
  return base && raw.startsWith('/') ? base + raw : raw;
33
60
  }
34
61
 
35
- /** Reactive: returns the loaded object URL for the node's thumb, kicking off
36
- * the fetch on first sight. null = not (yet) available → show the icon. */
62
+ /** Reactive, per node: returns the loaded object URL for the node's thumb,
63
+ * kicking off the fetch on first sight. null = not (yet) available → show
64
+ * the icon. Only what read THIS node's key is woken when it arrives. */
37
65
  function src(n: FileNode): string | null {
38
66
  const raw = n.thumb_url;
39
- if (!raw) return null;
40
- const got = urls.value[raw];
67
+ const key = thumbKey(n);
68
+ if (!raw || !key) return null;
69
+ const got = cache.get(key);
41
70
  if (got) return got;
42
- if (!failed.has(raw) && !pending.has(raw)) void load(raw);
71
+ if (!failed.has(raw) && !pending.has(key)) void load(raw, key);
43
72
  return null;
44
73
  }
45
74
 
46
- async function load(raw: string): Promise<void> {
47
- pending.add(raw);
75
+ async function load(raw: string, key: string): Promise<void> {
76
+ pending.add(key);
48
77
  try {
49
78
  const res = await fetch(resolveUrl(raw), {
50
79
  headers: await api.authHeaders({ Accept: '*/*' }),
@@ -53,22 +82,25 @@ export function useThumbs(apiBase: string | undefined, api: ThumbApiSlice) {
53
82
  if (!res.ok) throw new Error(String(res.status));
54
83
  const blob = await res.blob();
55
84
  if (order.length >= MAX_CACHED) {
85
+ // ⚠ Dropped without a word to the tile still showing it: woken, the
86
+ // tile would fetch it again, and that arrival would drop the next
87
+ // oldest — past MAX_CACHED thumbnails on screen, a loop that never
88
+ // ends. Its <img> has long loaded, and a loaded image outlives its
89
+ // revoked URL; a tile drawn later asks again, once.
56
90
  const evict = order.shift();
57
- if (evict && urls.value[evict]) {
58
- URL.revokeObjectURL(urls.value[evict]);
59
- const next = { ...urls.value };
60
- delete next[evict];
61
- urls.value = next;
91
+ const store = toRaw(cache);
92
+ const old = evict ? store.get(evict) : undefined;
93
+ if (evict && old) {
94
+ URL.revokeObjectURL(old);
95
+ store.delete(evict);
62
96
  }
63
97
  }
64
- order.push(raw);
65
- urls.value = { ...urls.value, [raw]: URL.createObjectURL(blob) };
98
+ order.push(key);
99
+ cache.set(key, URL.createObjectURL(blob));
66
100
  } catch {
67
- // 404 (thumb evicted server-side) or auth hiccup — icon fallback, and we
68
- // don't retry within this session to avoid hammering.
69
101
  failed.add(raw);
70
102
  } finally {
71
- pending.delete(raw);
103
+ pending.delete(key);
72
104
  }
73
105
  }
74
106
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * whenNearViewport — call `cb` once `el` comes within NEAR of the viewport.
3
+ *
4
+ * A folder view puts every tile on the page at once, and a thumbnail is a
5
+ * request: a folder of 344 files asked for ~240 of them the moment it opened,
6
+ * most for tiles nobody had scrolled to. ThumbTile asks only once this says
7
+ * its tile is close.
8
+ *
9
+ * ONE IntersectionObserver for the page, not one per tile, and each element is
10
+ * heard once and then forgotten. With a null root it watches the viewport, and
11
+ * a tile hidden by the scroll box it sits in (the explorer's pane scrolls on
12
+ * its own) does not count as near. Where IntersectionObserver does not exist
13
+ * (an old engine, a test DOM) every element counts as near at once — which is
14
+ * how every tile behaved before.
15
+ */
16
+
17
+ /** How far outside the viewport a tile already counts as near: a thumbnail
18
+ * should be on its way before the tile scrolls in, not after. */
19
+ const NEAR = '300px';
20
+
21
+ let io: IntersectionObserver | null = null;
22
+ const waiting = new Map<Element, Set<() => void>>();
23
+
24
+ function observer(): IntersectionObserver {
25
+ if (!io) {
26
+ io = new IntersectionObserver(
27
+ (entries) => {
28
+ for (const e of entries) {
29
+ if (!e.isIntersecting) continue;
30
+ const cbs = waiting.get(e.target);
31
+ if (!cbs) continue;
32
+ waiting.delete(e.target);
33
+ io?.unobserve(e.target);
34
+ cbs.forEach((cb) => cb());
35
+ }
36
+ },
37
+ // `rootMargin` widens the viewport only: a tile in the pane's own scroll
38
+ // box is still cut off at that box's edge. `scrollMargin` widens the
39
+ // scroll boxes too (Chromium 120+; an engine that does not know it
40
+ // ignores it, and the picture is asked for as the tile scrolls in).
41
+ { rootMargin: NEAR, scrollMargin: NEAR } as IntersectionObserverInit,
42
+ );
43
+ }
44
+ return io;
45
+ }
46
+
47
+ /** Call `cb` once `el` is near the viewport. Returns a function that cancels
48
+ * the wait (call it when the element goes away first). */
49
+ export function whenNearViewport(el: Element | null | undefined, cb: () => void): () => void {
50
+ if (!el || typeof IntersectionObserver === 'undefined') {
51
+ cb();
52
+ return () => {};
53
+ }
54
+ let cbs = waiting.get(el);
55
+ if (!cbs) {
56
+ cbs = new Set();
57
+ waiting.set(el, cbs);
58
+ observer().observe(el);
59
+ }
60
+ cbs.add(cb);
61
+ return () => {
62
+ const set = waiting.get(el);
63
+ if (!set) return;
64
+ set.delete(cb);
65
+ if (set.size === 0) {
66
+ waiting.delete(el);
67
+ io?.unobserve(el);
68
+ }
69
+ };
70
+ }
71
+
72
+ /** Tests only: drop the shared observer so the next wait builds a fresh one. */
73
+ export function __resetNearViewport(): void {
74
+ io?.disconnect();
75
+ io = null;
76
+ waiting.clear();
77
+ }