@swiftbrowser/web 0.17.8 → 0.18.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/package.json +1 -1
- package/src/animation.ts +60 -27
- package/src/appFiles.ts +21 -16
- package/src/audioRecorder.ts +392 -0
- package/src/editorSelection.ts +80 -0
- package/src/files.ts +442 -0
- package/src/gestureComposition.ts +92 -0
- package/src/hostRequests.ts +38 -4
- package/src/interaction.ts +108 -27
- package/src/layout/engine.ts +295 -19
- package/src/layout/types.ts +6 -0
- package/src/layoutDom.ts +13 -5
- package/src/main.ts +4 -0
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +304 -13
- package/src/renderer.ts +354 -43
- package/src/richEditor.ts +330 -0
- package/src/scrollGeometry.ts +79 -0
- package/src/scrollTransition.ts +145 -0
- package/src/styles.css +142 -11
- package/src/symbols.ts +22 -0
- package/src/touchPaging.ts +23 -2
- package/src/undo.ts +126 -0
- package/src/video.ts +59 -8
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selection a bound `texteditor` shares with the app (docs/ops-protocol.md,
|
|
3
|
+
* "Rich text editors"), the same for the rich editor (src/richEditor.ts) and
|
|
4
|
+
* the plain textarea (src/plainEditor.ts): what the editor last reported, or
|
|
5
|
+
* was last moved to, so a selection coming back from the app is not applied
|
|
6
|
+
* again and the viewer's is not echoed; and one the app set while the editor
|
|
7
|
+
* was not focused, kept for when the app focuses it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { TextSelectionRange } from './protocol';
|
|
11
|
+
|
|
12
|
+
/** The selection each bound editor last reported, or was last moved to. */
|
|
13
|
+
const reported = new WeakMap<HTMLElement, string>();
|
|
14
|
+
/** A selection the app set while the editor was not focused: where its caret goes when the app focuses it. */
|
|
15
|
+
const pending = new WeakMap<HTMLElement, TextSelectionRange>();
|
|
16
|
+
|
|
17
|
+
const key = (range: TextSelectionRange): string => `${range.start}:${range.end}`;
|
|
18
|
+
|
|
19
|
+
/** Whether the editor's `selection` is bound (the prop is in its props): only then is the viewer's reported. */
|
|
20
|
+
export function markSelectionBound(el: HTMLElement, bound: boolean): void {
|
|
21
|
+
if (bound) el.dataset['sbSelection'] = '';
|
|
22
|
+
else delete el.dataset['sbSelection'];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isSelectionBound(el: HTMLElement): boolean {
|
|
26
|
+
return el.dataset['sbSelection'] !== undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether the field or editor has `deleteBackward` in its props: Backspace
|
|
31
|
+
* with nothing selected and the caret at offset 0 then sends `deletebackward`
|
|
32
|
+
* and deletes nothing (docs/ops-protocol.md, "The delete key at the start of
|
|
33
|
+
* a field"); the same mark for a `textfield`, a plain editor and a rich one.
|
|
34
|
+
*/
|
|
35
|
+
export function markDeleteBackward(el: HTMLElement, wanted: boolean): void {
|
|
36
|
+
if (wanted) el.dataset['sbDeleteBackward'] = '';
|
|
37
|
+
else delete el.dataset['sbDeleteBackward'];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function wantsDeleteBackward(el: HTMLElement): boolean {
|
|
41
|
+
return el.dataset['sbDeleteBackward'] !== undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The app sent `range` (or no selection): the range the editor moves to now,
|
|
46
|
+
* when it is focused; otherwise null, the range kept for `takePendingSelection`
|
|
47
|
+
* when the app focuses the editor. Nothing for the selection the editor
|
|
48
|
+
* itself reported, coming back.
|
|
49
|
+
*/
|
|
50
|
+
export function selectionToApply(el: HTMLElement, range: TextSelectionRange | undefined): TextSelectionRange | null {
|
|
51
|
+
if (!range || reported.get(el) === key(range)) return null;
|
|
52
|
+
reported.set(el, key(range));
|
|
53
|
+
if (el.ownerDocument.activeElement === el) return range;
|
|
54
|
+
pending.set(el, range);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The selection the app set while the editor was not focused, taken as the app focuses it. */
|
|
59
|
+
export function takePendingSelection(el: HTMLElement): TextSelectionRange | undefined {
|
|
60
|
+
const range = pending.get(el);
|
|
61
|
+
pending.delete(el);
|
|
62
|
+
return range;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The viewer's selection: whether it is new (not the one the app set or the editor last reported), and so to report. */
|
|
66
|
+
export function noteViewerSelection(el: HTMLElement, range: TextSelectionRange): boolean {
|
|
67
|
+
if (reported.get(el) === key(range)) return false;
|
|
68
|
+
reported.set(el, key(range));
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The viewer focused the editor: their caret, not one the app set before. */
|
|
73
|
+
export function viewerFocused(el: HTMLElement): void {
|
|
74
|
+
pending.delete(el);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The editor lost focus: it has no caret, so the selection the app holds is one to move to again. */
|
|
78
|
+
export function editorBlurred(el: HTMLElement): void {
|
|
79
|
+
reported.delete(el);
|
|
80
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's files from JavaScript: `FileManager` in @swiftbrowser/react
|
|
3
|
+
* (packages/react/src/fileManager.ts) over the `files` field of the host
|
|
4
|
+
* request environment, a `FilesHost`. On a page the host is this module: the
|
|
5
|
+
* same iOS-like home a Swift app has (appFiles.ts, "Phase 19"), `/data` with
|
|
6
|
+
* `Documents`, `Library/Application Support` and `Library/Caches`, kept in
|
|
7
|
+
* the app's IndexedDB database (`sb-files:<App>`, the `entries` store, one
|
|
8
|
+
* row per file or directory), and `/tmp`, kept in memory for the session.
|
|
9
|
+
* Natively the host is the app's sandbox (packages/native/src/hostRequests.ts,
|
|
10
|
+
* Host/FilesBridge.swift). Paths are `file:` URLs on both sides, and
|
|
11
|
+
* `loadURL` turns one into a URL a media element or an image can load (an
|
|
12
|
+
* object URL of the bytes here, the URL itself natively), which the renderer
|
|
13
|
+
* does for a `file:` URL in a `video` or `image` element.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ENTRIES, HOME, HOME_DIRECTORIES, TMP, done, openFilesDatabase, request, type EntryRow } from './appFiles';
|
|
17
|
+
|
|
18
|
+
/** What `attributes` answers for a file or directory that exists. */
|
|
19
|
+
export interface FileAttributes {
|
|
20
|
+
/** The file's bytes; 0 for a directory. */
|
|
21
|
+
size: number;
|
|
22
|
+
/** When it was last written, in milliseconds since the epoch, where the host knows. */
|
|
23
|
+
modified: number | null;
|
|
24
|
+
directory: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The app's standard directories, as `file:` URLs. */
|
|
28
|
+
export interface FileDirectories {
|
|
29
|
+
documents: string;
|
|
30
|
+
applicationSupport: string;
|
|
31
|
+
caches: string;
|
|
32
|
+
temporary: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The host of the app's files. Every URL is a `file:` URL in the app's home;
|
|
37
|
+
* one outside it fails `unsupported: …`. Failures are errors whose message
|
|
38
|
+
* starts with a word and a colon: `notFound: …` (no such file or directory,
|
|
39
|
+
* or the destination's directory), `exists: …` (a destination, or a file
|
|
40
|
+
* where a directory is made), `isDirectory: …` (a directory read as a file),
|
|
41
|
+
* `notDirectory: …` (a file listed as a directory).
|
|
42
|
+
*/
|
|
43
|
+
export interface FilesHost {
|
|
44
|
+
readonly directories: FileDirectories;
|
|
45
|
+
/** The file's bytes. */
|
|
46
|
+
read(url: string): Promise<Uint8Array>;
|
|
47
|
+
/** Writes the bytes as the file, replacing one there; its directory must exist. */
|
|
48
|
+
write(url: string, data: Uint8Array): Promise<void>;
|
|
49
|
+
/** Removes a file, or a directory and everything in it. */
|
|
50
|
+
remove(url: string): Promise<void>;
|
|
51
|
+
/** Makes the directory and the directories above it; one already there is fine. */
|
|
52
|
+
createDirectory(url: string): Promise<void>;
|
|
53
|
+
/** The URLs of what the directory holds, by name. */
|
|
54
|
+
list(url: string): Promise<string[]>;
|
|
55
|
+
/** The attributes of what is at the URL, or null when nothing is. */
|
|
56
|
+
attributes(url: string): Promise<FileAttributes | null>;
|
|
57
|
+
/** Moves a file or directory to a URL nothing is at, in a directory that exists. */
|
|
58
|
+
move(from: string, to: string): Promise<void>;
|
|
59
|
+
/** Copies a file or directory to a URL nothing is at, in a directory that exists. */
|
|
60
|
+
copy(from: string, to: string): Promise<void>;
|
|
61
|
+
/** A URL the page can load the file at: an object URL of its bytes on a page, the URL itself natively. */
|
|
62
|
+
loadURL(url: string): Promise<string>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Paths
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** The home on a page, as the wasm module sees it, and the session's temporary directory. */
|
|
70
|
+
export const HOME_URL = `file://${HOME}`;
|
|
71
|
+
export const TMP_URL = `file://${TMP}`;
|
|
72
|
+
|
|
73
|
+
export const BROWSER_DIRECTORIES: FileDirectories = {
|
|
74
|
+
documents: `${HOME_URL}/Documents`,
|
|
75
|
+
applicationSupport: `${HOME_URL}/Library/Application Support`,
|
|
76
|
+
caches: `${HOME_URL}/Library/Caches`,
|
|
77
|
+
temporary: TMP_URL,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The segments of a `file:` URL's path, `.` and `..` resolved; null for another scheme. */
|
|
81
|
+
export function fileSegments(url: string): string[] | null {
|
|
82
|
+
let parsed: URL;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(url);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (parsed.protocol !== 'file:') return null;
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
for (const raw of parsed.pathname.split('/')) {
|
|
91
|
+
let part = raw;
|
|
92
|
+
try {
|
|
93
|
+
part = decodeURIComponent(raw);
|
|
94
|
+
} catch {
|
|
95
|
+
// Not percent-encoded: as written.
|
|
96
|
+
}
|
|
97
|
+
if (part === '' || part === '.') continue;
|
|
98
|
+
if (part === '..') out.pop();
|
|
99
|
+
else out.push(part);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A `file:` URL of `root` (a `file:` URL) and the path below it. */
|
|
105
|
+
export function fileURL(root: string, path: string): string {
|
|
106
|
+
const encoded = path
|
|
107
|
+
.split('/')
|
|
108
|
+
.filter((part) => part !== '')
|
|
109
|
+
.map((part) => encodeURIComponent(part))
|
|
110
|
+
.join('/');
|
|
111
|
+
return encoded === '' ? root : `${root.replace(/\/+$/, '')}/${encoded}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `path`'s directory ('' at the top). */
|
|
115
|
+
function parentOf(path: string): string {
|
|
116
|
+
const at = path.lastIndexOf('/');
|
|
117
|
+
return at < 0 ? '' : path.slice(0, at);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Rows: a home's files by path, in IndexedDB or memory
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** A file's bytes, or null for a directory, and when it was written. */
|
|
125
|
+
export interface FileRow {
|
|
126
|
+
data: Uint8Array | null;
|
|
127
|
+
modified: number | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The rows of one home, by path relative to it (`Documents/notes.txt`). */
|
|
131
|
+
export interface FileRows {
|
|
132
|
+
get(path: string): Promise<FileRow | undefined>;
|
|
133
|
+
put(path: string, row: FileRow): Promise<void>;
|
|
134
|
+
remove(paths: readonly string[]): Promise<void>;
|
|
135
|
+
/** Every path that has a row. */
|
|
136
|
+
keys(): Promise<string[]>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Rows in a Map: `/tmp`, a page without IndexedDB, and tests. */
|
|
140
|
+
export function memoryRows(): FileRows {
|
|
141
|
+
const rows = new Map<string, FileRow>();
|
|
142
|
+
return {
|
|
143
|
+
get: async (path) => rows.get(path),
|
|
144
|
+
put: async (path, row) => void rows.set(path, { data: row.data === null ? null : row.data.slice(), modified: row.modified }),
|
|
145
|
+
remove: async (paths) => paths.forEach((path) => rows.delete(path)),
|
|
146
|
+
keys: async () => [...rows.keys()],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The app's rows in its IndexedDB database (the `entries` store the wasm loader reads). */
|
|
151
|
+
export function indexedDBRows(app: string, factory: IDBFactory = indexedDB): FileRows {
|
|
152
|
+
let opened: Promise<IDBDatabase> | null = null;
|
|
153
|
+
const database = (): Promise<IDBDatabase> => (opened ??= openFilesDatabase(app, factory));
|
|
154
|
+
const store = async (mode: IDBTransactionMode): Promise<IDBObjectStore> => (await database()).transaction(ENTRIES, mode).objectStore(ENTRIES);
|
|
155
|
+
return {
|
|
156
|
+
async get(path) {
|
|
157
|
+
const row = (await request((await store('readonly')).get(path))) as EntryRow | undefined;
|
|
158
|
+
if (!row) return undefined;
|
|
159
|
+
return { data: row.directory ? null : new Uint8Array(row.data ?? new Uint8Array()), modified: typeof row.modified === 'number' ? row.modified : null };
|
|
160
|
+
},
|
|
161
|
+
async put(path, row) {
|
|
162
|
+
const entries = await store('readwrite');
|
|
163
|
+
const stored: EntryRow = row.data === null ? { path, directory: true } : { path, data: row.data.slice() };
|
|
164
|
+
if (row.modified !== null) stored.modified = row.modified;
|
|
165
|
+
entries.put(stored);
|
|
166
|
+
await done(entries.transaction);
|
|
167
|
+
},
|
|
168
|
+
async remove(paths) {
|
|
169
|
+
if (paths.length === 0) return;
|
|
170
|
+
const entries = await store('readwrite');
|
|
171
|
+
for (const path of paths) entries.delete(path);
|
|
172
|
+
await done(entries.transaction);
|
|
173
|
+
},
|
|
174
|
+
async keys() {
|
|
175
|
+
return (await request((await store('readonly')).getAllKeys())).map(String);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// The host
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export class FilesFailure extends Error {
|
|
185
|
+
constructor(kind: 'unsupported' | 'notFound' | 'exists' | 'isDirectory' | 'notDirectory', detail: string) {
|
|
186
|
+
super(`${kind}: ${detail}`);
|
|
187
|
+
this.name = 'FilesFailure';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A mounted root: its URL, where its rows are, and the directories it always has. */
|
|
192
|
+
interface Mount {
|
|
193
|
+
url: string;
|
|
194
|
+
prefix: string[];
|
|
195
|
+
rows: () => FileRows;
|
|
196
|
+
standard: readonly string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const MEDIA_TYPES: Record<string, string> = {
|
|
200
|
+
m4a: 'audio/mp4', mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/mp4', webm: 'video/webm', wav: 'audio/wav', mp3: 'audio/mpeg',
|
|
201
|
+
aac: 'audio/aac', ogg: 'audio/ogg', oga: 'audio/ogg', flac: 'audio/flac', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
202
|
+
webp: 'image/webp', svg: 'image/svg+xml', heic: 'image/heic', avif: 'image/avif', pdf: 'application/pdf', json: 'application/json', txt: 'text/plain',
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** The media type a file's extension says, for the blob a media element loads. */
|
|
206
|
+
export function mediaType(path: string): string {
|
|
207
|
+
const dot = path.lastIndexOf('.');
|
|
208
|
+
return (dot >= 0 && MEDIA_TYPES[path.slice(dot + 1).toLowerCase()]) || 'application/octet-stream';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface BrowserFilesOptions {
|
|
212
|
+
/** The home's rows (the app's IndexedDB database by default). */
|
|
213
|
+
home?: FileRows;
|
|
214
|
+
/** `/tmp`'s rows (memory by default). */
|
|
215
|
+
temporary?: FileRows;
|
|
216
|
+
/** `URL.createObjectURL` and `URL.revokeObjectURL`, or null for `data:` URLs. */
|
|
217
|
+
objectURLs?: { create(blob: Blob): string; revoke(url: string): void } | null;
|
|
218
|
+
now?: () => number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The page's files host over rows (see the module comment). `app` names the
|
|
223
|
+
* database the home's rows are in, read when the home is first touched after
|
|
224
|
+
* `setApp` (mount's `app` option); `app` by default.
|
|
225
|
+
*/
|
|
226
|
+
export class BrowserFiles implements FilesHost {
|
|
227
|
+
readonly directories = BROWSER_DIRECTORIES;
|
|
228
|
+
private app = 'app';
|
|
229
|
+
private homeRows: FileRows | null;
|
|
230
|
+
private readonly temporaryRows: FileRows;
|
|
231
|
+
private readonly objectURLs: { create(blob: Blob): string; revoke(url: string): void } | null;
|
|
232
|
+
private readonly now: () => number;
|
|
233
|
+
/** Object URLs handed out, by mount URL and path, revoked when the file changes. */
|
|
234
|
+
private readonly loaded = new Map<string, string>();
|
|
235
|
+
|
|
236
|
+
/** Whether the home's rows are the database named by `app` (not rows an option gave). */
|
|
237
|
+
private readonly defaultRows: boolean;
|
|
238
|
+
|
|
239
|
+
constructor(options: BrowserFilesOptions = {}) {
|
|
240
|
+
this.homeRows = options.home ?? null;
|
|
241
|
+
this.defaultRows = options.home === undefined;
|
|
242
|
+
this.temporaryRows = options.temporary ?? memoryRows();
|
|
243
|
+
this.objectURLs =
|
|
244
|
+
options.objectURLs !== undefined
|
|
245
|
+
? options.objectURLs
|
|
246
|
+
: typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
|
|
247
|
+
? { create: (blob) => URL.createObjectURL(blob), revoke: (url) => URL.revokeObjectURL(url) }
|
|
248
|
+
: null;
|
|
249
|
+
this.now = options.now ?? (() => Date.now());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Names the app whose database holds the home (`mount`'s `app` option),
|
|
254
|
+
* for what the app does with its files from here on; name it before the
|
|
255
|
+
* app touches them, since what was written before stays in the database
|
|
256
|
+
* named then.
|
|
257
|
+
*/
|
|
258
|
+
setApp(app: string): void {
|
|
259
|
+
if (app === this.app) return;
|
|
260
|
+
this.app = app;
|
|
261
|
+
if (this.defaultRows) this.homeRows = null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private readonly mounts: Mount[] = [
|
|
265
|
+
{
|
|
266
|
+
url: HOME_URL,
|
|
267
|
+
prefix: HOME.split('/').filter(Boolean),
|
|
268
|
+
rows: () => (this.homeRows ??= typeof indexedDB === 'undefined' ? memoryRows() : indexedDBRows(this.app)),
|
|
269
|
+
standard: HOME_DIRECTORIES,
|
|
270
|
+
},
|
|
271
|
+
{ url: TMP_URL, prefix: TMP.split('/').filter(Boolean), rows: () => this.temporaryRows, standard: [] },
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
/** The mount a URL falls in and its path there, or a failure for a URL outside the app's files. */
|
|
275
|
+
private locate(url: string): { mount: Mount; path: string } {
|
|
276
|
+
const segments = fileSegments(url);
|
|
277
|
+
if (segments) {
|
|
278
|
+
for (const mount of this.mounts) {
|
|
279
|
+
if (segments.length >= mount.prefix.length && mount.prefix.every((part, i) => segments[i] === part)) {
|
|
280
|
+
return { mount, path: segments.slice(mount.prefix.length).join('/') };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
throw new FilesFailure('unsupported', `${url} is not a file in the app's home (${HOME_URL}) or its temporary directory (${TMP_URL})`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Whether `path` is a directory the mount always has, or an ancestor of a row (a directory the rows imply). */
|
|
288
|
+
private async impliedDirectory(mount: Mount, path: string): Promise<boolean> {
|
|
289
|
+
if (path === '' || mount.standard.includes(path)) return true;
|
|
290
|
+
const prefix = `${path}/`;
|
|
291
|
+
return (await mount.rows().keys()).some((key) => key.startsWith(prefix));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The row at the path, a directory row for an implied directory, or undefined. */
|
|
295
|
+
private async rowAt(mount: Mount, path: string): Promise<FileRow | undefined> {
|
|
296
|
+
const row = await mount.rows().get(path);
|
|
297
|
+
if (row) return row;
|
|
298
|
+
return (await this.impliedDirectory(mount, path)) ? { data: null, modified: null } : undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async requireDirectory(mount: Mount, path: string, url: string): Promise<void> {
|
|
302
|
+
const row = await this.rowAt(mount, path);
|
|
303
|
+
if (!row) throw new FilesFailure('notFound', `no directory at ${fileURL(mount.url, path)} (for ${url})`);
|
|
304
|
+
if (row.data !== null) throw new FilesFailure('notDirectory', `${fileURL(mount.url, path)} is a file`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private forget(mount: Mount, path: string): void {
|
|
308
|
+
const key = `${mount.url}/${path}`;
|
|
309
|
+
const url = this.loaded.get(key);
|
|
310
|
+
if (url === undefined) return;
|
|
311
|
+
this.loaded.delete(key);
|
|
312
|
+
this.objectURLs?.revoke(url);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async read(url: string): Promise<Uint8Array> {
|
|
316
|
+
const { mount, path } = this.locate(url);
|
|
317
|
+
const row = await this.rowAt(mount, path);
|
|
318
|
+
if (!row) throw new FilesFailure('notFound', `no file at ${url}`);
|
|
319
|
+
if (row.data === null) throw new FilesFailure('isDirectory', `${url} is a directory`);
|
|
320
|
+
return row.data;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async write(url: string, data: Uint8Array): Promise<void> {
|
|
324
|
+
const { mount, path } = this.locate(url);
|
|
325
|
+
if (path === '') throw new FilesFailure('isDirectory', `${url} is a directory`);
|
|
326
|
+
await this.requireDirectory(mount, parentOf(path), url);
|
|
327
|
+
const existing = await this.rowAt(mount, path);
|
|
328
|
+
if (existing && existing.data === null) throw new FilesFailure('isDirectory', `${url} is a directory`);
|
|
329
|
+
await mount.rows().put(path, { data, modified: this.now() });
|
|
330
|
+
this.forget(mount, path);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async remove(url: string): Promise<void> {
|
|
334
|
+
const { mount, path } = this.locate(url);
|
|
335
|
+
if (path === '') throw new FilesFailure('unsupported', `${url} is the home itself`);
|
|
336
|
+
const row = await this.rowAt(mount, path);
|
|
337
|
+
if (!row) throw new FilesFailure('notFound', `nothing at ${url}`);
|
|
338
|
+
const prefix = `${path}/`;
|
|
339
|
+
const below = (await mount.rows().keys()).filter((key) => key === path || key.startsWith(prefix));
|
|
340
|
+
await mount.rows().remove(below);
|
|
341
|
+
for (const key of below) this.forget(mount, key);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async createDirectory(url: string): Promise<void> {
|
|
345
|
+
const { mount, path } = this.locate(url);
|
|
346
|
+
const parts = path.split('/').filter(Boolean);
|
|
347
|
+
for (let depth = 1; depth <= parts.length; depth += 1) {
|
|
348
|
+
const at = parts.slice(0, depth).join('/');
|
|
349
|
+
const row = await mount.rows().get(at);
|
|
350
|
+
if (row?.data) throw new FilesFailure('exists', `${fileURL(mount.url, at)} is a file`);
|
|
351
|
+
if (!row && !mount.standard.includes(at)) await mount.rows().put(at, { data: null, modified: this.now() });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async list(url: string): Promise<string[]> {
|
|
356
|
+
const { mount, path } = this.locate(url);
|
|
357
|
+
await this.requireDirectory(mount, path, url);
|
|
358
|
+
const prefix = path === '' ? '' : `${path}/`;
|
|
359
|
+
const names = new Set<string>();
|
|
360
|
+
for (const key of [...(await mount.rows().keys()), ...mount.standard]) {
|
|
361
|
+
if (!key.startsWith(prefix) || key === path) continue;
|
|
362
|
+
const rest = key.slice(prefix.length);
|
|
363
|
+
const slash = rest.indexOf('/');
|
|
364
|
+
names.add(slash < 0 ? rest : rest.slice(0, slash));
|
|
365
|
+
}
|
|
366
|
+
return [...names].sort((a, b) => a.localeCompare(b)).map((name) => fileURL(mount.url, prefix + name));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async attributes(url: string): Promise<FileAttributes | null> {
|
|
370
|
+
const { mount, path } = this.locate(url);
|
|
371
|
+
const row = await this.rowAt(mount, path);
|
|
372
|
+
if (!row) return null;
|
|
373
|
+
return { size: row.data?.byteLength ?? 0, modified: row.modified, directory: row.data === null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Every row at or below `path`, by path. */
|
|
377
|
+
private async subtree(mount: Mount, path: string): Promise<Map<string, FileRow>> {
|
|
378
|
+
const out = new Map<string, FileRow>();
|
|
379
|
+
const own = await this.rowAt(mount, path);
|
|
380
|
+
if (own) out.set(path, own);
|
|
381
|
+
const prefix = `${path}/`;
|
|
382
|
+
for (const key of await mount.rows().keys()) {
|
|
383
|
+
if (!key.startsWith(prefix)) continue;
|
|
384
|
+
const row = await mount.rows().get(key);
|
|
385
|
+
if (row) out.set(key, row);
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async transfer(from: string, to: string, removeSource: boolean): Promise<void> {
|
|
391
|
+
const source = this.locate(from);
|
|
392
|
+
const target = this.locate(to);
|
|
393
|
+
if (source.path === '' || target.path === '') throw new FilesFailure('unsupported', `the home itself cannot be ${removeSource ? 'moved' : 'copied'}`);
|
|
394
|
+
const rows = await this.subtree(source.mount, source.path);
|
|
395
|
+
if (rows.size === 0) throw new FilesFailure('notFound', `nothing at ${from}`);
|
|
396
|
+
if (await this.rowAt(target.mount, target.path)) throw new FilesFailure('exists', `something is at ${to} already`);
|
|
397
|
+
await this.requireDirectory(target.mount, parentOf(target.path), to);
|
|
398
|
+
if (target.path.startsWith(`${source.path}/`) && target.mount === source.mount) throw new FilesFailure('unsupported', `${to} is inside ${from}`);
|
|
399
|
+
const now = this.now();
|
|
400
|
+
for (const [path, row] of rows) {
|
|
401
|
+
const moved = target.path + path.slice(source.path.length);
|
|
402
|
+
await target.mount.rows().put(moved, { data: row.data, modified: removeSource ? row.modified : now });
|
|
403
|
+
}
|
|
404
|
+
if (removeSource) {
|
|
405
|
+
await source.mount.rows().remove([...rows.keys()].filter((key) => key !== source.path || !source.mount.standard.includes(key)));
|
|
406
|
+
for (const key of rows.keys()) this.forget(source.mount, key);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
move(from: string, to: string): Promise<void> {
|
|
411
|
+
return this.transfer(from, to, true);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
copy(from: string, to: string): Promise<void> {
|
|
415
|
+
return this.transfer(from, to, false);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async loadURL(url: string): Promise<string> {
|
|
419
|
+
const { mount, path } = this.locate(url);
|
|
420
|
+
const key = `${mount.url}/${path}`;
|
|
421
|
+
const known = this.loaded.get(key);
|
|
422
|
+
if (known !== undefined) return known;
|
|
423
|
+
const data = await this.read(url);
|
|
424
|
+
const type = mediaType(path);
|
|
425
|
+
if (!this.objectURLs) return `data:${type};base64,${base64Of(data)}`;
|
|
426
|
+
const made = this.objectURLs.create(new Blob([data.slice()], { type }));
|
|
427
|
+
this.loaded.set(key, made);
|
|
428
|
+
return made;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function base64Of(bytes: Uint8Array): string {
|
|
433
|
+
let binary = '';
|
|
434
|
+
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
435
|
+
return btoa(binary);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The page's files host, where the page keeps files (IndexedDB) or can at least hold them for the session. */
|
|
439
|
+
export function browserFiles(): BrowserFiles | undefined {
|
|
440
|
+
if (typeof indexedDB === 'undefined' && typeof document === 'undefined') return undefined;
|
|
441
|
+
return new BrowserFiles();
|
|
442
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How gestures compose (docs/ops-protocol.md, "Gesture spaces and
|
|
3
|
+
* composition"): the order the wrappers on a pointer's way to the root are
|
|
4
|
+
* asked in, and the taps a `tapgesture` with `count` collects. Pure, so the
|
|
5
|
+
* rules are tested in Node; src/interaction.ts and src/renderer.ts apply them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GestureMode } from './protocol';
|
|
9
|
+
|
|
10
|
+
/** A behaviour that could own a pointer: a `gesture` element's carries its mode. */
|
|
11
|
+
export interface Wrapper {
|
|
12
|
+
kind: string;
|
|
13
|
+
mode?: GestureMode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The double-tap interval: a single tap beside a double tap waits this long. */
|
|
17
|
+
export const DOUBLE_TAP_MS = 300;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Puts a wrapper found on the way up into `out`, which lists the wrappers
|
|
21
|
+
* found so far innermost first, from `wrapperStart` (what comes before them
|
|
22
|
+
* is the system's: a row's grip, the back swipe). A `highPriority` gesture
|
|
23
|
+
* goes in front of the wrappers inside it, as `.highPriorityGesture` wins
|
|
24
|
+
* over the gestures of the views it wraps; an outer one found later goes in
|
|
25
|
+
* front of it in turn. Everything else goes at the end, innermost first.
|
|
26
|
+
*/
|
|
27
|
+
export function placeWrapper<T extends Wrapper>(out: T[], candidate: T, wrapperStart: number): void {
|
|
28
|
+
if (candidate.kind === 'gesture' && candidate.mode === 'highPriority') out.splice(wrapperStart, 0, candidate);
|
|
29
|
+
else out.push(candidate);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `.simultaneousGesture`: runs beside the pointer's owner, never claiming. */
|
|
33
|
+
export function isSimultaneous(candidate: Wrapper): boolean {
|
|
34
|
+
return candidate.kind === 'gesture' && candidate.mode === 'simultaneous';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The taps a click lands: `multi` is the innermost `tapgesture` with a
|
|
39
|
+
* `count` above one on the click's way up (null when none), `single` the
|
|
40
|
+
* innermost of one tap; `detail` is what the firing needs of the click (its
|
|
41
|
+
* point), the last click's when several make a tap. A click with no `multi`
|
|
42
|
+
* fires `single` at once. With one, the clicks are counted: the `count`th
|
|
43
|
+
* within the interval fires `multi`; should the interval pass first, `single`
|
|
44
|
+
* fires instead (as iOS waits for a double tap before a single tap lands). A
|
|
45
|
+
* click elsewhere ends the count, firing the pending single.
|
|
46
|
+
*/
|
|
47
|
+
export class TapCounter<T, D = void> {
|
|
48
|
+
private pending: { multi: T; single: T | null; detail: D; taps: number; timer: unknown } | null = null;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly fire: (target: T, detail: D) => void,
|
|
52
|
+
private readonly schedule: (run: () => void, ms: number) => unknown = (run, ms) => setTimeout(run, ms),
|
|
53
|
+
private readonly cancel: (timer: unknown) => void = (timer) => clearTimeout(timer as number),
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
click(multi: T | null, count: number, single: T | null, detail: D): void {
|
|
57
|
+
if (this.pending && this.pending.multi !== multi) this.flush();
|
|
58
|
+
if (!multi) {
|
|
59
|
+
if (single) this.fire(single, detail);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (this.pending) {
|
|
63
|
+
this.pending.taps += 1;
|
|
64
|
+
this.pending.detail = detail;
|
|
65
|
+
if (this.pending.taps < count) return;
|
|
66
|
+
this.cancel(this.pending.timer);
|
|
67
|
+
this.pending = null;
|
|
68
|
+
this.fire(multi, detail);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (count <= 1) {
|
|
72
|
+
this.fire(multi, detail);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const pending = { multi, single, detail, taps: 1, timer: null as unknown };
|
|
76
|
+
pending.timer = this.schedule(() => {
|
|
77
|
+
if (this.pending !== pending) return;
|
|
78
|
+
this.pending = null;
|
|
79
|
+
if (single) this.fire(single, pending.detail);
|
|
80
|
+
}, DOUBLE_TAP_MS);
|
|
81
|
+
this.pending = pending;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Ends a count early: the single tap it was holding fires (nothing when there was none). */
|
|
85
|
+
flush(): void {
|
|
86
|
+
const pending = this.pending;
|
|
87
|
+
if (!pending) return;
|
|
88
|
+
this.cancel(pending.timer);
|
|
89
|
+
this.pending = null;
|
|
90
|
+
if (pending.single) this.fire(pending.single, pending.detail);
|
|
91
|
+
}
|
|
92
|
+
}
|