@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.
- package/dist/{ArchiveViewer-BqU761Wk.js → ArchiveViewer-Bn-pwgX8.js} +2 -2
- package/dist/{ArchiveViewer-BqU761Wk.js.map → ArchiveViewer-Bn-pwgX8.js.map} +1 -1
- package/dist/{CsvViewer-ByHVK5ga.js → CsvViewer-I0AceHh8.js} +2 -2
- package/dist/{CsvViewer-ByHVK5ga.js.map → CsvViewer-I0AceHh8.js.map} +1 -1
- package/dist/{DrawioViewer-Bgrj8-zG.js → DrawioViewer-B2vl10hY.js} +2 -2
- package/dist/{DrawioViewer-Bgrj8-zG.js.map → DrawioViewer-B2vl10hY.js.map} +1 -1
- package/dist/{EpubViewer-DJwUKlHW.js → EpubViewer-L3rxrx5i.js} +2 -2
- package/dist/{EpubViewer-DJwUKlHW.js.map → EpubViewer-L3rxrx5i.js.map} +1 -1
- package/dist/{IpynbViewer-Bz0eJ4NS.js → IpynbViewer-Do8n0RzJ.js} +2 -2
- package/dist/{IpynbViewer-Bz0eJ4NS.js.map → IpynbViewer-Do8n0RzJ.js.map} +1 -1
- package/dist/{MermaidViewer-CEDTD_pp.js → MermaidViewer-0JqH6k0f.js} +2 -2
- package/dist/{MermaidViewer-CEDTD_pp.js.map → MermaidViewer-0JqH6k0f.js.map} +1 -1
- package/dist/{PsdViewer-CZcxUpZO.js → PsdViewer-BnI-NDr8.js} +2 -2
- package/dist/{PsdViewer-CZcxUpZO.js.map → PsdViewer-BnI-NDr8.js.map} +1 -1
- package/dist/{TiffViewer-BwI62CbQ.js → TiffViewer-CrLDXI_f.js} +2 -2
- package/dist/{TiffViewer-BwI62CbQ.js.map → TiffViewer-CrLDXI_f.js.map} +1 -1
- package/dist/{Viewer3D-DoeZcuMm.js → Viewer3D-CpB1K0-0.js} +2 -2
- package/dist/{Viewer3D-DoeZcuMm.js.map → Viewer3D-CpB1K0-0.js.map} +1 -1
- package/dist/filex-core.js +1 -1
- package/dist/filex-core.umd.cjs +49 -49
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/{index-DuWnGuv5.js → index-D9_EBsqF.js} +6970 -6905
- package/dist/index-D9_EBsqF.js.map +1 -0
- package/package.json +1 -1
- package/src/components/GalleryView.vue +24 -30
- package/src/components/GridView.vue +33 -36
- package/src/components/ListView.vue +15 -11
- package/src/components/ThumbTile.vue +58 -0
- package/src/composables/useThumbs.ts +53 -21
- package/src/lib/nearViewport.ts +77 -0
- 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.
|
|
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
|
-
<!--
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
v-html
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
v-html
|
|
540
|
-
|
|
541
|
-
|
|
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.
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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,
|
|
36
|
-
* the fetch on first sight. null = not (yet) available → show
|
|
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
|
-
|
|
40
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
65
|
-
|
|
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(
|
|
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
|
+
}
|