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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/mcp.mjs +1 -1
- package/src/native.mjs +147 -15
- package/src/tools.mjs +135 -5
- package/web/build/geometryview-thumbs-web.mjs +2 -0
- package/web/build/geometryview-thumbs-web.wasm +0 -0
- package/web/build/geometryview-web.mjs +1 -1
- package/web/build/geometryview-web.wasm +0 -0
- package/web/engine.js +4 -0
- package/web/gallery-core.js +359 -0
- package/web/gallery-library.js +262 -0
- package/web/gallery-toolbar.js +148 -0
- package/web/gallery-view.js +1018 -0
- package/web/gallery.css +94 -0
- package/web/gallery.js +563 -0
- package/web/icons.js +6 -0
- package/web/import-queue.js +168 -0
- package/web/import-worker.js +63 -9
- package/web/importers.js +140 -0
- package/web/index.html +1 -0
- package/web/kept.js +263 -0
- package/web/library-store.js +116 -3
- package/web/library.js +25 -1
- package/web/occt/geometryview-step-web.wasm +0 -0
- package/web/occt-threads/geometryview-step-web.wasm +0 -0
- package/web/pdf.js +54 -0
- package/web/thumbs-worker.js +86 -0
- package/web/thumbs.js +97 -0
- package/web/viewer.js +721 -25
package/web/kept.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// What this browser keeps of its imports, in the origin-private file system under library2/: each
|
|
2
|
+
// file's cache file, its note, its pictures, and, for a STEP file that cannot be read again from
|
|
3
|
+
// a connected folder, its source, for refining. Each is named by a key, a hash of where the file
|
|
4
|
+
// came from, its path there, its size, its time and the cache version: two files of one name in
|
|
5
|
+
// two folders never meet, and a changed file is a new key.
|
|
6
|
+
//
|
|
7
|
+
// library2/blobs/<key>.geoblob the cache file
|
|
8
|
+
// library2/sources/<key>.source a STEP file's source, when it cannot be had again
|
|
9
|
+
// library2/thumbs/<key>.<T>.png its pictures, one per drawing spec T, each with a .json note
|
|
10
|
+
// library2/notes/<key>.json the note, written last: an entry without one is unfinished
|
|
11
|
+
//
|
|
12
|
+
// What is kept is evicted least recently used first, to a budget from the browser's storage
|
|
13
|
+
// estimate. The first library/ folder, named by bare file names, is still read until its files are
|
|
14
|
+
// imported again.
|
|
15
|
+
|
|
16
|
+
export const kRoot = "library2";
|
|
17
|
+
const kNote = "notes", kBlobs = "blobs", kSources = "sources", kThumbs = "thumbs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The key of a file: the first 128 bits of SHA-256 over where it came from (a connected folder's
|
|
21
|
+
* name, or "file" for one opened on its own), its path there, its size, its modification time and
|
|
22
|
+
* the cache version, as 32 hex digits.
|
|
23
|
+
*/
|
|
24
|
+
export async function keyOf({ source, path, size, modified, version }) {
|
|
25
|
+
const text = JSON.stringify([String(source), String(path), Number(size), Number(modified), Number(version)]);
|
|
26
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
27
|
+
return [...digest.subarray(0, 16)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The share of the browser's quota each kind of entry may take. */
|
|
31
|
+
export const kBlobShare = 0.5;
|
|
32
|
+
export const kPictureShare = 0.02;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which entries to delete so that what is kept fits `budget` bytes: the least recently used first,
|
|
36
|
+
* never those in `keep`. Notes are { key, bytes, usedAt }.
|
|
37
|
+
*/
|
|
38
|
+
export function evictions(notes, budget, keep = new Set()) {
|
|
39
|
+
let total = notes.reduce((sum, note) => sum + (note.bytes ?? 0), 0);
|
|
40
|
+
const dropped = [];
|
|
41
|
+
for (const note of [...notes].sort((a, b) => (a.usedAt ?? 0) - (b.usedAt ?? 0))) {
|
|
42
|
+
if (total <= budget) break;
|
|
43
|
+
if (keep.has(note.key)) continue;
|
|
44
|
+
dropped.push(note.key);
|
|
45
|
+
total -= note.bytes ?? 0;
|
|
46
|
+
}
|
|
47
|
+
return dropped;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- The origin-private file system ------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
async function folder(name) {
|
|
53
|
+
const root = await (await navigator.storage.getDirectory()).getDirectoryHandle(kRoot, { create: true });
|
|
54
|
+
return root.getDirectoryHandle(name, { create: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readText(directory, name) {
|
|
58
|
+
return (await (await directory.getFileHandle(name)).getFile()).text();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Written whole through a writable stream, which replaces the file only when closed.
|
|
62
|
+
async function writeText(directory, name, text) {
|
|
63
|
+
const writable = await (await directory.getFileHandle(name, { create: true })).createWritable();
|
|
64
|
+
await writable.write(text);
|
|
65
|
+
await writable.close();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A cache file's version, from its header: 8 bytes of magic, then a little-endian u32. */
|
|
69
|
+
export async function versionOf(blob) {
|
|
70
|
+
const header = new DataView(await blob.slice(0, 12).arrayBuffer());
|
|
71
|
+
return header.byteLength >= 12 ? header.getUint32(8, true) : -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** An entry's note, or null if it is not kept whole. */
|
|
75
|
+
export async function noteOf(key) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(await readText(await folder(kNote), `${key}.json`));
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The kept import of `key`: { blob, note }, or null if there is none, it is unfinished, or its
|
|
85
|
+
* cache file is in another version than `version`. Marks it used.
|
|
86
|
+
*/
|
|
87
|
+
export async function keptImport(key, version = null) {
|
|
88
|
+
const note = await noteOf(key);
|
|
89
|
+
if (!note) return null;
|
|
90
|
+
try {
|
|
91
|
+
const blob = await (await (await folder(kBlobs)).getFileHandle(`${key}.geoblob`)).getFile();
|
|
92
|
+
if (version !== null && (await versionOf(blob)) !== version) return null;
|
|
93
|
+
await touch(key, note);
|
|
94
|
+
return { blob, note };
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The STEP source kept for `key`, or null if none was kept whole. */
|
|
101
|
+
export async function keptSource(key) {
|
|
102
|
+
const note = await noteOf(key);
|
|
103
|
+
if (!note?.sourceBytes) return null;
|
|
104
|
+
try {
|
|
105
|
+
const source = await (await (await folder(kSources)).getFileHandle(`${key}.source`)).getFile();
|
|
106
|
+
return source.size === note.sourceBytes ? source : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Marks an entry as just used, so eviction keeps it longest. */
|
|
113
|
+
export async function touch(key, note = null) {
|
|
114
|
+
note ??= await noteOf(key);
|
|
115
|
+
if (!note) return;
|
|
116
|
+
note.usedAt = Date.now();
|
|
117
|
+
await writeText(await folder(kNote), `${key}.json`, JSON.stringify(note)).catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Deletes everything kept for `key`: its note first, so a reader never sees half an entry. */
|
|
121
|
+
export async function forget(key) {
|
|
122
|
+
try {
|
|
123
|
+
await (await folder(kNote)).removeEntry(`${key}.json`).catch(() => {});
|
|
124
|
+
await (await folder(kBlobs)).removeEntry(`${key}.geoblob`).catch(() => {});
|
|
125
|
+
await (await folder(kSources)).removeEntry(`${key}.source`).catch(() => {});
|
|
126
|
+
const thumbs = await folder(kThumbs);
|
|
127
|
+
for await (const name of thumbs.keys()) {
|
|
128
|
+
if (name.startsWith(`${key}.`)) await thumbs.removeEntry(name).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// No origin-private file system here: nothing is kept.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Deletes everything kept of files named `name`, wherever they came from: for checks. */
|
|
136
|
+
export async function forgetNamed(name) {
|
|
137
|
+
for (const note of await notes()) if (note.name === name) await forget(note.key);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every finished entry's note. */
|
|
141
|
+
export async function notes() {
|
|
142
|
+
const found = [];
|
|
143
|
+
try {
|
|
144
|
+
const directory = await folder(kNote);
|
|
145
|
+
for await (const [name, handle] of directory.entries()) {
|
|
146
|
+
if (handle.kind !== "file" || !name.endsWith(".json")) continue;
|
|
147
|
+
try {
|
|
148
|
+
found.push(JSON.parse(await (await handle.getFile()).text()));
|
|
149
|
+
} catch {
|
|
150
|
+
// Being written: not finished.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// Nothing kept here.
|
|
155
|
+
}
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The bytes kept imports may hold: a share of the browser's quota. */
|
|
160
|
+
export async function blobBudget() {
|
|
161
|
+
const { quota = 0 } = (await navigator.storage?.estimate?.().catch(() => null)) ?? {};
|
|
162
|
+
return quota > 0 ? quota * kBlobShare : 2 * 1024 ** 3;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Evicts kept imports to the budget, sparing `keep` (keys open or in view). Returns the keys dropped. */
|
|
166
|
+
export async function evict(keep = new Set(), budget = null) {
|
|
167
|
+
budget ??= await blobBudget();
|
|
168
|
+
const dropped = evictions(await notes(), budget, keep);
|
|
169
|
+
for (const key of dropped) await forget(key);
|
|
170
|
+
return dropped;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let persisted = false;
|
|
174
|
+
/** Asks once that what is kept not be cleared under storage pressure: once a folder is connected. */
|
|
175
|
+
export function persistOnce() {
|
|
176
|
+
if (persisted) return;
|
|
177
|
+
persisted = true;
|
|
178
|
+
navigator.storage?.persist?.().catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- Writing, from a worker --------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
async function writeWhole(directory, name, bytes) {
|
|
184
|
+
const access = await (await directory.getFileHandle(name, { create: true })).createSyncAccessHandle();
|
|
185
|
+
access.truncate(0);
|
|
186
|
+
for (let at = 0; at < bytes.length;) at += access.write(bytes.subarray(at), { at });
|
|
187
|
+
access.flush();
|
|
188
|
+
access.close();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Keeps an import, from a worker, where sync access handles write a cache file without holding a
|
|
193
|
+
* second copy: the cache file, then the note, last, which makes it whole.
|
|
194
|
+
*/
|
|
195
|
+
export async function keepImport(key, bytes, note) {
|
|
196
|
+
await writeWhole(await folder(kBlobs), `${key}.geoblob`, bytes);
|
|
197
|
+
const whole = { ...note, key, bytes: bytes.length, sourceBytes: 0, usedAt: Date.now() };
|
|
198
|
+
await writeWhole(await folder(kNote), `${key}.json`, new TextEncoder().encode(JSON.stringify(whole)));
|
|
199
|
+
return whole;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Keeps a STEP file's source beside its kept import, from a worker, read in pieces, once the import
|
|
204
|
+
* is done, since nothing waits for it; then notes it, so a copy cut short is never read.
|
|
205
|
+
*/
|
|
206
|
+
export async function keepSource(key, file) {
|
|
207
|
+
const access = await (await (await folder(kSources)).getFileHandle(`${key}.source`, { create: true }))
|
|
208
|
+
.createSyncAccessHandle();
|
|
209
|
+
access.truncate(0);
|
|
210
|
+
for (let at = 0; at < file.size; at += kSourcePiece) {
|
|
211
|
+
const piece = new Uint8Array(await file.slice(at, at + kSourcePiece).arrayBuffer());
|
|
212
|
+
for (let done = 0; done < piece.length;) done += access.write(piece.subarray(done), { at: at + done });
|
|
213
|
+
}
|
|
214
|
+
access.flush();
|
|
215
|
+
access.close();
|
|
216
|
+
const note = await noteOf(key);
|
|
217
|
+
if (!note) return;
|
|
218
|
+
note.sourceBytes = file.size;
|
|
219
|
+
note.bytes += file.size;
|
|
220
|
+
await writeWhole(await folder(kNote), `${key}.json`, new TextEncoder().encode(JSON.stringify(note)));
|
|
221
|
+
}
|
|
222
|
+
const kSourcePiece = 8 << 20;
|
|
223
|
+
|
|
224
|
+
// ---- Pictures and facts --------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/** The key of a picture of a file: a hash of how it is drawn and the engine's version. */
|
|
227
|
+
export async function pictureKeyOf({ style, colorMode, pixels, pixelsPerPoint, lineScale, folderColor, version,
|
|
228
|
+
supersample = 1 }) {
|
|
229
|
+
// Supersampled: another picture, smoother; one drawn plainly keeps its key.
|
|
230
|
+
const fields = [style, colorMode, pixels, pixelsPerPoint, lineScale, folderColor, version];
|
|
231
|
+
const text = JSON.stringify(supersample > 1 ? [...fields, `ss${supersample}`] : fields);
|
|
232
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
233
|
+
return [...digest.subarray(0, 8)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A file's facts and its picture kept under `key`: { facts, png, note } or null for each missing. */
|
|
237
|
+
export async function keptPicture(key, picture) {
|
|
238
|
+
try {
|
|
239
|
+
const thumbs = await folder(kThumbs);
|
|
240
|
+
const facts = JSON.parse(await readText(thumbs, `${key}.facts.json`));
|
|
241
|
+
let png = null, note = null;
|
|
242
|
+
try {
|
|
243
|
+
note = JSON.parse(await readText(thumbs, `${key}.${picture}.json`));
|
|
244
|
+
png = await (await thumbs.getFileHandle(`${key}.${picture}.png`)).getFile();
|
|
245
|
+
} catch {
|
|
246
|
+
// Drawn in another style, or not at all.
|
|
247
|
+
}
|
|
248
|
+
return { facts, png, note };
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Keeps a picture, its note and the file's facts, from a worker: the PNG, then the facts, then the
|
|
256
|
+
* note, last, which marks the picture whole.
|
|
257
|
+
*/
|
|
258
|
+
export async function keepPicture(key, picture, png, note, facts) {
|
|
259
|
+
const thumbs = await folder(kThumbs);
|
|
260
|
+
await writeWhole(thumbs, `${key}.${picture}.png`, new Uint8Array(await png.arrayBuffer()));
|
|
261
|
+
if (facts) await writeWhole(thumbs, `${key}.facts.json`, new TextEncoder().encode(JSON.stringify(facts)));
|
|
262
|
+
await writeWhole(thumbs, `${key}.${picture}.json`, new TextEncoder().encode(JSON.stringify(note)));
|
|
263
|
+
}
|
package/web/library-store.js
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
// for this visit only.
|
|
9
9
|
//
|
|
10
10
|
// Nothing is read until it is shown: a folder is listed when it is opened, by names alone, and a
|
|
11
|
-
// file is read when it is opened.
|
|
11
|
+
// file is read when it is opened. A gallery walks a folder with walk(), which yields its folders
|
|
12
|
+
// and STEP and STL files, by names first wherever the browser allows. The recent files are kept in
|
|
13
|
+
// IndexedDB everywhere.
|
|
12
14
|
|
|
15
|
+
import { keptImport } from "./kept.js";
|
|
13
16
|
import { keptFile } from "./library.js";
|
|
14
17
|
import { remember as rememberIn } from "./library-rows.js";
|
|
15
18
|
|
|
@@ -42,6 +45,10 @@ const all = (name) => store(name, "readonly", (s) => s.getAll()).catch(() => [])
|
|
|
42
45
|
const put = (name, value) => store(name, "readwrite", (s) => s.put(value)).catch(() => {});
|
|
43
46
|
const remove = (name, key) => store(name, "readwrite", (s) => s.delete(key)).catch(() => {});
|
|
44
47
|
|
|
48
|
+
// What a gallery shows, and how far a walk goes, as on the Mac (Engine/library/scan.h).
|
|
49
|
+
export const isModelName = (name) => /\.(step|stp|stl)$/i.test(name);
|
|
50
|
+
export const kWalkLimits = { depth: 32, files: 20000 };
|
|
51
|
+
|
|
45
52
|
const kModelTypes = [{ description: "STEP, STL or cache files",
|
|
46
53
|
accept: { "application/octet-stream": [".step", ".stp", ".stl", ".geoblob"] } }];
|
|
47
54
|
|
|
@@ -120,6 +127,28 @@ class AccessFolders {
|
|
|
120
127
|
return (await this.#directory(id, parts.join("/"))).getFileHandle(name);
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
// A gallery's walk, by names alone: { path, kind, handle } for each folder and STEP or STL file.
|
|
131
|
+
// A file's size and time come from handle.getFile(), asked for when its tile is in view.
|
|
132
|
+
async *walk(id, path = "", { depth = kWalkLimits.depth, files = kWalkLimits.files } = {}) {
|
|
133
|
+
const start = await this.#directory(id, path);
|
|
134
|
+
const pending = [[start, path, 0]];
|
|
135
|
+
let found = 0;
|
|
136
|
+
while (pending.length > 0) {
|
|
137
|
+
const [directory, at, level] = pending.shift();
|
|
138
|
+
for await (const [name, handle] of directory.entries()) {
|
|
139
|
+
if (name.startsWith(".")) continue;
|
|
140
|
+
const inner = at ? `${at}/${name}` : name;
|
|
141
|
+
if (handle.kind === "directory") {
|
|
142
|
+
yield { path: inner, kind: "folder", handle };
|
|
143
|
+
if (level + 1 < depth) pending.push([handle, inner, level + 1]);
|
|
144
|
+
} else if (isModelName(name)) {
|
|
145
|
+
if (found++ === files) return;
|
|
146
|
+
yield { path: inner, kind: "file", handle };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
123
152
|
async file(id, path) {
|
|
124
153
|
return (await this.fileHandle(id, path)).getFile();
|
|
125
154
|
}
|
|
@@ -172,6 +201,21 @@ class VisitFolders {
|
|
|
172
201
|
return this.#add({ name, files });
|
|
173
202
|
}
|
|
174
203
|
|
|
204
|
+
// A folder served elsewhere, for checks: its listing [{ path, folder, bytes, modified }], each
|
|
205
|
+
// file read from `base` + its path only when asked for.
|
|
206
|
+
adoptRemote(name, listing, base) {
|
|
207
|
+
const files = new Map();
|
|
208
|
+
for (const entry of listing) {
|
|
209
|
+
if (entry.folder) continue;
|
|
210
|
+
files.set(entry.path, async () => {
|
|
211
|
+
const response = await fetch(base + entry.path.split("/").map(encodeURIComponent).join("/"));
|
|
212
|
+
if (!response.ok) throw new Error(`${entry.path} could not be read.`);
|
|
213
|
+
return new File([await response.blob()], entry.path.split("/").pop(), { lastModified: entry.modified });
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return this.#add({ name, files, listing });
|
|
217
|
+
}
|
|
218
|
+
|
|
175
219
|
#add(folder) {
|
|
176
220
|
const id = crypto.randomUUID();
|
|
177
221
|
this.#folders.push({ id, ...folder });
|
|
@@ -218,7 +262,10 @@ class VisitFolders {
|
|
|
218
262
|
async file(id, path) {
|
|
219
263
|
const folder = this.#folders.find((f) => f.id === id);
|
|
220
264
|
if (!folder) throw new Error("That folder is no longer connected.");
|
|
221
|
-
if (folder.files)
|
|
265
|
+
if (folder.files) {
|
|
266
|
+
const file = folder.files.get(path);
|
|
267
|
+
return typeof file === "function" ? file() : file;
|
|
268
|
+
}
|
|
222
269
|
const parts = path.split("/");
|
|
223
270
|
const name = parts.pop();
|
|
224
271
|
const directory = await this.#entry(folder.entry, parts.join("/"));
|
|
@@ -227,6 +274,57 @@ class VisitFolders {
|
|
|
227
274
|
}
|
|
228
275
|
|
|
229
276
|
pickFiles() { return Promise.resolve(undefined); } // the page's own file input instead
|
|
277
|
+
|
|
278
|
+
// A gallery's walk: { path, kind, file? }, files with their File where the folder came whole.
|
|
279
|
+
async *walk(id, path = "", { depth = kWalkLimits.depth, files = kWalkLimits.files } = {}) {
|
|
280
|
+
const folder = this.#folders.find((f) => f.id === id);
|
|
281
|
+
if (!folder) throw new Error("That folder is no longer connected.");
|
|
282
|
+
let found = 0;
|
|
283
|
+
if (folder.listing) {
|
|
284
|
+
const prefix = path ? `${path}/` : "";
|
|
285
|
+
for (const entry of folder.listing) {
|
|
286
|
+
if (!entry.path.startsWith(prefix)) continue;
|
|
287
|
+
if (!entry.folder && found++ === files) return;
|
|
288
|
+
yield { path: entry.path, kind: entry.folder ? "folder" : "file", bytes: entry.bytes, modified: entry.modified };
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (folder.files) {
|
|
293
|
+
const prefix = path ? `${path}/` : "";
|
|
294
|
+
const folders = new Set();
|
|
295
|
+
for (const [inner, file] of folder.files) {
|
|
296
|
+
if (!inner.startsWith(prefix)) continue;
|
|
297
|
+
const parts = inner.slice(prefix.length).split("/");
|
|
298
|
+
if (parts.some((part) => part.startsWith(".")) || parts.length > depth) continue;
|
|
299
|
+
for (let i = 1; i < parts.length; i++) {
|
|
300
|
+
const dir = prefix + parts.slice(0, i).join("/");
|
|
301
|
+
if (!folders.has(dir)) {
|
|
302
|
+
folders.add(dir);
|
|
303
|
+
yield { path: dir, kind: "folder" };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!isModelName(inner)) continue;
|
|
307
|
+
if (found++ === files) return;
|
|
308
|
+
yield { path: inner, kind: "file", file };
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const pending = [[path, 0]];
|
|
313
|
+
while (pending.length > 0) {
|
|
314
|
+
const [at, level] = pending.shift();
|
|
315
|
+
for (const entry of await this.list(id, at)) {
|
|
316
|
+
if (entry.name.startsWith(".")) continue;
|
|
317
|
+
const inner = at ? `${at}/${entry.name}` : entry.name;
|
|
318
|
+
if (entry.kind === "folder") {
|
|
319
|
+
yield { path: inner, kind: "folder" };
|
|
320
|
+
if (level + 1 < depth) pending.push([inner, level + 1]);
|
|
321
|
+
} else if (isModelName(entry.name)) {
|
|
322
|
+
if (found++ === files) return;
|
|
323
|
+
yield { path: inner, kind: "file" };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
230
328
|
}
|
|
231
329
|
|
|
232
330
|
// ---- The iPhone shell ------------------------------------------------------------------------
|
|
@@ -262,6 +360,20 @@ class ShellFolders {
|
|
|
262
360
|
return this.#fetchFile(`/open/${id}/${encodePath(path)}`, path.split("/").pop());
|
|
263
361
|
}
|
|
264
362
|
|
|
363
|
+
// A gallery's walk, from the shell's /scan, which walks the folder natively: { path, kind, bytes,
|
|
364
|
+
// modified, cloud }, a file that lives only in iCloud marked cloud.
|
|
365
|
+
async *walk(id, path = "", { depth = kWalkLimits.depth, files = kWalkLimits.files } = {}) {
|
|
366
|
+
const response = await fetch(`/scan/${id}/${encodePath(path)}?depth=${depth}&limit=${files}`);
|
|
367
|
+
if (!response.ok) throw new Error("This folder could not be read.");
|
|
368
|
+
const prefix = path ? `${path}/` : "";
|
|
369
|
+
for (const line of (await response.text()).split("\n")) {
|
|
370
|
+
if (!line) continue;
|
|
371
|
+
const entry = JSON.parse(line);
|
|
372
|
+
if (entry.done) return;
|
|
373
|
+
yield { ...entry, path: prefix + entry.path };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
265
377
|
async #fetchFile(url, name) {
|
|
266
378
|
const response = await fetch(url);
|
|
267
379
|
if (!response.ok) throw new Error(`${name} could not be read.`);
|
|
@@ -357,7 +469,8 @@ export class LibraryStore {
|
|
|
357
469
|
}
|
|
358
470
|
const permission = await entry.handle?.queryPermission?.({ mode: "read" }).catch(() => "denied");
|
|
359
471
|
if (permission === "granted") return { file: await entry.handle.getFile() };
|
|
360
|
-
const cache = await
|
|
472
|
+
const cache = (entry.cacheKey ? (await keptImport(entry.cacheKey))?.blob : null) ??
|
|
473
|
+
await keptFile(entry.name).catch(() => null);
|
|
361
474
|
if (cache) return { cache };
|
|
362
475
|
if (entry.handle && (await entry.handle.requestPermission({ mode: "read" }).catch(() => "denied")) === "granted") {
|
|
363
476
|
return { file: await entry.handle.getFile() };
|
package/web/library.js
CHANGED
|
@@ -108,7 +108,12 @@ export class LibraryCard {
|
|
|
108
108
|
#recent = [];
|
|
109
109
|
#open = new Set();
|
|
110
110
|
|
|
111
|
-
// `on` takes: open({ root, path, name }, add), reopen(entry, add), choose(), connect(), switch(show)
|
|
111
|
+
// `on` takes: open({ root, path, name }, add), reopen(entry, add), choose(), connect(), switch(show),
|
|
112
|
+
// showFolder(root, path), search(query) while the gallery shows
|
|
113
|
+
#search;
|
|
114
|
+
#searchBox;
|
|
115
|
+
#found;
|
|
116
|
+
|
|
112
117
|
constructor(store, on) {
|
|
113
118
|
this.#store = store;
|
|
114
119
|
this.#on = on;
|
|
@@ -124,12 +129,29 @@ export class LibraryCard {
|
|
|
124
129
|
connect.hidden = !store.canConnect;
|
|
125
130
|
this.#list = new VirtualList({ role: "tree", rowHeight, count: () => this.#rows.length,
|
|
126
131
|
row: (index) => this.#row(this.#rows[index]) });
|
|
132
|
+
// While the gallery shows: search the folder in view. Typing in the gallery searches too.
|
|
133
|
+
this.#search = el("input.search", { type: "search", placeholder: "Search this folder",
|
|
134
|
+
"aria-label": "Search this folder" });
|
|
135
|
+
this.#search.addEventListener("input", () => on.search?.(this.#search.value));
|
|
136
|
+
this.#found = el("span.match");
|
|
137
|
+
this.#searchBox = el("label.searchbox", { hidden: true },
|
|
138
|
+
[el("span.glyph", { html: icons.search(14) }), this.#search, this.#found]);
|
|
127
139
|
this.element = el("section.card.structure.library", { hidden: true, "aria-label": "Library" }, [
|
|
128
140
|
el("div.header", {}, [this.switcher.element, el("span.grow"), add, connect]),
|
|
141
|
+
this.#searchBox,
|
|
129
142
|
this.#list.element,
|
|
130
143
|
]);
|
|
131
144
|
}
|
|
132
145
|
|
|
146
|
+
/** Shows the gallery's search, with its query and how many files it found (null: no search). */
|
|
147
|
+
showSearch(shown, query = "", found = null) {
|
|
148
|
+
this.#searchBox.hidden = !shown;
|
|
149
|
+
if (this.#search.value !== query) this.#search.value = query;
|
|
150
|
+
this.#found.textContent = found === null ? "" : `${found} found`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get searchInput() { return this.#search; }
|
|
154
|
+
|
|
133
155
|
// Reads the folders and recent files again; `kept` are the imports this browser kept, which
|
|
134
156
|
// fill Recent on a first visit.
|
|
135
157
|
async refresh(kept) {
|
|
@@ -212,6 +234,8 @@ export class LibraryCard {
|
|
|
212
234
|
this.#expanded.add(row.key);
|
|
213
235
|
await this.#readListing(row.root, row.path);
|
|
214
236
|
}
|
|
237
|
+
// A folder's gallery shows beside the card.
|
|
238
|
+
this.#on.showFolder?.({ id: row.root, name: this.folderName(row.root) }, row.path);
|
|
215
239
|
}
|
|
216
240
|
|
|
217
241
|
async #disconnect(row) {
|
|
Binary file
|
|
Binary file
|
package/web/pdf.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A PDF of pictures: each page one JPEG, at its size in points. No fonts and no dependencies: the
|
|
2
|
+
// gallery's posters and its catalogue, drawn as they show, for the browser to save. (The Mac's are
|
|
3
|
+
// drawn from its layers, their text and lines kept as text and lines.)
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {{ jpeg: Uint8Array, width: number, height: number, points: [number, number] }[]} pages
|
|
7
|
+
* each page's JPEG, its size in pixels, and the page's size in points
|
|
8
|
+
* @returns {Blob}
|
|
9
|
+
*/
|
|
10
|
+
export function pdfOfJpegs(pages) {
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const chunks = [];
|
|
13
|
+
const offsets = [];
|
|
14
|
+
let length = 0;
|
|
15
|
+
const push = (data) => {
|
|
16
|
+
const bytes = typeof data === "string" ? encoder.encode(data) : data;
|
|
17
|
+
chunks.push(bytes);
|
|
18
|
+
length += bytes.length;
|
|
19
|
+
};
|
|
20
|
+
const object = (n, body) => {
|
|
21
|
+
offsets[n] = length;
|
|
22
|
+
push(`${n} 0 obj\n`);
|
|
23
|
+
body();
|
|
24
|
+
push("\nendobj\n");
|
|
25
|
+
};
|
|
26
|
+
const number = (v) => (Math.round(v * 100) / 100).toString();
|
|
27
|
+
// The header, and a comment of high bytes so tools take the file as binary.
|
|
28
|
+
push("%PDF-1.4\n");
|
|
29
|
+
push(new Uint8Array([0x25, 0xe2, 0xe3, 0xcf, 0xd3, 0x0a]));
|
|
30
|
+
// 1 the catalogue, 2 the page tree, then for each page: the page, its content, its picture.
|
|
31
|
+
const count = 3 + pages.length * 3;
|
|
32
|
+
object(1, () => push("<< /Type /Catalog /Pages 2 0 R >>"));
|
|
33
|
+
object(2, () => push(`<< /Type /Pages /Kids [${pages.map((_, i) => `${3 + i * 3} 0 R`).join(" ")}] /Count ${pages.length} >>`));
|
|
34
|
+
pages.forEach((p, i) => {
|
|
35
|
+
const page = 3 + i * 3, content = page + 1, picture = page + 2;
|
|
36
|
+
const [w, h] = p.points.map(number);
|
|
37
|
+
object(page, () => push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] ` +
|
|
38
|
+
`/Resources << /XObject << /P ${picture} 0 R >> >> /Contents ${content} 0 R >>`));
|
|
39
|
+
const draw = `q ${w} 0 0 ${h} 0 0 cm /P Do Q`;
|
|
40
|
+
object(content, () => push(`<< /Length ${draw.length} >>\nstream\n${draw}\nendstream`));
|
|
41
|
+
object(picture, () => {
|
|
42
|
+
push(`<< /Type /XObject /Subtype /Image /Width ${p.width} /Height ${p.height} /ColorSpace /DeviceRGB ` +
|
|
43
|
+
`/BitsPerComponent 8 /Filter /DCTDecode /Length ${p.jpeg.length} >>\nstream\n`);
|
|
44
|
+
push(p.jpeg);
|
|
45
|
+
push("\nendstream");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
// The cross-reference table: each entry exactly 20 bytes.
|
|
49
|
+
const table = length;
|
|
50
|
+
push(`xref\n0 ${count}\n0000000000 65535 f \n`);
|
|
51
|
+
for (let n = 1; n < count; n++) push(`${String(offsets[n]).padStart(10, "0")} 00000 n \n`);
|
|
52
|
+
push(`trailer\n<< /Size ${count} /Root 1 0 R >>\nstartxref\n${table}\n%%EOF\n`);
|
|
53
|
+
return new Blob(chunks, { type: "application/pdf" });
|
|
54
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The gallery's picture worker: the engine's WebGPU renderer with a GPU device of its own
|
|
2
|
+
// (geometryview-thumbs-web, Engine/browser/web_thumbs.cpp), drawing cache files one at a time as
|
|
3
|
+
// the Mac's thumbnailer does, so pictures never hold up the page's own frames.
|
|
4
|
+
//
|
|
5
|
+
// Messages in: { type: "draw", id, blob, pixels, style, colorMode, pixelsPerPoint, lineScale,
|
|
6
|
+
// firstColor, folderColor, keep }, blob a cache file as a Blob or ArrayBuffer.
|
|
7
|
+
// A file of one part takes `folderColor`, if given. With keep { key, picture },
|
|
8
|
+
// the picture, its note and the file's facts are kept in the origin-private file
|
|
9
|
+
// system (kept.js), written here since only a worker can write there everywhere.
|
|
10
|
+
// { type: "trim" }, between pictures: lets go of the last file drawn
|
|
11
|
+
// Messages out: { type: "ready" } or { type: "failed", message }, once
|
|
12
|
+
// { type: "drawn", id, bitmap, png, width, height, worldPerPixel, partRect, inkRect,
|
|
13
|
+
// facts, firstColor, heapMB }, the bitmap to show at once and the PNG to keep; or
|
|
14
|
+
// { type: "drawn", id, message } if it could not be drawn
|
|
15
|
+
//
|
|
16
|
+
// Its memory never shrinks, so the page replaces it once heapMB passes a limit.
|
|
17
|
+
|
|
18
|
+
import createThumbs from "./build/geometryview-thumbs-web.mjs";
|
|
19
|
+
import { keepPicture } from "./kept.js";
|
|
20
|
+
|
|
21
|
+
let module = null;
|
|
22
|
+
let answer = null; // the draw waiting for thumbsHost.drawn
|
|
23
|
+
|
|
24
|
+
const host = {
|
|
25
|
+
ready() {
|
|
26
|
+
self.postMessage({ type: "ready" });
|
|
27
|
+
},
|
|
28
|
+
failed(message) {
|
|
29
|
+
self.postMessage({ type: "failed", message });
|
|
30
|
+
},
|
|
31
|
+
drawn(pixels, width, height, worldPerPixel, rects, message) {
|
|
32
|
+
const resolve = answer;
|
|
33
|
+
answer = null;
|
|
34
|
+
resolve?.({ pixels, width, height, worldPerPixel, rects, message });
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function draw(data) {
|
|
39
|
+
const bytes = new Uint8Array(data.blob instanceof Blob ? await data.blob.arrayBuffer() : data.blob);
|
|
40
|
+
// Each call may grow the heap, which detaches views made before it: read them after.
|
|
41
|
+
const staging = module._thumbs_staging(bytes.length);
|
|
42
|
+
module.HEAPU8.set(bytes, staging);
|
|
43
|
+
// Its facts first: drawing lets go of the staged file.
|
|
44
|
+
const factsText = module.UTF8ToString(module._thumbs_facts());
|
|
45
|
+
const facts = factsText ? JSON.parse(factsText) : null;
|
|
46
|
+
const firstColor = data.folderColor >= 0 && facts?.parts === 1 ? data.folderColor : data.firstColor ?? -1;
|
|
47
|
+
const drawn = await new Promise((resolve) => {
|
|
48
|
+
answer = resolve;
|
|
49
|
+
module._thumbs_draw(data.pixels, data.style, data.colorMode, data.pixelsPerPoint ?? 2,
|
|
50
|
+
data.lineScale ?? 0.5, firstColor, data.turn ?? 0, data.worldPerPixel ?? 0,
|
|
51
|
+
data.supersample ?? 1);
|
|
52
|
+
});
|
|
53
|
+
const heapMB = Math.round(module._thumbs_heap_bytes() / 2 ** 20);
|
|
54
|
+
if (!drawn.pixels) {
|
|
55
|
+
self.postMessage({ type: "drawn", id: data.id, message: drawn.message || "This picture could not be drawn.", heapMB });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const { width, height } = drawn;
|
|
59
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
60
|
+
canvas.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(drawn.pixels.buffer), width, height), 0, 0);
|
|
61
|
+
const png = await canvas.convertToBlob({ type: "image/png" });
|
|
62
|
+
const bitmap = canvas.transferToImageBitmap();
|
|
63
|
+
const note = { width, height, mmPerPixel: drawn.worldPerPixel, part: drawn.rects.slice(0, 4),
|
|
64
|
+
ink: drawn.rects.slice(4, 8), firstColor };
|
|
65
|
+
if (data.keep) await keepPicture(data.keep.key, data.keep.picture, png, note, facts).catch(() => {});
|
|
66
|
+
self.postMessage({ type: "drawn", id: data.id, bitmap, png, width, height, worldPerPixel: drawn.worldPerPixel,
|
|
67
|
+
partRect: note.part, inkRect: note.ink, facts, firstColor, heapMB }, [bitmap]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One message at a time, in order, so a draw never starts while another waits for the GPU.
|
|
71
|
+
let queue = Promise.resolve();
|
|
72
|
+
self.onmessage = ({ data }) => {
|
|
73
|
+
queue = queue.then(async () => {
|
|
74
|
+
try {
|
|
75
|
+
if (data.type === "draw") await draw(data);
|
|
76
|
+
else if (data.type === "trim") module?._thumbs_trim();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (data.type === "draw") self.postMessage({ type: "drawn", id: data.id, message: String(error.message ?? error) });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
createThumbs({ thumbsHost: host }).then((created) => {
|
|
84
|
+
module = created;
|
|
85
|
+
module._thumbs_start();
|
|
86
|
+
}, (error) => host.failed(String(error.message ?? error)));
|