@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/app/index.html +11 -7
- package/package.json +1 -1
- package/src/appFiles.ts +12 -3
- package/src/audioEngine.ts +1467 -0
- package/src/chartDom.ts +1 -1
- package/src/deviceFrame.ts +188 -0
- package/src/dragDrop.ts +66 -10
- package/src/files.ts +68 -13
- package/src/hostRequests.ts +53 -3
- package/src/interaction.ts +6 -1
- package/src/languageModel.ts +309 -0
- package/src/layout/engine.ts +128 -48
- package/src/layoutDom.ts +43 -3
- package/src/main.ts +12 -1
- package/src/page.css +65 -0
- package/src/pinnedViews.ts +38 -1
- package/src/protocol.ts +70 -5
- package/src/renderer.ts +299 -53
- package/src/scrollTransition.ts +2 -1
- package/src/styles.css +374 -42
- package/src/styles.ts +13 -15
- package/src/symbols.ts +6 -0
- package/src/viewRegistry.ts +58 -0
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
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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.
|
|
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
|
-
/**
|
|
52
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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
|
}
|
package/src/hostRequests.ts
CHANGED
|
@@ -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
|
-
|
|
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', () =>
|
|
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 } : {}),
|
package/src/interaction.ts
CHANGED
|
@@ -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
|
-
|
|
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
|