feat(library): a shelf that fills in as it reads, and one that carries the studio's look and frame back with it
- tiles land one at a time. The screenful a picked folder asks for was built all at once, and a HEIC or a RAW in it is a decode of its own, so the strip stood empty until every one of them was done: 2293ms on a roll of eighteen, with the batch arriving in one go (0/18 then 18/18 in a single step). Tiles are built in two lanes now and each one goes up the moment it is drawn — first picture at 36ms, then 1, 2, 4, 6, 8, 9, 11, 12, 14, 15, 16, 17, 18 of 18, done at 2496ms. The threading is handed back between tiles (`scheduler.yield`), so a click still answers while the strip fills: no long tasks either side, worst frame gap 169ms -> 164ms, a view toggle 49ms -> 41ms. The tiles are the same tiles; the wall's is drawn from the same counter. - the studio's look reaches the shelf. A frame graded in the studio writes the tile the shelf draws, at neutral geometry — no turn, crop, watermark or frame, since the shelf turns a tile itself — and every screen holding the tile is told the moment it is written, so the row repaints without a reload. - the shelf comes back on the frame the studio was holding. The frame a visit opened is kept with the visit, and coming back from the studio raises it: the strip centres it and the wall brings its card to the middle, even when the frame sits in another folder of the same tree. A shelf that opened on whatever it was left standing on sent the reader looking for the frame they had just been working on. - the frame's name stands under the picture, in the studio's own meta bar.
This commit is contained in:
@@ -20,10 +20,11 @@ import { developRaw, isRawName, isTiffName, tiffDevelop } from './engine/rawDeve
|
|||||||
import { sharePhoto, type ShareSite } from './engine/share';
|
import { sharePhoto, type ShareSite } from './engine/share';
|
||||||
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||||
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
||||||
import { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
|
import { getPhoto, loadEdit, makeTile, putPhotoThumb, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
|
||||||
import { offerInstall } from './pwa/install';
|
import { offerInstall } from './pwa/install';
|
||||||
import { cameraSupported } from './engine/camera';
|
import { cameraSupported } from './engine/camera';
|
||||||
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session';
|
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session';
|
||||||
|
import { keep } from './remember';
|
||||||
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
||||||
import { api, type Photo, type SavedRecipe } from './api';
|
import { api, type Photo, type SavedRecipe } from './api';
|
||||||
import { refreshAuth, useAuth } from './auth';
|
import { refreshAuth, useAuth } from './auth';
|
||||||
@@ -106,6 +107,10 @@ const MODEL_GAIN = 4;
|
|||||||
// on every notch.
|
// on every notch.
|
||||||
const PREVIEW_QUANTUM = 512;
|
const PREVIEW_QUANTUM = 512;
|
||||||
const PREVIEW_DEBOUNCE_MS = 120;
|
const PREVIEW_DEBOUNCE_MS = 120;
|
||||||
|
// The catalogue frame the studio is holding, under the catalogue's own key: the
|
||||||
|
// shelf reads it when it comes back up and opens on that frame — the reader has
|
||||||
|
// just been working on it (see Library.tsx).
|
||||||
|
const LIBRARY_LAST_KEY = 'recipescam.library.last';
|
||||||
|
|
||||||
const FRAMES: { id: FrameId; label: MsgKey }[] = [
|
const FRAMES: { id: FrameId; label: MsgKey }[] = [
|
||||||
{ id: 'none', label: 'frame.none' },
|
{ id: 'none', label: 'frame.none' },
|
||||||
@@ -921,6 +926,49 @@ export function Workspace() {
|
|||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
return URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' }));
|
return URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' }));
|
||||||
});
|
});
|
||||||
|
// A frame that came out of the catalogue leaves its own tile behind it:
|
||||||
|
// the shelf would otherwise keep showing the negative the reader has just
|
||||||
|
// graded. The tile is the look alone — the frame's turn, straighten, crop,
|
||||||
|
// frame and stamps are the library's to draw, and every tile it draws
|
||||||
|
// carries the frame's own standing on top — so the render in hand serves
|
||||||
|
// as the tile exactly while it carries none of them. The hovered look is
|
||||||
|
// not the frame's: only the committed one is filed.
|
||||||
|
if (libPhotoId && !preview && !cropEditing) {
|
||||||
|
const bare =
|
||||||
|
!options.cropRect &&
|
||||||
|
!options.watermark &&
|
||||||
|
!options.gpsWatermark &&
|
||||||
|
rotation === 0 &&
|
||||||
|
!straighten &&
|
||||||
|
!flipH &&
|
||||||
|
!flipV &&
|
||||||
|
frame === 'none';
|
||||||
|
const graded =
|
||||||
|
bare
|
||||||
|
? out.bytes
|
||||||
|
: (
|
||||||
|
await renderPhoto({
|
||||||
|
source: previewBytes,
|
||||||
|
recipe: shownRecipe,
|
||||||
|
frameId: 'none',
|
||||||
|
useGeotag: false,
|
||||||
|
gpsInfo: null,
|
||||||
|
assets: engine.assets,
|
||||||
|
options: {
|
||||||
|
...options,
|
||||||
|
photoRotation: 0,
|
||||||
|
photoStraighten: 0,
|
||||||
|
photoFlipH: false,
|
||||||
|
photoFlipV: false,
|
||||||
|
cropRect: null,
|
||||||
|
watermark: null,
|
||||||
|
gpsWatermark: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)?.bytes;
|
||||||
|
const tile = graded ? await makeTile(new Blob([graded as BlobPart], { type: 'image/jpeg' })) : null;
|
||||||
|
if (tile && token === renderToken.current) void putPhotoThumb(libPhotoId, tile);
|
||||||
|
}
|
||||||
if (!comparing) {
|
if (!comparing) {
|
||||||
setCompareUrl((prev) => {
|
setCompareUrl((prev) => {
|
||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
@@ -2170,6 +2218,10 @@ export function Workspace() {
|
|||||||
// frame on the neutral look and so at 0.
|
// frame on the neutral look and so at 0.
|
||||||
setRotation(row.rot ?? 0);
|
setRotation(row.rot ?? 0);
|
||||||
setLibPhotoId(id);
|
setLibPhotoId(id);
|
||||||
|
// The shelf is told which frame the studio is holding, so coming back to
|
||||||
|
// it opens on this one rather than on wherever the tree was left — and it
|
||||||
|
// outlives the visit, because the address bar no longer carries the id.
|
||||||
|
keep(LIBRARY_LAST_KEY, id);
|
||||||
} catch {
|
} catch {
|
||||||
setError(t('err.decode'));
|
setError(t('err.decode'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4060,6 +4112,7 @@ export function Workspace() {
|
|||||||
}}
|
}}
|
||||||
shotSpecs={shotSpecs}
|
shotSpecs={shotSpecs}
|
||||||
place={place}
|
place={place}
|
||||||
|
photoName={source?.name ?? null}
|
||||||
onCropRectChange={setCropRect}
|
onCropRectChange={setCropRect}
|
||||||
onStageZoom={onStageZoom}
|
onStageZoom={onStageZoom}
|
||||||
canCompare={!!source}
|
canCompare={!!source}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
|||||||
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
|
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
|
||||||
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||||
import {
|
import {
|
||||||
|
breathe,
|
||||||
cachedFolders,
|
cachedFolders,
|
||||||
cachedPhotos,
|
cachedPhotos,
|
||||||
canBrowseFolders,
|
canBrowseFolders,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
makeThumbs,
|
makeThumbs,
|
||||||
normPath,
|
normPath,
|
||||||
onLibraryBlocked,
|
onLibraryBlocked,
|
||||||
|
onThumbWritten,
|
||||||
pickFolder,
|
pickFolder,
|
||||||
readPhotoFile,
|
readPhotoFile,
|
||||||
readPhotos,
|
readPhotos,
|
||||||
@@ -96,6 +98,9 @@ const MIN_STAR_KEY = 'recipescam.library.minStar';
|
|||||||
const YEAR_KEY = 'recipescam.library.year';
|
const YEAR_KEY = 'recipescam.library.year';
|
||||||
const FROM_KEY = 'recipescam.library.from';
|
const FROM_KEY = 'recipescam.library.from';
|
||||||
const TO_KEY = 'recipescam.library.to';
|
const TO_KEY = 'recipescam.library.to';
|
||||||
|
// The frame the studio is holding (written on its side, see App.tsx): the shelf
|
||||||
|
// comes back up on that frame rather than on wherever the tree was left.
|
||||||
|
const LAST_KEY = 'recipescam.library.last';
|
||||||
|
|
||||||
function TimeRangeSelectInput({
|
function TimeRangeSelectInput({
|
||||||
from,
|
from,
|
||||||
@@ -184,6 +189,11 @@ const STRIP_KEEP = 8;
|
|||||||
// cards is a couple of hundred pixels tall, so this is the row under the eye and
|
// cards is a couple of hundred pixels tall, so this is the row under the eye and
|
||||||
// two beyond it — enough that a flick lands on pictures.
|
// two beyond it — enough that a flick lands on pictures.
|
||||||
const WALL_ROWS = 2;
|
const WALL_ROWS = 2;
|
||||||
|
// How many tiles the wall reads and decodes at once when it is the one asking.
|
||||||
|
// The wall's own frames come first and they come visibly: two at a time, the
|
||||||
|
// thread handed back between them (`breathe`), is a shelf that fills in front of
|
||||||
|
// the reader — a screenful of frames read end to end is a dead screen.
|
||||||
|
const WALL_LANES = 2;
|
||||||
// What `.adm-grid` leaves between its rows. A spacer has to stand the same
|
// What `.adm-grid` leaves between its rows. A spacer has to stand the same
|
||||||
// distance past the last row it replaces as the row itself would have.
|
// distance past the last row it replaces as the row itself would have.
|
||||||
const WALL_GAP = 12;
|
const WALL_GAP = 12;
|
||||||
@@ -343,6 +353,10 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
// the first, the way a node that is gone falls back to its folder.
|
// the first, the way a node that is gone falls back to its folder.
|
||||||
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
|
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
|
||||||
const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
|
const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
|
||||||
|
// This screen is mounted for the life of the tab and only shown and hidden
|
||||||
|
// (see Shell in main.tsx), so a visit that comes back from the studio is not a
|
||||||
|
// mount: the address is what says the shelf is the screen up again.
|
||||||
|
const [here, setHere] = useState(() => window.location.pathname.startsWith('/library'));
|
||||||
// How the thumbnail view reads the open folder: every frame it holds, or only
|
// 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
|
// 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
|
// these hours — and in the order the reader picked. A way of looking, not part
|
||||||
@@ -1444,7 +1458,20 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
() => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
|
() => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
|
||||||
[listed, wall.from, wall.to]
|
[listed, wall.from, wall.to]
|
||||||
);
|
);
|
||||||
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame);
|
// A tile the studio graded while this screen was dark: the copy in hand is the
|
||||||
|
// negative's, and `onThumbWritten` hands over the one that replaces it. The
|
||||||
|
// counter is what makes the rows look again — the list is unchanged, so nothing
|
||||||
|
// else would make them.
|
||||||
|
const [tilesAt, setTilesAt] = useState(0);
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onThumbWritten((id, blob) => {
|
||||||
|
cacheObjectUrl(id, blob);
|
||||||
|
setTilesAt((n) => n + 1);
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame, tilesAt);
|
||||||
|
|
||||||
// Auto-scroll & focus tree sidebar to the active node selected in last session
|
// Auto-scroll & focus tree sidebar to the active node selected in last session
|
||||||
const treeRef = useRef<HTMLElement>(null);
|
const treeRef = useRef<HTMLElement>(null);
|
||||||
@@ -1540,7 +1567,39 @@ function Catalogue({ admin }: { admin: boolean }) {
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [view, listed, wall.ref]);
|
// `up` is in the list for the frame the studio hands back: it is the one that
|
||||||
|
// moves when the shelf comes up on another frame, and a wall standing where it
|
||||||
|
// already was is a wall that would not follow it.
|
||||||
|
}, [view, listed, wall.ref, up]);
|
||||||
|
|
||||||
|
// This screen's own comings and goings: it never unmounts, so `here` is the
|
||||||
|
// only sign of the shelf being the screen up again (see `here` above).
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = () => setHere(window.location.pathname.startsWith('/library'));
|
||||||
|
window.addEventListener('popstate', onPop);
|
||||||
|
return () => window.removeEventListener('popstate', onPop);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Coming back from the studio, the shelf opens on the frame the studio was
|
||||||
|
// holding rather than on wherever the tree was left — that frame is the one the
|
||||||
|
// reader has just been working on. Both views are sent to it: the strip centres
|
||||||
|
// it and the wall, if that is the view up, scrolls to its card. It may also sit
|
||||||
|
// in another folder of the same tree (a frame id and a tree key are spelled the
|
||||||
|
// same way), and a frame the open node cannot list is a frame the shelf cannot
|
||||||
|
// raise at all, so the folder goes with it.
|
||||||
|
const wasHere = useRef(here);
|
||||||
|
useEffect(() => {
|
||||||
|
const came = here && !wasHere.current;
|
||||||
|
wasHere.current = here;
|
||||||
|
if (!came) return;
|
||||||
|
const last = recall(LAST_KEY);
|
||||||
|
if (!last) return;
|
||||||
|
setUp(last);
|
||||||
|
scrolledFrameRef.current = null;
|
||||||
|
centredRef.current = last;
|
||||||
|
const dir = last.slice(0, last.lastIndexOf('/'));
|
||||||
|
if (dir && nodes.some((n) => n.key === dir)) setNode(dir);
|
||||||
|
}, [here, nodes]);
|
||||||
|
|
||||||
// The way back, and the way in: the frame the wall is standing on is the card
|
// The way back, and the way in: the frame the wall is standing on is the card
|
||||||
// nearest the middle of its viewport — the same card the reader's eye is on.
|
// nearest the middle of its viewport — the same card the reader's eye is on.
|
||||||
@@ -2881,7 +2940,7 @@ function cacheObjectUrl(id: string, blob: Blob): string {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
|
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record<string, string> {
|
||||||
const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]);
|
const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]);
|
||||||
|
|
||||||
const [urls, setUrls] = useState<Record<string, string>>(() => {
|
const [urls, setUrls] = useState<Record<string, string>>(() => {
|
||||||
@@ -2920,23 +2979,42 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
|
|||||||
// The tile is `makeThumb`'s business: the store first, the backup behind it,
|
// The tile is `makeThumb`'s business: the store first, the backup behind it,
|
||||||
// and only then the file — a RAW's own preview, or a decode. A frame the disk
|
// and only then the file — a RAW's own preview, or a decode. A frame the disk
|
||||||
// will not hand over is a frame with no tile rather than a strip that stops.
|
// will not hand over is a frame with no tile rather than a strip that stops.
|
||||||
|
//
|
||||||
|
// Every tile is drawn the moment it lands, and the file is worked a couple of
|
||||||
|
// frames at a time with the thread handed back between them. Waiting for the
|
||||||
|
// whole shelf and painting once at the end is a screen that shows nothing for
|
||||||
|
// seconds and answers nothing while it waits; this way the frames come in one
|
||||||
|
// after another and a click during the fill still lands.
|
||||||
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
|
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
|
||||||
const updates: Record<string, string> = {};
|
const landed: Record<string, string> = {};
|
||||||
for (const photo of list) {
|
for (const photo of list) {
|
||||||
if (!alive) return;
|
|
||||||
const kept = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
const kept = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
||||||
const blob = kept ?? (photo.handle ? await makeThumb(photo) : null);
|
if (kept) landed[photo.id] = cacheObjectUrl(photo.id, kept);
|
||||||
if (blob) updates[photo.id] = cacheObjectUrl(photo.id, blob);
|
|
||||||
}
|
}
|
||||||
if (alive && Object.keys(updates).length > 0) {
|
if (alive && Object.keys(landed).length > 0) {
|
||||||
setUrls((prev) => ({ ...prev, ...updates }));
|
setUrls((prev) => ({ ...prev, ...landed }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queue = list.filter((p) => !(p.id in landed) && p.handle);
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(WALL_LANES, queue.length) }, async () => {
|
||||||
|
while (alive && queue.length) {
|
||||||
|
const photo = queue.shift()!;
|
||||||
|
const blob = await makeThumb(photo);
|
||||||
|
if (!alive) return;
|
||||||
|
if (blob) setUrls((prev) => ({ ...prev, [photo.id]: cacheObjectUrl(photo.id, blob) }));
|
||||||
|
await breathe();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [list]);
|
// `stamp` is a tile rewritten under this row: the list is the same one, and
|
||||||
|
// what has to happen again is the read of the store.
|
||||||
|
}, [list, stamp]);
|
||||||
|
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1840,11 +1840,24 @@ export async function getPhotoThumb(id: string): Promise<Blob | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whoever is drawing a tile out of what the store holds (`Library.tsx`) is told
|
||||||
|
// about a tile rewritten under it: the URL it has is a copy of the old blob, and
|
||||||
|
// the write hands over the new one, so the row is redrawn without a second read.
|
||||||
|
const thumbListeners = new Set<(id: string, blob: Blob) => void>();
|
||||||
|
|
||||||
|
export function onThumbWritten(listener: (id: string, blob: Blob) => void): () => void {
|
||||||
|
thumbListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
thumbListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// A tile made because someone looked at the frame, kept where the next visit
|
// A tile made because someone looked at the frame, kept where the next visit
|
||||||
// will find it: the decode is paid once per frame, on the visit that first drew
|
// will find it: the decode is paid once per frame, on the visit that first drew
|
||||||
// it, and the reads after that are the same reads a scanned tile would have had.
|
// it, and the reads after that are the same reads a scanned tile would have had.
|
||||||
export async function putPhotoThumb(id: string, blob: Blob): Promise<void> {
|
export async function putPhotoThumb(id: string, blob: Blob): Promise<void> {
|
||||||
cacheThumb(id, blob);
|
cacheThumb(id, blob);
|
||||||
|
for (const listener of thumbListeners) listener(id, blob);
|
||||||
try {
|
try {
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
if (!db.objectStoreNames.contains(THUMBS)) return;
|
if (!db.objectStoreNames.contains(THUMBS)) return;
|
||||||
@@ -1930,23 +1943,19 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
|
|||||||
// stutter, and it bought nothing: the wall mints the frames in view itself.
|
// stutter, and it bought nothing: the wall mints the frames in view itself.
|
||||||
const THUMB_LANES = 2;
|
const THUMB_LANES = 2;
|
||||||
|
|
||||||
// The breath between frames. The idle queue where the browser has one — the tile
|
// The breath between frames: the thread handed back so the page repaints and a
|
||||||
// then waits for a gap rather than making one — and a plain macrotask otherwise;
|
// click still lands while the shelves fill in. This used to be the idle queue, and
|
||||||
// the timeout is there because an idle queue on a busy page can stay empty.
|
// that is what broke it — on a page somebody is using, an idle callback is parked
|
||||||
|
// for its whole timeout, so every tile cost 120ms of nothing and the tiles looked
|
||||||
|
// like they never came. `scheduler.yield` hands the thread over and comes straight
|
||||||
|
// back; a macrotask is the same thing where there is no such call.
|
||||||
//
|
//
|
||||||
// ponytail: the walk's own scan is not paced from here and does not stop for a
|
// ponytail: `THUMB_LANES` jobs share one budget — the thread is handed over after
|
||||||
// tile; the two share the thread when a folder is opened mid-read. One budget for
|
// a tile, not on a clock. Pace by elapsed time if a folder of RAW ever wants it.
|
||||||
// both is the next step if a scan ever runs visibly slower beside a tile job.
|
export function breathe(): Promise<void> {
|
||||||
function breathe(): Promise<void> {
|
const s = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
|
||||||
return new Promise((resolve) => {
|
if (s?.yield) return s.yield();
|
||||||
const idle = (
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
globalThis as {
|
|
||||||
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
|
|
||||||
}
|
|
||||||
).requestIdleCallback;
|
|
||||||
if (idle) idle(() => resolve(), { timeout: 120 });
|
|
||||||
else setTimeout(resolve, 0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One frame's tile, in flight. The wall scrolls to a frame and asks for its tile
|
// One frame's tile, in flight. The wall scrolls to a frame and asks for its tile
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ export function ImageStage({
|
|||||||
dims = null,
|
dims = null,
|
||||||
shotSpecs = null,
|
shotSpecs = null,
|
||||||
place = null,
|
place = null,
|
||||||
|
photoName = null,
|
||||||
frameId = 'none',
|
frameId = 'none',
|
||||||
framePanX = 0,
|
framePanX = 0,
|
||||||
onFramePanXChange,
|
onFramePanXChange,
|
||||||
@@ -384,6 +385,9 @@ export function ImageStage({
|
|||||||
dims?: { w: number; h: number } | null;
|
dims?: { w: number; h: number } | null;
|
||||||
shotSpecs?: ShotSpecs | null;
|
shotSpecs?: ShotSpecs | null;
|
||||||
place?: string | null;
|
place?: string | null;
|
||||||
|
// The file the stage is holding, as the disk named it: the row under the photo
|
||||||
|
// is where a reader checks that the frame they meant to open is the one open.
|
||||||
|
photoName?: string | null;
|
||||||
frameId?: FrameId;
|
frameId?: FrameId;
|
||||||
framePanX?: number;
|
framePanX?: number;
|
||||||
onFramePanXChange?: (panX: number) => void;
|
onFramePanXChange?: (panX: number) => void;
|
||||||
@@ -1998,9 +2002,14 @@ export function ImageStage({
|
|||||||
|
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
<>
|
<>
|
||||||
{shotSpecs || place || dims ? (
|
{shotSpecs || place || dims || photoName ? (
|
||||||
<div className="photo-meta-bar" data-key="photo-meta-bar">
|
<div className="photo-meta-bar" data-key="photo-meta-bar">
|
||||||
{specsLine(shotSpecs ?? null, place, dims)}
|
{specsLine(shotSpecs ?? null, place, dims)}
|
||||||
|
{photoName ? (
|
||||||
|
<span className="photo-meta-name" data-key="photo-name">
|
||||||
|
{photoName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
|
|||||||
Reference in New Issue
Block a user