@swiftbrowser/web 0.1.0 → 0.3.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/README.md +141 -63
- package/app/index.html +21 -10
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +12 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +68 -3
- package/src/landing.ts +2 -2
- package/src/largeTitle.ts +79 -0
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +681 -174
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +66 -6
- package/src/main.ts +127 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +4 -3
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +782 -13
- package/src/renderer.ts +603 -44
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +521 -100
- package/src/styles.ts +61 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +658 -28
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<meta name="referrer" content="no-referrer" />
|
|
8
|
+
<title>Signing in…</title>
|
|
9
|
+
<style>
|
|
10
|
+
body {
|
|
11
|
+
margin: 0;
|
|
12
|
+
min-height: 100vh;
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
font: 17px/1.4 -apple-system, system-ui, sans-serif;
|
|
17
|
+
background: Canvas;
|
|
18
|
+
color: CanvasText;
|
|
19
|
+
}
|
|
20
|
+
p {
|
|
21
|
+
margin: 16px;
|
|
22
|
+
text-align: center;
|
|
23
|
+
opacity: 0.7;
|
|
24
|
+
}
|
|
25
|
+
</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<p id="status">Signing in…</p>
|
|
29
|
+
<script>
|
|
30
|
+
// The end of a sign-in (docs/ops-protocol.md, "Signing in"). The app's
|
|
31
|
+
// OAuth redirect (`icecubesapp://`) was swapped for this page, so the
|
|
32
|
+
// provider sent the sign-in window here, with `code` (or `error`) in the
|
|
33
|
+
// query. Hand the URL to the app's page (src/webAuth.ts) and close: by
|
|
34
|
+
// postMessage to the opener, and on a BroadcastChannel for when a
|
|
35
|
+
// provider's Cross-Origin-Opener-Policy cut the window off from it.
|
|
36
|
+
(function () {
|
|
37
|
+
var message = { type: 'sb-web-auth-callback', url: location.href };
|
|
38
|
+
var delivered = false;
|
|
39
|
+
try {
|
|
40
|
+
if (window.opener && !window.opener.closed) {
|
|
41
|
+
window.opener.postMessage(message, location.origin);
|
|
42
|
+
delivered = true;
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {}
|
|
45
|
+
try {
|
|
46
|
+
var channel = new BroadcastChannel('sb-web-auth');
|
|
47
|
+
channel.postMessage(message);
|
|
48
|
+
channel.close();
|
|
49
|
+
delivered = true;
|
|
50
|
+
} catch (error) {}
|
|
51
|
+
// The code is single-use and the app has it now; keep it out of history.
|
|
52
|
+
try {
|
|
53
|
+
history.replaceState(null, '', location.pathname);
|
|
54
|
+
} catch (error) {}
|
|
55
|
+
document.getElementById('status').textContent = delivered
|
|
56
|
+
? 'You can close this window.'
|
|
57
|
+
: 'Return to the app to finish signing in.';
|
|
58
|
+
if (delivered) setTimeout(function () { window.close(); }, 50);
|
|
59
|
+
})();
|
|
60
|
+
</script>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
package/src/animation.ts
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
* - removed elements leave a visual clone (stripped of element ids) at their
|
|
16
16
|
* old position which plays the `transition` backwards and is then dropped.
|
|
17
17
|
*
|
|
18
|
+
* A frame tween is retargeted when a later layout pass moves the element
|
|
19
|
+
* again before it finished: the running tween is replaced by one from where
|
|
20
|
+
* the element is drawn now to its new frame, in the new commit's animation or,
|
|
21
|
+
* for a pass that is not animated (a custom `Layout` or `GeometryReader`
|
|
22
|
+
* answering the animated pass), in the time the old tween had left. Without
|
|
23
|
+
* that the old tween, which overrides the inline frame, kept drawing the
|
|
24
|
+
* element toward the stale frame until it ended and then snapped.
|
|
25
|
+
*
|
|
18
26
|
* `Animation` maps to CSS easings; `spring` becomes a `linear()` easing
|
|
19
27
|
* sampled from a damped spring where supported.
|
|
20
28
|
*/
|
|
@@ -165,6 +173,21 @@ export function easingFor(animation: Animation | undefined): string {
|
|
|
165
173
|
* by which time the spring has settled to within ~1%.
|
|
166
174
|
*/
|
|
167
175
|
export function springEasing(duration: number, bounce: number, samples = SPRING_SAMPLES): string {
|
|
176
|
+
// An animated pass asks for the same few curves once per tweened element.
|
|
177
|
+
const key = `${duration} ${bounce} ${samples}`;
|
|
178
|
+
let easing = springEasings.get(key);
|
|
179
|
+
if (easing === undefined) {
|
|
180
|
+
if (springEasings.size >= MAX_SPRING_EASINGS) springEasings.clear();
|
|
181
|
+
easing = sampleSpring(duration, bounce, samples);
|
|
182
|
+
springEasings.set(key, easing);
|
|
183
|
+
}
|
|
184
|
+
return easing;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const springEasings = new Map<string, string>();
|
|
188
|
+
const MAX_SPRING_EASINGS = 64;
|
|
189
|
+
|
|
190
|
+
function sampleSpring(duration: number, bounce: number, samples: number): string {
|
|
168
191
|
const d = duration > 0 ? duration : 0.5;
|
|
169
192
|
const zeta = Math.min(1, Math.max(0, 1 - bounce));
|
|
170
193
|
const omega = (2 * Math.PI) / d;
|
|
@@ -178,6 +201,49 @@ export function springEasing(duration: number, bounce: number, samples = SPRING_
|
|
|
178
201
|
return `linear(${points.join(', ')})`;
|
|
179
202
|
}
|
|
180
203
|
|
|
204
|
+
/**
|
|
205
|
+
* The eased progress of `animation` `elapsed` milliseconds after it began
|
|
206
|
+
* (its delay included): 0 before it starts, its curve's value while it runs
|
|
207
|
+
* (a spring that bounces passes 1), 1 after. For what CSS cannot tween, such
|
|
208
|
+
* as a custom shape's path (src/pathShape.ts), in the curve its CSS
|
|
209
|
+
* counterpart (`easingFor`) uses. Repeats are not applied.
|
|
210
|
+
*/
|
|
211
|
+
export function easedProgress(animation: Animation | undefined, elapsed: number): number {
|
|
212
|
+
const timing = timingFor(animation);
|
|
213
|
+
if (elapsed <= timing.delay) return 0;
|
|
214
|
+
if (timing.duration <= 0 || elapsed >= timing.delay + timing.duration) return 1;
|
|
215
|
+
const t = (elapsed - timing.delay) / timing.duration;
|
|
216
|
+
switch (animation?.type) {
|
|
217
|
+
case 'linear':
|
|
218
|
+
return t;
|
|
219
|
+
case 'easeIn':
|
|
220
|
+
return cubicBezier(0.42, 0, 1, 1, t);
|
|
221
|
+
case 'easeOut':
|
|
222
|
+
return cubicBezier(0, 0, 0.58, 1, t);
|
|
223
|
+
case 'spring': {
|
|
224
|
+
const d = animation.duration ?? 0.5;
|
|
225
|
+
const zeta = Math.min(1, Math.max(0, 1 - (animation.bounce ?? 0)));
|
|
226
|
+
return springPosition(t * (d > 0 ? d : 0.5), (2 * Math.PI) / (d > 0 ? d : 0.5), zeta);
|
|
227
|
+
}
|
|
228
|
+
default:
|
|
229
|
+
return cubicBezier(0.42, 0, 0.58, 1, t);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The y of the CSS cubic Bézier through (0,0), (x1,y1), (x2,y2), (1,1) at x = `t`. */
|
|
234
|
+
function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: number): number {
|
|
235
|
+
const at = (a: number, b: number, s: number) => 3 * a * s * (1 - s) ** 2 + 3 * b * s * s * (1 - s) + s ** 3;
|
|
236
|
+
// Bisection on x (monotonic for CSS's curves).
|
|
237
|
+
let lo = 0;
|
|
238
|
+
let hi = 1;
|
|
239
|
+
for (let i = 0; i < 30; i++) {
|
|
240
|
+
const mid = (lo + hi) / 2;
|
|
241
|
+
if (at(x1, x2, mid) < t) lo = mid;
|
|
242
|
+
else hi = mid;
|
|
243
|
+
}
|
|
244
|
+
return at(y1, y2, (lo + hi) / 2);
|
|
245
|
+
}
|
|
246
|
+
|
|
181
247
|
function springPosition(t: number, omega: number, zeta: number): number {
|
|
182
248
|
if (zeta >= 1) {
|
|
183
249
|
// Critically damped.
|
|
@@ -282,16 +348,24 @@ export const EXIT_CLONE_CLASS = 'sb-exit-clone';
|
|
|
282
348
|
export interface AnimatorOptions {
|
|
283
349
|
/** Kinds (by `data-sb-kind`) that animate their own enter/exit (navigation, sheets). */
|
|
284
350
|
selfAnimatingKinds?: ReadonlySet<string>;
|
|
351
|
+
/**
|
|
352
|
+
* Plays what an updated element animates itself (a custom shape's path
|
|
353
|
+
* frames, src/pathShape.ts) in the animation its update has; returns
|
|
354
|
+
* whether it had anything to play.
|
|
355
|
+
*/
|
|
356
|
+
playOwnFrames?: (el: HTMLElement, animation: Animation | undefined) => boolean;
|
|
285
357
|
}
|
|
286
358
|
|
|
287
359
|
export class Animator {
|
|
288
360
|
/** The element clones of removed elements are appended to (the screen). */
|
|
289
361
|
private readonly root: HTMLElement;
|
|
290
362
|
private readonly selfAnimating: ReadonlySet<string>;
|
|
363
|
+
private readonly playOwnFrames: AnimatorOptions['playOwnFrames'];
|
|
291
364
|
|
|
292
365
|
constructor(root: HTMLElement, options: AnimatorOptions = {}) {
|
|
293
366
|
this.root = root;
|
|
294
367
|
this.selfAnimating = options.selfAnimatingKinds ?? new Set();
|
|
368
|
+
this.playOwnFrames = options.playOwnFrames;
|
|
295
369
|
}
|
|
296
370
|
|
|
297
371
|
/** Computed values of the animated properties, or null when not rendered. */
|
|
@@ -304,12 +378,22 @@ export class Animator {
|
|
|
304
378
|
return snap;
|
|
305
379
|
}
|
|
306
380
|
|
|
307
|
-
/**
|
|
308
|
-
|
|
309
|
-
|
|
381
|
+
/** The animation root's rectangle in the viewport. */
|
|
382
|
+
rootRect(): DOMRect {
|
|
383
|
+
return this.root.getBoundingClientRect();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Rectangle of `el` relative to the animation root (`base`: its rectangle,
|
|
388
|
+
* if already read), or null when it is not drawn. A removed element's exit
|
|
389
|
+
* clone is appended to the root, outside whatever hid the element: a
|
|
390
|
+
* screen below the top one (`visibility: hidden`) or an ancestor at
|
|
391
|
+
* opacity 0 must not leave a clone over the visible screen.
|
|
392
|
+
*/
|
|
393
|
+
rectOf(el: HTMLElement, base: DOMRect = this.rootRect()): DOMRect | null {
|
|
394
|
+
if (!el.isConnected || !isDrawn(el)) return null;
|
|
310
395
|
const r = el.getBoundingClientRect();
|
|
311
396
|
if (r.width === 0 && r.height === 0) return null;
|
|
312
|
-
const base = this.root.getBoundingClientRect();
|
|
313
397
|
return new DOMRect(r.left - base.left, r.top - base.top, r.width, r.height);
|
|
314
398
|
}
|
|
315
399
|
|
|
@@ -323,7 +407,12 @@ export class Animator {
|
|
|
323
407
|
for (const [el, rec] of pass.updated) {
|
|
324
408
|
if (rec.tokenChanged) tokenRoots.set(el, rec.animation);
|
|
325
409
|
}
|
|
326
|
-
if (!commitAnimation && tokenRoots.size === 0)
|
|
410
|
+
if (!commitAnimation && tokenRoots.size === 0) {
|
|
411
|
+
// Not animated: frames still tweening toward a box this pass replaced
|
|
412
|
+
// carry on to the new box in the time they had left.
|
|
413
|
+
for (const change of frames) this.retargetFrame(change, null);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
327
416
|
|
|
328
417
|
const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
|
|
329
418
|
for (const el of chain) {
|
|
@@ -340,15 +429,50 @@ export class Animator {
|
|
|
340
429
|
this.animateChanges(el, rec.before, animation);
|
|
341
430
|
}
|
|
342
431
|
|
|
432
|
+
// What elements animate themselves (a custom shape's path).
|
|
433
|
+
if (this.playOwnFrames) {
|
|
434
|
+
for (const el of pass.updated.keys()) {
|
|
435
|
+
if (!el.isConnected) continue;
|
|
436
|
+
const animation = animationFor(ancestorsAndSelf(el));
|
|
437
|
+
if (animation !== false) this.playOwnFrames(el, animation);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
343
441
|
// Frame changes: tween left/top/width/height from the previous box. The
|
|
344
442
|
// inline values are relative to the parent's origin, so apply the delta.
|
|
443
|
+
const tweens: { change: FrameChange; from: FrameRect; animation: Animation | undefined; moved: boolean }[] = [];
|
|
444
|
+
const jumped = new Map<HTMLElement, { x: number; y: number }>();
|
|
345
445
|
for (const change of frames) {
|
|
346
446
|
const { el, from, to } = change;
|
|
347
447
|
if (!from || !el.isConnected || pass.inserted.has(el)) continue;
|
|
348
448
|
if (this.selfAnimating.has(change.kind)) continue;
|
|
349
449
|
const animation = animationFor(ancestorsAndSelf(el));
|
|
350
|
-
if (animation === false)
|
|
351
|
-
|
|
450
|
+
if (animation === false) {
|
|
451
|
+
// Moved in place, unless it carries on with a tween of its own.
|
|
452
|
+
if (!this.retargetFrame(change, null)) jumped.set(el, { x: to.x - from.x, y: to.y - from.y });
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
tweens.push({ change, from, animation, moved: true });
|
|
456
|
+
}
|
|
457
|
+
// The root of an `.animation(_:value:)` subtree whose own box did not
|
|
458
|
+
// change still moves with an ancestor that jumped.
|
|
459
|
+
const changed = new Set(frames.map((change) => change.el));
|
|
460
|
+
for (const [el, animation] of tokenRoots) {
|
|
461
|
+
if (changed.has(el) || !el.isConnected || pass.inserted.has(el)) continue;
|
|
462
|
+
const box = { x: parseFloat(el.style.left) || 0, y: parseFloat(el.style.top) || 0, width: el.offsetWidth, height: el.offsetHeight };
|
|
463
|
+
tweens.push({ change: { el, kind: el.dataset['sbKind'] ?? '', from: box, to: box }, from: box, animation: animation ?? { type: 'default' }, moved: false });
|
|
464
|
+
}
|
|
465
|
+
// An animated element under an ancestor that jumps (outside the animated
|
|
466
|
+
// subtree) starts from where it was drawn, not from its old place in the
|
|
467
|
+
// moved ancestor: SwiftUI animates its position on screen, so a shape
|
|
468
|
+
// that grows in a stack that re-centers grows about its center.
|
|
469
|
+
const tweened = new Set(tweens.map((tween) => tween.change.el));
|
|
470
|
+
for (const { change, from, animation, moved } of tweens) {
|
|
471
|
+
const shift = jumpAbove(change.el, jumped, tweened, this.root);
|
|
472
|
+
if (!moved && !shift) continue;
|
|
473
|
+
const start = shift ? { ...from, x: from.x - shift.x, y: from.y - shift.y } : from;
|
|
474
|
+
if (this.retargetFrame({ ...change, from: start }, animation, shift)) continue;
|
|
475
|
+
this.animateFrame(change.el, start, change.to, animation);
|
|
352
476
|
}
|
|
353
477
|
|
|
354
478
|
// Inserted elements: only subtree roots (a nested fade would compound).
|
|
@@ -419,7 +543,76 @@ export class Animator {
|
|
|
419
543
|
end['height'] = `${to.height}px`;
|
|
420
544
|
}
|
|
421
545
|
const timing = timingFor(animation);
|
|
422
|
-
el.animate([start, end], { ...timing, fill: 'none' });
|
|
546
|
+
this.trackFrame(el, el.animate([start, end], { ...timing, fill: 'none' }));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The frame tween running on each element (see `retargetFrame`). */
|
|
550
|
+
private readonly frameTweens = new WeakMap<HTMLElement, globalThis.Animation>();
|
|
551
|
+
|
|
552
|
+
private trackFrame(el: HTMLElement, tween: globalThis.Animation): void {
|
|
553
|
+
this.frameTweens.set(el, tween);
|
|
554
|
+
tween.addEventListener('finish', () => {
|
|
555
|
+
if (this.frameTweens.get(el) === tween) this.frameTweens.delete(el);
|
|
556
|
+
});
|
|
557
|
+
tween.addEventListener('cancel', () => {
|
|
558
|
+
if (this.frameTweens.get(el) === tween) this.frameTweens.delete(el);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* When `change.el` is still tweening toward the box this pass replaced,
|
|
564
|
+
* replace that tween with one from where the element is drawn now to its
|
|
565
|
+
* new inline frame: in `animation`'s timing when the pass animates the
|
|
566
|
+
* element, else (`null`) in the time the old tween had left, with its
|
|
567
|
+
* easing. Returns whether there was a tween to retarget.
|
|
568
|
+
*/
|
|
569
|
+
retargetFrame(change: FrameChange, animation: Animation | undefined | null, shift: { x: number; y: number } | null = null): boolean {
|
|
570
|
+
const { el, from, to } = change;
|
|
571
|
+
const tween = this.frameTweens.get(el);
|
|
572
|
+
if (!tween || tween.playState === 'finished' || tween.playState === 'idle') return false;
|
|
573
|
+
if (!el.isConnected) {
|
|
574
|
+
tween.cancel();
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
// The drawn values: the tween's own properties from the computed style
|
|
578
|
+
// (it is still applied), the others from the box before this pass.
|
|
579
|
+
const tweened = new Set<string>();
|
|
580
|
+
for (const frame of (tween.effect as KeyframeEffect | null)?.getKeyframes() ?? []) {
|
|
581
|
+
for (const key of ['left', 'top', 'width', 'height']) if (key in frame) tweened.add(key);
|
|
582
|
+
}
|
|
583
|
+
const cs = getComputedStyle(el);
|
|
584
|
+
const left = parseFloat(el.style.left) || 0;
|
|
585
|
+
const top = parseFloat(el.style.top) || 0;
|
|
586
|
+
const prev = from ?? to;
|
|
587
|
+
const drawn: Record<'left' | 'top' | 'width' | 'height', number> = {
|
|
588
|
+
left: tweened.has('left') ? parseFloat(cs.left) - (shift?.x ?? 0) : left - (to.x - prev.x),
|
|
589
|
+
top: tweened.has('top') ? parseFloat(cs.top) - (shift?.y ?? 0) : top - (to.y - prev.y),
|
|
590
|
+
width: tweened.has('width') ? parseFloat(cs.width) : prev.width,
|
|
591
|
+
height: tweened.has('height') ? parseFloat(cs.height) : prev.height,
|
|
592
|
+
};
|
|
593
|
+
const target = { left, top, width: to.width, height: to.height };
|
|
594
|
+
let timing: KeyframeAnimationOptions;
|
|
595
|
+
if (animation === null) {
|
|
596
|
+
const computed = tween.effect?.getComputedTiming();
|
|
597
|
+
const end = Number(computed?.endTime ?? 0);
|
|
598
|
+
const now = Number(tween.currentTime ?? 0);
|
|
599
|
+
const remaining = Math.max(0, end - now);
|
|
600
|
+
const easing = tween.effect?.getTiming().easing ?? 'linear';
|
|
601
|
+
timing = { duration: remaining, easing };
|
|
602
|
+
} else {
|
|
603
|
+
timing = timingFor(animation);
|
|
604
|
+
}
|
|
605
|
+
tween.cancel();
|
|
606
|
+
const start: Keyframe = {};
|
|
607
|
+
const finish: Keyframe = {};
|
|
608
|
+
for (const key of ['left', 'top', 'width', 'height'] as const) {
|
|
609
|
+
if (!Number.isFinite(drawn[key]) || Math.abs(drawn[key] - target[key]) < 0.01) continue;
|
|
610
|
+
start[key] = `${drawn[key]}px`;
|
|
611
|
+
finish[key] = `${target[key]}px`;
|
|
612
|
+
}
|
|
613
|
+
if (Object.keys(start).length === 0 || !timing.duration) return true;
|
|
614
|
+
this.trackFrame(el, el.animate([start, finish], { ...timing, fill: 'none' }));
|
|
615
|
+
return true;
|
|
423
616
|
}
|
|
424
617
|
|
|
425
618
|
/** Play `transition` forwards on a freshly inserted element. */
|
|
@@ -484,6 +677,18 @@ export class Animator {
|
|
|
484
677
|
}
|
|
485
678
|
}
|
|
486
679
|
|
|
680
|
+
/** Whether `el` is drawn: displayed, not `visibility: hidden`, no ancestor at opacity 0. */
|
|
681
|
+
function isDrawn(el: HTMLElement): boolean {
|
|
682
|
+
if (typeof el.checkVisibility === 'function') {
|
|
683
|
+
return el.checkVisibility({ visibilityProperty: true, opacityProperty: true });
|
|
684
|
+
}
|
|
685
|
+
if (getComputedStyle(el).visibility === 'hidden') return false;
|
|
686
|
+
for (let e: HTMLElement | null = el; e; e = e.parentElement) {
|
|
687
|
+
if (getComputedStyle(e).opacity === '0') return false;
|
|
688
|
+
}
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
|
|
487
692
|
/** Transitions are stored by the renderer per element (see `setTransition`). */
|
|
488
693
|
const TRANSITIONS = new WeakMap<HTMLElement, Transition>();
|
|
489
694
|
|
|
@@ -519,3 +724,25 @@ function hasAncestorIn(el: HTMLElement, set: ReadonlySet<HTMLElement>): boolean
|
|
|
519
724
|
}
|
|
520
725
|
return false;
|
|
521
726
|
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* How far the ancestors of `el` that jumped in this pass (moved without a
|
|
730
|
+
* tween) moved it, up to the nearest ancestor that tweens (which carries `el`
|
|
731
|
+
* from where it was drawn) or `root`; null when nothing above it jumped.
|
|
732
|
+
*/
|
|
733
|
+
function jumpAbove(
|
|
734
|
+
el: HTMLElement,
|
|
735
|
+
jumped: ReadonlyMap<HTMLElement, { x: number; y: number }>,
|
|
736
|
+
tweened: ReadonlySet<HTMLElement>,
|
|
737
|
+
root: HTMLElement,
|
|
738
|
+
): { x: number; y: number } | null {
|
|
739
|
+
let x = 0;
|
|
740
|
+
let y = 0;
|
|
741
|
+
for (let node = el.parentElement; node && node !== root && !tweened.has(node); node = node.parentElement) {
|
|
742
|
+
const jump = jumped.get(node);
|
|
743
|
+
if (!jump) continue;
|
|
744
|
+
x += jump.x;
|
|
745
|
+
y += jump.y;
|
|
746
|
+
}
|
|
747
|
+
return x || y ? { x, y } : null;
|
|
748
|
+
}
|
package/src/appFiles.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 19: the app's home directory, kept in IndexedDB.
|
|
3
|
+
*
|
|
4
|
+
* The module sees an iOS-like home at `/data` (`HOME`): `Documents`,
|
|
5
|
+
* `Library/Application Support` (`XDG_DATA_HOME`) and `Library/Caches`
|
|
6
|
+
* (`XDG_CACHE_HOME`), so `URL.documentsDirectory`, `FileManager.urls(for:in:)`
|
|
7
|
+
* and `Data.write(to:)` work as on a phone; `/tmp` is a second mount that
|
|
8
|
+
* nothing keeps. The page loads the home from the app's database before the
|
|
9
|
+
* module starts, and after any event (or executor run) that wrote to a file
|
|
10
|
+
* it compares the tree with what it last saved and writes the difference.
|
|
11
|
+
* One database per app, `sb-files:<App>`, with one object store, `entries`,
|
|
12
|
+
* keyed by `path` (relative to the home, `Documents/notes.txt`); a row is
|
|
13
|
+
* `{path, data}` for a file and `{path, directory: true}` for a directory.
|
|
14
|
+
* See docs/ops-protocol.md, "Phase 19".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Directory, File, type Inode } from '@bjorn3/browser_wasi_shim';
|
|
18
|
+
|
|
19
|
+
/** Where the home is mounted, and the environment that points Foundation at it. */
|
|
20
|
+
export const HOME = '/data';
|
|
21
|
+
export const TMP = '/tmp';
|
|
22
|
+
export const HOME_ENVIRONMENT: readonly string[] = [
|
|
23
|
+
`HOME=${HOME}`,
|
|
24
|
+
`XDG_DATA_HOME=${HOME}/Library/Application Support`,
|
|
25
|
+
`XDG_CACHE_HOME=${HOME}/Library/Caches`,
|
|
26
|
+
`TMPDIR=${TMP}`,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** The directories every home has, as on iOS. */
|
|
30
|
+
export const HOME_DIRECTORIES: readonly string[] = ['Documents', 'Library', 'Library/Application Support', 'Library/Caches'];
|
|
31
|
+
|
|
32
|
+
/** A home's contents: path → bytes for a file, `null` for a directory. */
|
|
33
|
+
export type HomeEntries = Map<string, Uint8Array | null>;
|
|
34
|
+
|
|
35
|
+
/** A writable WASI directory tree from saved entries, with the standard directories. */
|
|
36
|
+
export function homeDirectory(entries: HomeEntries): Directory {
|
|
37
|
+
const root = new Directory(new Map<string, Inode>());
|
|
38
|
+
const directoryAt = (parts: readonly string[]): Directory => {
|
|
39
|
+
let dir = root;
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
const existing = dir.contents.get(part);
|
|
42
|
+
if (existing instanceof Directory) {
|
|
43
|
+
dir = existing;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const next = new Directory(new Map<string, Inode>());
|
|
47
|
+
dir.contents.set(part, next);
|
|
48
|
+
dir = next;
|
|
49
|
+
}
|
|
50
|
+
return dir;
|
|
51
|
+
};
|
|
52
|
+
for (const path of HOME_DIRECTORIES) directoryAt(path.split('/'));
|
|
53
|
+
// Directories before files, shallow paths first, so a parent always exists.
|
|
54
|
+
const sorted = [...entries].sort(([a, aData], [b, bData]) => Number(aData !== null) - Number(bData !== null) || a.split('/').length - b.split('/').length);
|
|
55
|
+
for (const [path, data] of sorted) {
|
|
56
|
+
const parts = path.split('/').filter((part) => part !== '' && part !== '.' && part !== '..');
|
|
57
|
+
if (parts.length === 0) continue;
|
|
58
|
+
if (data === null) {
|
|
59
|
+
directoryAt(parts);
|
|
60
|
+
} else {
|
|
61
|
+
directoryAt(parts.slice(0, -1)).contents.set(parts[parts.length - 1]!, new File(data));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return root;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every file and directory under `root`, by path (the live bytes of each file). */
|
|
68
|
+
export function homeEntries(root: Directory): HomeEntries {
|
|
69
|
+
const entries: HomeEntries = new Map();
|
|
70
|
+
const walk = (dir: Directory, prefix: string): void => {
|
|
71
|
+
for (const [name, child] of dir.contents) {
|
|
72
|
+
const path = prefix === '' ? name : `${prefix}/${name}`;
|
|
73
|
+
if (child instanceof Directory) {
|
|
74
|
+
entries.set(path, null);
|
|
75
|
+
walk(child, path);
|
|
76
|
+
} else if (child instanceof File) {
|
|
77
|
+
entries.set(path, child.data);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
walk(root, '');
|
|
82
|
+
return entries;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
86
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
87
|
+
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What changed from `saved` to `current`: entries to write (copied) and paths to delete. */
|
|
92
|
+
export interface HomeChanges {
|
|
93
|
+
put: [string, Uint8Array | null][];
|
|
94
|
+
remove: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function diffHome(saved: HomeEntries, current: HomeEntries): HomeChanges {
|
|
98
|
+
const put: [string, Uint8Array | null][] = [];
|
|
99
|
+
const remove: string[] = [];
|
|
100
|
+
for (const [path, data] of current) {
|
|
101
|
+
const before = saved.get(path);
|
|
102
|
+
if (before === undefined) {
|
|
103
|
+
put.push([path, data === null ? null : data.slice()]);
|
|
104
|
+
} else if (data === null ? before !== null : before === null || !sameBytes(before, data)) {
|
|
105
|
+
put.push([path, data === null ? null : data.slice()]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const path of saved.keys()) if (!current.has(path)) remove.push(path);
|
|
109
|
+
return { put, remove };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `saved` with `changes` applied (what was just written). */
|
|
113
|
+
export function applyHomeChanges(saved: HomeEntries, changes: HomeChanges): HomeEntries {
|
|
114
|
+
const next: HomeEntries = new Map(saved);
|
|
115
|
+
for (const path of changes.remove) next.delete(path);
|
|
116
|
+
for (const [path, data] of changes.put) next.set(path, data);
|
|
117
|
+
return next;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Where an app's home lives between launches. */
|
|
121
|
+
export interface FilesPersistence {
|
|
122
|
+
load(): Promise<HomeEntries>;
|
|
123
|
+
/** Writes one set of changes; writes happen in the order they were made. */
|
|
124
|
+
apply(changes: HomeChanges): Promise<void>;
|
|
125
|
+
clear(): Promise<void>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface EntryRow {
|
|
129
|
+
path: string;
|
|
130
|
+
data?: Uint8Array;
|
|
131
|
+
directory?: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ENTRIES = 'entries';
|
|
135
|
+
|
|
136
|
+
export function filesDatabaseName(app: string): string {
|
|
137
|
+
return `sb-files:${app}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function request<T>(req: IDBRequest<T>): Promise<T> {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
req.onsuccess = () => resolve(req.result);
|
|
143
|
+
req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function done(transaction: IDBTransaction): Promise<void> {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
transaction.oncomplete = () => resolve();
|
|
150
|
+
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
|
|
151
|
+
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The app's home in IndexedDB (`factory` is the browser's unless a test passes one). */
|
|
156
|
+
export function indexedDBFiles(app: string, factory: IDBFactory = indexedDB): FilesPersistence {
|
|
157
|
+
let opened: Promise<IDBDatabase> | null = null;
|
|
158
|
+
const database = (): Promise<IDBDatabase> => {
|
|
159
|
+
opened ??= new Promise((resolve, reject) => {
|
|
160
|
+
const open = factory.open(filesDatabaseName(app), 1);
|
|
161
|
+
open.onupgradeneeded = () => {
|
|
162
|
+
if (!open.result.objectStoreNames.contains(ENTRIES)) open.result.createObjectStore(ENTRIES, { keyPath: 'path' });
|
|
163
|
+
};
|
|
164
|
+
open.onsuccess = () => resolve(open.result);
|
|
165
|
+
open.onerror = () => reject(open.error ?? new Error(`could not open ${filesDatabaseName(app)}`));
|
|
166
|
+
open.onblocked = () => reject(new Error(`${filesDatabaseName(app)} is blocked by another tab`));
|
|
167
|
+
});
|
|
168
|
+
return opened;
|
|
169
|
+
};
|
|
170
|
+
let queue: Promise<void> = Promise.resolve();
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
async load() {
|
|
174
|
+
const db = await database();
|
|
175
|
+
const rows = await request(db.transaction(ENTRIES, 'readonly').objectStore(ENTRIES).getAll() as IDBRequest<EntryRow[]>);
|
|
176
|
+
const entries: HomeEntries = new Map();
|
|
177
|
+
for (const row of rows) entries.set(row.path, row.directory ? null : new Uint8Array(row.data ?? new Uint8Array()));
|
|
178
|
+
return entries;
|
|
179
|
+
},
|
|
180
|
+
apply(changes) {
|
|
181
|
+
queue = queue.then(async () => {
|
|
182
|
+
const db = await database();
|
|
183
|
+
const transaction = db.transaction(ENTRIES, 'readwrite');
|
|
184
|
+
const store = transaction.objectStore(ENTRIES);
|
|
185
|
+
for (const path of changes.remove) store.delete(path);
|
|
186
|
+
for (const [path, data] of changes.put) {
|
|
187
|
+
const row: EntryRow = data === null ? { path, directory: true } : { path, data };
|
|
188
|
+
store.put(row);
|
|
189
|
+
}
|
|
190
|
+
await done(transaction);
|
|
191
|
+
});
|
|
192
|
+
return queue;
|
|
193
|
+
},
|
|
194
|
+
async clear() {
|
|
195
|
+
const db = await database();
|
|
196
|
+
const transaction = db.transaction(ENTRIES, 'readwrite');
|
|
197
|
+
transaction.objectStore(ENTRIES).clear();
|
|
198
|
+
await done(transaction);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
package/src/base64.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Base64 for the bytes that cross between Swift and the page (fetch bodies, image files); no DOM, so a worker can use it too. */
|
|
2
|
+
|
|
3
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
4
|
+
|
|
5
|
+
/** Standard base64 (RFC 4648, padded); chunked so large bodies do not blow the call stack. */
|
|
6
|
+
export function base64Encode(bytes: Uint8Array): string {
|
|
7
|
+
if (typeof btoa === 'function') {
|
|
8
|
+
let binary = '';
|
|
9
|
+
const chunk = 0x8000;
|
|
10
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
11
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
12
|
+
}
|
|
13
|
+
return btoa(binary);
|
|
14
|
+
}
|
|
15
|
+
let out = '';
|
|
16
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
17
|
+
const a = bytes[i]!;
|
|
18
|
+
const b = i + 1 < bytes.length ? bytes[i + 1]! : 0;
|
|
19
|
+
const c = i + 2 < bytes.length ? bytes[i + 2]! : 0;
|
|
20
|
+
const n = (a << 16) | (b << 8) | c;
|
|
21
|
+
out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!;
|
|
22
|
+
out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '=';
|
|
23
|
+
out += i + 2 < bytes.length ? B64[n & 63]! : '=';
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function base64Decode(text: string): Uint8Array {
|
|
29
|
+
if (typeof atob === 'function') {
|
|
30
|
+
const binary = atob(text);
|
|
31
|
+
const bytes = new Uint8Array(binary.length);
|
|
32
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
33
|
+
return bytes;
|
|
34
|
+
}
|
|
35
|
+
const clean = text.replace(/[^A-Za-z0-9+/]/g, '');
|
|
36
|
+
const out: number[] = [];
|
|
37
|
+
for (let i = 0; i < clean.length; i += 4) {
|
|
38
|
+
const n = [0, 1, 2, 3].reduce((acc, k) => (acc << 6) | (i + k < clean.length ? B64.indexOf(clean[i + k]!) : 0), 0);
|
|
39
|
+
const count = Math.min(3, Math.floor(((clean.length - i) * 6) / 8));
|
|
40
|
+
for (let k = 0; k < count; k++) out.push((n >> (16 - 8 * k)) & 0xff);
|
|
41
|
+
}
|
|
42
|
+
return new Uint8Array(out);
|
|
43
|
+
}
|