@swiftbrowser/web 0.17.7 → 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/node/dist/node/apps.d.ts +5 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +3 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +2 -0
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +2 -0
- package/node/dist/node/config.js.map +1 -1
- package/package.json +1 -1
- package/public/_headers +5 -0
- 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 +50 -5
- 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 +10 -1
- package/src/notifications.ts +134 -1
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +307 -14
- 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
- package/src/vite-env.d.ts +2 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recording from the microphone, as AVFoundation's `AVAudioRecorder` does it
|
|
3
|
+
* for an iOS app: the page side of `useAudioRecorder` (@swiftbrowser/react).
|
|
4
|
+
* The browser's `getUserMedia` and `MediaRecorder` record; an `AnalyserNode`
|
|
5
|
+
* meters the input as `averagePower(forChannel:)` would. A recording ends as
|
|
6
|
+
* a file in the app's `Documents/Recordings` (files.ts: the app's IndexedDB
|
|
7
|
+
* home), a `file:` URL that plays in a `video` element and that `discard`
|
|
8
|
+
* deletes, as the native app's does; a page that keeps no files answers a
|
|
9
|
+
* `data:` URL instead, which goes with its string. `trim` cuts a recording
|
|
10
|
+
* with the Web Audio API (decoded, the kept parts copied, written as WAV, to
|
|
11
|
+
* a new file). The native app answers the same calls with `AVAudioRecorder`
|
|
12
|
+
* and `AVAssetExportSession` (packages/native, AudioRecorderBridge.swift).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fileURL, type FilesHost } from './files';
|
|
16
|
+
|
|
17
|
+
/** A finished recording: a URL that plays it (a `file:` URL in `Documents/Recordings`, or a `data:` URL where the page keeps no files) and its length. */
|
|
18
|
+
export interface AudioRecording {
|
|
19
|
+
url: string;
|
|
20
|
+
/** Seconds. */
|
|
21
|
+
duration: number;
|
|
22
|
+
/** The MIME type it was recorded in. */
|
|
23
|
+
type: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The input now: its level (0 silent … 1 loud) and the seconds recorded so far. */
|
|
27
|
+
export interface AudioMeter {
|
|
28
|
+
level: number;
|
|
29
|
+
time: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A stretch of a recording, in seconds from its start. */
|
|
33
|
+
export interface AudioRange {
|
|
34
|
+
start: number;
|
|
35
|
+
end: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One recording at a time. `start` asks for the microphone (rejecting with
|
|
40
|
+
* `permission: …` when it is refused); `stop` ends it and resolves with the
|
|
41
|
+
* recording; `cancel` ends it and keeps nothing; `discard` deletes one made
|
|
42
|
+
* before; `trim` makes a new recording out of parts of one.
|
|
43
|
+
*/
|
|
44
|
+
export interface AudioRecorderHost {
|
|
45
|
+
start(): Promise<void>;
|
|
46
|
+
pause(): Promise<void>;
|
|
47
|
+
resume(): Promise<void>;
|
|
48
|
+
stop(): Promise<AudioRecording>;
|
|
49
|
+
cancel(): Promise<void>;
|
|
50
|
+
meter(): Promise<AudioMeter>;
|
|
51
|
+
/** Deletes a recording `stop` made (its file; a `data:` URL is nothing to delete). */
|
|
52
|
+
discard(url: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* A new recording made of the parts of `url` in `keep` (seconds, in
|
|
55
|
+
* order, concatenated): `[{ start, end }]` trims to that range, two parts
|
|
56
|
+
* around a range delete it. The original is left as it was. Rejects with
|
|
57
|
+
* `unsupported: …` where the host cannot cut audio, `empty: …` when
|
|
58
|
+
* nothing of the recording falls in `keep`.
|
|
59
|
+
*/
|
|
60
|
+
trim(url: string, keep: readonly AudioRange[]): Promise<AudioRecording>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The formats to ask for, best first: AAC in MP4 (what Voice Memos keeps; Safari), else Opus in WebM. */
|
|
64
|
+
const TYPES = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'];
|
|
65
|
+
|
|
66
|
+
/** A level from an RMS amplitude: -50 dB and below is silence, 0 dB full, as Voice Memos' bars scale. */
|
|
67
|
+
export function levelOf(rms: number): number {
|
|
68
|
+
if (rms <= 0) return 0;
|
|
69
|
+
const db = 20 * Math.log10(rms);
|
|
70
|
+
return Math.max(0, Math.min(1, (db + 50) / 50));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Session {
|
|
74
|
+
stream: MediaStream;
|
|
75
|
+
recorder: MediaRecorder;
|
|
76
|
+
chunks: Blob[];
|
|
77
|
+
context: AudioContext | null;
|
|
78
|
+
analyser: AnalyserNode | null;
|
|
79
|
+
samples: Float32Array<ArrayBuffer> | null;
|
|
80
|
+
/** Seconds recorded before the current stretch. */
|
|
81
|
+
recorded: number;
|
|
82
|
+
/** When the current stretch began (ms), or null while paused. */
|
|
83
|
+
since: number | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The file extension a recording of `type` is named with. */
|
|
87
|
+
export function extensionOf(type: string): string {
|
|
88
|
+
const base = (type.split(';')[0] ?? type).trim().toLowerCase();
|
|
89
|
+
switch (base) {
|
|
90
|
+
case 'audio/mp4': case 'audio/x-m4a': case 'audio/aac': return 'm4a';
|
|
91
|
+
case 'audio/webm': return 'webm';
|
|
92
|
+
case 'audio/ogg': return 'ogg';
|
|
93
|
+
case 'audio/wav': case 'audio/wave': case 'audio/x-wav': return 'wav';
|
|
94
|
+
case 'audio/mpeg': return 'mp3';
|
|
95
|
+
default: return 'bin';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The app's `Documents/Recordings`, made if need be. */
|
|
100
|
+
async function recordingsFolder(files: FilesHost): Promise<string> {
|
|
101
|
+
const folder = fileURL(files.directories.documents, 'Recordings');
|
|
102
|
+
await files.createDirectory(folder);
|
|
103
|
+
return folder;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Writes `bytes` as a new file in `Documents/Recordings`, named by a UUID and the type's extension: its URL. */
|
|
107
|
+
export async function recordingFile(files: FilesHost, bytes: Uint8Array, type: string): Promise<string> {
|
|
108
|
+
const folder = await recordingsFolder(files);
|
|
109
|
+
const name = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID().toUpperCase() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
110
|
+
const url = fileURL(folder, `${name}.${extensionOf(type)}`);
|
|
111
|
+
await files.write(url, bytes);
|
|
112
|
+
return url;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class BrowserAudioRecorder implements AudioRecorderHost {
|
|
116
|
+
private session: Session | null = null;
|
|
117
|
+
|
|
118
|
+
/** `files` is the app's files host, looked up at each call (the environment fills it in after this is made). */
|
|
119
|
+
constructor(private readonly files: () => FilesHost | undefined = () => undefined) {}
|
|
120
|
+
|
|
121
|
+
/** The recording's URL: a file in `Documents/Recordings` where the page keeps files, else a `data:` URL. */
|
|
122
|
+
private async keep(blob: Blob, type: string): Promise<string> {
|
|
123
|
+
const files = this.files();
|
|
124
|
+
if (files) {
|
|
125
|
+
try {
|
|
126
|
+
return await recordingFile(files, new Uint8Array(await blob.arrayBuffer()), type);
|
|
127
|
+
} catch {
|
|
128
|
+
// The file could not be written (the page's storage refused it): the sound stays with its URL.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return dataURL(blob);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The bytes of a recording: its file, or what its `data:` URL holds. */
|
|
135
|
+
private async bytesOf(url: string): Promise<ArrayBuffer> {
|
|
136
|
+
const files = this.files();
|
|
137
|
+
if (url.startsWith('file:') && files) {
|
|
138
|
+
const bytes = await files.read(url);
|
|
139
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
140
|
+
}
|
|
141
|
+
return (await fetch(url)).arrayBuffer();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async start(): Promise<void> {
|
|
145
|
+
if (this.session) throw new Error('busy: already recording');
|
|
146
|
+
// The context first, in the tap that asked to record: Safari starts one
|
|
147
|
+
// made after the microphone prompt (outside the gesture) suspended, and
|
|
148
|
+
// its analyser then reads silence, a flat waveform for a loud recording.
|
|
149
|
+
let context: AudioContext | null = null;
|
|
150
|
+
try {
|
|
151
|
+
context = new AudioContext();
|
|
152
|
+
void context.resume();
|
|
153
|
+
} catch {
|
|
154
|
+
// No Web Audio: the recording goes on, its level stays 0.
|
|
155
|
+
}
|
|
156
|
+
let stream: MediaStream;
|
|
157
|
+
try {
|
|
158
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: true } });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
void context?.close();
|
|
161
|
+
const name = (error as { name?: string }).name;
|
|
162
|
+
if (name === 'NotAllowedError' || name === 'SecurityError') throw new Error('permission: the microphone was not allowed');
|
|
163
|
+
throw new Error(`unavailable: no microphone (${String(error)})`);
|
|
164
|
+
}
|
|
165
|
+
const mimeType = TYPES.find((type) => MediaRecorder.isTypeSupported(type));
|
|
166
|
+
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
|
167
|
+
const session: Session = { stream, recorder, chunks: [], context: null, analyser: null, samples: null, recorded: 0, since: performance.now() };
|
|
168
|
+
recorder.addEventListener('dataavailable', (event) => {
|
|
169
|
+
if (event.data.size > 0) session.chunks.push(event.data);
|
|
170
|
+
});
|
|
171
|
+
if (context) {
|
|
172
|
+
try {
|
|
173
|
+
const analyser = context.createAnalyser();
|
|
174
|
+
analyser.fftSize = 1024;
|
|
175
|
+
context.createMediaStreamSource(stream).connect(analyser);
|
|
176
|
+
session.context = context;
|
|
177
|
+
session.analyser = analyser;
|
|
178
|
+
session.samples = new Float32Array(analyser.fftSize);
|
|
179
|
+
} catch {
|
|
180
|
+
void context.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
recorder.start(250);
|
|
184
|
+
this.session = session;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async pause(): Promise<void> {
|
|
188
|
+
const session = this.active();
|
|
189
|
+
if (session.since === null) return;
|
|
190
|
+
session.recorder.pause();
|
|
191
|
+
session.recorded += (performance.now() - session.since) / 1000;
|
|
192
|
+
session.since = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async resume(): Promise<void> {
|
|
196
|
+
const session = this.active();
|
|
197
|
+
if (session.since !== null) return;
|
|
198
|
+
session.recorder.resume();
|
|
199
|
+
session.since = performance.now();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async stop(): Promise<AudioRecording> {
|
|
203
|
+
const session = this.active();
|
|
204
|
+
const duration = this.time(session);
|
|
205
|
+
const stopped = new Promise<void>((resolve) => session.recorder.addEventListener('stop', () => resolve(), { once: true }));
|
|
206
|
+
session.recorder.stop();
|
|
207
|
+
await stopped;
|
|
208
|
+
this.end(session);
|
|
209
|
+
const type = session.recorder.mimeType || session.chunks[0]?.type || 'audio/webm';
|
|
210
|
+
const blob = new Blob(session.chunks, { type });
|
|
211
|
+
const plain = type.split(';')[0] ?? type;
|
|
212
|
+
return { url: await this.keep(blob, plain), duration, type: plain };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async cancel(): Promise<void> {
|
|
216
|
+
const session = this.session;
|
|
217
|
+
if (!session) return;
|
|
218
|
+
if (session.recorder.state !== 'inactive') session.recorder.stop();
|
|
219
|
+
this.end(session);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async meter(): Promise<AudioMeter> {
|
|
223
|
+
const session = this.active();
|
|
224
|
+
let level = 0;
|
|
225
|
+
if (session.analyser && session.samples && session.since !== null) {
|
|
226
|
+
// A context the browser suspended anyway (a long permission prompt) is asked again.
|
|
227
|
+
if (session.context?.state === 'suspended') void session.context.resume();
|
|
228
|
+
session.analyser.getFloatTimeDomainData(session.samples);
|
|
229
|
+
let sum = 0;
|
|
230
|
+
for (const sample of session.samples) sum += sample * sample;
|
|
231
|
+
level = levelOf(Math.sqrt(sum / session.samples.length));
|
|
232
|
+
}
|
|
233
|
+
return { level, time: this.time(session) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async discard(url: string): Promise<void> {
|
|
237
|
+
// A `data:` URL's sound goes with the URL; a file in Recordings is deleted (one gone already is nothing to delete).
|
|
238
|
+
const files = this.files();
|
|
239
|
+
if (!url.startsWith('file:') || !files) return;
|
|
240
|
+
await files.remove(url).catch((error: unknown) => {
|
|
241
|
+
if (!(error instanceof Error && error.message.startsWith('notFound:'))) throw error;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async trim(url: string, keep: readonly AudioRange[]): Promise<AudioRecording> {
|
|
246
|
+
const decoder = audioDecoder();
|
|
247
|
+
if (!decoder) throw new Error('unsupported: this page cannot cut audio');
|
|
248
|
+
let decoded: AudioBuffer;
|
|
249
|
+
try {
|
|
250
|
+
decoded = await decoder.context.decodeAudioData(await this.bytesOf(url));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw new Error(`decode: the recording could not be read (${String(error)})`);
|
|
253
|
+
} finally {
|
|
254
|
+
decoder.done();
|
|
255
|
+
}
|
|
256
|
+
const channels: Float32Array[] = [];
|
|
257
|
+
for (let channel = 0; channel < decoded.numberOfChannels; channel += 1) channels.push(decoded.getChannelData(channel));
|
|
258
|
+
const kept = sliceSamples(channels, decoded.sampleRate, keptRanges(keep, decoded.duration));
|
|
259
|
+
const frames = kept[0]?.length ?? 0;
|
|
260
|
+
if (frames === 0) throw new Error('empty: nothing of the recording was kept');
|
|
261
|
+
const wav = encodeWAV(kept, decoded.sampleRate);
|
|
262
|
+
return { url: await this.keep(new Blob([wav], { type: 'audio/wav' }), 'audio/wav'), duration: frames / decoded.sampleRate, type: 'audio/wav' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private active(): Session {
|
|
266
|
+
if (!this.session) throw new Error('idle: not recording');
|
|
267
|
+
return this.session;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private time(session: Session): number {
|
|
271
|
+
return session.recorded + (session.since === null ? 0 : (performance.now() - session.since) / 1000);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private end(session: Session): void {
|
|
275
|
+
for (const track of session.stream.getTracks()) track.stop();
|
|
276
|
+
void session.context?.close().catch(() => undefined);
|
|
277
|
+
if (this.session === session) this.session = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The ranges of a `duration`-second recording that `trim` keeps: each clamped
|
|
283
|
+
* to the recording, the empty ones dropped, in time order, and overlapping
|
|
284
|
+
* ones merged, so the kept parts never repeat a sound.
|
|
285
|
+
*/
|
|
286
|
+
export function keptRanges(keep: readonly AudioRange[], duration: number): AudioRange[] {
|
|
287
|
+
const clamped = keep
|
|
288
|
+
.map((range) => ({ start: Math.max(0, Math.min(duration, range.start)), end: Math.max(0, Math.min(duration, range.end)) }))
|
|
289
|
+
.filter((range) => Number.isFinite(range.start) && Number.isFinite(range.end) && range.end > range.start)
|
|
290
|
+
.sort((a, b) => a.start - b.start);
|
|
291
|
+
const merged: AudioRange[] = [];
|
|
292
|
+
for (const range of clamped) {
|
|
293
|
+
const last = merged[merged.length - 1];
|
|
294
|
+
if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
|
|
295
|
+
else merged.push({ ...range });
|
|
296
|
+
}
|
|
297
|
+
return merged;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The frames of `channels` (one array of samples per channel, at
|
|
302
|
+
* `sampleRate`) that fall in `ranges` (seconds), concatenated in order: one
|
|
303
|
+
* new array per channel, all the same length.
|
|
304
|
+
*/
|
|
305
|
+
export function sliceSamples(channels: readonly Float32Array[], sampleRate: number, ranges: readonly AudioRange[]): Float32Array[] {
|
|
306
|
+
const length = channels[0]?.length ?? 0;
|
|
307
|
+
const frames = ranges.map((range) => {
|
|
308
|
+
const start = Math.min(length, Math.max(0, Math.round(range.start * sampleRate)));
|
|
309
|
+
const end = Math.min(length, Math.max(start, Math.round(range.end * sampleRate)));
|
|
310
|
+
return { start, end };
|
|
311
|
+
});
|
|
312
|
+
const total = frames.reduce((sum, range) => sum + (range.end - range.start), 0);
|
|
313
|
+
return channels.map((samples) => {
|
|
314
|
+
const kept = new Float32Array(total);
|
|
315
|
+
let at = 0;
|
|
316
|
+
for (const range of frames) {
|
|
317
|
+
kept.set(samples.subarray(range.start, range.end), at);
|
|
318
|
+
at += range.end - range.start;
|
|
319
|
+
}
|
|
320
|
+
return kept;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `channels` (one array of -1…1 samples per channel, all the same length) as
|
|
326
|
+
* a WAV file: a RIFF header and the samples interleaved as 16-bit PCM, which
|
|
327
|
+
* every media element plays.
|
|
328
|
+
*/
|
|
329
|
+
export function encodeWAV(channels: readonly Float32Array[], sampleRate: number): ArrayBuffer {
|
|
330
|
+
const channelCount = channels.length;
|
|
331
|
+
const frames = channels[0]?.length ?? 0;
|
|
332
|
+
const blockAlign = channelCount * 2;
|
|
333
|
+
const dataSize = frames * blockAlign;
|
|
334
|
+
const buffer = new ArrayBuffer(44 + dataSize);
|
|
335
|
+
const view = new DataView(buffer);
|
|
336
|
+
const ascii = (offset: number, text: string): void => {
|
|
337
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
338
|
+
};
|
|
339
|
+
ascii(0, 'RIFF');
|
|
340
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
341
|
+
ascii(8, 'WAVE');
|
|
342
|
+
ascii(12, 'fmt ');
|
|
343
|
+
view.setUint32(16, 16, true); // The fmt chunk's size.
|
|
344
|
+
view.setUint16(20, 1, true); // PCM.
|
|
345
|
+
view.setUint16(22, channelCount, true);
|
|
346
|
+
view.setUint32(24, sampleRate, true);
|
|
347
|
+
view.setUint32(28, sampleRate * blockAlign, true);
|
|
348
|
+
view.setUint16(32, blockAlign, true);
|
|
349
|
+
view.setUint16(34, 16, true); // Bits per sample.
|
|
350
|
+
ascii(36, 'data');
|
|
351
|
+
view.setUint32(40, dataSize, true);
|
|
352
|
+
let offset = 44;
|
|
353
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
354
|
+
for (const samples of channels) {
|
|
355
|
+
const sample = Math.max(-1, Math.min(1, samples[frame] ?? 0));
|
|
356
|
+
view.setInt16(offset, sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff), true);
|
|
357
|
+
offset += 2;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return buffer;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* A context to decode with, and how to let it go: an `OfflineAudioContext`
|
|
365
|
+
* where there is one (it touches no hardware and needs no closing), else an
|
|
366
|
+
* `AudioContext`, closed once it has decoded (it holds the hardware, and
|
|
367
|
+
* Safari allows a page few); nothing where the page has no Web Audio.
|
|
368
|
+
*/
|
|
369
|
+
function audioDecoder(): { context: BaseAudioContext; done(): void } | undefined {
|
|
370
|
+
if (typeof OfflineAudioContext !== 'undefined') return { context: new OfflineAudioContext(1, 1, 44_100), done: () => undefined };
|
|
371
|
+
if (typeof AudioContext !== 'undefined') {
|
|
372
|
+
const context = new AudioContext();
|
|
373
|
+
return { context, done: () => void context.close().catch(() => undefined) };
|
|
374
|
+
}
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function dataURL(blob: Blob): Promise<string> {
|
|
379
|
+
return new Promise((resolve, reject) => {
|
|
380
|
+
const reader = new FileReader();
|
|
381
|
+
reader.onload = () => resolve(String(reader.result));
|
|
382
|
+
reader.onerror = () => reject(reader.error ?? new Error('read: the recording could not be read'));
|
|
383
|
+
reader.readAsDataURL(blob);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The browser's recorder, where it has a microphone API and `MediaRecorder`; `files` is where recordings go (the environment's files host). */
|
|
388
|
+
export function browserAudioRecorder(files: () => FilesHost | undefined = () => undefined): AudioRecorderHost | undefined {
|
|
389
|
+
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') return undefined;
|
|
390
|
+
if (typeof MediaRecorder === 'undefined') return undefined;
|
|
391
|
+
return new BrowserAudioRecorder(files);
|
|
392
|
+
}
|
|
@@ -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
|
+
}
|