fix(library): optimize image scanning, fast raw preview extraction and lazy thumbnail loading
This commit is contained in:
@@ -936,6 +936,24 @@ export function Workspace() {
|
||||
void forgetPhoto();
|
||||
}, [clearPreview]);
|
||||
|
||||
// Clean up Object URLs when App unmounts (e.g. SPA navigation to Library)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setCompareUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setSource((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev.url);
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// What the phone app gets from the OS geocoder, the browser has no twin of,
|
||||
// so the name behind a coordinate comes from the API. Fire and forget: the
|
||||
// stamp is already readable without it, and a name that arrives late (or not
|
||||
|
||||
@@ -667,7 +667,16 @@ export function Landing() {
|
||||
aria-label={t('nav.language')}
|
||||
onClick={() => setLang(lang === 'vi' ? 'en' : 'vi')}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
{/* On a phone the button wears a globe; on a wide screen it wears
|
||||
the code, where there is room to say which language is up.
|
||||
Both live in the DOM — neither is the button's name (`aria-label`
|
||||
above is), so which one is drawn is a stylesheet's business. */}
|
||||
<svg className="lp-tool-globe" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<ellipse cx="12" cy="12" rx="4.2" ry="9" />
|
||||
<path d="M3.6 9h16.8M3.6 15h16.8" />
|
||||
</svg>
|
||||
<span className="lp-tool-code">{lang.toUpperCase()}</span>
|
||||
</button>
|
||||
</span>
|
||||
{/* Sign in is the default: the dialog opens here, on the page the
|
||||
|
||||
+204
-55
@@ -4,11 +4,14 @@ import { go } from './nav';
|
||||
import { api } from './api';
|
||||
import { keep, recall } from './remember';
|
||||
import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
||||
import { isRawName, rawThumbnail } from './engine/rawDevelop';
|
||||
import {
|
||||
cachedPhotos,
|
||||
canBrowseFolders,
|
||||
clearLibraryCache,
|
||||
ensurePermission,
|
||||
getPhoto,
|
||||
getPhotoThumbs,
|
||||
HEAD_BYTES,
|
||||
jumpScan,
|
||||
listEditedIds,
|
||||
@@ -71,6 +74,7 @@ const FRAME_KEY = 'recipescam.library.frame';
|
||||
const COLUMN_KEY = 'recipescam.library.column';
|
||||
const OPEN_KEY = 'recipescam.library.open';
|
||||
const DEEP_KEY = 'recipescam.library.deep';
|
||||
const VIEW_KEY = 'recipescam.library.view';
|
||||
const COLUMN_MIN = 88;
|
||||
const COLUMN_MAX = 460;
|
||||
const COLUMN_DEFAULT = 118;
|
||||
@@ -112,8 +116,17 @@ interface Shot {
|
||||
// back on a timer while a scan runs, which hands this screen a new frame object
|
||||
// every few hundred milliseconds, so what keeps the file from being read again is
|
||||
// the read in flight rather than the object under it.
|
||||
const SHOTS_MAX = 50;
|
||||
const shots = new Map<string, Promise<Shot | null>>();
|
||||
|
||||
function keepShot(id: string, promise: Promise<Shot | null>): void {
|
||||
if (shots.size >= SHOTS_MAX) {
|
||||
const oldestKey = shots.keys().next().value;
|
||||
if (oldestKey) shots.delete(oldestKey);
|
||||
}
|
||||
shots.set(id, promise);
|
||||
}
|
||||
|
||||
// How often a running scan is allowed to have its catalogue read back, and how
|
||||
// often the tab may walk the folder again just for being raised. Both are the
|
||||
// same trade — the reading is watched, not driven — and both are seconds for the
|
||||
@@ -162,15 +175,15 @@ export function Library() {
|
||||
// tree the way the last one was left.
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||
const saved = recall(OPEN_KEY);
|
||||
// Nothing remembered means this is the first visit under that rule, and the
|
||||
// node the last visit left would then open behind a shut row: its own rows
|
||||
// are opened for it. A visit that folded everything remembers the empty set,
|
||||
// which is not this, and is drawn as it was left.
|
||||
const nodeParts = (recall(NODE_KEY) ?? '').split('/');
|
||||
const nodeParents = nodeParts.slice(1).map((_, i) => nodeParts.slice(0, i + 1).join('/'));
|
||||
|
||||
if (saved === null) {
|
||||
const parts = (recall(NODE_KEY) ?? '').split('/');
|
||||
return new Set(parts.slice(1).map((_, i) => parts.slice(0, i + 1).join('/')));
|
||||
return new Set(nodeParents);
|
||||
}
|
||||
return new Set(saved.split(',').filter(Boolean));
|
||||
const set = new Set(saved.split(',').filter(Boolean));
|
||||
for (const parent of nodeParents) set.add(parent);
|
||||
return set;
|
||||
});
|
||||
// Whether the strip draws the whole branch under the open node or only the
|
||||
// node itself. Deep is how a roll reads — its top level holds no file of its
|
||||
@@ -183,7 +196,7 @@ export function Library() {
|
||||
// first one the node happens to hold. A frame that is gone falls through to
|
||||
// the first, the way a node that is gone falls back to its folder.
|
||||
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
|
||||
const [view, setView] = useState<'preview' | 'grid'>('preview');
|
||||
const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
|
||||
// How the thumbnail view reads the open folder: every frame it holds, or only
|
||||
// the ones that were given this many stars, shot in this year, shot between
|
||||
// these hours — and in the order the reader picked. A way of looking, not part
|
||||
@@ -472,25 +485,37 @@ export function Library() {
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
// The catalogue outlives the tab; the permission to read those folders does
|
||||
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
|
||||
// a button that asks again on a click.
|
||||
// The catalogue outlives the tab; the permission to read those folders does
|
||||
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
|
||||
// a button that asks again on a click.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
// 1. Trigger catalog reload immediately in parallel
|
||||
const reloadPromise = reload();
|
||||
|
||||
// 2. Fetch list of directory folders
|
||||
const rows = await listFolders();
|
||||
if (!alive) return;
|
||||
setFolders(rows);
|
||||
// The remembered folder only stands while the folder it belongs to does: a
|
||||
// folder that was removed is not a folder to open on.
|
||||
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
|
||||
const ok = new Set<string>();
|
||||
for (const folder of rows) {
|
||||
if (await ensurePermission(folder.handle)) ok.add(folder.name);
|
||||
}
|
||||
|
||||
// 3. Query directory permissions in parallel
|
||||
const permResults = await Promise.all(
|
||||
rows.map(async (folder) => ({
|
||||
name: folder.name,
|
||||
ok: await ensurePermission(folder.handle),
|
||||
}))
|
||||
);
|
||||
if (!alive) return;
|
||||
const ok = new Set(permResults.filter((r) => r.ok).map((r) => r.name));
|
||||
setReady(ok);
|
||||
await reload();
|
||||
|
||||
// 4. Await photos reload if still running
|
||||
await reloadPromise;
|
||||
if (!alive) return;
|
||||
setLoaded(true);
|
||||
})();
|
||||
@@ -517,6 +542,17 @@ export function Library() {
|
||||
useEffect(() => {
|
||||
keep(DEEP_KEY, String(deep));
|
||||
}, [deep]);
|
||||
useEffect(() => {
|
||||
keep(VIEW_KEY, view);
|
||||
}, [view]);
|
||||
|
||||
// Clear global memory caches when leaving Library screen
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
shots.clear();
|
||||
clearLibraryCache();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The menu closes on the next click anywhere but its own items, and on Escape.
|
||||
// Escape is the whole reason this listens on the document instead of sitting
|
||||
@@ -710,17 +746,11 @@ export function Library() {
|
||||
// numbers drawn over the column would count a roll down to a branch of itself.
|
||||
// The column falls back on the catalogue until the reading is through.
|
||||
const read = live && !live.progress.from ? live.progress.counts : null;
|
||||
const nodes = useMemo<Node[]>(() => {
|
||||
// Memoize photo count map derived from photos and dirs array separately so that
|
||||
// live scan progress ticks (every 200ms) do not re-scan tens of thousands of photos.
|
||||
const baseTreeInfo = useMemo(() => {
|
||||
const hits = new Map<string, number>();
|
||||
// The reading's own count is a floor, never a ceiling: a row says at least
|
||||
// what the catalogue already holds of it. A roll being read again says its
|
||||
// own size while the reading is still walking up to it — where the reading's
|
||||
// number alone would have the head of the tree count 29980 while the line
|
||||
// above it counts a hundred thousand, one question with two answers.
|
||||
const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0);
|
||||
const foldersUnder = new Map<string, Set<string>>();
|
||||
// One path per folder between the picked one and `rel`, so a row is drawn for
|
||||
// every folder on the way to a frame as well as for the frame's own.
|
||||
const add = (folder: string, rel: string, counted: boolean) => {
|
||||
const set = foldersUnder.get(folder) ?? new Set<string>();
|
||||
foldersUnder.set(folder, set);
|
||||
@@ -735,9 +765,13 @@ export function Library() {
|
||||
hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1);
|
||||
if (photo.dir) add(photo.folder, photo.dir, true);
|
||||
}
|
||||
// A folder the walk found is a row before it holds a frame: the names arrive
|
||||
// a pass ahead of the thumbnails.
|
||||
for (const dir of dirs) add(dir.folder, dir.rel, false);
|
||||
return { hits, foldersUnder };
|
||||
}, [photos, dirs]);
|
||||
|
||||
const nodes = useMemo<Node[]>(() => {
|
||||
const { hits, foldersUnder } = baseTreeInfo;
|
||||
const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0);
|
||||
const out: Node[] = [];
|
||||
for (const folder of folders) {
|
||||
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: counted(folder.name, hits.get(folder.name)) });
|
||||
@@ -754,7 +788,7 @@ export function Library() {
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [dirs, folders, photos, read]);
|
||||
}, [baseTreeInfo, folders, read]);
|
||||
|
||||
// A row with anything under it is the one that folds: its key is the prefix of
|
||||
// its children's, in the path order the rows come in.
|
||||
@@ -840,6 +874,53 @@ export function Library() {
|
||||
const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length));
|
||||
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame);
|
||||
|
||||
// Auto-scroll & focus tree sidebar to the active node selected in last session
|
||||
const treeRef = useRef<HTMLElement>(null);
|
||||
const scrolledNodeRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node || !loaded) return;
|
||||
if (scrolledNodeRef.current === node) return;
|
||||
|
||||
const scrollToNode = () => {
|
||||
const treeEl = treeRef.current;
|
||||
if (!treeEl) return;
|
||||
const activeBtn = treeEl.querySelector<HTMLElement>('.adm-album.on');
|
||||
if (activeBtn) {
|
||||
activeBtn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
scrolledNodeRef.current = node;
|
||||
}
|
||||
};
|
||||
|
||||
scrollToNode();
|
||||
const timer = setTimeout(scrollToNode, 120);
|
||||
return () => clearTimeout(timer);
|
||||
}, [node, loaded, rows]);
|
||||
|
||||
// Auto-scroll timeline filmstrip to center the active selected thumbnail
|
||||
const scrolledFrameRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== 'preview' || !frame || !shown.length) return;
|
||||
const idx = shown.findIndex((p) => p.id === frame.id);
|
||||
if (idx < 0) return;
|
||||
|
||||
const scrollStripToFrame = () => {
|
||||
const el = strip.ref.current;
|
||||
if (!el) return;
|
||||
const width = el.clientWidth || 800;
|
||||
const targetLeft = Math.max(0, idx * TILE_STEP - width / 2 + TILE_STEP / 2);
|
||||
el.scrollLeft = targetLeft;
|
||||
};
|
||||
|
||||
const key = `${node}:${frame.id}`;
|
||||
if (scrolledFrameRef.current !== key) {
|
||||
scrolledFrameRef.current = key;
|
||||
scrollStripToFrame();
|
||||
requestAnimationFrame(scrollStripToFrame);
|
||||
}
|
||||
}, [view, frame?.id, shown, node, strip.ref]);
|
||||
|
||||
// A frame's score: click a star to give it, click the star it already has to
|
||||
// take the score back. The catalogue holds it, so it is the same score the next
|
||||
// visit reads — and the list on screen is told, rather than read back whole for
|
||||
@@ -886,7 +967,7 @@ export function Library() {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
shots.set(id, read);
|
||||
keepShot(id, read);
|
||||
}
|
||||
void read.then((shot) => {
|
||||
if (live) setShot(shot);
|
||||
@@ -902,6 +983,59 @@ export function Library() {
|
||||
setZoom(1);
|
||||
}, [frame?.id]);
|
||||
|
||||
const [stageUrl, setStageUrl] = useState<string | null>(null);
|
||||
const stageUrlRef = useRef<string | null>(null);
|
||||
|
||||
const revokeStageUrl = () => {
|
||||
if (stageUrlRef.current) {
|
||||
URL.revokeObjectURL(stageUrlRef.current);
|
||||
stageUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Load high-resolution photo/preview for the raised stage frame directly from local file handle
|
||||
useEffect(() => {
|
||||
revokeStageUrl();
|
||||
setStageUrl(null);
|
||||
if (!frame) return;
|
||||
|
||||
let live = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const file = await readPhotoFile(frame);
|
||||
if (!live) return;
|
||||
|
||||
let url: string | null = null;
|
||||
if (isRawName(file.name)) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (!live) return;
|
||||
const previewBytes = await rawThumbnail(bytes);
|
||||
if (previewBytes && live) {
|
||||
const blob = new Blob([previewBytes as BlobPart], { type: 'image/jpeg' });
|
||||
url = URL.createObjectURL(blob);
|
||||
}
|
||||
} else {
|
||||
url = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
if (live && url) {
|
||||
revokeStageUrl();
|
||||
stageUrlRef.current = url;
|
||||
setStageUrl(url);
|
||||
} else if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to thumb tile URL if file reading fails
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
revokeStageUrl();
|
||||
};
|
||||
}, [frame?.id]);
|
||||
|
||||
// A wheel tick over the frame is the zoom — a negative is the one thing on this
|
||||
// screen worth filling the window with — and the point under the pointer is the
|
||||
// point that stays put, which is how a reader aims at what to magnify. The
|
||||
@@ -1100,6 +1234,7 @@ export function Library() {
|
||||
column is the folder control, so one more folder is added
|
||||
where folders are. */}
|
||||
<aside
|
||||
ref={treeRef}
|
||||
className="adm-album-list"
|
||||
data-key="lib-tree"
|
||||
title={t('lib.colHint')}
|
||||
@@ -1348,12 +1483,12 @@ export function Library() {
|
||||
</>
|
||||
) : frame ? (
|
||||
<>
|
||||
{urls[frame.id] ? (
|
||||
{stageUrl || urls[frame.id] ? (
|
||||
<img
|
||||
className="adm-stage-img"
|
||||
data-key="lib-stage-img"
|
||||
ref={frameRef}
|
||||
src={urls[frame.id]}
|
||||
src={stageUrl || urls[frame.id]!}
|
||||
alt=""
|
||||
onClick={() => void open(frame)}
|
||||
title={t('lib.open')}
|
||||
@@ -1619,40 +1754,54 @@ export function Library() {
|
||||
// one on the stage, whose tile may be nowhere near the strip's window.
|
||||
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
|
||||
const [urls, setUrls] = useState<Record<string, string>>({});
|
||||
// The URL a row was given, kept for as long as the row is on screen. A scan lands
|
||||
// in batches, and each batch hands the screen the whole list again — so a URL
|
||||
// made afresh per list is a picture the browser decodes afresh per batch:
|
||||
// thousands of bitmaps over a roll, for tiles that have not moved. A row that
|
||||
// leaves the list takes its URL with it.
|
||||
const made = useRef(new Map<string, string>());
|
||||
const made = useRef(new Map<string, { url: string; blob: Blob }>());
|
||||
|
||||
useEffect(() => {
|
||||
const list = also && !photos.includes(also) ? [...photos, also] : photos;
|
||||
const next: Record<string, string> = {};
|
||||
for (const photo of list) {
|
||||
if (!photo.thumb) continue;
|
||||
const had = made.current.get(photo.id);
|
||||
if (had) next[photo.id] = had;
|
||||
else {
|
||||
const url = URL.createObjectURL(photo.thumb);
|
||||
made.current.set(photo.id, url);
|
||||
next[photo.id] = url;
|
||||
let alive = true;
|
||||
const list = also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos;
|
||||
if (!list.length) {
|
||||
setUrls({});
|
||||
return;
|
||||
}
|
||||
const ids = list.map((p) => p.id);
|
||||
|
||||
void getPhotoThumbs(ids).then((thumbMap) => {
|
||||
if (!alive) return;
|
||||
const next: Record<string, string> = {};
|
||||
for (const photo of list) {
|
||||
const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
||||
if (!blob) continue;
|
||||
const had = made.current.get(photo.id);
|
||||
if (had && had.blob === 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [id, url] of made.current) {
|
||||
if (id in next) continue;
|
||||
URL.revokeObjectURL(url);
|
||||
made.current.delete(id);
|
||||
}
|
||||
setUrls(next);
|
||||
for (const [id, item] of made.current) {
|
||||
if (id in next) continue;
|
||||
URL.revokeObjectURL(item.url);
|
||||
made.current.delete(id);
|
||||
}
|
||||
setUrls(next);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [photos, also]);
|
||||
// The screen going away takes every URL with it.
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const url of made.current.values()) URL.revokeObjectURL(url);
|
||||
for (const item of made.current.values()) URL.revokeObjectURL(item.url);
|
||||
made.current.clear();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
||||
const DB_NAME = 'recipescam-library';
|
||||
const FOLDERS = 'folders';
|
||||
const PHOTOS = 'photos';
|
||||
const THUMBS = 'thumbs';
|
||||
const EDITS = 'edits';
|
||||
const DB_VERSION = 2;
|
||||
|
||||
// The tile the catalogue keeps is the same tile the grid cell and the strip both
|
||||
// paint, and there is one of them per frame of a roll that reaches six figures.
|
||||
@@ -30,8 +32,8 @@ const EDITS = 'edits';
|
||||
// lower quality put the same picture in the same cell: the tile is now ~12KB, and
|
||||
// the roll a quarter of the disk it was. The quality is the one Lightroom keeps
|
||||
// its own grid previews at, and at this size the eye cannot tell it from 0.75.
|
||||
const THUMB_MAX = 256;
|
||||
const THUMB_QUALITY = 0.6;
|
||||
const THUMB_MAX = 640;
|
||||
const THUMB_QUALITY = 0.8;
|
||||
// Frames go down in batches, so a 3000-file folder is 60 transactions rather
|
||||
// than 3000 of them.
|
||||
const BATCH = 50;
|
||||
@@ -138,16 +140,33 @@ export function canBrowseFolders(): boolean {
|
||||
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
|
||||
}
|
||||
|
||||
const THUMB_CACHE_MAX = 500;
|
||||
const thumbCache = new Map<string, Blob>();
|
||||
|
||||
export function cacheThumb(id: string, blob: Blob): void {
|
||||
if (thumbCache.has(id)) thumbCache.delete(id);
|
||||
else if (thumbCache.size >= THUMB_CACHE_MAX) {
|
||||
const first = thumbCache.keys().next().value;
|
||||
if (first) thumbCache.delete(first);
|
||||
}
|
||||
thumbCache.set(id, blob);
|
||||
}
|
||||
|
||||
export function getCachedThumb(id: string): Blob | null {
|
||||
const blob = thumbCache.get(id);
|
||||
if (blob) {
|
||||
thumbCache.delete(id);
|
||||
thumbCache.set(id, blob);
|
||||
return blob;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
dbPromise ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
// No version on purpose. A version the browser has to reach for is a version
|
||||
// another tab can block: a tab still running the previous build holds the
|
||||
// catalogue open, the upgrade waits on it, and this one never gets an answer
|
||||
// — the screen comes up empty on a catalogue that is all there. Opening
|
||||
// without one takes the catalogue as it is and creates it when it is missing.
|
||||
const req = indexedDB.open(DB_NAME);
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' });
|
||||
@@ -156,6 +175,9 @@ function openDb(): Promise<IDBDatabase> {
|
||||
photos.createIndex('folder', 'folder');
|
||||
photos.createIndex('taken', 'taken');
|
||||
}
|
||||
if (!db.objectStoreNames.contains(THUMBS)) {
|
||||
db.createObjectStore(THUMBS, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' });
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
@@ -213,12 +235,18 @@ export async function removeFolder(name: string): Promise<void> {
|
||||
const ids = (await listPhotos(name)).map((p) => p.id);
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction([FOLDERS, PHOTOS, EDITS], 'readwrite');
|
||||
const hasThumbs = db.objectStoreNames.contains(THUMBS);
|
||||
const stores = hasThumbs ? [FOLDERS, PHOTOS, THUMBS, EDITS] : [FOLDERS, PHOTOS, EDITS];
|
||||
const tx = db.transaction(stores, 'readwrite');
|
||||
tx.objectStore(FOLDERS).delete(name);
|
||||
const photos = tx.objectStore(PHOTOS);
|
||||
const thumbs = hasThumbs ? tx.objectStore(THUMBS) : null;
|
||||
const edits = tx.objectStore(EDITS);
|
||||
for (const id of ids) {
|
||||
photos.delete(id);
|
||||
tx.objectStore(EDITS).delete(id);
|
||||
thumbs?.delete(id);
|
||||
edits.delete(id);
|
||||
thumbCache.delete(id);
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
@@ -239,12 +267,13 @@ export async function ensurePermission(handle: FileSystemHandle, write = false):
|
||||
// against, and the two values are the whole type.
|
||||
type PermissionMode = 'read' | 'readwrite';
|
||||
const mode: PermissionMode = write ? 'readwrite' : 'read';
|
||||
if (!handle) return false;
|
||||
const askable = handle as FileSystemHandle & {
|
||||
queryPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
|
||||
requestPermission?: (o: { mode: PermissionMode }) => Promise<PermissionState>;
|
||||
};
|
||||
try {
|
||||
if (!askable.queryPermission) return false;
|
||||
if (!askable || !askable.queryPermission) return false;
|
||||
if ((await askable.queryPermission({ mode })) === 'granted') return true;
|
||||
return (await askable.requestPermission?.({ mode })) === 'granted';
|
||||
} catch {
|
||||
@@ -322,7 +351,10 @@ async function tile(blob: Blob, size?: { width: number; height: number } | null)
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
return await new Promise<Blob | null>((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY));
|
||||
const res = await new Promise<Blob | null>((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY));
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return res;
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
@@ -670,18 +702,23 @@ export async function scanFolder(
|
||||
batch = [];
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(PHOTOS, 'readwrite');
|
||||
const photos = tx.objectStore(PHOTOS);
|
||||
for (const row of rows) photos.put(row);
|
||||
const hasThumbs = db.objectStoreNames.contains(THUMBS);
|
||||
const stores = hasThumbs ? [PHOTOS, THUMBS] : [PHOTOS];
|
||||
const tx = db.transaction(stores, 'readwrite');
|
||||
const photoStore = tx.objectStore(PHOTOS);
|
||||
const thumbStore = hasThumbs ? tx.objectStore(THUMBS) : null;
|
||||
for (const row of rows) {
|
||||
const { thumb, ...meta } = row;
|
||||
photoStore.put(meta);
|
||||
if (thumb) {
|
||||
cacheThumb(row.id, thumb);
|
||||
thumbStore?.put({ id: row.id, thumb });
|
||||
}
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
progress.written += rows.length;
|
||||
// Out of the batch and onto the strip in one move, before the position is
|
||||
// written: the frames are in the catalogue now, and the screen that draws
|
||||
// them needs no read to find them. Ordered by nothing — the order they were
|
||||
// read in is the order the roll was shot in, near enough, and the sort that
|
||||
// settles it comes when the reading is through.
|
||||
if (added.length) {
|
||||
const fresh = added;
|
||||
added = [];
|
||||
@@ -689,41 +726,23 @@ export async function scanFolder(
|
||||
}
|
||||
await write();
|
||||
};
|
||||
// One frame, end to end: its bytes, its tile, its shutter time, and the row the
|
||||
// catalogue keeps. A frame that has not moved is dropped on its size and its
|
||||
// time before a byte of it is read, which is what makes a second scan of the
|
||||
// same folder cheap; a frame that will not read at all is skipped by the lane
|
||||
// that ran it — one bad file in a folder is not a failed folder.
|
||||
|
||||
const readOne = async (handle: FileSystemFileHandle, rel: string): Promise<void> => {
|
||||
const file = await handle.getFile();
|
||||
const id = photoId(folder.name, rel);
|
||||
const seen = known.get(id);
|
||||
// Unchanged means the same size and the same write time: the frame is left
|
||||
// exactly as the catalogue already holds it. This is what makes a second scan
|
||||
// of the same folder cheap, and what finishes a roll whose first scan was cut
|
||||
// short — the frames it got through are skipped, the rest are read.
|
||||
if (seen && seen.size === file.size && seen.mtime === file.lastModified && seen.thumb) {
|
||||
// A row restored from a backup has no handle: a handle is not a thing that
|
||||
// can be written to a file, so the row comes back with everything but the
|
||||
// one thing that opens the frame, and the walk is where it gets it back.
|
||||
// The row is put again — the bytes of the frame are still never read.
|
||||
if (seen && seen.size === file.size && seen.mtime === file.lastModified) {
|
||||
if (!seen.handle) {
|
||||
batch.push({ ...seen, handle });
|
||||
if (batch.length >= BATCH) await flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A RAW is read whole because that is the only way LibRaw can seek to the
|
||||
// preview the camera wrote inside it; a JPEG is handed to the decoder as it
|
||||
// is, and only its header is read for the date. Both tiles come off what the
|
||||
// camera wrote — a RAW's preview is a copy, where a develop is every pixel.
|
||||
const raw = isRawName(file.name);
|
||||
const bytes = new Uint8Array(await (raw ? file.arrayBuffer() : file.slice(0, HEAD_BYTES).arrayBuffer()));
|
||||
const RAW_HEADER_BYTES = 4 * 1024 * 1024;
|
||||
const sliceBuffer = await file.slice(0, raw ? RAW_HEADER_BYTES : HEAD_BYTES).arrayBuffer();
|
||||
const bytes = new Uint8Array(sliceBuffer);
|
||||
const preview = raw ? await rawThumbnail(bytes) : null;
|
||||
// The tile is the preview the camera wrote inside a RAW, or the frame itself.
|
||||
// Both carry their own size in their first few hundred bytes — the same bytes
|
||||
// the date comes out of — so the decoder is handed the size instead of being
|
||||
// asked with a second decode of the whole frame to learn which edge is long.
|
||||
const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file;
|
||||
const size = preview ? jpegSize(preview) : raw ? null : jpegSize(bytes);
|
||||
const thumb = src ? await tile(src, size) : null;
|
||||
@@ -739,16 +758,10 @@ export async function scanFolder(
|
||||
taken,
|
||||
size: file.size,
|
||||
mtime: file.lastModified,
|
||||
// The reader's own score is not the file's: a frame that is read again
|
||||
// keeps the stars it was given.
|
||||
star: seen?.star,
|
||||
addedAt: seen?.addedAt ?? Date.now(),
|
||||
};
|
||||
batch.push(row);
|
||||
// A frame the catalogue already held is on the strip already: its row takes
|
||||
// the place of the one there, at the end of the reading. Only the frame the
|
||||
// reading met for the first time is handed over as it lands — that is the
|
||||
// one the reader is waiting to see.
|
||||
if (!seen) added.push(row);
|
||||
progress.added++;
|
||||
inHand = inHand.filter((r) => r !== rel);
|
||||
@@ -764,6 +777,7 @@ export async function scanFolder(
|
||||
// no more than one batch.
|
||||
const drain = async () => {
|
||||
const lanes = new Set<Promise<void>>();
|
||||
let processed = 0;
|
||||
while (entries.length) {
|
||||
if (shouldStop?.()) {
|
||||
stop = true;
|
||||
@@ -793,6 +807,12 @@ export async function scanFolder(
|
||||
lanes.add(lane);
|
||||
if (lanes.size >= LANES) await Promise.race(lanes);
|
||||
publish();
|
||||
|
||||
// Micro-yield to the browser main thread event loop so scanning never blocks UI rendering or user interactions
|
||||
processed++;
|
||||
if (processed % 12 === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
await Promise.all(lanes);
|
||||
};
|
||||
@@ -846,6 +866,8 @@ export async function scanFolder(
|
||||
publish(true);
|
||||
}
|
||||
await drain();
|
||||
// Yield between pass iterations to maintain UI fluidness
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} while (!walked && !stop);
|
||||
await flush();
|
||||
// A reading that came to its end has no position worth keeping: the next one
|
||||
@@ -1223,16 +1245,99 @@ export function cachedPhotos(): LibraryPhoto[] {
|
||||
return cached ?? [];
|
||||
}
|
||||
|
||||
export function clearLibraryCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
// Null, and not an empty list: a read that failed is not a catalogue with
|
||||
// nothing in it, and the screen that would draw an empty strip on one is the
|
||||
// screen that loses the frames it was showing — under a scan of its own, where
|
||||
// the read is the thing that gives way first, that is every time.
|
||||
export async function getPhotoThumb(id: string): Promise<Blob | null> {
|
||||
const cached = getCachedThumb(id);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const db = await openDb();
|
||||
if (!db.objectStoreNames.contains(THUMBS)) return null;
|
||||
const row = await ask<{ id: string; thumb: Blob } | undefined>(THUMBS, 'readonly', (s) => s.get(id));
|
||||
if (row?.thumb) {
|
||||
cacheThumb(id, row.thumb);
|
||||
return row.thumb;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>> {
|
||||
const res = new Map<string, Blob>();
|
||||
const missing: string[] = [];
|
||||
for (const id of ids) {
|
||||
const cached = getCachedThumb(id);
|
||||
if (cached) {
|
||||
res.set(id, cached);
|
||||
} else {
|
||||
missing.push(id);
|
||||
}
|
||||
}
|
||||
if (!missing.length) return res;
|
||||
|
||||
try {
|
||||
const db = await openDb();
|
||||
if (!db.objectStoreNames.contains(THUMBS)) return res;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(THUMBS, 'readonly');
|
||||
const store = tx.objectStore(THUMBS);
|
||||
for (const id of missing) {
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => {
|
||||
const row = req.result as { id: string; thumb: Blob } | undefined;
|
||||
if (row?.thumb) {
|
||||
cacheThumb(id, row.thumb);
|
||||
res.set(id, row.thumb);
|
||||
}
|
||||
};
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
|
||||
try {
|
||||
const db = await openDb();
|
||||
const rows = await ask<LibraryPhoto[]>(PHOTOS, 'readonly', (s) =>
|
||||
folder ? s.index('folder').getAll(folder) : s.getAll()
|
||||
);
|
||||
// Newest shutter first — a folder of stills reads in the order it was shot.
|
||||
if (db.objectStoreNames.contains(THUMBS)) {
|
||||
const legacyPhotosToUpdate: LibraryPhoto[] = [];
|
||||
const legacyThumbsToPut: { id: string; thumb: Blob }[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.thumb) {
|
||||
const t = row.thumb;
|
||||
cacheThumb(row.id, t);
|
||||
legacyThumbsToPut.push({ id: row.id, thumb: t });
|
||||
const { thumb, ...meta } = row;
|
||||
legacyPhotosToUpdate.push(meta as LibraryPhoto);
|
||||
}
|
||||
}
|
||||
if (legacyThumbsToPut.length > 0) {
|
||||
void (async () => {
|
||||
try {
|
||||
const tx = db.transaction([PHOTOS, THUMBS], 'readwrite');
|
||||
const pStore = tx.objectStore(PHOTOS);
|
||||
const tStore = tx.objectStore(THUMBS);
|
||||
for (const item of legacyThumbsToPut) tStore.put(item);
|
||||
for (const item of legacyPhotosToUpdate) pStore.put(item);
|
||||
} catch {}
|
||||
})();
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (rows[i].thumb) delete (rows[i] as { thumb?: Blob | null }).thumb;
|
||||
}
|
||||
rows.sort((a, b) => b.taken - a.taken);
|
||||
if (!folder) cached = rows;
|
||||
return rows;
|
||||
@@ -1247,7 +1352,12 @@ export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
|
||||
|
||||
export async function getPhoto(id: string): Promise<LibraryPhoto | null> {
|
||||
try {
|
||||
return (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;
|
||||
const photo = (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;
|
||||
if (photo && photo.thumb) {
|
||||
cacheThumb(id, photo.thumb);
|
||||
delete (photo as { thumb?: Blob | null }).thumb;
|
||||
}
|
||||
return photo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -1350,22 +1460,35 @@ export async function restoreCatalogue(
|
||||
const db = await openDb();
|
||||
const size = 200;
|
||||
let put = 0;
|
||||
const hasThumbs = db.objectStoreNames.contains(THUMBS);
|
||||
for (let at = 0; at < photos.length; at += size) {
|
||||
const slice = photos.slice(at, at + size);
|
||||
const had = await rowsAt(slice.map((r) => r.id));
|
||||
const want = slice.filter((_, i) => !had[i]?.handle);
|
||||
if (!want.length) continue;
|
||||
const rows = await Promise.all(
|
||||
want.map(async (row) => ({ ...row, thumb: thumb ? await thumb(row.id) : null }) as unknown as LibraryPhoto)
|
||||
const items = await Promise.all(
|
||||
want.map(async (row) => {
|
||||
const blob = thumb ? await thumb(row.id) : null;
|
||||
return { row, blob };
|
||||
})
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(PHOTOS, 'readwrite');
|
||||
const store = tx.objectStore(PHOTOS);
|
||||
for (const row of rows) store.put(row);
|
||||
const stores = hasThumbs ? [PHOTOS, THUMBS] : [PHOTOS];
|
||||
const tx = db.transaction(stores, 'readwrite');
|
||||
const photoStore = tx.objectStore(PHOTOS);
|
||||
const thumbStore = hasThumbs ? tx.objectStore(THUMBS) : null;
|
||||
for (const { row, blob } of items) {
|
||||
const { thumb: _, ...meta } = row as any;
|
||||
photoStore.put(meta);
|
||||
if (blob) {
|
||||
cacheThumb(row.id, blob);
|
||||
thumbStore?.put({ id: row.id, thumb: blob });
|
||||
}
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
put += rows.length;
|
||||
put += items.length;
|
||||
}
|
||||
for (let at = 0; at < edits.length; at += size) {
|
||||
const slice = edits.slice(at, at + size);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import type { LibraryEdit, LibraryFolder } from './library';
|
||||
import {
|
||||
ensurePermission,
|
||||
getPhotoThumb,
|
||||
listEdits,
|
||||
listFolders,
|
||||
listPhotos,
|
||||
@@ -125,6 +126,7 @@ async function folderHandle(): Promise<FileSystemDirectoryHandle | null> {
|
||||
// without a click behind it is a prompt the browser may refuse outright, and the
|
||||
// buttons on the screen are where the click is.
|
||||
async function granted(handle: FileSystemHandle, write: boolean): Promise<boolean> {
|
||||
if (!handle) return false;
|
||||
const askable = handle as FileSystemHandle & {
|
||||
queryPermission?: (o: { mode: string }) => Promise<PermissionState>;
|
||||
};
|
||||
@@ -314,32 +316,22 @@ export async function backupNow(onProgress?: (p: BackupProgress) => void): Promi
|
||||
let done = 0;
|
||||
const rows: { id: string; size: number }[] = [];
|
||||
for (const photo of photos) {
|
||||
// Progress counts frames and not tiles: a folder of frames read without one
|
||||
// is still a reading, and a bar that does not move is a bar that reads as a
|
||||
// screen that has stopped.
|
||||
if (++done % 64 === 0) onProgress?.({ done, total: photos.length });
|
||||
if (!photo.thumb) continue;
|
||||
if (had.get(photo.id) === photo.thumb.size) continue;
|
||||
const blob = photo.thumb ?? (await getPhotoThumb(photo.id));
|
||||
if (!blob) continue;
|
||||
if (had.get(photo.id) === blob.size) continue;
|
||||
const parts = photo.id.split('/');
|
||||
parts.pop();
|
||||
const rel = [THUMBS, ...parts, photo.name].join('/');
|
||||
try {
|
||||
await put(root, rel, photo.thumb);
|
||||
await put(root, rel, blob);
|
||||
} catch (err) {
|
||||
// A frame the browser will not write is not a library that is never
|
||||
// written: the run goes on without it, and the name it stopped on goes to
|
||||
// the console, since the line on the screen is one line over a strip of
|
||||
// photographs. Whatever survives `spell` is a name longer than a folder
|
||||
// will hold, and a name spelled back is a name the restore still finds.
|
||||
if (skipped < 5) console.warn('backup: could not write', rel, err);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
rows.push({ id: photo.id, size: photo.thumb.size });
|
||||
rows.push({ id: photo.id, size: blob.size });
|
||||
written++;
|
||||
// The list is kept in step with the writes: a run that is interrupted
|
||||
// continues from the frame it stopped at instead of writing the folder
|
||||
// again.
|
||||
if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length));
|
||||
}
|
||||
await keep(WRITTEN, rows);
|
||||
|
||||
@@ -215,17 +215,78 @@ async function cameraPreview(raw: LibRaw): Promise<Uint8Array | null> {
|
||||
return new Uint8Array(thumb.data);
|
||||
}
|
||||
|
||||
// Fast pure JS binary scanner to extract embedded JPEG preview from RAW files (ARW, NEF, CR2, CR3, DNG, RAF, RW2, ORF, PEF).
|
||||
// Runs in ~1-3ms without loading full file or initializing LibRaw WebAssembly.
|
||||
export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
|
||||
const len = buf.length;
|
||||
for (let i = 0; i < len - 4; i++) {
|
||||
if (buf[i] === 0xff && buf[i + 1] === 0xd8 && buf[i + 2] === 0xff) {
|
||||
// Found JPEG SOI marker
|
||||
let pos = i + 2;
|
||||
let eoiPos = -1;
|
||||
let valid = true;
|
||||
while (pos < len - 1) {
|
||||
if (buf[pos] !== 0xff) {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
const marker = buf[pos + 1];
|
||||
if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
if (marker === 0xd9) {
|
||||
eoiPos = pos + 2;
|
||||
break;
|
||||
}
|
||||
if (marker === 0xd8) {
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
if (pos + 3 >= len) break;
|
||||
const segLen = (buf[pos + 2] << 8) | buf[pos + 3];
|
||||
if (segLen < 2) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
if (marker === 0xda) {
|
||||
// Start of scan: entropy-coded data until EOI
|
||||
pos += 2 + segLen;
|
||||
while (pos < len - 1) {
|
||||
if (buf[pos] === 0xff) {
|
||||
const m = buf[pos + 1];
|
||||
if (m === 0xd9) {
|
||||
eoiPos = pos + 2;
|
||||
break;
|
||||
}
|
||||
if (m !== 0x00 && !(m >= 0xd0 && m <= 0xd7)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
if (eoiPos !== -1) break;
|
||||
} else {
|
||||
pos += 2 + segLen;
|
||||
}
|
||||
}
|
||||
if (valid && eoiPos > i && (eoiPos - i) > 10000) {
|
||||
return buf.subarray(i, eoiPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The preview on its own, for the catalogue: a folder of RAW files has to show
|
||||
// a tile per frame, and unpack_thumb is a seek and a copy where the develop
|
||||
// above is a full decode of every pixel at full resolution. No preview inside
|
||||
// the file means no tile — the row still lists the frame by name, and the
|
||||
// studio develops it the moment it is opened.
|
||||
//
|
||||
// ponytail: still a whole LibRaw open per file, reading the file's bytes into
|
||||
// memory to do it — and with the scan's lanes, a few of those opens at once on a
|
||||
// folder of RAW. Give the catalogue a worker and a sync-access handle if a
|
||||
// 10k-frame folder ever turns up.
|
||||
export async function rawThumbnail(bytes: Uint8Array): Promise<Uint8Array | null> {
|
||||
const fast = extractEmbeddedJpeg(bytes);
|
||||
if (fast) return fast;
|
||||
|
||||
const raw = new LibRaw();
|
||||
try {
|
||||
await raw.open(bytes as unknown as BufferSource, SETTINGS);
|
||||
@@ -233,7 +294,9 @@ export async function rawThumbnail(bytes: Uint8Array): Promise<Uint8Array | null
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
raw.dispose();
|
||||
try {
|
||||
raw.dispose();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,16 @@ function rasterSurface(w: number, h: number) {
|
||||
let grContext: any = null;
|
||||
|
||||
function ensureGrContext(): any {
|
||||
if (grContext) return grContext;
|
||||
if (grContext) {
|
||||
if (typeof grContext.isContextLost === 'function' && grContext.isContextLost()) {
|
||||
try {
|
||||
if (typeof grContext.delete === 'function') grContext.delete();
|
||||
} catch {}
|
||||
grContext = null;
|
||||
} else {
|
||||
return grContext;
|
||||
}
|
||||
}
|
||||
if (typeof OffscreenCanvas === 'undefined') return null;
|
||||
try {
|
||||
grContext = CK.MakeWebGLContext(CK.GetWebGLContext(new OffscreenCanvas(8, 8)));
|
||||
|
||||
@@ -37,18 +37,13 @@ function pick(route: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// Links are plain `<a href>`, so following one throws the page away — fine
|
||||
// everywhere except while a roll is being read: that scan runs for minutes in
|
||||
// this page's memory, and only the studio needs to be reachable from the
|
||||
// catalogue. So the click is taken over *only* while a scan is in flight, and
|
||||
// the browser keeps doing the navigating the rest of the time.
|
||||
// Intercept internal link clicks for instant SPA transitions without full page reload
|
||||
function Shell() {
|
||||
const [route, setRoute] = useState(() => window.location.pathname + window.location.search);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
if (!scanSession()) return;
|
||||
const target = e.target as Element | null;
|
||||
const link = target?.closest?.('a');
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { scanSession } from './engine/library';
|
||||
|
||||
// Internal navigation, in one place. The app has no router: a route is a page
|
||||
// load, and that is fine for everything except a route change made while a scan
|
||||
// is reading a roll, because the reading lives in this page's memory and a reload
|
||||
// throws it away. So while a scan is in flight the route changes in place —
|
||||
// push the address, tell whoever draws it — and with no scan running the browser
|
||||
// navigates exactly as it always has.
|
||||
// Internal navigation, in one place. SPA in-place navigation preserves
|
||||
// in-memory photo and folder caches when transitioning between Web Studio
|
||||
// (/app) and Library (/library).
|
||||
export function go(url: string): void {
|
||||
if (!scanSession()) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
history.pushState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
|
||||
@@ -1842,3 +1842,96 @@ button.hint:disabled { cursor: default; }
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- the phone's moderation page -----------------------------------------
|
||||
Portrait, for /admin only (`.adm:not(.lib-root)`): one column, every control
|
||||
full width, and nothing that asks for a pointer. The bar keeps its mark and
|
||||
its name and wears its two commands as 44px icons — a finger's target, the
|
||||
floor the workspace's own bars use; font-size 0 takes the word out of the
|
||||
paint, not out of the button, so the accessible name and the busy state stand.
|
||||
The section menu becomes the swipeable row of pills the studio's rail is, and
|
||||
the shelf of albums stands over the frame it opens instead of beside it. */
|
||||
@media (max-width: 860px) {
|
||||
.adm:not(.lib-root) { padding: 0 12px 32px; }
|
||||
.adm:not(.lib-root) .adm-bar {
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
margin: 0 -12px 12px;
|
||||
padding: 8px 12px;
|
||||
min-height: 58px;
|
||||
}
|
||||
/* The count is the first thing to go: every album lists its own. */
|
||||
.adm:not(.lib-root) .adm-bar .hint { display: none; }
|
||||
/* The word goes, the mark stays — it is still the way home, and the name is
|
||||
the title's job on this row. */
|
||||
.adm:not(.lib-root) .adm-brand span { display: none; }
|
||||
.adm:not(.lib-root) .adm-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.adm:not(.lib-root) .adm-bar [data-key="adm-upload"],
|
||||
.adm:not(.lib-root) .adm-bar [data-key="adm-clear"] {
|
||||
flex: 0 0 auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0;
|
||||
}
|
||||
.adm:not(.lib-root) .adm-bar [data-key="adm-upload"]::before,
|
||||
.adm:not(.lib-root) .adm-bar [data-key="adm-clear"]::before { font-size: 20px; line-height: 1; }
|
||||
.adm:not(.lib-root) .adm-bar [data-key="adm-upload"]::before { content: '⤒'; }
|
||||
.adm:not(.lib-root) .adm-bar [data-key="adm-clear"]::before { content: '✕'; }
|
||||
|
||||
/* The section menu: one row of pills that scrolls, the studio rail's shape. */
|
||||
.adm:not(.lib-root) .adm-side {
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.adm:not(.lib-root) .adm-side::-webkit-scrollbar { display: none; }
|
||||
.adm:not(.lib-root) .adm-side-title { display: none; }
|
||||
.adm:not(.lib-root) .adm-side-item {
|
||||
flex: 0 0 auto;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.adm:not(.lib-root) .adm-side-item.on { border-color: var(--accent); }
|
||||
|
||||
.adm:not(.lib-root) .adm-pane { padding: 12px; }
|
||||
|
||||
/* Portrait: the albums stand over the frame they open, and the shelf stops
|
||||
pretending to be a sidebar — it scrolls with the page. */
|
||||
.adm:not(.lib-root) .adm-split { grid-template-columns: minmax(0, 1fr); }
|
||||
.adm:not(.lib-root) .adm-album-list { max-height: 40vh; }
|
||||
.adm:not(.lib-root) .adm-album { min-height: 44px; }
|
||||
|
||||
/* A destination chip is a control before it is a label. */
|
||||
.adm:not(.lib-root) .adm-choice { min-height: 44px; padding: 8px 12px; }
|
||||
/* The strip's tiles: two and a bit to a screen, so a thumb is worth aiming at. */
|
||||
.adm:not(.lib-root) .adm-thumb { flex: 0 0 152px; }
|
||||
.adm:not(.lib-root) .adm-bulk { flex-wrap: wrap; }
|
||||
.adm:not(.lib-root) .adm-bulk .btn { min-height: 44px; }
|
||||
/* The accounts table is wider than a phone, so it scrolls as one piece; the
|
||||
rows grow to a thumb. */
|
||||
.adm:not(.lib-root) .adm-table-wrap { -webkit-overflow-scrolling: touch; }
|
||||
.adm:not(.lib-root) .adm-table th,
|
||||
.adm:not(.lib-root) .adm-table td { padding: 9px 10px; }
|
||||
.adm:not(.lib-root) .adm-table .btn { min-height: 40px; }
|
||||
}
|
||||
|
||||
@@ -185,6 +185,17 @@ html { scroll-behavior: smooth; scroll-padding-top: 148px; }
|
||||
cursor: pointer;
|
||||
}
|
||||
.lp-tool:hover { color: var(--lp-text); border-color: var(--accent-line); }
|
||||
/* The language button carries a globe and the code at once; which one is drawn
|
||||
is the stylesheet's call (the phone block below swaps them). */
|
||||
.lp-tool-globe {
|
||||
display: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
/* The theme popover borrows the workspace's .popover/.chip shell, re-skinned for
|
||||
the landing palette. */
|
||||
.lp-theme-pop { background: var(--lp-card); border-color: var(--lp-line); }
|
||||
@@ -211,16 +222,29 @@ html { scroll-behavior: smooth; scroll-padding-top: 148px; }
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.lp-subnav, .lp-nav .lp-wrap > .lp-btn, .lp-nav .lp-account { display: none; }
|
||||
/* The bar has collapsed, so it is the phone's bar: every control is a glyph
|
||||
on a finger's target. 44 is the floor the studio's own bars use — a 34px
|
||||
circle with an 11px word in it was neither drawn for a thumb nor legible
|
||||
on one. The language button swaps its code for a globe here; both live in
|
||||
the DOM, and `aria-label` is the name either way. */
|
||||
.lp-tool { width: 44px; height: 44px; font-size: 20px; }
|
||||
.lp-tool-code { display: none; }
|
||||
.lp-tool-globe { display: block; }
|
||||
.lp-burger {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--lp-line);
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lp-burger i { display: block; width: 20px; height: 2px; background: var(--lp-text); }
|
||||
.lp-burger i { display: block; width: 22px; height: 2px; background: var(--lp-text); }
|
||||
.lp-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -601,11 +625,9 @@ html { scroll-behavior: smooth; scroll-padding-top: 148px; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
/* logo + theme + language + burger have to fit a 360px phone */
|
||||
.lp-nav .lp-wrap { gap: 10px; }
|
||||
.lp-nav .lp-wrap { gap: 8px; padding: 0 14px; }
|
||||
.lp-logo { font-size: 19px; }
|
||||
.lp-logo-mark { width: 24px; height: 24px; }
|
||||
.lp-tool { width: 30px; height: 30px; font-size: 11px; }
|
||||
.lp-burger { padding: 6px; }
|
||||
.lp-sec { padding: 64px 0; }
|
||||
/* The subnav is gone under 1160px, so only the notice has to be paid for. */
|
||||
.lp-hero { padding: 144px 0 52px; }
|
||||
|
||||
Reference in New Issue
Block a user