geometryview 0.1.2 → 0.3.0

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.
@@ -0,0 +1,94 @@
1
+ /* The gallery: a folder's display list, laid out by the core (gallery-core.js) and painted by
2
+ gallery-view.js as absolutely placed elements over the style's sheet. Nothing here moves
3
+ anything: every position comes from the core, so the Mac and the browser agree. */
4
+
5
+ .gallery {
6
+ position: fixed;
7
+ inset: 0;
8
+ z-index: 0;
9
+ font-family: "Plex Mono", ui-monospace, monospace;
10
+ font-variant-ligatures: none;
11
+ font-kerning: none;
12
+ }
13
+ .gallery[hidden] { display: none; }
14
+ .gallery-sheet { position: absolute; inset: 0; }
15
+ /* The view's area: what scrolls, what is fixed over it (Peek), and, during a move, a copy of the
16
+ old view beneath; a zoom stays inside it. */
17
+ .gallery-stage { position: absolute; overflow: hidden; }
18
+ .gallery-clip {
19
+ position: absolute;
20
+ inset: 0;
21
+ overflow-x: hidden;
22
+ overflow-y: auto;
23
+ scrollbar-width: thin;
24
+ overscroll-behavior: contain;
25
+ }
26
+ .gallery-fixed, .gallery-ghost { position: absolute; inset: 0; pointer-events: none; }
27
+ /* Each layer stacks its own items: the old view's copy, then the content, then Peek over both. */
28
+ .gallery-ghost, .gallery-clip { z-index: 0; }
29
+ .gallery-fixed { z-index: 1; }
30
+ .gallery-ghost { overflow: hidden; }
31
+ .gallery-ghost-inner { position: absolute; inset: 0; }
32
+ .gallery-content { position: relative; width: 100%; }
33
+ .gallery-content > *, .gallery-fixed > *, .g-lane-content > * {
34
+ position: absolute;
35
+ box-sizing: border-box;
36
+ margin: 0;
37
+ padding: 0;
38
+ pointer-events: none;
39
+ }
40
+ .gallery-content > [hidden], .gallery-fixed > [hidden], .g-lane-content > [hidden] { display: none; }
41
+ /* A lane: a band that scrolls sideways on its own, its items placed in content points inside. */
42
+ .g-lane { overflow: hidden; }
43
+ .g-lane-content { position: absolute; width: 0; height: 0; }
44
+ .g-text { white-space: pre; line-height: 1.3; overflow: visible; }
45
+ .g-probe { display: inline-block; width: 0; height: 0; vertical-align: baseline; }
46
+ .g-picture { image-rendering: auto; }
47
+
48
+ /* The gallery's toolbar (gallery-toolbar.js), in the viewer toolbar's place. */
49
+ /* One line: the bar is sized from its contents, and a label that could wrap would narrow it. */
50
+ .gallery-toolbar { white-space: nowrap; }
51
+ .gallery-toolbar .group { display: flex; align-items: center; gap: 8px; }
52
+ .gallery-toolbar .group.off, .gallery-toolbar .views button[hidden] { display: none; }
53
+ .gallery-toolbar .tlabel {
54
+ font-size: var(--text-xs);
55
+ font-weight: 600;
56
+ letter-spacing: 0.06em;
57
+ color: var(--muted);
58
+ padding: 0 2px 0 4px;
59
+ }
60
+ .gallery-toolbar .views button { display: inline-flex; align-items: baseline; gap: 6px; padding: 0 9px; }
61
+ .gallery-toolbar .views button .glyph { display: inline-flex; align-self: center; }
62
+ .gallery-toolbar .views button .nm, .gallery-toolbar .views button .k { line-height: 28px; }
63
+ .gallery-toolbar .views button .k { font-size: 9.5px; font-weight: 500; color: var(--faint); }
64
+ .gallery-toolbar .views button.on .k { color: var(--muted); }
65
+ .gallery-toolbar .slider { width: 110px; }
66
+ /* Short of room (gallery-toolbar.js #fit): the view switch's keys go, then two pills' titles. */
67
+ .gallery-toolbar.tight .views button .k { display: none; }
68
+ .gallery-toolbar.tighter .pill.fold > span { display: none; }
69
+ .gallery-toolbar.tighter .pill.fold { padding: 0 9.5px; }
70
+ body.phone .gallery-toolbar .nm, body.phone .gallery-toolbar .k, body.phone .gallery-toolbar .tlabel { display: none; }
71
+
72
+ /* While a poster shows: its two buttons in the top right margin, outside the printed picture. */
73
+ .gallery-poster-bar {
74
+ position: fixed;
75
+ top: 6px;
76
+ right: 30px;
77
+ z-index: 5;
78
+ display: flex;
79
+ gap: 6px;
80
+ padding: 3px;
81
+ }
82
+ .gallery-poster-bar[hidden] { display: none; }
83
+ .gallery-poster-bar .pill { height: 24px; min-width: 24px; padding: 0 8px; font-size: var(--text-xs); }
84
+
85
+ /* On a phone: the view between the masthead and the toolbar, edge to edge but for 8 pt, and the
86
+ toolbar's buttons as icons. */
87
+ body.phone .gallery-stage {
88
+ left: 8px !important;
89
+ right: 8px !important;
90
+ top: calc(58px + env(safe-area-inset-top)) !important;
91
+ bottom: calc(66px + env(safe-area-inset-bottom)) !important;
92
+ }
93
+ body.phone .gallery-toolbar .pill > span { display: none; }
94
+ body.phone .gallery-toolbar .pill { padding: 0; width: var(--pill); }
package/web/gallery.js ADDED
@@ -0,0 +1,563 @@
1
+ // A connected folder's gallery in the page: its walk, the library drawing what is in view first, and
2
+ // the view painting the layout as facts and pictures arrive. The page's counterpart of
3
+ // App/Sources/Gallery/GalleryController.swift.
4
+
5
+ import { pdfOfJpegs } from "./pdf.js";
6
+ import { GalleryCore, Keys, Scales, Sorts } from "./gallery-core.js";
7
+ import { GalleryLibrary, layoutFacts } from "./gallery-library.js";
8
+ import { GalleryView } from "./gallery-view.js";
9
+ import { Thumbnailer } from "./thumbs.js";
10
+
11
+ export { Actions } from "./gallery-core.js";
12
+
13
+ export class Gallery {
14
+ core;
15
+ view;
16
+ root = null; // { id, name }
17
+ #library;
18
+ #store;
19
+ #on;
20
+ #entries = new Map(); // path -> entry
21
+ #facts = new Map(); // path -> layout facts
22
+ #pictures = new Map(); // path -> { png, note }
23
+ #failures = new Map();
24
+ #phone = false;
25
+ // Detail pictures: the files drawn large, and how to stop asking for them.
26
+ #detailFiles = "";
27
+ #detailAbort = null;
28
+ // Peek's turntable: the file it is for, and how to stop it.
29
+ #turnFile = null;
30
+ #turnAbort = null;
31
+ #reloadScheduled = false;
32
+ #wantScheduled = false;
33
+ #walking = null;
34
+ #style = 0;
35
+ #sheet = { scale: Scales.Folder, cell: 128, sort: Sorts.Name };
36
+ #ladders = { shared: false };
37
+
38
+ /**
39
+ * `on` takes open(root, path) to open a file in the viewer, leave() when Esc reaches the top,
40
+ * folderChanged() for the masthead, changed() when the view or its controls changed, for the
41
+ * toolbar, and search(query, found) for the search box. `views` are those that may be shown, a bit
42
+ * each. With `probes`, texts carry baseline marks, for the checks.
43
+ */
44
+ constructor({ engine, store, importers, phone = false, probes = false, views = 0b11 }, on) {
45
+ this.#store = store;
46
+ this.#on = on;
47
+ this.core = new GalleryCore(engine.module);
48
+ this.core.setViews(views);
49
+ try {
50
+ Object.assign(this.#sheet, JSON.parse(localStorage.getItem("gallery.sheet") ?? "{}"));
51
+ } catch {
52
+ // A browser that keeps nothing starts from the defaults.
53
+ }
54
+ this.core.setSheet(this.#sheet.scale, this.#sheet.cell, this.#sheet.sort);
55
+ try {
56
+ Object.assign(this.#ladders, JSON.parse(localStorage.getItem("gallery.ladders") ?? "{}"));
57
+ } catch {
58
+ // The defaults.
59
+ }
60
+ this.core.setLadders(this.#ladders.shared);
61
+ this.view = new GalleryView(this.core, {
62
+ action: (action) => this.#act(action),
63
+ viewChanged: () => this.#scheduleWant(),
64
+ atlasMoved: () => this.#on.changed?.(),
65
+ }, { probes, phone });
66
+ this.view.element.hidden = true;
67
+ this.#phone = phone;
68
+ this.#library = new GalleryLibrary({ importers, thumbnailer: new Thumbnailer(phone ? { recycleMB: 128 } : {}),
69
+ version: engine.cacheVersion, phone, walkStats: store.kind === "shell" }, {
70
+ facts: (path, facts) => this.#receiveFacts(path, facts),
71
+ measured: (path, bytes, modified) => this.#measured(path, bytes, modified),
72
+ reading: (path, fraction) => {
73
+ const file = this.core.fileAt(path);
74
+ if (file === null) return;
75
+ this.core.setReading(file, fraction);
76
+ this.#scheduleReload();
77
+ },
78
+ picture: (path, png, note) => this.#receivePicture(path, png, note),
79
+ failed: (path, message) => this.#receiveFailure(path, message),
80
+ });
81
+ }
82
+
83
+ get title() { return this.core.title; }
84
+ get subtitle() { return this.core.subtitle; }
85
+ get walking() { return this.#walking !== null; }
86
+
87
+ /** Shows `path` inside the connected folder `root` ({ id, name }), walking the folder first. */
88
+ async show(root, path) {
89
+ if (this.root?.id !== root.id) {
90
+ this.root = root;
91
+ this.#facts.clear();
92
+ this.#pictures.clear();
93
+ this.#failures.clear();
94
+ this.view.clearImages();
95
+ this.#detailFiles = "";
96
+ this.#library.setRoot(this.#store.folders, root);
97
+ this.#load([]);
98
+ this.#walking = this.#walk(root);
99
+ }
100
+ this.core.setFolder(path);
101
+ this.view.scrollToTop();
102
+ this.view.reload();
103
+ this.#on.folderChanged?.();
104
+ await this.#walking;
105
+ this.core.setFolder(path);
106
+ this.view.reload();
107
+ this.#on.folderChanged?.();
108
+ this.#changed();
109
+ this.#scheduleWant();
110
+ }
111
+
112
+ /** The view in use, those enabled, Sheet's controls and the style: what the toolbar shows. */
113
+ get state() {
114
+ return { view: this.core.view, views: this.core.views, sheet: { ...this.#sheet }, style: this.#style,
115
+ ladders: { ...this.#ladders }, atlasLevel: this.core.atlasCamera.level, catalogue: this.core.catalogue };
116
+ }
117
+
118
+ /** Shows a view, if enabled: parts in both fly to their places. */
119
+ setView(view) {
120
+ if (!this.view.setView(view)) return false;
121
+ this.#changed();
122
+ this.#scheduleWant();
123
+ return true;
124
+ }
125
+
126
+ /** Changes Sheet's scale, picture size or order: { scale, cell, sort }, any of them. */
127
+ setSheet(change) {
128
+ Object.assign(this.#sheet, change);
129
+ try {
130
+ localStorage.setItem("gallery.sheet", JSON.stringify(this.#sheet));
131
+ } catch {
132
+ // Kept for this visit only.
133
+ }
134
+ this.core.setSheet(this.#sheet.scale, this.#sheet.cell, this.#sheet.sort);
135
+ this.view.reload();
136
+ this.#changed();
137
+ this.#scheduleWant();
138
+ }
139
+
140
+ /** Shows the view as a poster, dated today, or as the gallery again. */
141
+ setPoster(poster) {
142
+ const date = new Date().toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
143
+ this.core.setPoster(poster, date);
144
+ this.view.scrollToTop();
145
+ this.view.reload();
146
+ this.#scheduleWant();
147
+ }
148
+
149
+ get poster() { return this.core.poster; }
150
+
151
+ /** Atlas: { level } goes to a level, { step } a level in (1) or out (−1). */
152
+ atlas({ level, step }) {
153
+ const now = this.core.atlasCamera.level;
154
+ this.core.atlasLevel(level ?? Math.min(2, Math.max(0, now + (step ?? 0))));
155
+ this.view.repaint();
156
+ this.view.reload();
157
+ this.#changed();
158
+ this.#scheduleWant();
159
+ }
160
+
161
+ /**
162
+ * The view as a PDF: the poster on one page, or in Catalogue the whole book, a spread a page,
163
+ * each drawn once its pictures have come (20 s at most a spread). `progress(page, pages)` is
164
+ * told each page as it is begun.
165
+ */
166
+ async posterPDF(progress) {
167
+ if (this.core.view !== 4) {
168
+ progress?.(1, 1);
169
+ return pdfOfJpegs([await this.view.posterJPEG()]);
170
+ }
171
+ const { spread: open, spreads } = this.core.catalogue;
172
+ const pages = [];
173
+ for (let spread = 0; spread < spreads; spread++) {
174
+ progress?.(spread + 1, spreads);
175
+ this.core.setCatalogueSpread(spread);
176
+ this.view.reload();
177
+ this.#scheduleWant();
178
+ const started = performance.now();
179
+ while (this.waitingInView > 0 && performance.now() - started < 20_000) {
180
+ await new Promise((resolve) => setTimeout(resolve, 150));
181
+ }
182
+ await new Promise(requestAnimationFrame);
183
+ pages.push(await this.view.posterJPEG());
184
+ }
185
+ this.core.setCatalogueSpread(open);
186
+ this.view.reload();
187
+ this.#changed();
188
+ return pdfOfJpegs(pages);
189
+ }
190
+
191
+ /** Catalogue: { step } turns a spread, { spread } goes to one, { index } to the index. */
192
+ catalogue({ step, spread, index }) {
193
+ if (index) this.core.catalogueIndex();
194
+ else this.core.setCatalogueSpread(spread ?? Math.max(0, this.core.catalogue.spread + (step ?? 0)));
195
+ this.view.reload();
196
+ this.#changed();
197
+ this.#scheduleWant();
198
+ }
199
+
200
+ /** Ladders' scale: { shared }. */
201
+ setLadders(change) {
202
+ Object.assign(this.#ladders, change);
203
+ try {
204
+ localStorage.setItem("gallery.ladders", JSON.stringify(this.#ladders));
205
+ } catch {
206
+ // Kept for this visit only.
207
+ }
208
+ this.core.setLadders(this.#ladders.shared);
209
+ this.view.reload();
210
+ this.#changed();
211
+ this.#scheduleWant();
212
+ }
213
+
214
+ /** Searches the folder in view; empty clears it. */
215
+ setSearch(query) {
216
+ if (query === this.core.search) return;
217
+ this.core.setSearch(query);
218
+ this.view.reload();
219
+ this.#syncSearch();
220
+ }
221
+
222
+ #changed() {
223
+ this.#on.changed?.();
224
+ this.#syncSearch();
225
+ }
226
+
227
+ #syncSearch() {
228
+ this.#on.search?.(this.core.search, this.core.matches);
229
+ }
230
+
231
+ async #walk(root) {
232
+ const entries = [];
233
+ let unreadable = false;
234
+ try {
235
+ for await (const entry of this.#store.folders.walk(root.id, "")) {
236
+ entries.push({ path: entry.path, folder: entry.kind === "folder", bytes: entry.bytes ?? 0,
237
+ modified: entry.modified ?? 0 });
238
+ }
239
+ } catch {
240
+ unreadable = entries.length === 0;
241
+ }
242
+ if (this.root?.id === root.id) {
243
+ // A folder that cannot be read shows as last seen, faded, with Reconnect; else what was found.
244
+ const kept = unreadable ? keptListing(root.id) : null;
245
+ this.#setLost(Boolean(kept));
246
+ if (kept) {
247
+ this.#load(kept);
248
+ } else {
249
+ // Sizes and times already known stay with the new listing.
250
+ const known = new Map((keptListing(root.id) ?? []).map((e) => [e.path, e]));
251
+ for (const e of entries) {
252
+ const k = known.get(e.path);
253
+ if (k && !e.bytes) Object.assign(e, { bytes: k.bytes, modified: k.modified });
254
+ }
255
+ this.#load(entries);
256
+ this.#scheduleKeep();
257
+ }
258
+ }
259
+ this.#walking = null;
260
+ }
261
+
262
+ #lost = false;
263
+ #setLost(lost) {
264
+ this.#lost = lost;
265
+ this.core.setLost(lost);
266
+ this.#library.setLost(lost);
267
+ }
268
+
269
+ /** Permission given again: the folder is walked afresh. */
270
+ async reconnected() {
271
+ const root = this.root, folder = this.core.folder;
272
+ if (!root) return;
273
+ this.#setLost(false);
274
+ this.root = null;
275
+ await this.show(root, folder);
276
+ }
277
+
278
+ #measured(path, bytes, modified) {
279
+ const entry = this.#entries.get(path);
280
+ if (!entry || (entry.bytes === bytes && entry.modified === modified)) return;
281
+ Object.assign(entry, { bytes, modified });
282
+ this.#scheduleKeep();
283
+ }
284
+
285
+ // The listing, with every size and time known, kept for when the folder cannot be read.
286
+ #keepScheduled = null;
287
+ #scheduleKeep() {
288
+ if (this.#lost || !this.root) return;
289
+ clearTimeout(this.#keepScheduled);
290
+ const id = this.root.id;
291
+ this.#keepScheduled = setTimeout(() => keepListing(id, [...this.#entries.values()]), 2000);
292
+ }
293
+
294
+ setStyle(style) {
295
+ this.#style = style;
296
+ // A phone's pictures are smaller and drawn once: each is held decoded while it shows, and a
297
+ // phone shows its parts small.
298
+ const pixels = this.#phone ? 320 : this.core.picturePixels;
299
+ this.#library.setSpec({ style, colorMode: 1, pixels, pixelsPerPoint: 2,
300
+ lineScale: Math.min(1, Math.max(0.5, pixels / 2 / 320)),
301
+ supersample: this.#phone ? 1 : 2 });
302
+ this.#pictures.clear();
303
+ // Details in the old style go, and are asked for again in the new.
304
+ this.view.keepDetails(new Set());
305
+ this.#detailFiles = "";
306
+ this.view.setStyle(style);
307
+ this.#changed();
308
+ this.#scheduleWant();
309
+ }
310
+
311
+ /**
312
+ * A key while the gallery shows; true if it took it. ⌥1–⌥5 switch views (browsers keep ⌘1–⌘5);
313
+ * typing searches, digits too, and Backspace edits the search, never going up a folder. In the
314
+ * search box itself only Esc is the gallery's: it clears the search.
315
+ */
316
+ key(e) {
317
+ if (this.core.poster) {
318
+ // A poster takes no keys but Esc, which the page turns into putting it away.
319
+ if (e.key === "Escape") this.#on.leavePoster?.();
320
+ return true;
321
+ }
322
+ const field = e.target?.closest?.("input, textarea, select");
323
+ if (field) {
324
+ if (e.key !== "Escape" || field.type !== "search") return false;
325
+ this.setSearch("");
326
+ field.blur();
327
+ return true;
328
+ }
329
+ const mod = e.metaKey || e.ctrlKey;
330
+ if (e.altKey && !mod && e.code === "KeyP") {
331
+ this.#on.leavePoster?.(true);
332
+ return true;
333
+ }
334
+ if (e.altKey && !mod && /^Digit[1-5]$/.test(e.code)) {
335
+ this.setView(Number(e.code.slice(5)) - 1);
336
+ return true;
337
+ }
338
+ const keys = { ArrowLeft: Keys.Left, ArrowRight: Keys.Right, ArrowUp: Keys.Up, ArrowDown: Keys.Down,
339
+ Enter: Keys.Enter, " ": Keys.Space, Escape: Keys.Escape };
340
+ let key = keys[e.key];
341
+ if (e.key === "ArrowUp" && mod) key = Keys.FolderUp;
342
+ // In Atlas, + and − go a level in and out, while nothing is being searched.
343
+ if (this.core.view === 3 && !mod && !this.core.search && (e.key === "+" || e.key === "=" || e.key === "-")) {
344
+ this.atlas({ step: e.key === "-" ? -1 : 1 });
345
+ return true;
346
+ }
347
+ if (key === undefined) {
348
+ if (e.key === "Backspace" && !mod) {
349
+ this.setSearch(Array.from(this.core.search).slice(0, -1).join(""));
350
+ return true;
351
+ }
352
+ if (e.key.length === 1 && !mod && !e.altKey) {
353
+ this.setSearch(this.core.search + e.key);
354
+ return true;
355
+ }
356
+ return false;
357
+ }
358
+ this.view.key(key);
359
+ this.#syncSearch();
360
+ this.#on.changed?.(); // a turned page, a level: the toolbar says so
361
+ return true;
362
+ }
363
+
364
+ /** Files in view still waiting for their picture, for the checks. */
365
+ get waitingInView() {
366
+ return this.core.wanted().filter((w) => {
367
+ const path = this.core.path(w.file);
368
+ return w.band <= 2 && !this.#pictures.has(path) && !this.#failures.has(path);
369
+ }).length;
370
+ }
371
+
372
+ #load(entries) {
373
+ const folder = this.core.folder;
374
+ this.#entries = new Map(entries.map((e) => [e.path, e]));
375
+ this.core.setEntries(this.root?.name ?? "", entries);
376
+ this.core.setFolder(folder);
377
+ for (const [path, facts] of this.#facts) {
378
+ const file = this.core.fileAt(path);
379
+ if (file !== null) this.core.setFacts(file, facts);
380
+ }
381
+ for (const [path, { png, note }] of this.#pictures) {
382
+ const file = this.core.fileAt(path);
383
+ if (file === null) continue;
384
+ this.core.setPicture(file, note);
385
+ this.view.setImage(file, URL.createObjectURL(png));
386
+ }
387
+ for (const [path, message] of this.#failures) {
388
+ const file = this.core.fileAt(path);
389
+ if (file !== null) this.core.setFailure(file, message);
390
+ }
391
+ }
392
+
393
+ // ---- The library ----
394
+
395
+ #scheduleWant() {
396
+ if (this.#wantScheduled) return;
397
+ this.#wantScheduled = true;
398
+ queueMicrotask(() => {
399
+ this.#wantScheduled = false;
400
+ this.#library.want(this.core.wanted().map((w) => {
401
+ const path = this.core.path(w.file);
402
+ const entry = this.#entries.get(path);
403
+ return { path, band: w.band, order: w.order, folderColor: w.folderColor, bytes: entry?.bytes ?? 0,
404
+ modified: entry?.modified ?? 0 };
405
+ }));
406
+ this.#updateTurn();
407
+ this.#updateDetails();
408
+ });
409
+ }
410
+
411
+ #receiveFacts(path, facts) {
412
+ const file = this.core.fileAt(path);
413
+ if (file === null) return;
414
+ const f = layoutFacts(facts);
415
+ this.#facts.set(path, f);
416
+ this.core.setFacts(file, f);
417
+ this.#scheduleReload();
418
+ }
419
+
420
+ #receivePicture(path, png, note) {
421
+ const file = this.core.fileAt(path);
422
+ if (file === null) return;
423
+ this.#pictures.set(path, { png, note });
424
+ this.core.setPicture(file, note);
425
+ this.view.setImage(file, URL.createObjectURL(png));
426
+ this.#scheduleReload(true);
427
+ }
428
+
429
+ #receiveFailure(path, message) {
430
+ const file = this.core.fileAt(path);
431
+ if (file === null) return;
432
+ this.#failures.set(path, message);
433
+ this.core.setFailure(file, message);
434
+ this.#scheduleReload();
435
+ }
436
+
437
+ // Facts and pictures arrive in bursts: one layout a frame.
438
+ #scheduleReload(repaint = false) {
439
+ this.#repaint ||= repaint;
440
+ if (this.#reloadScheduled) return;
441
+ this.#reloadScheduled = true;
442
+ requestAnimationFrame(() => {
443
+ this.#reloadScheduled = false;
444
+ if (this.#repaint) {
445
+ this.#repaint = false;
446
+ this.view.repaint();
447
+ }
448
+ this.view.reload();
449
+ this.#syncSearch();
450
+ });
451
+ }
452
+ #repaint = false;
453
+
454
+ /** Files whose turntable frames have come in Peek, 24 a turn, 768 pixels square. */
455
+ static turnFrames = 24;
456
+ static turnPixels = 768;
457
+
458
+ // Asks for the detail pictures of what is drawn large, if that changed, one after another, and
459
+ // lets the others go.
460
+ #updateDetails() {
461
+ // A phone draws its parts small, and keeps its memory for them: no details there.
462
+ if (this.#phone) return;
463
+ const files = this.core.details();
464
+ const key = files.join(",");
465
+ if (key === this.#detailFiles) return;
466
+ this.#detailFiles = key;
467
+ this.#detailAbort?.abort();
468
+ const abort = new AbortController();
469
+ this.#detailAbort = abort;
470
+ this.view.keepDetails(new Set(files));
471
+ const colours = new Map(this.core.wanted().map((w) => [w.file, w.folderColor]));
472
+ (async () => {
473
+ for (const file of files) {
474
+ if (abort.signal.aborted) return;
475
+ const path = this.core.path(file);
476
+ const drawn = await this.#library.detail(path, colours.get(file) ?? -1, abort.signal, this.#entries.get(path))
477
+ .catch(() => null);
478
+ if (drawn && !abort.signal.aborted && this.core.fileAt(path) === file) this.view.setDetail(file, drawn.png);
479
+ }
480
+ })();
481
+ }
482
+
483
+ // Asks for the turntable of the file in Peek, if it changed, and puts the last one away. Once all
484
+ // its frames have come, Peek can turn.
485
+ #updateTurn() {
486
+ const peeking = this.core.peeking;
487
+ if (peeking === this.#turnFile) return;
488
+ this.#turnFile = peeking;
489
+ this.#turnAbort?.abort();
490
+ this.#turnAbort = null;
491
+ this.view.setTurntable(null);
492
+ if (peeking === null || !this.root) return;
493
+ const abort = new AbortController();
494
+ this.#turnAbort = abort;
495
+ const path = this.core.path(peeking);
496
+ const folderColor = this.core.wanted().find((w) => w.file === peeking)?.folderColor ?? -1;
497
+ const facts = this.#facts.get(path);
498
+ const frames = [];
499
+ let ink = null;
500
+ // A phone's turntable is smaller: its frames are held in the page's memory.
501
+ this.#library.turn(path, folderColor, Gallery.turnFrames, this.#phone ? 512 : Gallery.turnPixels,
502
+ facts?.hasBox ? facts.size : null, (frame, bitmap, note) => {
503
+ frames[frame] = bitmap;
504
+ // The box holding every frame's ink, as its corners: x0, y0, x1, y1.
505
+ const [x, y, w, h] = note.ink;
506
+ ink = ink ? [Math.min(ink[0], x), Math.min(ink[1], y), Math.max(ink[2], x + w), Math.max(ink[3], y + h)]
507
+ : [x, y, x + w, y + h];
508
+ }, abort.signal, this.#entries.get(path)).then((done) => {
509
+ if (!done || abort.signal.aborted || this.#turnFile !== peeking || frames.length !== Gallery.turnFrames) {
510
+ for (const frame of frames) frame?.close?.();
511
+ return;
512
+ }
513
+ this.view.setTurntable({ file: peeking, frames, ink: [ink[0], ink[1], ink[2] - ink[0], ink[3] - ink[1]] });
514
+ this.core.setPeekTurnable(true);
515
+ this.view.reload();
516
+ }).catch(() => {
517
+ for (const frame of frames) frame?.close?.();
518
+ });
519
+ }
520
+
521
+ #act(action) {
522
+ this.#updateTurn(); // Peek opened, closed or moved: its turntable follows
523
+ switch (action.kind) {
524
+ case 2: // into a folder: it shows the view it last showed
525
+ this.#on.folderChanged?.();
526
+ this.#changed();
527
+ this.#scheduleWant();
528
+ break;
529
+ case 3: // open
530
+ this.#on.open?.(this.root, this.core.path(action.target));
531
+ break;
532
+ case 5:
533
+ this.#on.leave?.();
534
+ break;
535
+ case 1: // handled: a page turned from the book itself
536
+ if (this.core.view === 4) this.#on.changed?.();
537
+ break;
538
+ case 6: // Reconnect: asked in the click itself, as the browser wants
539
+ this.#on.reconnect?.(this.root);
540
+ break;
541
+ }
542
+ }
543
+ }
544
+
545
+ // A root's last listing, in this browser's storage: [path, folder, bytes, modified] for each entry.
546
+ // A listing past 2 MB is not kept.
547
+ function keepListing(id, entries) {
548
+ try {
549
+ const text = JSON.stringify(entries.map((e) => [e.path, e.folder ? 1 : 0, e.bytes ?? 0, e.modified ?? 0]));
550
+ if (text.length < 2_000_000) localStorage.setItem(`gallery.listing.${id}`, text);
551
+ } catch {
552
+ // Kept for this visit only.
553
+ }
554
+ }
555
+
556
+ function keptListing(id) {
557
+ try {
558
+ const rows = JSON.parse(localStorage.getItem(`gallery.listing.${id}`) ?? "null");
559
+ return rows?.map(([path, folder, bytes, modified]) => ({ path, folder: folder === 1, bytes, modified })) ?? null;
560
+ } catch {
561
+ return null;
562
+ }
563
+ }
package/web/icons.js CHANGED
@@ -30,6 +30,12 @@ export const iconMarkup = {
30
30
  parts: `<path d="M3 4h10M3 8h10M3 12h10"/>`,
31
31
  more: `<circle cx="3.5" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="8" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="12.5" cy="8" r="1.1" fill="currentColor" stroke="none"/>`,
32
32
  check: `<path d="M3.5 8.5l3 3 6-7"/>`,
33
+ plates: `<rect x="1.75" y="2.25" width="5.5" height="5" rx="1"/><rect x="8.75" y="2.25" width="5.5" height="5" rx="1"/><rect x="1.75" y="8.75" width="5.5" height="5" rx="1"/><rect x="8.75" y="8.75" width="5.5" height="5" rx="1"/><path d="M3 6h1M4.75 6h1M10 6h3M3 12.5h3M10 12.5h1"/>`,
34
+ sheet: `<path d="M2 3.5h12 M2 7h12 M2 10.5h12 M2 14h12"/><circle cx="4" cy="5.25" r=".6"/><circle cx="7" cy="5.25" r=".6"/><circle cx="10" cy="5.25" r=".6"/><circle cx="13" cy="5.25" r=".6"/><circle cx="4" cy="12.25" r=".6"/><circle cx="7" cy="12.25" r=".6"/>`,
35
+ ladders: `<path d="M1.5 12.5h13 M2.5 12.5v-1.5 M5.5 12.5v-3 M9 12.5v-5 M13 12.5v-8 M1.5 14.5v-2 M5 14.5v-2 M8.5 14.5v-2 M12 14.5v-2"/>`,
36
+ atlas: `<path d="M1.75 3.5l4-1.5 4.5 1.5 4-1.5v10.5l-4 1.5-4.5-1.5-4 1.5z M5.75 2v10.5 M10.25 3.5V14"/>`,
37
+ poster: `<rect x="2.5" y="1.75" width="11" height="12.5" rx="1"/><path d="M4.5 11h7 M4.5 5l2.5 3 2-2 2.5 3"/>`,
38
+ catalogue: `<path d="M8 3.5c-1.5-1-3.5-1.25-6-1v10c2.5-.25 4.5 0 6 1 1.5-1 3.5-1.25 6-1v-10c-2.5-.25-4.5 0-6 1z M8 3.5v10"/>`,
33
39
  };
34
40
 
35
41
  export const icons = Object.fromEntries(