@swiftbrowser/web 0.6.4 → 0.6.5
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 +54 -14
- package/src/largeTitle.ts +2 -2
- package/src/layoutDom.ts +9 -3
- package/src/renderer.ts +21 -5
- package/src/scrollEdge.ts +15 -11
- package/src/scrollSync.ts +56 -3
package/package.json
CHANGED
package/src/animation.ts
CHANGED
|
@@ -469,6 +469,39 @@ export class Animator {
|
|
|
469
469
|
this.playOwnFrames = options.playOwnFrames;
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
+
/** Commits waiting for the next animation frame (`commitNextFrame`), in order. */
|
|
473
|
+
private pending: { pass: PassRecord; animation: Animation | undefined; frames: readonly FrameChange[] }[] = [];
|
|
474
|
+
private pendingFrame: number | null = null;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* `commit` in the next animation frame's callback, before the frame
|
|
478
|
+
* paints. A commit reads what the pass just wrote (the updated elements'
|
|
479
|
+
* computed styles, the removed boxes' places): read in the pass, they
|
|
480
|
+
* make the browser lay its frames out once more; read in the frame's
|
|
481
|
+
* callback, they cost the frame its one layout and the tweens still start
|
|
482
|
+
* on it. In a hidden page nothing paints, so the commit runs now.
|
|
483
|
+
*/
|
|
484
|
+
commitNextFrame(pass: PassRecord, animation: Animation | undefined, frames: readonly FrameChange[] = []): void {
|
|
485
|
+
if (typeof requestAnimationFrame !== 'function' || (typeof document !== 'undefined' && document.hidden)) {
|
|
486
|
+
this.flushCommits();
|
|
487
|
+
this.commit(pass, animation, frames);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this.pending.push({ pass, animation, frames });
|
|
491
|
+
this.pendingFrame ??= requestAnimationFrame(() => this.flushCommits());
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Run the commits waiting for the next frame now, in order. */
|
|
495
|
+
flushCommits(): void {
|
|
496
|
+
if (this.pendingFrame !== null) {
|
|
497
|
+
cancelAnimationFrame(this.pendingFrame);
|
|
498
|
+
this.pendingFrame = null;
|
|
499
|
+
}
|
|
500
|
+
const pending = this.pending;
|
|
501
|
+
this.pending = [];
|
|
502
|
+
for (const { pass, animation, frames } of pending) this.commit(pass, animation, frames);
|
|
503
|
+
}
|
|
504
|
+
|
|
472
505
|
/** Computed values of the animated properties, or null when not rendered. */
|
|
473
506
|
snapshot(el: HTMLElement): StyleSnapshot | null {
|
|
474
507
|
if (!el.isConnected) return null;
|
|
@@ -558,14 +591,25 @@ export class Animator {
|
|
|
558
591
|
return false;
|
|
559
592
|
};
|
|
560
593
|
|
|
594
|
+
// The animation of the token root `el`, found `depth` levels up.
|
|
595
|
+
const rootAnimation = (el: HTMLElement, depth: number): Animation | undefined | false => {
|
|
596
|
+
// (below an item of a group the bar's own spring moves: not animated)
|
|
597
|
+
if (groupsOnly.has(el) && depth > 1) return false;
|
|
598
|
+
return tokenRoots.get(el) ?? { type: 'default' };
|
|
599
|
+
};
|
|
561
600
|
const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
|
|
562
601
|
let depth = 0;
|
|
563
602
|
for (const el of chain) {
|
|
564
|
-
if (tokenRoots.has(el))
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
603
|
+
if (tokenRoots.has(el)) return rootAnimation(el, depth);
|
|
604
|
+
depth++;
|
|
605
|
+
}
|
|
606
|
+
return commitAnimation ?? false;
|
|
607
|
+
};
|
|
608
|
+
// The same for an element in the page, up its ancestors (no chain built).
|
|
609
|
+
const animationAt = (el: HTMLElement): Animation | undefined | false => {
|
|
610
|
+
let depth = 0;
|
|
611
|
+
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
|
|
612
|
+
if (tokenRoots.has(node)) return rootAnimation(node, depth);
|
|
569
613
|
depth++;
|
|
570
614
|
}
|
|
571
615
|
return commitAnimation ?? false;
|
|
@@ -574,7 +618,7 @@ export class Animator {
|
|
|
574
618
|
// Style changes.
|
|
575
619
|
for (const [el, rec] of pass.updated) {
|
|
576
620
|
if (!rec.before || !el.isConnected || hidden(el)) continue;
|
|
577
|
-
const animation =
|
|
621
|
+
const animation = animationAt(el);
|
|
578
622
|
if (animation === false) continue;
|
|
579
623
|
this.animateChanges(el, rec.before, animation);
|
|
580
624
|
}
|
|
@@ -583,7 +627,7 @@ export class Animator {
|
|
|
583
627
|
if (this.playOwnFrames) {
|
|
584
628
|
for (const el of pass.updated.keys()) {
|
|
585
629
|
if (!el.isConnected || hidden(el)) continue;
|
|
586
|
-
const animation =
|
|
630
|
+
const animation = animationAt(el);
|
|
587
631
|
if (animation !== false) this.playOwnFrames(el, animation);
|
|
588
632
|
}
|
|
589
633
|
}
|
|
@@ -602,7 +646,7 @@ export class Animator {
|
|
|
602
646
|
this.retargetFrame(change, null);
|
|
603
647
|
continue;
|
|
604
648
|
}
|
|
605
|
-
let animation =
|
|
649
|
+
let animation = animationAt(el);
|
|
606
650
|
if (animation === false && change.kind === BAR_GLASS_KIND && glassScreens.has(el.parentElement)) {
|
|
607
651
|
animation = TOOLBAR_ANIMATION;
|
|
608
652
|
}
|
|
@@ -659,7 +703,7 @@ export class Animator {
|
|
|
659
703
|
if (!el.isConnected || pass.updated.has(el)) continue;
|
|
660
704
|
if (hasAncestorIn(el, pass.inserted) || hidden(el)) continue;
|
|
661
705
|
if (this.selfAnimating.has(el.dataset['sbKind'] ?? '')) continue;
|
|
662
|
-
const animation =
|
|
706
|
+
const animation = animationAt(el);
|
|
663
707
|
if (animation === false) continue;
|
|
664
708
|
this.enter(el, transitionOf(el) ?? implicitTransitionOf(el), animation);
|
|
665
709
|
}
|
|
@@ -667,7 +711,7 @@ export class Animator {
|
|
|
667
711
|
// Content transitions: the old text leaves as the new one arrives.
|
|
668
712
|
for (const change of pass.contentChanges) {
|
|
669
713
|
if (!change.el.isConnected || hidden(change.el)) continue;
|
|
670
|
-
const animation =
|
|
714
|
+
const animation = animationAt(change.el);
|
|
671
715
|
if (animation === false) continue;
|
|
672
716
|
this.transitionContent(change, animation);
|
|
673
717
|
}
|
|
@@ -999,10 +1043,6 @@ export function transitionOf(root: HTMLElement): Transition | undefined {
|
|
|
999
1043
|
return undefined;
|
|
1000
1044
|
}
|
|
1001
1045
|
|
|
1002
|
-
function* ancestorsAndSelf(el: HTMLElement): Generator<HTMLElement> {
|
|
1003
|
-
for (let node: HTMLElement | null = el; node; node = node.parentElement) yield node;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
1046
|
function hasAncestorIn(el: HTMLElement, set: ReadonlySet<HTMLElement>): boolean {
|
|
1007
1047
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
1008
1048
|
if (set.has(node)) return true;
|
package/src/largeTitle.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import type { Rect } from './layout';
|
|
22
22
|
import { edgeStrength, setProperty, setValue } from './scrollEdge';
|
|
23
|
-
import { runScrollSync, type ScrollSync } from './scrollSync';
|
|
23
|
+
import { runScrollSync, scrollTopOf, type ScrollSync } from './scrollSync';
|
|
24
24
|
|
|
25
25
|
/** The scroll offset past which the title has gone under the bar. */
|
|
26
26
|
const COLLAPSE_OFFSET = 44;
|
|
@@ -109,7 +109,7 @@ export function syncScrollUnderBar(scroller: HTMLElement): void {
|
|
|
109
109
|
/** The sync as a read (`scrollTop`) and the writes from it (scrollSync.ts). */
|
|
110
110
|
function scrollUnderBarSync(scroller: HTMLElement): ScrollSync<number | null> {
|
|
111
111
|
return {
|
|
112
|
-
measure: () => (scroller.dataset['sbUnderBar'] === undefined || !screenOf(scroller) ? null : scroller
|
|
112
|
+
measure: () => (scroller.dataset['sbUnderBar'] === undefined || !screenOf(scroller) ? null : scrollTopOf(scroller)),
|
|
113
113
|
apply: (offset) => {
|
|
114
114
|
const screen = screenOf(scroller);
|
|
115
115
|
if (offset === null || !screen) return;
|
package/src/layoutDom.ts
CHANGED
|
@@ -42,7 +42,7 @@ import { bitmapLaidOut } from './bitmaps';
|
|
|
42
42
|
import { drawRichText } from './richText';
|
|
43
43
|
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
|
|
44
44
|
import { bindScrollEdgeBottom } from './scrollEdge';
|
|
45
|
-
import { beginScrollSyncBatch, flushScrollSyncBatch } from './scrollSync';
|
|
45
|
+
import { beginScrollSyncBatch, flushScrollSyncBatch, type ScrollExtent } from './scrollSync';
|
|
46
46
|
import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
|
|
47
47
|
|
|
48
48
|
export interface LayoutDomContext {
|
|
@@ -696,19 +696,22 @@ function applyChrome(
|
|
|
696
696
|
case 'list': {
|
|
697
697
|
const content = el.querySelector<HTMLElement>(':scope > .sb-list-content');
|
|
698
698
|
const size = result.contentSizes.get(node.id);
|
|
699
|
+
let extent: ScrollExtent | undefined;
|
|
699
700
|
if (content && size) {
|
|
700
701
|
content.style.width = `${size.width}px`;
|
|
701
702
|
// Extra scrollable space at the end (the engine's frames are unchanged).
|
|
702
703
|
content.style.height = `${size.height + bottomInset}px`;
|
|
704
|
+
extent = { content: size.height + bottomInset, viewport: frame.height };
|
|
703
705
|
}
|
|
704
706
|
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
705
|
-
bindScrollEdgeBottom(el, chrome?.['bottomInset']);
|
|
707
|
+
bindScrollEdgeBottom(el, chrome?.['bottomInset'], extent);
|
|
706
708
|
return;
|
|
707
709
|
}
|
|
708
710
|
case 'scrollview': {
|
|
709
711
|
const content = el.querySelector<HTMLElement>(':scope > .sb-scroll-content');
|
|
710
712
|
const size = result.contentSizes.get(node.id);
|
|
711
713
|
const rect = chrome?.['content'];
|
|
714
|
+
let extent: ScrollExtent | undefined;
|
|
712
715
|
if (content && size) {
|
|
713
716
|
const s = content.style;
|
|
714
717
|
const axes = String(node.props['axes'] ?? 'vertical');
|
|
@@ -726,9 +729,12 @@ function applyChrome(
|
|
|
726
729
|
s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
|
|
727
730
|
el.style.scrollPaddingLeft = before ? `${before}px` : '';
|
|
728
731
|
el.style.scrollPaddingRight = after ? `${after}px` : '';
|
|
732
|
+
if (axes !== 'horizontal') {
|
|
733
|
+
extent = { content: (rect ? rect.y - frame.y : 0) + size.height + bottomInset, viewport: frame.height };
|
|
734
|
+
}
|
|
729
735
|
}
|
|
730
736
|
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
731
|
-
bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
|
|
737
|
+
bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset'], extent);
|
|
732
738
|
return;
|
|
733
739
|
}
|
|
734
740
|
case 'navscreen': {
|
package/src/renderer.ts
CHANGED
|
@@ -157,6 +157,7 @@ import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointer
|
|
|
157
157
|
import { colorMultiplyFilter } from './colorMultiply';
|
|
158
158
|
import { largeTitleOf } from './largeTitle';
|
|
159
159
|
import { applyEdgeEffect } from './scrollEdge';
|
|
160
|
+
import { discardScrollOffsets, readScrollOffsets, trackScroller } from './scrollSync';
|
|
160
161
|
import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
|
|
161
162
|
|
|
162
163
|
export type EventCallback = (event: RendererEvent) => void;
|
|
@@ -413,6 +414,8 @@ export class Renderer {
|
|
|
413
414
|
private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
|
|
414
415
|
/** Size last reported for every `geometry` element (whole px). */
|
|
415
416
|
private readonly reportedGeometry = new Map<ElementId, { width: number; height: number; key: string }>();
|
|
417
|
+
/** The elements whose size is reported (`geometry`, or `reportsSize` props): what `collectGeometry` visits. */
|
|
418
|
+
private readonly geometryReporters = new Set<ElementId>();
|
|
416
419
|
/** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
|
|
417
420
|
private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
|
|
418
421
|
/** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
|
|
@@ -938,11 +941,16 @@ export class Renderer {
|
|
|
938
941
|
|
|
939
942
|
/** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
|
|
940
943
|
applyOps(ops: readonly Op[]): void {
|
|
944
|
+
// The scroll views' offsets, read while nothing is waiting to be laid
|
|
945
|
+
// out: the pass's syncs use them (scrollSync.ts) instead of reading
|
|
946
|
+
// after its writes.
|
|
947
|
+
if (this.applying === 0 && ops.length > 0) readScrollOffsets();
|
|
941
948
|
this.applying += 1;
|
|
942
949
|
try {
|
|
943
950
|
this.applyAll(ops);
|
|
944
951
|
} finally {
|
|
945
952
|
this.applying -= 1;
|
|
953
|
+
if (this.applying === 0) discardScrollOffsets();
|
|
946
954
|
}
|
|
947
955
|
// Geometry events go out after the whole batch (one batch per layout
|
|
948
956
|
// pass); Swift's answer re-enters `applyOps` with the next pass.
|
|
@@ -986,7 +994,7 @@ export class Renderer {
|
|
|
986
994
|
this.preSnapshots.clear();
|
|
987
995
|
this.preRects.clear();
|
|
988
996
|
const changes = this.layout();
|
|
989
|
-
this.animator.
|
|
997
|
+
this.animator.commitNextFrame(pass, op.animation, changes);
|
|
990
998
|
this.commits += 1;
|
|
991
999
|
this.syncFocus();
|
|
992
1000
|
this.performScrolls(op.animation != null);
|
|
@@ -1087,6 +1095,7 @@ export class Renderer {
|
|
|
1087
1095
|
this.preRects.clear();
|
|
1088
1096
|
this.closeMenu();
|
|
1089
1097
|
this.reportedGeometry.clear();
|
|
1098
|
+
this.geometryReporters.clear();
|
|
1090
1099
|
this.layoutReporter.clear();
|
|
1091
1100
|
this.pendingGeometry = [];
|
|
1092
1101
|
refreshSheets(this.root);
|
|
@@ -1260,8 +1269,9 @@ export class Renderer {
|
|
|
1260
1269
|
*/
|
|
1261
1270
|
private collectGeometry(result: LayoutResult, framesOnly = false): void {
|
|
1262
1271
|
let rootRect: DOMRect | null = null;
|
|
1263
|
-
for (const
|
|
1264
|
-
|
|
1272
|
+
for (const id of this.geometryReporters) {
|
|
1273
|
+
const node = this.nodes.get(id);
|
|
1274
|
+
if (!node) continue;
|
|
1265
1275
|
const reportsFrame = node.props['reportsFrame'] === true;
|
|
1266
1276
|
if (framesOnly && !reportsFrame) continue;
|
|
1267
1277
|
const frame = result.frames.get(id);
|
|
@@ -1522,8 +1532,10 @@ export class Renderer {
|
|
|
1522
1532
|
el.dataset[KIND_ATTR] = kind;
|
|
1523
1533
|
this.elements.set(id, el);
|
|
1524
1534
|
this.nodes.set(id, { id, kind, props: asRecord(props), children: [] });
|
|
1535
|
+
if (kind === 'geometry' || (props as { reportsSize?: unknown }).reportsSize === true) this.geometryReporters.add(id);
|
|
1525
1536
|
applyProps(el, kind, props, this.assets);
|
|
1526
1537
|
this.noteScroll(id, kind, props);
|
|
1538
|
+
if (kind === 'list' || kind === 'scrollview') trackScroller(el);
|
|
1527
1539
|
if (kind === 'lazyspacer') this.lazySpacers.add(id);
|
|
1528
1540
|
if (kind === 'host') {
|
|
1529
1541
|
this.hosts.add(id);
|
|
@@ -1647,8 +1659,11 @@ export class Renderer {
|
|
|
1647
1659
|
// Another Swift view now reads this element's size (a `geometry`
|
|
1648
1660
|
// element kept for a new page's reader): it knows no size yet, so the
|
|
1649
1661
|
// next layout pass reports the size even when it is unchanged.
|
|
1650
|
-
if (
|
|
1651
|
-
this.
|
|
1662
|
+
if (kind === 'geometry' || next['reportsSize'] === true) {
|
|
1663
|
+
this.geometryReporters.add(id);
|
|
1664
|
+
if (next['reader'] !== node.props['reader']) this.reportedGeometry.delete(id);
|
|
1665
|
+
} else {
|
|
1666
|
+
this.geometryReporters.delete(id);
|
|
1652
1667
|
}
|
|
1653
1668
|
node.props = next;
|
|
1654
1669
|
// Phase 9: the next layout re-measures this subtree and its ancestors;
|
|
@@ -1883,6 +1898,7 @@ export class Renderer {
|
|
|
1883
1898
|
this.boxes.delete(id);
|
|
1884
1899
|
this.dirty.delete(id);
|
|
1885
1900
|
this.reportedGeometry.delete(id);
|
|
1901
|
+
this.geometryReporters.delete(id);
|
|
1886
1902
|
this.layoutReporter.forget(id);
|
|
1887
1903
|
const node = this.nodes.get(id);
|
|
1888
1904
|
const parentId = this.parents.get(id);
|
package/src/scrollEdge.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import type { EdgeEffectProps } from './protocol';
|
|
19
19
|
import type { Rect } from './layout';
|
|
20
|
-
import { runScrollSync, type ScrollSync } from './scrollSync';
|
|
20
|
+
import { runScrollSync, scrollTopOf, type ScrollExtent, type ScrollSync } from './scrollSync';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* How far content has to be under a bar for its edge effect to be at full
|
|
@@ -59,9 +59,10 @@ function ownerOf(scroller: HTMLElement): HTMLElement | null {
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Called for every list and scroll view the layout reaches with the engine's
|
|
62
|
-
* `bottomInset` rect (absent when no bar covers its bottom)
|
|
62
|
+
* `bottomInset` rect (absent when no bar covers its bottom) and the extent
|
|
63
|
+
* the pass gave it, so the sync needs no scroll metric the writes change.
|
|
63
64
|
*/
|
|
64
|
-
export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined): void {
|
|
65
|
+
export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined, extent?: ScrollExtent): void {
|
|
65
66
|
const owner = ownerOf(scroller);
|
|
66
67
|
if (covered === undefined || covered.height <= 0) {
|
|
67
68
|
if (scroller.dataset['sbUnderBottom'] !== undefined) delete scroller.dataset['sbUnderBottom'];
|
|
@@ -74,20 +75,23 @@ export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | unde
|
|
|
74
75
|
bound.add(scroller);
|
|
75
76
|
scroller.addEventListener('scroll', () => syncScrollEdgeBottom(scroller), { passive: true });
|
|
76
77
|
}
|
|
77
|
-
syncScrollEdgeBottom(scroller);
|
|
78
|
+
syncScrollEdgeBottom(scroller, extent);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
export function syncScrollEdgeBottom(scroller: HTMLElement): void {
|
|
81
|
-
runScrollSync(scrollEdgeBottomSync(scroller));
|
|
81
|
+
export function syncScrollEdgeBottom(scroller: HTMLElement, extent?: ScrollExtent): void {
|
|
82
|
+
runScrollSync(scrollEdgeBottomSync(scroller, extent));
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/** The sync as a read (the scroll metrics) and the writes from it (scrollSync.ts). */
|
|
85
|
-
function scrollEdgeBottomSync(scroller: HTMLElement): ScrollSync<number | null> {
|
|
86
|
+
function scrollEdgeBottomSync(scroller: HTMLElement, extent: ScrollExtent | undefined): ScrollSync<number | null> {
|
|
87
|
+
// Content is under the bar until the end has scrolled clear of it (the
|
|
88
|
+
// inset is room past the content's end). From a layout pass the lengths
|
|
89
|
+
// are the pass's own; from a scroll event they are read.
|
|
90
|
+
const remaining = extent
|
|
91
|
+
? () => Math.max(extent.content, extent.viewport) - extent.viewport - scrollTopOf(scroller)
|
|
92
|
+
: () => scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
|
|
86
93
|
return {
|
|
87
|
-
|
|
88
|
-
// inset is room past the content's end).
|
|
89
|
-
measure: () =>
|
|
90
|
-
scroller.dataset['sbUnderBottom'] === undefined || !ownerOf(scroller) ? null : scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop,
|
|
94
|
+
measure: () => (scroller.dataset['sbUnderBottom'] === undefined || !ownerOf(scroller) ? null : remaining()),
|
|
91
95
|
apply: (remaining) => applyScrollEdgeBottom(scroller, remaining),
|
|
92
96
|
};
|
|
93
97
|
}
|
package/src/scrollSync.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scroll-state syncs batched into one read phase.
|
|
2
|
+
* Scroll-state syncs batched into one read phase, before the pass writes.
|
|
3
3
|
*
|
|
4
4
|
* Binding a scroll view's under-bar and bottom-edge state (largeTitle.ts,
|
|
5
5
|
* scrollEdge.ts) reads its scroll metrics and writes flags from them. Done
|
|
6
6
|
* in the middle of a layout pass, each read follows DOM writes and forces
|
|
7
7
|
* the browser to lay out again (six times on a split-view toggle with three
|
|
8
8
|
* lists). While a batch is open the syncs are queued instead, and `flush`
|
|
9
|
-
* measures all of them before applying any
|
|
9
|
+
* measures all of them before applying any.
|
|
10
|
+
*
|
|
11
|
+
* The one metric the pass cannot know, the scroll offset, is read for every
|
|
12
|
+
* scroll view when the batch opens, before any frame is written: a frame
|
|
13
|
+
* does not move the offset, so the read costs the layout of what the ops
|
|
14
|
+
* changed rather than of every frame the pass applied. (A pass that makes
|
|
15
|
+
* the content shorter than the offset has the browser clamp it afterwards,
|
|
16
|
+
* which fires `scroll`, and the sync on it catches up.) The extents the
|
|
17
|
+
* frames change are the pass's own numbers (`ScrollExtent`).
|
|
10
18
|
*/
|
|
11
19
|
|
|
12
20
|
export interface ScrollSync<T> {
|
|
@@ -14,7 +22,46 @@ export interface ScrollSync<T> {
|
|
|
14
22
|
apply(value: T): void;
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
/** A scroll view's content and viewport lengths on its axis, as the pass sets them. */
|
|
26
|
+
export interface ScrollExtent {
|
|
27
|
+
content: number;
|
|
28
|
+
viewport: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
let batch: ScrollSync<unknown>[] | null = null;
|
|
32
|
+
const scrollers = new Set<HTMLElement>();
|
|
33
|
+
let offsets: Map<HTMLElement, number> | null = null;
|
|
34
|
+
|
|
35
|
+
/** Reads this scroll view's offset before each pass (a view made in a pass starts at 0). */
|
|
36
|
+
export function trackScroller(scroller: HTMLElement): void {
|
|
37
|
+
scrollers.add(scroller);
|
|
38
|
+
if (offsets && !offsets.has(scroller)) offsets.set(scroller, 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reads every tracked scroll view's offset: the renderer calls this before a
|
|
43
|
+
* pass's first op touches the page, where nothing is waiting to be laid out.
|
|
44
|
+
*/
|
|
45
|
+
export function readScrollOffsets(): void {
|
|
46
|
+
offsets = new Map();
|
|
47
|
+
for (const scroller of scrollers) {
|
|
48
|
+
if (!scroller.isConnected) {
|
|
49
|
+
scrollers.delete(scroller);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
offsets.set(scroller, scroller.scrollTop);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Forgets the offsets read for a pass (a pass that did not lay out). */
|
|
57
|
+
export function discardScrollOffsets(): void {
|
|
58
|
+
offsets = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The scroll offset read when the batch opened, else read now. */
|
|
62
|
+
export function scrollTopOf(scroller: HTMLElement): number {
|
|
63
|
+
return offsets?.get(scroller) ?? scroller.scrollTop;
|
|
64
|
+
}
|
|
18
65
|
|
|
19
66
|
/** Run `sync` now, or queue it while a batch is open. */
|
|
20
67
|
export function runScrollSync<T>(sync: ScrollSync<T>): void {
|
|
@@ -22,15 +69,21 @@ export function runScrollSync<T>(sync: ScrollSync<T>): void {
|
|
|
22
69
|
else sync.apply(sync.measure());
|
|
23
70
|
}
|
|
24
71
|
|
|
72
|
+
/** Opens a batch, reading the offsets now unless the pass read them before its writes. */
|
|
25
73
|
export function beginScrollSyncBatch(): void {
|
|
26
74
|
batch ??= [];
|
|
75
|
+
if (!offsets) readScrollOffsets();
|
|
27
76
|
}
|
|
28
77
|
|
|
29
78
|
/** Measure every queued sync, then apply them all; closes the batch. */
|
|
30
79
|
export function flushScrollSyncBatch(): void {
|
|
31
80
|
const queued = batch;
|
|
32
81
|
batch = null;
|
|
33
|
-
if (!queued)
|
|
82
|
+
if (!queued) {
|
|
83
|
+
discardScrollOffsets();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
34
86
|
const values = queued.map((sync) => sync.measure());
|
|
87
|
+
discardScrollOffsets();
|
|
35
88
|
queued.forEach((sync, i) => sync.apply(values[i]));
|
|
36
89
|
}
|