@swiftbrowser/web 0.19.0 → 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/app/index.html CHANGED
@@ -40,9 +40,16 @@
40
40
  var coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
41
41
  var device = mode === 'device' || (mode !== 'frame' && (coarse || site.pageMode === 'device'));
42
42
  document.documentElement.setAttribute('data-sb-mode', device ? 'device' : 'frame');
43
+ // The preview's device (src/deviceFrame.ts in @swiftbrowser/web): ?device=, else the last one picked.
44
+ var frame = new URLSearchParams(location.search).get('device');
45
+ var embedded = new URLSearchParams(location.search).get('embed') === '1';
46
+ try { frame = frame || (embedded ? null : localStorage.getItem('sb-device-frame')); } catch (e) {}
47
+ document.documentElement.setAttribute('data-sb-frame', frame === 'ipad' || frame === 'none' ? frame : 'iphone');
48
+ // ?toolbar=0: the toolbar's close button hid it (src/deviceFrame.ts).
49
+ if (new URLSearchParams(location.search).get('toolbar') === '0') document.documentElement.setAttribute('data-sb-toolbar', 'hidden');
43
50
  // ?embed=1: the page is in another's frame (the playground's
44
51
  // simulator), which has its own controls: the device alone.
45
- if (new URLSearchParams(location.search).get('embed') === '1') document.documentElement.setAttribute('data-sb-embed', '');
52
+ if (embedded) document.documentElement.setAttribute('data-sb-embed', '');
46
53
  })();
47
54
  // Point the manifest, the home-screen icon and the titles at the app
48
55
  // this page runs (?app=, else the site's default), so Share -> Add to
@@ -73,9 +80,6 @@
73
80
  </head>
74
81
  <body>
75
82
  <header class="toolbar" role="toolbar" aria-label="SwiftBrowser controls">
76
- <span class="toolbar-brand">SwiftBrowser</span>
77
- <span class="toolbar-sep" aria-hidden="true"></span>
78
- <span class="toolbar-label">App</span>
79
83
  <span id="app-name" class="toolbar-value">Loading…</span>
80
84
  <span class="toolbar-spacer"></span>
81
85
  <span id="build-status" class="toolbar-build" role="status" aria-live="polite" hidden>Rebuilding…</span>
@@ -114,9 +118,9 @@
114
118
  </svg>
115
119
  <!-- Wi-Fi -->
116
120
  <svg class="glyph glyph-wifi" viewBox="0 0 16.3 12.33" width="16.3" height="12.33">
117
- <path class="wifi-band" d="M1.08 3.87A11.0 11.0 0 0 1 15.22 3.87" stroke-width="2.6" />
118
- <path class="wifi-band" d="M3.91 7.24A6.6 6.6 0 0 1 12.39 7.24" stroke-width="2.9" />
119
- <path d="M8.15 12.3L5.64 9.31A3.9 3.9 0 0 1 10.66 9.31Z" />
121
+ <path d="M0.02 3.3A12.15 12.15 0 0 1 16.28 3.3L14.41 5.38A9.35 9.35 0 0 0 1.89 5.38Z" />
122
+ <path d="M2.8 6.38A8 8 0 0 1 13.5 6.38L11.66 8.43A5.25 5.25 0 0 0 4.64 8.43Z" />
123
+ <path d="M8.15 12.33L5.54 9.43A3.9 3.9 0 0 1 10.76 9.43Z" />
120
124
  </svg>
121
125
  <!-- Battery -->
122
126
  <svg class="glyph glyph-battery" viewBox="0 0 28 13" width="28" height="13">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
package/src/appFiles.ts CHANGED
@@ -125,10 +125,15 @@ export interface FilesPersistence {
125
125
  clear(): Promise<void>;
126
126
  }
127
127
 
128
- /** A row of the `entries` store: a file's bytes or a directory, with when it was last written where the writer said. */
128
+ /**
129
+ * A row of the `entries` store: a file's bytes or a directory, with when it
130
+ * was last written where the writer said. A React app's `fileManager`
131
+ * (files.ts) may keep a file as the `Blob` the viewer handed over, which
132
+ * IndexedDB stores without reading; the module's loader reads one on load.
133
+ */
129
134
  export interface EntryRow {
130
135
  path: string;
131
- data?: Uint8Array;
136
+ data?: Uint8Array | Blob;
132
137
  directory?: boolean;
133
138
  /** Milliseconds since the epoch; rows the module wrote (and older rows) have none. */
134
139
  modified?: number;
@@ -181,7 +186,11 @@ export function indexedDBFiles(app: string, factory: IDBFactory = indexedDB): Fi
181
186
  const db = await database();
182
187
  const rows = await request(db.transaction(ENTRIES, 'readonly').objectStore(ENTRIES).getAll() as IDBRequest<EntryRow[]>);
183
188
  const entries: HomeEntries = new Map();
184
- for (const row of rows) entries.set(row.path, row.directory ? null : new Uint8Array(row.data ?? new Uint8Array()));
189
+ for (const row of rows) {
190
+ if (row.directory) entries.set(row.path, null);
191
+ else if (row.data !== undefined && typeof Blob !== 'undefined' && row.data instanceof Blob) entries.set(row.path, new Uint8Array(await row.data.arrayBuffer()));
192
+ else entries.set(row.path, new Uint8Array((row.data as Uint8Array | undefined) ?? new Uint8Array()));
193
+ }
185
194
  return entries;
186
195
  },
187
196
  apply(changes) {
@@ -68,10 +68,11 @@ export type AudioEngineCommand =
68
68
  | { action: 'playerTime'; node: number }
69
69
  | { action: 'loadZones'; node: number; zones: SamplerZone[]; attack: number; release: number }
70
70
  | { action: 'loadInstrument'; node: number; url: string; program?: number; bankMSB?: number; bankLSB?: number }
71
- | { action: 'startNote'; node: number; note: number; velocity: number; channel: number }
72
- | { action: 'stopNote'; node: number; note: number; channel: number }
73
- | { action: 'controller'; node: number; controller: number; value: number; channel: number }
74
- | { action: 'pitchBend'; node: number; value: number; channel: number }
71
+ // `beat`: when on a package node's transport (a DSP program's); a sampler ignores it.
72
+ | { action: 'startNote'; node: number; note: number; velocity: number; channel: number; beat?: number }
73
+ | { action: 'stopNote'; node: number; note: number; channel: number; beat?: number }
74
+ | { action: 'controller'; node: number; controller: number; value: number; channel: number; beat?: number }
75
+ | { action: 'pitchBend'; node: number; value: number; channel: number; beat?: number }
75
76
  | { action: 'meter'; node: number };
76
77
 
77
78
  /** The note commands an instrument takes: a sampler, or a node of a kind a package adds. */
@@ -149,7 +150,7 @@ export function browserAudioEngine(files: () => FilesHost | undefined): AudioEng
149
150
  if (typeof AudioContext === 'undefined') return undefined;
150
151
  let context: AudioContext | null = null;
151
152
  return new WebAudioEngineHost({
152
- context: () => (context ??= new AudioContext({ latencyHint: 'interactive' })),
153
+ context: () => (context ??= resumeOnActivation(new AudioContext({ latencyHint: 'interactive' }))),
153
154
  read: async (url) => {
154
155
  const host = files();
155
156
  if (url.startsWith('file:') && host) {
@@ -166,6 +167,29 @@ export function browserAudioEngine(files: () => FilesHost | undefined): AudioEng
166
167
  });
167
168
  }
168
169
 
170
+ /**
171
+ * Resumes `context` at each user activation while it is not running. A
172
+ * page's context starts only inside one, and a touch's start is not one
173
+ * (Safari and Chrome count its end, a click, a key): an app that starts its
174
+ * engine as a finger lands, a keyboard's note, would otherwise leave the
175
+ * context suspended and the engine silent. Safari also suspends or
176
+ * interrupts a running context (a call, the page in the background), and the
177
+ * next tap brings it back.
178
+ */
179
+ function resumeOnActivation(context: AudioContext): AudioContext {
180
+ if (typeof document === 'undefined') return context;
181
+ const events = ['touchend', 'pointerup', 'click', 'keydown'] as const;
182
+ const resume = (): void => {
183
+ if (context.state === 'closed') {
184
+ for (const type of events) document.removeEventListener(type, resume, true);
185
+ return;
186
+ }
187
+ if (context.state !== 'running') context.resume().catch(() => undefined);
188
+ };
189
+ for (const type of events) document.addEventListener(type, resume, { capture: true, passive: true });
190
+ return context;
191
+ }
192
+
169
193
  /** Decibels as a gain. */
170
194
  export const gainOf = (decibels: number): number => Math.pow(10, decibels / 20);
171
195
 
@@ -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
  }