@swiftbrowser/web 0.3.1 → 0.4.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 +3 -1
- package/package.json +4 -2
- package/src/animation.ts +154 -3
- package/src/dragDrop.ts +549 -0
- package/src/hostContent.ts +37 -0
- package/src/interaction.ts +128 -22
- package/src/largeTitle.ts +95 -54
- package/src/layout/engine.ts +415 -88
- package/src/layout/index.ts +5 -2
- package/src/layout/measure.ts +2 -0
- package/src/layout/text.ts +6 -3
- package/src/layout/types.ts +16 -1
- package/src/layout/typography.ts +30 -10
- package/src/layoutDom.ts +66 -16
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +188 -9
- package/src/renderer.ts +316 -15
- package/src/scrollEdge.ts +87 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +413 -16
- package/src/typography.ts +5 -2
package/README.md
CHANGED
|
@@ -32,7 +32,9 @@ src/bitmaps.ts UIImage bitmaps: draws recipes on each image's <canvas>, thr
|
|
|
32
32
|
src/bitmapStore.ts The bitmap store: keeps `imagedata` sources, decodes them, draws recipes (bitmapPlan.ts, imageOps.ts); no DOM
|
|
33
33
|
src/bitmapWorker.ts The store in a worker, answering drawings with ImageBitmaps
|
|
34
34
|
src/imageOps.ts Core Image's steps (sepia, crystallize, edges, blur, pixellate, unsharp mask, vignette, crop) on rasters; no DOM
|
|
35
|
-
src/
|
|
35
|
+
src/hostContent.ts `host` elements: the registry of the page's own DOM elements the renderer adopts (docs/ops-protocol.md, "Host elements")
|
|
36
|
+
src/pointerEffects.ts Hover events, tooltips, cursors and symbol effects for page-side producers; frame reports' safe-area overlap (docs/ops-protocol.md, "Pointer, effects, matched geometry and frames on screen")
|
|
37
|
+
src/styles.css iOS tokens, device frame, device mode, element styles, host boxes and islands
|
|
36
38
|
node/ Node side: apps.ts (the AppSpec registry type), config.ts (Vite config from specs),
|
|
37
39
|
examples.ts (Examples/* as specs, build-wasm.sh runner, descriptions),
|
|
38
40
|
index.ts (createDevServer / buildSite, the package entry); compiled to node/dist/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swiftbrowser/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./node/dist/node/index.d.ts",
|
|
16
16
|
"default": "./node/dist/node/index.js"
|
|
17
|
-
}
|
|
17
|
+
},
|
|
18
|
+
"./src/styles.css": "./src/styles.css",
|
|
19
|
+
"./src/*": "./src/*.ts"
|
|
18
20
|
},
|
|
19
21
|
"files": [
|
|
20
22
|
"node/dist",
|
package/src/animation.ts
CHANGED
|
@@ -56,6 +56,11 @@ export interface UpdatedRecord {
|
|
|
56
56
|
/** `animationToken` changed in this pass; `animation` is the subtree's. */
|
|
57
57
|
tokenChanged: boolean;
|
|
58
58
|
animation?: Animation;
|
|
59
|
+
/**
|
|
60
|
+
* The change animates by default (an inspector shown or hidden): with the
|
|
61
|
+
* commit's animation when it has one, else with `default`.
|
|
62
|
+
*/
|
|
63
|
+
implicit?: boolean;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
export interface RemovedRecord {
|
|
@@ -65,6 +70,17 @@ export interface RemovedRecord {
|
|
|
65
70
|
/** Position relative to the animation root, before removal. */
|
|
66
71
|
rect: DOMRect | null;
|
|
67
72
|
transition: Transition | undefined;
|
|
73
|
+
/** `.matchedGeometryEffect` boxes in the removed subtree (itself included): their ids and where they were. */
|
|
74
|
+
matched?: { id: string; el: HTMLElement; rect: DOMRect }[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A text whose content changed under a `contentTransition`: a copy of how it looked, and where. */
|
|
78
|
+
export interface ContentChange {
|
|
79
|
+
el: HTMLElement;
|
|
80
|
+
clone: HTMLElement;
|
|
81
|
+
rect: DOMRect;
|
|
82
|
+
kind: 'numericText' | 'opacity' | 'interpolate';
|
|
83
|
+
countsDown: boolean;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
export interface PassRecord {
|
|
@@ -72,10 +88,11 @@ export interface PassRecord {
|
|
|
72
88
|
/** Elements attached by `insert` in this pass that were detached before. */
|
|
73
89
|
inserted: Set<HTMLElement>;
|
|
74
90
|
removed: RemovedRecord[];
|
|
91
|
+
contentChanges: ContentChange[];
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
export function emptyPass(): PassRecord {
|
|
78
|
-
return { updated: new Map(), inserted: new Set(), removed: [] };
|
|
95
|
+
return { updated: new Map(), inserted: new Set(), removed: [], contentChanges: [] };
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
export interface FrameRect {
|
|
@@ -401,11 +418,13 @@ export class Animator {
|
|
|
401
418
|
* Animate the pass. `commitAnimation` is the commit's own animation
|
|
402
419
|
* (`withAnimation`); subtrees whose `animationToken` changed use their own
|
|
403
420
|
* animation instead, as `.animation(_:value:)` overrides the transaction.
|
|
421
|
+
* An `implicit` change (an inspector shown or hidden) animates even without
|
|
422
|
+
* one, with the commit's animation or `default`.
|
|
404
423
|
*/
|
|
405
424
|
commit(pass: PassRecord, commitAnimation: Animation | undefined, frames: readonly FrameChange[] = []): void {
|
|
406
425
|
const tokenRoots = new Map<HTMLElement, Animation | undefined>();
|
|
407
426
|
for (const [el, rec] of pass.updated) {
|
|
408
|
-
if (rec.tokenChanged) tokenRoots.set(el, rec.animation);
|
|
427
|
+
if (rec.tokenChanged) tokenRoots.set(el, rec.implicit ? commitAnimation : rec.animation);
|
|
409
428
|
}
|
|
410
429
|
if (!commitAnimation && tokenRoots.size === 0) {
|
|
411
430
|
// Not animated: frames still tweening toward a box this pass replaced
|
|
@@ -414,6 +433,13 @@ export class Animator {
|
|
|
414
433
|
return;
|
|
415
434
|
}
|
|
416
435
|
|
|
436
|
+
// Matched geometry: a box inserted with the id of a removed one starts
|
|
437
|
+
// from the removed one's frame, and the removed one's exit travels to
|
|
438
|
+
// the new frame. Both frames are read now, before the tweens below hold
|
|
439
|
+
// ancestors at their old geometry.
|
|
440
|
+
const matchedPairs = this.matchedPairs(pass);
|
|
441
|
+
const matchedTo = new Map<RemovedRecord, { from: DOMRect; to: DOMRect }>();
|
|
442
|
+
|
|
417
443
|
const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
|
|
418
444
|
for (const el of chain) {
|
|
419
445
|
if (tokenRoots.has(el)) return tokenRoots.get(el) ?? { type: 'default' };
|
|
@@ -475,6 +501,22 @@ export class Animator {
|
|
|
475
501
|
this.animateFrame(change.el, start, change.to, animation);
|
|
476
502
|
}
|
|
477
503
|
|
|
504
|
+
// Matched geometry. The inserted box starts from the removed one's frame
|
|
505
|
+
// relative to where it is drawn now: its ancestors' tweens have started
|
|
506
|
+
// and carry it along, so the two together move it on screen. The removed
|
|
507
|
+
// box's exit clone sits at the root and travels to the final frame, read
|
|
508
|
+
// before any tween began.
|
|
509
|
+
if (matchedPairs.length > 0) {
|
|
510
|
+
const base = this.rootRect();
|
|
511
|
+
for (const pair of matchedPairs) {
|
|
512
|
+
const animation = animationFor([pair.box, ...ancestorsOf(pair.box)]);
|
|
513
|
+
if (animation === false) continue;
|
|
514
|
+
const drawn = this.rectOf(pair.box, base) ?? pair.to;
|
|
515
|
+
this.animateMatched(pair.box, pair.from, drawn, animation);
|
|
516
|
+
if (pair.rec.rect) matchedTo.set(pair.rec, { from: pair.from, to: pair.to });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
478
520
|
// Inserted elements: only subtree roots (a nested fade would compound).
|
|
479
521
|
for (const el of pass.inserted) {
|
|
480
522
|
if (!el.isConnected || pass.updated.has(el)) continue;
|
|
@@ -485,14 +527,116 @@ export class Animator {
|
|
|
485
527
|
this.enter(el, transitionOf(el), animation);
|
|
486
528
|
}
|
|
487
529
|
|
|
530
|
+
// Content transitions: the old text leaves as the new one arrives.
|
|
531
|
+
for (const change of pass.contentChanges) {
|
|
532
|
+
if (!change.el.isConnected) continue;
|
|
533
|
+
const animation = animationFor(ancestorsAndSelf(change.el));
|
|
534
|
+
if (animation === false) continue;
|
|
535
|
+
this.transitionContent(change, animation);
|
|
536
|
+
}
|
|
537
|
+
|
|
488
538
|
// Removed elements: leave a clone that plays the transition backwards.
|
|
489
539
|
for (const rec of pass.removed) {
|
|
490
540
|
if (!rec.rect) continue;
|
|
491
541
|
if (this.selfAnimating.has(rec.el.dataset['sbKind'] ?? '')) continue;
|
|
492
542
|
const animation = animationFor([rec.el, ...rec.ancestors]);
|
|
493
543
|
if (animation === false) continue;
|
|
494
|
-
this.exit(rec, animation);
|
|
544
|
+
const clone = this.exit(rec, animation);
|
|
545
|
+
const move = matchedTo.get(rec);
|
|
546
|
+
if (clone && move) this.moveClone(clone, move.from, move.to, animation);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** The matched boxes this pass inserted, with the frame of the removed box of the same id and their own. */
|
|
551
|
+
private matchedPairs(pass: PassRecord): { box: HTMLElement; rec: RemovedRecord; from: DOMRect; to: DOMRect }[] {
|
|
552
|
+
const removed = new Map<string, { rec: RemovedRecord; rect: DOMRect }>();
|
|
553
|
+
for (const rec of pass.removed) {
|
|
554
|
+
for (const m of rec.matched ?? []) removed.set(m.id, { rec, rect: m.rect });
|
|
495
555
|
}
|
|
556
|
+
if (removed.size === 0) return [];
|
|
557
|
+
const pairs: { box: HTMLElement; rec: RemovedRecord; from: DOMRect; to: DOMRect }[] = [];
|
|
558
|
+
const base = this.rootRect();
|
|
559
|
+
for (const el of pass.inserted) {
|
|
560
|
+
if (!el.isConnected || hasAncestorIn(el, pass.inserted)) continue;
|
|
561
|
+
const boxes = el.dataset['sbMatched'] !== undefined ? [el] : [];
|
|
562
|
+
boxes.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
|
|
563
|
+
for (const box of boxes) {
|
|
564
|
+
const source = removed.get(box.dataset['sbMatched']!);
|
|
565
|
+
const to = source ? this.rectOf(box, base) : null;
|
|
566
|
+
if (source && to) pairs.push({ box, rec: source.rec, from: source.rect, to });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return pairs;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* `.contentTransition`: a copy of the old text at its old place leaves
|
|
574
|
+
* (upward for `numericText`, downward when counting down) while the new
|
|
575
|
+
* text comes in from the other side; `opacity` crossfades in place.
|
|
576
|
+
*/
|
|
577
|
+
transitionContent(change: ContentChange, animation: Animation | undefined): void {
|
|
578
|
+
const clone = change.clone;
|
|
579
|
+
clone.classList.add(EXIT_CLONE_CLASS);
|
|
580
|
+
clone.setAttribute('aria-hidden', 'true');
|
|
581
|
+
clone.toggleAttribute('inert', true);
|
|
582
|
+
const s = clone.style;
|
|
583
|
+
s.position = 'absolute';
|
|
584
|
+
s.left = `${change.rect.left}px`;
|
|
585
|
+
s.top = `${change.rect.top}px`;
|
|
586
|
+
s.width = `${change.rect.width}px`;
|
|
587
|
+
s.height = `${change.rect.height}px`;
|
|
588
|
+
s.margin = '0';
|
|
589
|
+
s.pointerEvents = 'none';
|
|
590
|
+
this.root.append(clone);
|
|
591
|
+
const timing = baseTiming(timingFor(animation));
|
|
592
|
+
const rolls = change.kind === 'numericText';
|
|
593
|
+
const away = change.countsDown ? '0 45%' : '0 -45%';
|
|
594
|
+
const toward = change.countsDown ? '0 -45%' : '0 45%';
|
|
595
|
+
const out = clone.animate(
|
|
596
|
+
[
|
|
597
|
+
{ opacity: 1, translate: '0 0', filter: 'blur(0px)' },
|
|
598
|
+
{ opacity: 0, translate: rolls ? away : '0 0', filter: rolls ? 'blur(2px)' : 'blur(0px)' },
|
|
599
|
+
],
|
|
600
|
+
{ ...timing, fill: 'forwards' },
|
|
601
|
+
);
|
|
602
|
+
change.el.animate(
|
|
603
|
+
[
|
|
604
|
+
{ opacity: 0, translate: rolls ? toward : '0 0', filter: rolls ? 'blur(2px)' : 'blur(0px)' },
|
|
605
|
+
{ opacity: 1, translate: '0 0', filter: 'blur(0px)' },
|
|
606
|
+
],
|
|
607
|
+
{ ...timing, fill: 'backwards' },
|
|
608
|
+
);
|
|
609
|
+
const done = (): void => clone.remove();
|
|
610
|
+
out.finished.then(done, done);
|
|
611
|
+
window.setTimeout(done, timing.duration + timing.delay + 100);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** FLIP a matched box from the removed one's frame to its own. */
|
|
615
|
+
animateMatched(el: HTMLElement, from: DOMRect, to: DOMRect, animation: Animation | undefined): void {
|
|
616
|
+
const sx = to.width > 0 ? from.width / to.width : 1;
|
|
617
|
+
const sy = to.height > 0 ? from.height / to.height : 1;
|
|
618
|
+
const own = el.style.transform;
|
|
619
|
+
const start = `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${sx}, ${sy})`;
|
|
620
|
+
const timing = baseTiming(timingFor(animation));
|
|
621
|
+
el.animate(
|
|
622
|
+
[
|
|
623
|
+
{ transformOrigin: '0 0', transform: own ? `${start} ${own}` : start },
|
|
624
|
+
{ transformOrigin: '0 0', transform: own || 'none' },
|
|
625
|
+
],
|
|
626
|
+
{ ...timing, fill: 'backwards' },
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Move a removed box's exit clone so its matched box ends on the new frame. */
|
|
631
|
+
private moveClone(clone: HTMLElement, from: DOMRect, to: DOMRect, animation: Animation | undefined): void {
|
|
632
|
+
const sx = from.width > 0 ? to.width / from.width : 1;
|
|
633
|
+
const sy = from.height > 0 ? to.height / from.height : 1;
|
|
634
|
+
const timing = baseTiming(timingFor(animation));
|
|
635
|
+
clone.style.transformOrigin = `${from.left - parseFloat(clone.style.left || '0')}px ${from.top - parseFloat(clone.style.top || '0')}px`;
|
|
636
|
+
clone.animate(
|
|
637
|
+
[{ transform: 'none' }, { transform: `translate(${to.left - from.left}px, ${to.top - from.top}px) scale(${sx}, ${sy})` }],
|
|
638
|
+
{ ...timing, fill: 'forwards', composite: 'add' },
|
|
639
|
+
);
|
|
496
640
|
}
|
|
497
641
|
|
|
498
642
|
/** Tween every animated property whose computed value changed. */
|
|
@@ -746,3 +890,10 @@ function jumpAbove(
|
|
|
746
890
|
}
|
|
747
891
|
return x || y ? { x, y } : null;
|
|
748
892
|
}
|
|
893
|
+
|
|
894
|
+
/** The element's ancestors, nearest first. */
|
|
895
|
+
function ancestorsOf(el: HTMLElement): HTMLElement[] {
|
|
896
|
+
const out: HTMLElement[] = [];
|
|
897
|
+
for (let a = el.parentElement; a; a = a.parentElement) out.push(a);
|
|
898
|
+
return out;
|
|
899
|
+
}
|