@swiftbrowser/web 0.18.3 → 0.21.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/src/chartDom.ts CHANGED
@@ -325,7 +325,7 @@ function clipPathFor(doc: Document, defs: SVGDefsElement, mask: ChartMark, plot:
325
325
  /**
326
326
  * The SVG paint for a ShapeStyle: a CSS color for flat styles (the same
327
327
  * tokens the renderer uses: `--sb-color-*` for semantic and named colors,
328
- * the fill tokens for hierarchical levels), or `url(#gradient)` after adding
328
+ * the foreground's levels for hierarchical ones), or `url(#gradient)` after adding
329
329
  * a `<linearGradient>` / `<radialGradient>` to `defs`. `bbox` is the mark's
330
330
  * pixel bounds, which a radial gradient's center and radii refer to.
331
331
  */
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Which device a desktop preview draws the app in (frame mode only; a real
3
+ * phone or tablet is device mode, deviceMode.ts): an iPhone (the default),
4
+ * an iPad in landscape, or no device at all, the app filling the window
5
+ * under the toolbar. styles.css and page.css draw each from the document's
6
+ * `data-sb-frame`; the layout engine gets the device's safe area, and its
7
+ * size from the screen's box, so an iPad (or a wide window) lays out in the
8
+ * regular size class, sidebars and all.
9
+ *
10
+ * `?device=iphone|ipad|none` picks one (and is what the picker writes, so a
11
+ * link shares it); otherwise the last one picked on this site, from
12
+ * localStorage. The toolbar's close button (an x in a circle, at its end)
13
+ * hides the toolbar and writes `?toolbar=0`, so a link shows the app alone;
14
+ * without that parameter the toolbar is back. The pages' inline scripts apply the same rule before the
15
+ * first paint (app/index.html's, and the native apps' web/index.html), so
16
+ * an iPad never flashes an iPhone first.
17
+ */
18
+
19
+ import type { EdgeInsets } from './layout';
20
+
21
+ export type DeviceFrame = 'iphone' | 'ipad' | 'none';
22
+
23
+ export const DEVICE_FRAMES: readonly { id: DeviceFrame; label: string }[] = [
24
+ { id: 'iphone', label: 'iPhone' },
25
+ { id: 'ipad', label: 'iPad' },
26
+ { id: 'none', label: 'None' },
27
+ ];
28
+
29
+ /**
30
+ * The safe area each device gives the app: an iPhone 15 Pro's status bar and
31
+ * home indicator, an iPad's (24 and 20 points, as iPadOS draws them in
32
+ * landscape), and none without a device.
33
+ */
34
+ export const FRAME_SAFE_AREAS: Readonly<Record<DeviceFrame, EdgeInsets>> = {
35
+ iphone: { top: 59, leading: 0, bottom: 34, trailing: 0 },
36
+ ipad: { top: 24, leading: 0, bottom: 20, trailing: 0 },
37
+ none: { top: 0, leading: 0, bottom: 0, trailing: 0 },
38
+ };
39
+
40
+ /** Where the last device picked is kept. */
41
+ export const DEVICE_FRAME_KEY = 'sb-device-frame';
42
+
43
+ const isFrame = (value: unknown): value is DeviceFrame => value === 'iphone' || value === 'ipad' || value === 'none';
44
+
45
+ /** `?device=`, else the stored choice, else the iPhone. */
46
+ export function decideDeviceFrame(search: string = location.search, storage: Pick<Storage, 'getItem'> | null = safeStorage()): DeviceFrame {
47
+ const asked = new URLSearchParams(search).get('device');
48
+ if (isFrame(asked)) return asked;
49
+ let stored: string | null = null;
50
+ try {
51
+ stored = storage?.getItem(DEVICE_FRAME_KEY) ?? null;
52
+ } catch {
53
+ // Storage blocked: the default.
54
+ }
55
+ return isFrame(stored) ? stored : 'iphone';
56
+ }
57
+
58
+ /**
59
+ * Marks the document with the device (`data-sb-frame`) and gives the status
60
+ * bar the date an iPad shows beside the time.
61
+ */
62
+ export function applyDeviceFrame(frame: DeviceFrame, doc: Document = document): void {
63
+ doc.documentElement.dataset['sbFrame'] = frame;
64
+ const time = doc.querySelector('.status-time');
65
+ if (time && !time.querySelector('.status-date')) {
66
+ const date = doc.createElement('span');
67
+ date.className = 'status-date';
68
+ date.textContent = 'Tue Jan 9';
69
+ time.append(date);
70
+ }
71
+ }
72
+
73
+ /** What the picker drives: the renderer (or a mounted React root's). */
74
+ export interface DeviceFrameTarget {
75
+ setSafeArea(insets: EdgeInsets | null): void;
76
+ viewportChanged(): void;
77
+ }
78
+
79
+ export interface DeviceFramePickerOptions {
80
+ /** The page's toolbar, which gets the picker. */
81
+ toolbar?: HTMLElement | null;
82
+ /** The toolbar item the picker goes before; the theme toggle by default, else the end. */
83
+ before?: Element | null;
84
+ search?: string;
85
+ storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
86
+ /** Called after each change of device, the new one applied. */
87
+ onChange?: (frame: DeviceFrame) => void;
88
+ /** The close button at the toolbar's end (`installToolbarClose`); on unless false. */
89
+ closeButton?: boolean;
90
+ }
91
+
92
+ /**
93
+ * A desktop preview's device: applies the chosen one, lays the app out for
94
+ * it, and adds a Device picker to the toolbar that switches it in place (the
95
+ * app keeps its state) and remembers it. Does nothing in device mode, where
96
+ * the page is the device. Returns the device in use.
97
+ */
98
+ export function installDeviceFrame(target: DeviceFrameTarget, options: DeviceFramePickerOptions = {}): DeviceFrame {
99
+ const doc = document;
100
+ if (doc.documentElement.dataset['sbMode'] === 'device') return 'iphone';
101
+ const storage = options.storage === undefined ? safeStorage() : options.storage;
102
+ let frame = decideDeviceFrame(options.search ?? location.search, storage);
103
+ const show = (next: DeviceFrame): void => {
104
+ frame = next;
105
+ applyDeviceFrame(next, doc);
106
+ target.setSafeArea(FRAME_SAFE_AREAS[next]);
107
+ target.viewportChanged();
108
+ };
109
+ show(frame);
110
+
111
+ const toolbar = options.toolbar === undefined ? doc.querySelector<HTMLElement>('.toolbar') : options.toolbar;
112
+ if (toolbar && options.closeButton !== false) installToolbarClose(toolbar, target);
113
+ if (!toolbar || toolbar.querySelector('#device-frame')) return frame;
114
+ const label = doc.createElement('label');
115
+ label.className = 'toolbar-label';
116
+ label.htmlFor = 'device-frame';
117
+ label.textContent = 'Device';
118
+ const select = doc.createElement('select');
119
+ select.id = 'device-frame';
120
+ select.className = 'toolbar-select';
121
+ for (const { id, label: text } of DEVICE_FRAMES) select.append(new Option(text, id, false, id === frame));
122
+ select.addEventListener('change', () => {
123
+ if (!isFrame(select.value)) return;
124
+ show(select.value);
125
+ try {
126
+ storage?.setItem(DEVICE_FRAME_KEY, select.value);
127
+ } catch {
128
+ // Storage full or blocked: the choice lasts the page.
129
+ }
130
+ const url = new URL(location.href);
131
+ if (select.value === 'iphone') url.searchParams.delete('device');
132
+ else url.searchParams.set('device', select.value);
133
+ history.replaceState(history.state, '', url);
134
+ options.onChange?.(frame);
135
+ });
136
+ const before = options.before === undefined ? toolbar.querySelector('#theme-toggle') : options.before;
137
+ if (before && before.parentElement === toolbar) {
138
+ toolbar.insertBefore(label, before);
139
+ toolbar.insertBefore(select, before);
140
+ } else {
141
+ toolbar.append(label, select);
142
+ }
143
+ return frame;
144
+ }
145
+
146
+ /** Whether the URL asks for the page without its toolbar (`?toolbar=0`). */
147
+ export function toolbarHidden(search: string = location.search): boolean {
148
+ return new URLSearchParams(search).get('toolbar') === '0';
149
+ }
150
+
151
+ /**
152
+ * An x in a circle at the end of the toolbar that hides it, for the app
153
+ * alone in the page (a screenshot, a demo): the document gets
154
+ * `data-sb-toolbar="hidden"` (page.css), the URL `?toolbar=0` so a reload
155
+ * or a shared link keeps it hidden, and the renderer re-reads the screen's
156
+ * size, which grows without a device. The pages' inline scripts hide the
157
+ * toolbar before the first paint when the URL asks.
158
+ */
159
+ export function installToolbarClose(toolbar: HTMLElement, target?: Pick<DeviceFrameTarget, 'viewportChanged'>): HTMLButtonElement {
160
+ const doc = toolbar.ownerDocument;
161
+ const existing = toolbar.querySelector<HTMLButtonElement>('.toolbar-close');
162
+ if (existing) return existing;
163
+ if (toolbarHidden()) doc.documentElement.dataset['sbToolbar'] = 'hidden';
164
+ const button = doc.createElement('button');
165
+ button.type = 'button';
166
+ button.className = 'toolbar-close';
167
+ button.setAttribute('aria-label', 'Hide the toolbar');
168
+ button.title = 'Hide the toolbar (remove ?toolbar=0 from the address to show it again)';
169
+ button.innerHTML =
170
+ '<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true"><circle cx="10" cy="10" r="10" /><path d="M6.5 6.5l7 7M13.5 6.5l-7 7" /></svg>';
171
+ button.addEventListener('click', () => {
172
+ doc.documentElement.dataset['sbToolbar'] = 'hidden';
173
+ const url = new URL(location.href);
174
+ url.searchParams.set('toolbar', '0');
175
+ history.replaceState(history.state, '', url);
176
+ target?.viewportChanged();
177
+ });
178
+ toolbar.append(button);
179
+ return button;
180
+ }
181
+
182
+ function safeStorage(): Storage | null {
183
+ try {
184
+ return typeof localStorage === 'undefined' ? null : localStorage;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
package/src/dragDrop.ts CHANGED
@@ -32,6 +32,12 @@ export interface DragDropHost {
32
32
  readonly root: HTMLElement;
33
33
  send(event: RendererEvent): void;
34
34
  nodeOf(id: ElementId): LayoutNode | undefined;
35
+ /**
36
+ * How a dropped file reaches the app: as base64 `data` (a Swift app, whose
37
+ * events cross into linear memory), or by `reference` (a React app in the
38
+ * same page gets the `File` itself, and a dropped folder's files by path).
39
+ */
40
+ fileDrops?(): 'bytes' | 'reference';
35
41
  }
36
42
 
37
43
  function idOf(el: Element | null): ElementId | null {
@@ -488,7 +494,7 @@ export class ExternalDrops {
488
494
  const scale = scaleOf(this.host.root);
489
495
  const point = { x: round2((event.clientX - rect.left) / scale), y: round2((event.clientY - rect.top) / scale) };
490
496
  const patterns = patternsOf(destination);
491
- void readItems(transfer, patterns).then((items) => {
497
+ void readItems(transfer, patterns, { reference: this.host.fileDrops?.() === 'reference' }).then((items) => {
492
498
  if (items.length === 0) return;
493
499
  const drop: DropEvent = { type: 'drop', id, phase: 'performed', items, x: point.x, y: point.y };
494
500
  this.host.send(drop);
@@ -514,36 +520,86 @@ export class ExternalDrops {
514
520
  }
515
521
  }
516
522
 
517
- /** The drag's items the patterns accept: strings as text, files as base64 bytes. */
518
- export async function readItems(transfer: DataTransfer, patterns: readonly string[]): Promise<DropItem[]> {
519
- const reads: Promise<DropItem | null>[] = [];
523
+ export interface ReadItemsOptions {
524
+ /** Carry each file by reference (`file`, and `path` inside a dropped folder) instead of reading it into base64 `data`. */
525
+ reference?: boolean;
526
+ }
527
+
528
+ /** The entry behind a dragged item where the browser offers one (`webkitGetAsEntry`), for folders. */
529
+ type EntryItem = Pick<DataTransferItem, 'kind' | 'type' | 'getAsFile' | 'getAsString'> & { webkitGetAsEntry?: () => FileSystemEntry | null };
530
+
531
+ /**
532
+ * The drag's items the patterns accept: strings as text, files as base64
533
+ * bytes (or by reference, `options.reference`). A dropped folder is walked
534
+ * (`webkitGetAsEntry`, which every browser with drag and drop offers) and
535
+ * each file in it that the patterns accept comes as an item of its own,
536
+ * named by its path from the folder, the folder's name first, as the folder
537
+ * chooser names picked files (hostRequests.ts `folderFiles`).
538
+ */
539
+ export async function readItems(transfer: DataTransfer, patterns: readonly string[], options: ReadItemsOptions = {}): Promise<DropItem[]> {
540
+ const reads: Promise<DropItem[]>[] = [];
520
541
  const items = Array.from(transfer.items ?? []);
521
542
  if (items.length > 0) {
522
543
  for (const item of items) {
523
544
  if (item.kind === 'file') {
545
+ // The entry and the file must be taken now, inside the event: the item is empty afterwards.
546
+ const entry = (item as EntryItem).webkitGetAsEntry?.() ?? null;
547
+ if (entry?.isDirectory) {
548
+ reads.push(readDirectory(entry as FileSystemDirectoryEntry, entry.name, patterns, options));
549
+ continue;
550
+ }
524
551
  const file = item.getAsFile();
525
552
  const type = file?.type || 'application/octet-stream';
526
553
  if (!file || !acceptsType(patterns, type)) continue;
527
- reads.push(readFile(file, type));
554
+ reads.push(readFile(file, type, undefined, options).then((read) => (read ? [read] : [])));
528
555
  } else if (item.kind === 'string' && (item.type === 'text/plain' || item.type === 'text/uri-list') && acceptsType(patterns, item.type)) {
529
556
  const type = item.type;
530
- reads.push(new Promise((resolve) => item.getAsString((text) => resolve({ type, text }))));
557
+ reads.push(new Promise((resolve) => item.getAsString((text) => resolve([{ type, text }]))));
531
558
  }
532
559
  }
533
560
  } else {
534
561
  // Without `items` (an older browser, a synthetic transfer): the strings only.
535
562
  for (const type of ['text/plain', 'text/uri-list']) {
536
563
  const text = transfer.getData(type);
537
- if (text && acceptsType(patterns, type)) reads.push(Promise.resolve({ type, text }));
564
+ if (text && acceptsType(patterns, type)) reads.push(Promise.resolve([{ type, text }]));
538
565
  }
539
566
  }
540
567
  const results = await Promise.all(reads);
541
- return results.filter((item): item is DropItem => item !== null);
568
+ return results.flat();
569
+ }
570
+
571
+ /** Every file under a dropped folder's entry, at any depth, that the patterns accept, named by its path from the folder. */
572
+ async function readDirectory(directory: FileSystemDirectoryEntry, path: string, patterns: readonly string[], options: ReadItemsOptions): Promise<DropItem[]> {
573
+ const entries = await allEntries(directory);
574
+ const out: DropItem[][] = await Promise.all(
575
+ entries.map(async (entry) => {
576
+ const entryPath = `${path}/${entry.name}`;
577
+ if (entry.isDirectory) return readDirectory(entry as FileSystemDirectoryEntry, entryPath, patterns, options);
578
+ const file = await new Promise<File | null>((resolve) => (entry as FileSystemFileEntry).file(resolve, () => resolve(null)));
579
+ const type = file?.type || 'application/octet-stream';
580
+ if (!file || !acceptsType(patterns, type)) return [];
581
+ const read = await readFile(file, type, entryPath, options);
582
+ return read ? [read] : [];
583
+ }),
584
+ );
585
+ return out.flat();
586
+ }
587
+
588
+ /** A directory entry's children: `readEntries` answers a batch at a time, until an empty one. */
589
+ async function allEntries(directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> {
590
+ const reader = directory.createReader();
591
+ const all: FileSystemEntry[] = [];
592
+ for (;;) {
593
+ const batch = await new Promise<FileSystemEntry[]>((resolve) => reader.readEntries(resolve, () => resolve([])));
594
+ if (batch.length === 0) return all;
595
+ all.push(...batch);
596
+ }
542
597
  }
543
598
 
544
- function readFile(file: File, type: string): Promise<DropItem | null> {
599
+ function readFile(file: File, type: string, path: string | undefined, options: ReadItemsOptions): Promise<DropItem | null> {
600
+ if (options.reference) return Promise.resolve({ type, name: file.name, file, ...(path !== undefined ? { path } : {}) });
545
601
  return file
546
602
  .arrayBuffer()
547
- .then((buffer) => ({ type, name: file.name, data: base64Encode(new Uint8Array(buffer)) }))
603
+ .then((buffer) => ({ type, name: file.name, data: base64Encode(new Uint8Array(buffer)), ...(path !== undefined ? { path } : {}) }))
548
604
  .catch(() => null);
549
605
  }
package/src/files.ts CHANGED
@@ -48,8 +48,19 @@ export interface FilesHost {
48
48
  readonly directories: FileDirectories;
49
49
  /** The file's bytes. */
50
50
  read(url: string): Promise<Uint8Array>;
51
- /** Writes the bytes as the file, replacing one there; its directory must exist. */
52
- write(url: string, data: Uint8Array): Promise<void>;
51
+ /**
52
+ * The file's bytes from `start` up to `end` (exclusive, clamped to the
53
+ * file's size): what `read` would answer, sliced, without the rest of the
54
+ * file in memory where the host can avoid it. Hosts before 0.20 lack it;
55
+ * `fileManager` then reads the whole file and slices.
56
+ */
57
+ readRange?(url: string, start: number, end: number): Promise<Uint8Array>;
58
+ /**
59
+ * Writes the bytes as the file, replacing one there; its directory must
60
+ * exist. A `Blob` (a `File` the viewer picked or dropped) is kept as it
61
+ * is where the host can, so a large file never passes through memory.
62
+ */
63
+ write(url: string, data: Uint8Array | Blob): Promise<void>;
53
64
  /** Removes a file, or a directory and everything in it. */
54
65
  remove(url: string): Promise<void>;
55
66
  /** Makes the directory and the directories above it; one already there is fine. */
@@ -136,13 +147,47 @@ function parentOf(path: string): string {
136
147
  // Rows: a home's files by path, in IndexedDB or memory
137
148
  // ---------------------------------------------------------------------------
138
149
 
150
+ /**
151
+ * A file's contents: bytes, or a `Blob` kept as the viewer handed it over
152
+ * (IndexedDB stores one without reading it into memory).
153
+ */
154
+ export type FileData = Uint8Array | Blob;
155
+
139
156
  /** A file's bytes, or null for a directory, and when it was written and made. */
140
157
  export interface FileRow {
141
- data: Uint8Array | null;
158
+ data: FileData | null;
142
159
  modified: number | null;
143
160
  created: number | null;
144
161
  }
145
162
 
163
+ function isBlob(data: FileData): data is Blob {
164
+ return typeof Blob !== 'undefined' && data instanceof Blob;
165
+ }
166
+
167
+ /** How many bytes a file's data holds. */
168
+ export function dataSize(data: FileData): number {
169
+ return isBlob(data) ? data.size : data.byteLength;
170
+ }
171
+
172
+ /** A file's data as bytes. */
173
+ export async function dataBytes(data: FileData): Promise<Uint8Array> {
174
+ return isBlob(data) ? new Uint8Array(await data.arrayBuffer()) : data;
175
+ }
176
+
177
+ /** The bytes of a file's data from `start` up to `end`, clamped. */
178
+ export async function dataSlice(data: FileData, start: number, end: number): Promise<Uint8Array> {
179
+ const size = dataSize(data);
180
+ const from = Math.min(Math.max(0, Math.floor(start)), size);
181
+ const to = Math.min(Math.max(from, Math.floor(end)), size);
182
+ if (isBlob(data)) return new Uint8Array(await data.slice(from, to).arrayBuffer());
183
+ return data.subarray(from, to);
184
+ }
185
+
186
+ /** A copy of a file's data a row can keep: a Blob is immutable and kept as it is. */
187
+ function ownData(data: FileData): FileData {
188
+ return isBlob(data) ? data : data.slice();
189
+ }
190
+
146
191
  /** The rows of one home, by path relative to it (`Documents/notes.txt`). */
147
192
  export interface FileRows {
148
193
  get(path: string): Promise<FileRow | undefined>;
@@ -157,7 +202,7 @@ export function memoryRows(): FileRows {
157
202
  const rows = new Map<string, FileRow>();
158
203
  return {
159
204
  get: async (path) => rows.get(path),
160
- put: async (path, row) => void rows.set(path, { data: row.data === null ? null : row.data.slice(), modified: row.modified, created: row.created }),
205
+ put: async (path, row) => void rows.set(path, { data: row.data === null ? null : ownData(row.data), modified: row.modified, created: row.created }),
161
206
  remove: async (paths) => paths.forEach((path) => rows.delete(path)),
162
207
  keys: async () => [...rows.keys()],
163
208
  };
@@ -173,14 +218,15 @@ export function indexedDBRows(app: string, factory: IDBFactory = indexedDB): Fil
173
218
  const row = (await request((await store('readonly')).get(path))) as EntryRow | undefined;
174
219
  if (!row) return undefined;
175
220
  return {
176
- data: row.directory ? null : new Uint8Array(row.data ?? new Uint8Array()),
221
+ // A Blob row stays a Blob: the browser hands it over without reading it.
222
+ data: row.directory ? null : row.data !== undefined && isBlob(row.data) ? row.data : new Uint8Array(row.data ?? new Uint8Array()),
177
223
  modified: typeof row.modified === 'number' ? row.modified : null,
178
224
  created: typeof row.created === 'number' ? row.created : null,
179
225
  };
180
226
  },
181
227
  async put(path, row) {
182
228
  const entries = await store('readwrite');
183
- const stored: EntryRow = row.data === null ? { path, directory: true } : { path, data: row.data.slice() };
229
+ const stored: EntryRow = row.data === null ? { path, directory: true } : { path, data: ownData(row.data) };
184
230
  if (row.modified !== null) stored.modified = row.modified;
185
231
  if (row.created !== null) stored.created = row.created;
186
232
  entries.put(stored);
@@ -350,15 +396,24 @@ export class BrowserFiles implements FilesHost {
350
396
  this.objectURLs?.revoke(url);
351
397
  }
352
398
 
353
- async read(url: string): Promise<Uint8Array> {
399
+ /** The row of a file (not a directory) at the URL. */
400
+ private async fileRow(url: string): Promise<FileRow & { data: FileData }> {
354
401
  const { mount, path } = this.locate(url);
355
402
  const row = await this.rowAt(mount, path);
356
403
  if (!row) throw new FilesFailure('notFound', `no file at ${url}`);
357
404
  if (row.data === null) throw new FilesFailure('isDirectory', `${url} is a directory`);
358
- return row.data;
405
+ return row as FileRow & { data: FileData };
406
+ }
407
+
408
+ async read(url: string): Promise<Uint8Array> {
409
+ return dataBytes((await this.fileRow(url)).data);
410
+ }
411
+
412
+ async readRange(url: string, start: number, end: number): Promise<Uint8Array> {
413
+ return dataSlice((await this.fileRow(url)).data, start, end);
359
414
  }
360
415
 
361
- async write(url: string, data: Uint8Array): Promise<void> {
416
+ async write(url: string, data: FileData): Promise<void> {
362
417
  const { mount, path } = this.locate(url);
363
418
  if (path === '') throw new FilesFailure('isDirectory', `${url} is a directory`);
364
419
  await this.requireDirectory(mount, parentOf(path), url);
@@ -413,7 +468,7 @@ export class BrowserFiles implements FilesHost {
413
468
  const row = await this.rowAt(mount, path);
414
469
  if (!row) return null;
415
470
  const directory = row.data === null;
416
- return { size: row.data?.byteLength ?? 0, modified: row.modified, created: row.created, directory, type: directory ? '' : mediaType(path) };
471
+ return { size: row.data === null ? 0 : dataSize(row.data), modified: row.modified, created: row.created, directory, type: directory ? '' : mediaType(path) };
417
472
  }
418
473
 
419
474
  /** Every row at or below `path`, by path. */
@@ -464,10 +519,10 @@ export class BrowserFiles implements FilesHost {
464
519
  const key = `${mount.url}/${path}`;
465
520
  const known = this.loaded.get(key);
466
521
  if (known !== undefined) return known;
467
- const data = await this.read(url);
522
+ const { data } = await this.fileRow(url);
468
523
  const type = mediaType(path);
469
- if (!this.objectURLs) return `data:${type};base64,${base64Of(data)}`;
470
- const made = this.objectURLs.create(new Blob([data.slice()], { type }));
524
+ if (!this.objectURLs) return `data:${type};base64,${base64Of(await dataBytes(data))}`;
525
+ const made = this.objectURLs.create(isBlob(data) ? data.slice(0, data.size, type) : new Blob([data.slice()], { type }));
471
526
  this.loaded.set(key, made);
472
527
  return made;
473
528
  }
@@ -44,8 +44,10 @@ import type {
44
44
  UndoRequestParams,
45
45
  SharedFile,
46
46
  } from './protocol';
47
+ import { browserAudioEngine, type AudioEngineHost } from './audioEngine';
47
48
  import { browserAudioRecorder, type AudioRecorderHost } from './audioRecorder';
48
49
  import { browserFiles, type FilesHost } from './files';
50
+ import { browserLanguageModel, type LanguageModelHost } from './languageModel';
49
51
  import { browserClipboardHost, performClipboard, type ClipboardHost } from './clipboard';
50
52
  import { browserMediaHost, performMedia, type MediaHost } from './media';
51
53
  import { browserMediaSessionHost, MediaSessionBridge, performMediaSession } from './mediaSession';
@@ -194,8 +196,18 @@ export interface HostRequestEnvironment {
194
196
  clipboard?: ClipboardHost;
195
197
  /** The microphone, recording as `AVAudioRecorder` does (audioRecorder.ts); `useAudioRecorder` calls it. */
196
198
  audioRecorder?: AudioRecorderHost;
199
+ /** An engine shaped as `AVAudioEngine` (audioEngine.ts, on Web Audio); `AVAudioEngine` in @swiftbrowser/react sends it its commands. */
200
+ audioEngine?: AudioEngineHost;
201
+ /** The on-device language model (languageModel.ts: Chrome's Prompt API, else none available); `LanguageModelSession` in @swiftbrowser/react sends it its commands. */
202
+ languageModel?: LanguageModelHost;
197
203
  /** The app's files (files.ts: its IndexedDB home on a page, its sandbox natively); `FileManager` in @swiftbrowser/react calls it. */
198
204
  files?: FilesHost;
205
+ /**
206
+ * A dropped file the host left on disk (a `DropItem` with `url` and `size`,
207
+ * natively) as a `File` read on demand; a page never needs it, its drops
208
+ * carry the `File` itself.
209
+ */
210
+ fileFromReference?: (item: { type: string; name?: string; path?: string; url: string; size?: number }) => File;
199
211
  /** The browser's built-in translator (`translate`, translation.ts), where it has one. */
200
212
  translation?: TranslationHost;
201
213
  /** The browser's color chooser (`pickColor`, colorPicker.ts); it must open before it returns. */
@@ -204,18 +216,49 @@ export interface HostRequestEnvironment {
204
216
  undoManager?: UndoHost;
205
217
  }
206
218
 
219
+ /**
220
+ * The type that, among a chooser's `accept`, asks for a folder rather than files (SwiftUI's `UTType.folder`).
221
+ * The chooser then answers with the files in the folder, at any depth, that the rest of `accept` allows.
222
+ */
223
+ export const FOLDER_TYPE = 'public.folder';
224
+
225
+ /**
226
+ * A chosen folder's files, as a `pickFiles` request answers them: those the
227
+ * rest of `accept` allows (an extension, a MIME type or a `type/*` pattern;
228
+ * any when it names none), each named by its path from the folder, the
229
+ * folder's own name first (`Bank/Leads/Saw.fxp`).
230
+ */
231
+ export function folderFiles(files: File[], accept: string[]): File[] {
232
+ const allowed = accept.filter((part) => part !== FOLDER_TYPE).map((part) => part.toLowerCase());
233
+ const allows = (file: File): boolean =>
234
+ allowed.length === 0 ||
235
+ allowed.some((part) =>
236
+ part.startsWith('.') ? file.name.toLowerCase().endsWith(part)
237
+ : part.endsWith('/*') ? file.type.toLowerCase().startsWith(part.slice(0, -1))
238
+ : file.type.toLowerCase() === part);
239
+ return files.filter(allows).map((file) => {
240
+ const path = file.webkitRelativePath;
241
+ return path && path !== file.name ? new File([file], path, { type: file.type, lastModified: file.lastModified }) : file;
242
+ });
243
+ }
244
+
207
245
  /**
208
246
  * The browser's file chooser: a hidden `<input type="file">` clicked at once,
209
247
  * inside the tap that sent the request (the loader performs the request while
210
248
  * the tap's click handler is still running; see wasm.ts). Resolves on
211
249
  * `change` with the files, or on `cancel` (the viewer closed the chooser)
212
250
  * with none. A browser without the `cancel` event never answers a dismissal;
213
- * the app's selection then just stays as it was.
251
+ * the app's selection then just stays as it was. With `FOLDER_TYPE` in
252
+ * `accept`, it is the folder chooser, answered as `folderFiles` says.
214
253
  */
215
254
  function chooseFiles(options: { accept: string; multiple: boolean }): Promise<File[]> {
255
+ const accept = options.accept.split(',').map((part) => part.trim()).filter((part) => part !== '');
256
+ const folder = accept.includes(FOLDER_TYPE);
216
257
  const input = document.createElement('input');
217
258
  input.type = 'file';
218
- if (options.accept) input.accept = options.accept;
259
+ // A folder: the browser's folder chooser, which takes no `accept`; its files are filtered after.
260
+ if (folder) input.webkitdirectory = true;
261
+ else if (options.accept) input.accept = options.accept;
219
262
  input.multiple = options.multiple;
220
263
  // In the document (some browsers only open a chooser for an attached
221
264
  // input), but invisible and out of the way.
@@ -227,7 +270,10 @@ function chooseFiles(options: { accept: string; multiple: boolean }): Promise<Fi
227
270
  input.remove();
228
271
  resolve(files);
229
272
  };
230
- input.addEventListener('change', () => finish(Array.from(input.files ?? [])), { once: true });
273
+ input.addEventListener('change', () => {
274
+ const files = Array.from(input.files ?? []);
275
+ finish(folder ? folderFiles(files, accept) : files);
276
+ }, { once: true });
231
277
  input.addEventListener('cancel', () => finish([]), { once: true });
232
278
  });
233
279
  input.click();
@@ -292,6 +338,7 @@ export function installPushService(workerURL: string, scope: string): void {
292
338
  }
293
339
  const scannerHost = browserScannerHost();
294
340
  const audioRecorderHost = browserAudioRecorder(() => browserEnvironment.files);
341
+ const audioEngineHost = browserAudioEngine(() => browserEnvironment.files);
295
342
  const filesHost = browserFiles();
296
343
  const webAuthHost = browserWebAuthHost();
297
344
  const mediaHost = browserMediaHost(typeof document !== 'undefined' ? downloadBlob : undefined);
@@ -336,6 +383,9 @@ export const browserEnvironment: HostRequestEnvironment = {
336
383
  ...(mediaSessionHost ? { mediaSession: new MediaSessionBridge(mediaSessionHost) } : {}),
337
384
  ...(clipboardHost ? { clipboard: clipboardHost } : {}),
338
385
  ...(audioRecorderHost ? { audioRecorder: audioRecorderHost } : {}),
386
+ ...(audioEngineHost ? { audioEngine: audioEngineHost } : {}),
387
+ // A page's (a native app's runtime has no document, and fills this in with the host's).
388
+ ...(typeof document !== 'undefined' ? { languageModel: browserLanguageModel() } : {}),
339
389
  ...(filesHost ? { files: filesHost } : {}),
340
390
  ...(translationHost ? { translation: translationHost } : {}),
341
391
  ...(typeof document !== 'undefined' ? { pickColor: chooseColor } : {}),
@@ -102,6 +102,8 @@ export interface InteractionHost {
102
102
  readonly root: HTMLElement;
103
103
  send(event: RendererEvent): void;
104
104
  nodeOf(id: ElementId): LayoutNode | undefined;
105
+ /** How a dropped file reaches the app (src/dragDrop.ts `DragDropHost`). */
106
+ fileDrops?(): 'bytes' | 'reference';
105
107
  /** Open the popover of a `contextmenu` element anchored to it. */
106
108
  openContextMenu(anchor: HTMLElement, id: ElementId): void;
107
109
  /** A covered screen is about to show (a back swipe starts): lay it out if it is stale. */
@@ -318,7 +320,10 @@ export class InteractionController {
318
320
  // -------------------------------------------------------------------------
319
321
 
320
322
  private readonly onPointerDown = (event: PointerEvent): void => {
321
- if (this.session || !event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return;
323
+ // One pointer at a time, but not only the primary one: a finger on a
324
+ // knob while another holds a key (a `touches` element, src/touches.ts,
325
+ // owns that one) drags, as in SwiftUI.
326
+ if (this.session || (event.pointerType === 'mouse' && event.button !== 0)) return;
322
327
  const target = event.target as HTMLElement | null;
323
328
  if (!target) return;
324
329
  // Pointer downs in a text field select text, never gesture; a slider