perf(library): persistent SPA shell, in-memory folder tree caching, and zero-flicker LRU thumbnail caching
This commit is contained in:
@@ -7,6 +7,7 @@ import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
|||||||
import { isRawName, rawThumbnail } from './engine/rawDevelop';
|
import { isRawName, rawThumbnail } from './engine/rawDevelop';
|
||||||
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||||
import {
|
import {
|
||||||
|
cachedFolders,
|
||||||
cachedPhotos,
|
cachedPhotos,
|
||||||
canBrowseFolders,
|
canBrowseFolders,
|
||||||
clearLibraryCache,
|
clearLibraryCache,
|
||||||
@@ -197,7 +198,7 @@ export function Library() {
|
|||||||
|
|
||||||
function Catalogue() {
|
function Catalogue() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [folders, setFolders] = useState<LibraryFolder[]>([]);
|
const [folders, setFolders] = useState<LibraryFolder[]>(() => cachedFolders());
|
||||||
const [ready, setReady] = useState<Set<string>>(new Set());
|
const [ready, setReady] = useState<Set<string>>(new Set());
|
||||||
// The strip starts on the catalogue the last screen read: coming back from the
|
// The strip starts on the catalogue the last screen read: coming back from the
|
||||||
// studio is a screen opening, not a page loading, and the frames the reader was
|
// studio is a screen opening, not a page loading, and the frames the reader was
|
||||||
@@ -2177,58 +2178,83 @@ function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One object URL per tile, made when the row does and dropped with it: a folder
|
// One object URL per tile, made when the row does and dropped with it: a folder
|
||||||
// of thousands cannot hold thousands of live URLs, and the list the panels paint is
|
const globalThumbUrls = new Map<string, { url: string; blob: Blob }>();
|
||||||
// the only list to make them for. `also` is a frame drawn outside that list — the
|
const GLOBAL_URL_MAX = 2500;
|
||||||
// one on the stage, whose tile may be nowhere near the strip's window.
|
|
||||||
|
function cacheObjectUrl(id: string, blob: Blob): string {
|
||||||
|
const existing = globalThumbUrls.get(id);
|
||||||
|
if (existing && existing.blob === blob) {
|
||||||
|
return existing.url;
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
URL.revokeObjectURL(existing.url);
|
||||||
|
} else if (globalThumbUrls.size >= GLOBAL_URL_MAX) {
|
||||||
|
const oldestKey = globalThumbUrls.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
const item = globalThumbUrls.get(oldestKey);
|
||||||
|
if (item) URL.revokeObjectURL(item.url);
|
||||||
|
globalThumbUrls.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
globalThumbUrls.set(id, { url, blob });
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
|
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
|
||||||
const [urls, setUrls] = useState<Record<string, string>>({});
|
const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]);
|
||||||
const made = useRef(new Map<string, { url: string; blob: Blob }>());
|
|
||||||
|
const [urls, setUrls] = useState<Record<string, string>>(() => {
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const p of list) {
|
||||||
|
const cached = globalThumbUrls.get(p.id);
|
||||||
|
if (cached) initial[p.id] = cached.url;
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const list = also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos;
|
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
setUrls({});
|
setUrls({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ids = list.map((p) => p.id);
|
|
||||||
|
|
||||||
void getPhotoThumbs(ids).then((thumbMap) => {
|
const syncUrls: Record<string, string> = {};
|
||||||
|
const idsToFetch: string[] = [];
|
||||||
|
for (const p of list) {
|
||||||
|
const cached = globalThumbUrls.get(p.id);
|
||||||
|
if (cached) {
|
||||||
|
syncUrls[p.id] = cached.url;
|
||||||
|
} else {
|
||||||
|
idsToFetch.push(p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(syncUrls).length > 0) {
|
||||||
|
setUrls((prev) => ({ ...prev, ...syncUrls }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idsToFetch.length === 0) return;
|
||||||
|
|
||||||
|
void getPhotoThumbs(idsToFetch).then((thumbMap) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
const next: Record<string, string> = {};
|
setUrls((prev) => {
|
||||||
for (const photo of list) {
|
const next = { ...prev };
|
||||||
const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
for (const photo of list) {
|
||||||
if (!blob) continue;
|
const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
||||||
const had = made.current.get(photo.id);
|
if (blob) {
|
||||||
if (had && had.blob === blob) {
|
next[photo.id] = cacheObjectUrl(photo.id, blob);
|
||||||
next[photo.id] = had.url;
|
}
|
||||||
} else {
|
|
||||||
if (had) URL.revokeObjectURL(had.url);
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
made.current.set(photo.id, { url, blob });
|
|
||||||
next[photo.id] = url;
|
|
||||||
}
|
}
|
||||||
}
|
return next;
|
||||||
for (const [id, item] of made.current) {
|
});
|
||||||
if (id in next) continue;
|
|
||||||
URL.revokeObjectURL(item.url);
|
|
||||||
made.current.delete(id);
|
|
||||||
}
|
|
||||||
setUrls(next);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [photos, also]);
|
}, [list]);
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
for (const item of made.current.values()) URL.revokeObjectURL(item.url);
|
|
||||||
made.current.clear();
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function canBrowseFolders(): boolean {
|
|||||||
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
|
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THUMB_CACHE_MAX = 500;
|
const THUMB_CACHE_MAX = 5000;
|
||||||
const thumbCache = new Map<string, Blob>();
|
const thumbCache = new Map<string, Blob>();
|
||||||
|
|
||||||
export function cacheThumb(id: string, blob: Blob): void {
|
export function cacheThumb(id: string, blob: Blob): void {
|
||||||
@@ -278,14 +278,20 @@ function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- folders ---------------------------------------------------------------
|
let cachedFoldersList: LibraryFolder[] | null = null;
|
||||||
|
|
||||||
|
export function cachedFolders(): LibraryFolder[] {
|
||||||
|
return cachedFoldersList ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function listFolders(): Promise<LibraryFolder[]> {
|
export async function listFolders(): Promise<LibraryFolder[]> {
|
||||||
try {
|
try {
|
||||||
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
|
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
|
||||||
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
const sorted = rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
cachedFoldersList = sorted;
|
||||||
|
return sorted;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return cachedFoldersList ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ function pick(route: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intercept internal link clicks for instant SPA transitions without full page reload
|
|
||||||
function Shell() {
|
function Shell() {
|
||||||
const [route, setRoute] = useState(() => window.location.pathname + window.location.search);
|
const [route, setRoute] = useState(() => window.location.pathname + window.location.search);
|
||||||
|
|
||||||
@@ -64,7 +63,31 @@ function Shell() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return pick(route);
|
const isApp = route.startsWith('/app');
|
||||||
|
const isLibrary = route.startsWith('/library');
|
||||||
|
const isOther = !isApp && !isLibrary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: isApp ? 'block' : 'none', height: '100%' }}>
|
||||||
|
<Workspace />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: isLibrary ? 'block' : 'none', height: '100%' }}>
|
||||||
|
<Library />
|
||||||
|
</div>
|
||||||
|
{isOther ? (
|
||||||
|
route.startsWith('/admin') ? (
|
||||||
|
<Admin />
|
||||||
|
) : route.startsWith('/photos') ? (
|
||||||
|
<PhotosPage />
|
||||||
|
) : route.startsWith('/profile') ? (
|
||||||
|
<ProfilePage />
|
||||||
|
) : (
|
||||||
|
<Landing />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
|
|||||||
Reference in New Issue
Block a user