@swiftbrowser/web 0.6.3 → 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 +91 -20
- package/src/interaction.ts +3 -0
- package/src/largeTitle.ts +19 -10
- package/src/layout/engine.ts +33 -1
- package/src/layoutDom.ts +58 -10
- package/src/renderer.ts +88 -10
- package/src/scrollEdge.ts +25 -9
- package/src/scrollSync.ts +89 -0
- package/src/styles.css +2 -0
package/package.json
CHANGED
package/src/animation.ts
CHANGED
|
@@ -116,6 +116,11 @@ export interface FrameChange {
|
|
|
116
116
|
to: FrameRect;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** A screen below the top of its navigation stack (the renderer marks it). */
|
|
120
|
+
const BELOW_SCREEN = '.sb-navscreen[data-sb-nav-position="below"]';
|
|
121
|
+
/** A push or pop in progress in a stack: its top screen sliding in, or a popped screen's clone sliding out. */
|
|
122
|
+
const NAV_IN_FLIGHT = ':scope > .sb-slot > .sb-navscreen--push, :scope > .sb-navscreen--pop';
|
|
123
|
+
|
|
119
124
|
export const DEFAULT_DURATION = 0.35;
|
|
120
125
|
export const EASE_IN_OUT = 'cubic-bezier(0.42, 0, 0.58, 1)';
|
|
121
126
|
export const EASE_IN = 'cubic-bezier(0.42, 0, 1, 1)';
|
|
@@ -464,6 +469,39 @@ export class Animator {
|
|
|
464
469
|
this.playOwnFrames = options.playOwnFrames;
|
|
465
470
|
}
|
|
466
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
|
+
|
|
467
505
|
/** Computed values of the animated properties, or null when not rendered. */
|
|
468
506
|
snapshot(el: HTMLElement): StyleSnapshot | null {
|
|
469
507
|
if (!el.isConnected) return null;
|
|
@@ -534,14 +572,44 @@ export class Animator {
|
|
|
534
572
|
const matchedPairs = this.matchedPairs(pass);
|
|
535
573
|
const matchedTo = new Map<RemovedRecord, { from: DOMRect; to: DOMRect }>();
|
|
536
574
|
|
|
575
|
+
// What a screen below the top of its stack holds is not drawn once the
|
|
576
|
+
// push has settled (styles.css: `visibility: hidden`), so it is not
|
|
577
|
+
// animated: it jumps to its new boxes, as the rows of a list a pushed
|
|
578
|
+
// screen covers do when a split view's sidebar slides. During a push or
|
|
579
|
+
// pop the lower screen shows, sliding, so then it animates as usual.
|
|
580
|
+
const hiddenScreens = new Map<HTMLElement, boolean>();
|
|
581
|
+
const hidden = (el: HTMLElement): boolean => {
|
|
582
|
+
for (let screen = el.closest<HTMLElement>(BELOW_SCREEN); screen; screen = screen.parentElement?.closest<HTMLElement>(BELOW_SCREEN) ?? null) {
|
|
583
|
+
let settled = hiddenScreens.get(screen);
|
|
584
|
+
if (settled === undefined) {
|
|
585
|
+
const stack = screen.closest<HTMLElement>('.sb-navstack');
|
|
586
|
+
settled = stack !== null && stack.querySelector(NAV_IN_FLIGHT) === null;
|
|
587
|
+
hiddenScreens.set(screen, settled);
|
|
588
|
+
}
|
|
589
|
+
if (settled) return true;
|
|
590
|
+
}
|
|
591
|
+
return false;
|
|
592
|
+
};
|
|
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
|
+
};
|
|
537
600
|
const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
|
|
538
601
|
let depth = 0;
|
|
539
602
|
for (const el of chain) {
|
|
540
|
-
if (tokenRoots.has(el))
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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);
|
|
545
613
|
depth++;
|
|
546
614
|
}
|
|
547
615
|
return commitAnimation ?? false;
|
|
@@ -549,8 +617,8 @@ export class Animator {
|
|
|
549
617
|
|
|
550
618
|
// Style changes.
|
|
551
619
|
for (const [el, rec] of pass.updated) {
|
|
552
|
-
if (!rec.before || !el.isConnected) continue;
|
|
553
|
-
const animation =
|
|
620
|
+
if (!rec.before || !el.isConnected || hidden(el)) continue;
|
|
621
|
+
const animation = animationAt(el);
|
|
554
622
|
if (animation === false) continue;
|
|
555
623
|
this.animateChanges(el, rec.before, animation);
|
|
556
624
|
}
|
|
@@ -558,8 +626,8 @@ export class Animator {
|
|
|
558
626
|
// What elements animate themselves (a custom shape's path).
|
|
559
627
|
if (this.playOwnFrames) {
|
|
560
628
|
for (const el of pass.updated.keys()) {
|
|
561
|
-
if (!el.isConnected) continue;
|
|
562
|
-
const animation =
|
|
629
|
+
if (!el.isConnected || hidden(el)) continue;
|
|
630
|
+
const animation = animationAt(el);
|
|
563
631
|
if (animation !== false) this.playOwnFrames(el, animation);
|
|
564
632
|
}
|
|
565
633
|
}
|
|
@@ -572,7 +640,13 @@ export class Animator {
|
|
|
572
640
|
const { el, from, to } = change;
|
|
573
641
|
if (!from || !el.isConnected || pass.inserted.has(el)) continue;
|
|
574
642
|
if (this.selfAnimating.has(change.kind)) continue;
|
|
575
|
-
|
|
643
|
+
if (hidden(el)) {
|
|
644
|
+
// Not drawn: a tween still running on it (from a pass before the
|
|
645
|
+
// push settled) carries on to the new box; otherwise it jumps.
|
|
646
|
+
this.retargetFrame(change, null);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
let animation = animationAt(el);
|
|
576
650
|
if (animation === false && change.kind === BAR_GLASS_KIND && glassScreens.has(el.parentElement)) {
|
|
577
651
|
animation = TOOLBAR_ANIMATION;
|
|
578
652
|
}
|
|
@@ -590,8 +664,9 @@ export class Animator {
|
|
|
590
664
|
// change still moves with an ancestor that jumped.
|
|
591
665
|
const changed = new Set(frames.map((change) => change.el));
|
|
592
666
|
for (const [el, animation] of tokenRoots) {
|
|
593
|
-
if (changed.has(el) || !el.isConnected || pass.inserted.has(el)) continue;
|
|
594
|
-
|
|
667
|
+
if (changed.has(el) || !el.isConnected || pass.inserted.has(el) || hidden(el)) continue;
|
|
668
|
+
// (the inline frame the layout applied: no layout read mid-commit)
|
|
669
|
+
const box = { x: parseFloat(el.style.left) || 0, y: parseFloat(el.style.top) || 0, width: parseFloat(el.style.width) || 0, height: parseFloat(el.style.height) || 0 };
|
|
595
670
|
tweens.push({ change: { el, kind: el.dataset['sbKind'] ?? '', from: box, to: box }, from: box, animation: animation ?? { type: 'default' }, moved: false });
|
|
596
671
|
}
|
|
597
672
|
// An animated element under an ancestor that jumps (outside the animated
|
|
@@ -626,17 +701,17 @@ export class Animator {
|
|
|
626
701
|
// Inserted elements: only subtree roots (a nested fade would compound).
|
|
627
702
|
for (const el of pass.inserted) {
|
|
628
703
|
if (!el.isConnected || pass.updated.has(el)) continue;
|
|
629
|
-
if (hasAncestorIn(el, pass.inserted)) continue;
|
|
704
|
+
if (hasAncestorIn(el, pass.inserted) || hidden(el)) continue;
|
|
630
705
|
if (this.selfAnimating.has(el.dataset['sbKind'] ?? '')) continue;
|
|
631
|
-
const animation =
|
|
706
|
+
const animation = animationAt(el);
|
|
632
707
|
if (animation === false) continue;
|
|
633
708
|
this.enter(el, transitionOf(el) ?? implicitTransitionOf(el), animation);
|
|
634
709
|
}
|
|
635
710
|
|
|
636
711
|
// Content transitions: the old text leaves as the new one arrives.
|
|
637
712
|
for (const change of pass.contentChanges) {
|
|
638
|
-
if (!change.el.isConnected) continue;
|
|
639
|
-
const animation =
|
|
713
|
+
if (!change.el.isConnected || hidden(change.el)) continue;
|
|
714
|
+
const animation = animationAt(change.el);
|
|
640
715
|
if (animation === false) continue;
|
|
641
716
|
this.transitionContent(change, animation);
|
|
642
717
|
}
|
|
@@ -968,10 +1043,6 @@ export function transitionOf(root: HTMLElement): Transition | undefined {
|
|
|
968
1043
|
return undefined;
|
|
969
1044
|
}
|
|
970
1045
|
|
|
971
|
-
function* ancestorsAndSelf(el: HTMLElement): Generator<HTMLElement> {
|
|
972
|
-
for (let node: HTMLElement | null = el; node; node = node.parentElement) yield node;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
1046
|
function hasAncestorIn(el: HTMLElement, set: ReadonlySet<HTMLElement>): boolean {
|
|
976
1047
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
977
1048
|
if (set.has(node)) return true;
|
package/src/interaction.ts
CHANGED
|
@@ -97,6 +97,8 @@ export interface InteractionHost {
|
|
|
97
97
|
nodeOf(id: ElementId): LayoutNode | undefined;
|
|
98
98
|
/** Open the popover of a `contextmenu` element anchored to it. */
|
|
99
99
|
openContextMenu(anchor: HTMLElement, id: ElementId): void;
|
|
100
|
+
/** A covered screen is about to show (a back swipe starts): lay it out if it is stale. */
|
|
101
|
+
revealScreen(screen: HTMLElement): void;
|
|
100
102
|
/** Whether the popover of element `id` is open. */
|
|
101
103
|
isMenuOpen(id: ElementId): boolean;
|
|
102
104
|
closeMenu(): void;
|
|
@@ -550,6 +552,7 @@ export class InteractionController {
|
|
|
550
552
|
c.screen.dataset['sbSwipeDragging'] = '';
|
|
551
553
|
c.screen.style.transition = 'none';
|
|
552
554
|
if (c.below) {
|
|
555
|
+
this.host.revealScreen(c.below);
|
|
553
556
|
c.below.style.transition = 'none';
|
|
554
557
|
c.below.style.visibility = 'visible';
|
|
555
558
|
}
|
package/src/largeTitle.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import type { Rect } from './layout';
|
|
22
22
|
import { edgeStrength, setProperty, setValue } from './scrollEdge';
|
|
23
|
+
import { runScrollSync, scrollTopOf, type ScrollSync } from './scrollSync';
|
|
23
24
|
|
|
24
25
|
/** The scroll offset past which the title has gone under the bar. */
|
|
25
26
|
const COLLAPSE_OFFSET = 44;
|
|
@@ -102,16 +103,24 @@ function releaseTitle(screen: HTMLElement, scroller: HTMLElement): void {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
export function syncScrollUnderBar(scroller: HTMLElement): void {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
106
|
+
runScrollSync(scrollUnderBarSync(scroller));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The sync as a read (`scrollTop`) and the writes from it (scrollSync.ts). */
|
|
110
|
+
function scrollUnderBarSync(scroller: HTMLElement): ScrollSync<number | null> {
|
|
111
|
+
return {
|
|
112
|
+
measure: () => (scroller.dataset['sbUnderBar'] === undefined || !screenOf(scroller) ? null : scrollTopOf(scroller)),
|
|
113
|
+
apply: (offset) => {
|
|
114
|
+
const screen = screenOf(scroller);
|
|
115
|
+
if (offset === null || !screen) return;
|
|
116
|
+
setFlag(screen, 'sbScrolled', offset > 0);
|
|
117
|
+
// The edge effect's strength ramps up over the first `EDGE_RAMP` px.
|
|
118
|
+
setProperty(screen, '--sb-edge-top', edgeStrength(offset));
|
|
119
|
+
const title = titles.get(screen);
|
|
120
|
+
const carries = title !== undefined && title.parentElement === scroller;
|
|
121
|
+
setFlag(screen, 'sbCollapsed', carries && offset > COLLAPSE_OFFSET);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
function setFlag(el: HTMLElement, key: string, on: boolean): void {
|
package/src/layout/engine.ts
CHANGED
|
@@ -1228,6 +1228,8 @@ export class LayoutPass {
|
|
|
1228
1228
|
private readonly rootContext: Context;
|
|
1229
1229
|
/** Phase 9: where (and for what proposal) every node was last placed. */
|
|
1230
1230
|
private readonly placed = new Map<LayoutNode, { key: string; x: number; y: number }>();
|
|
1231
|
+
/** Covered screens to lay out anyway (`uncover`): one a back swipe is about to show. */
|
|
1232
|
+
private readonly uncovered = new Set<LayoutNode>();
|
|
1231
1233
|
/** Phase 9: the ids each node hid when it was last placed (menu items, tabs, ...). */
|
|
1232
1234
|
private readonly hiddenBy = new Map<LayoutNode, number[]>();
|
|
1233
1235
|
/** Phase 9: ids placed or hidden in the current `run`; what the DOM pass must visit. */
|
|
@@ -1389,9 +1391,22 @@ export class LayoutPass {
|
|
|
1389
1391
|
for (const child of node.children) this.invalidateDown(child);
|
|
1390
1392
|
}
|
|
1391
1393
|
|
|
1394
|
+
/**
|
|
1395
|
+
* A covered screen is about to show without a pop (a back swipe drags
|
|
1396
|
+
* the top screen aside): lay it out on the next run even though it is
|
|
1397
|
+
* covered, in case the stack resized or its content changed meanwhile.
|
|
1398
|
+
*/
|
|
1399
|
+
uncover(screen: LayoutNode): void {
|
|
1400
|
+
this.uncovered.add(screen);
|
|
1401
|
+
// The run must reach the stack: its ancestors' plans go (not the
|
|
1402
|
+
// screen's own, so one nothing changed under is still skipped as placed).
|
|
1403
|
+
for (let current = this.parentOf(screen); current; current = this.parentOf(current)) this.forgetPlan(current);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1392
1406
|
/** `node` left the tree: drop everything its subtree produced. */
|
|
1393
1407
|
remove(node: LayoutNode): void {
|
|
1394
1408
|
this.forgetPlan(node);
|
|
1409
|
+
this.uncovered.delete(node);
|
|
1395
1410
|
if (node.id >= 0) {
|
|
1396
1411
|
this.frames.delete(node.id);
|
|
1397
1412
|
this.text.delete(node.id);
|
|
@@ -2996,7 +3011,24 @@ export class LayoutPass {
|
|
|
2996
3011
|
const previous = index > 0 ? node.children[index - 1] : undefined;
|
|
2997
3012
|
this.previousTitles.set(child, previous ? (str(previous.props['title']) ?? '') : '');
|
|
2998
3013
|
});
|
|
2999
|
-
|
|
3014
|
+
// A screen below the top one is not drawn once its push has settled
|
|
3015
|
+
// (the page hides it), so a screen that was laid out once keeps the
|
|
3016
|
+
// frames it has while it is covered, however the stack resizes or
|
|
3017
|
+
// its content changes meanwhile: on Food Truck's order detail the
|
|
3018
|
+
// covered Orders list is 89% of the boxes. Its plan is left as it
|
|
3019
|
+
// is, so when a pop uncovers it, `placedAlready` passes only if
|
|
3020
|
+
// nothing changed since those frames were made, else it is laid
|
|
3021
|
+
// out afresh. A screen never laid out (pushed under another in one
|
|
3022
|
+
// pass) is placed, so it has frames to show when uncovered.
|
|
3023
|
+
const top = node.children[node.children.length - 1];
|
|
3024
|
+
for (const child of node.children) {
|
|
3025
|
+
if (child !== top && child.id >= 0 && this.frames.has(child.id) && !this.uncovered.has(child)) {
|
|
3026
|
+
this.stats.skipped += 1;
|
|
3027
|
+
continue;
|
|
3028
|
+
}
|
|
3029
|
+
if (child === top) this.uncovered.delete(child);
|
|
3030
|
+
this.place(child, cctx, screenProposal, x, y);
|
|
3031
|
+
}
|
|
3000
3032
|
return;
|
|
3001
3033
|
}
|
|
3002
3034
|
case 'navscreen':
|
package/src/layoutDom.ts
CHANGED
|
@@ -42,6 +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, type ScrollExtent } from './scrollSync';
|
|
45
46
|
import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
|
|
46
47
|
|
|
47
48
|
export interface LayoutDomContext {
|
|
@@ -80,8 +81,31 @@ const SWITCH_SIZE = { width: 51, height: 31 };
|
|
|
80
81
|
const CHEVRON_SIZE = 14;
|
|
81
82
|
const NAV_BAR_HEIGHT = 44;
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The changes of the layout being applied, for `placeChrome` to report the
|
|
86
|
+
* chrome it moves (null outside `applyLayout`).
|
|
87
|
+
*/
|
|
88
|
+
let chromeChanges: BoxChange[] | null = null;
|
|
89
|
+
|
|
90
|
+
/** Kind of a chrome node's `BoxChange` (no id): tweened with its element's pass. */
|
|
91
|
+
export const CHROME_KIND = 'chrome';
|
|
92
|
+
|
|
83
93
|
export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxChange[] {
|
|
84
94
|
const changes: BoxChange[] = [];
|
|
95
|
+
chromeChanges = changes;
|
|
96
|
+
// The scroll views' state syncs read scroll metrics; batched, they share
|
|
97
|
+
// one layout after the pass's writes instead of forcing one each.
|
|
98
|
+
beginScrollSyncBatch();
|
|
99
|
+
try {
|
|
100
|
+
applyFrames(ctx, result, changes);
|
|
101
|
+
} finally {
|
|
102
|
+
chromeChanges = null;
|
|
103
|
+
flushScrollSyncBatch();
|
|
104
|
+
}
|
|
105
|
+
return changes;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxChange[]): void {
|
|
85
109
|
// Phase 9: an incremental result names what it touched; everything else
|
|
86
110
|
// kept its frame, chrome and box. A one-off result is visited whole.
|
|
87
111
|
const boxes = result.changed ? ctx.boxes : new Map<number, Rect>();
|
|
@@ -132,7 +156,6 @@ export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxCha
|
|
|
132
156
|
|
|
133
157
|
ctx.boxes = boxes;
|
|
134
158
|
ctx.dirty.clear();
|
|
135
|
-
return changes;
|
|
136
159
|
}
|
|
137
160
|
|
|
138
161
|
const LIST_ROW_MIN_HEIGHT = 44;
|
|
@@ -307,7 +330,7 @@ function syncBarGlass(
|
|
|
307
330
|
changes.push({ id: -1, el: glass, kind: BAR_GLASS_KIND, from: null, to: relative });
|
|
308
331
|
}
|
|
309
332
|
glass.dataset['sbToolbars'] = ids.join(' ');
|
|
310
|
-
placeChrome(glass, rect, frame);
|
|
333
|
+
placeChrome(glass, rect, frame, false);
|
|
311
334
|
}
|
|
312
335
|
for (const extra of unmatched) transitionGlass(extra, true);
|
|
313
336
|
}
|
|
@@ -346,19 +369,31 @@ function transitionGlass(glass: HTMLElement, exit: boolean): void {
|
|
|
346
369
|
window.setTimeout(done, timing.duration + timing.delay + 100);
|
|
347
370
|
}
|
|
348
371
|
|
|
349
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Position a chrome node inside its element: `rect` is in screen coordinates.
|
|
374
|
+
* A node that was shown and moves or resizes is reported (kind `chrome`), so
|
|
375
|
+
* an animated pass tweens it with the boxes around it: a section's card, a
|
|
376
|
+
* row's badge or chevron follow their row as a split view's column widens,
|
|
377
|
+
* instead of jumping to the end (`report` false: the caller reports it).
|
|
378
|
+
*/
|
|
379
|
+
function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect, report = true): void {
|
|
350
380
|
if (!node) return;
|
|
351
381
|
if (!rect) {
|
|
352
382
|
node.hidden = true;
|
|
353
383
|
return;
|
|
354
384
|
}
|
|
355
|
-
node.hidden = false;
|
|
356
385
|
const s = node.style;
|
|
386
|
+
const to = { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height };
|
|
387
|
+
if (report && chromeChanges && !node.hidden && s.position === 'absolute') {
|
|
388
|
+
const from = { x: parseFloat(s.left) || 0, y: parseFloat(s.top) || 0, width: parseFloat(s.width) || 0, height: parseFloat(s.height) || 0 };
|
|
389
|
+
if (!sameRect(from, to)) chromeChanges.push({ id: -1, el: node, kind: CHROME_KIND, from, to });
|
|
390
|
+
}
|
|
391
|
+
node.hidden = false;
|
|
357
392
|
s.position = 'absolute';
|
|
358
|
-
s.left = `${
|
|
359
|
-
s.top = `${
|
|
360
|
-
s.width = `${
|
|
361
|
-
s.height = `${
|
|
393
|
+
s.left = `${to.x}px`;
|
|
394
|
+
s.top = `${to.y}px`;
|
|
395
|
+
s.width = `${to.width}px`;
|
|
396
|
+
s.height = `${to.height}px`;
|
|
362
397
|
}
|
|
363
398
|
|
|
364
399
|
function positionSheetCard(sheet: HTMLElement, frame: Rect): void {
|
|
@@ -661,19 +696,22 @@ function applyChrome(
|
|
|
661
696
|
case 'list': {
|
|
662
697
|
const content = el.querySelector<HTMLElement>(':scope > .sb-list-content');
|
|
663
698
|
const size = result.contentSizes.get(node.id);
|
|
699
|
+
let extent: ScrollExtent | undefined;
|
|
664
700
|
if (content && size) {
|
|
665
701
|
content.style.width = `${size.width}px`;
|
|
666
702
|
// Extra scrollable space at the end (the engine's frames are unchanged).
|
|
667
703
|
content.style.height = `${size.height + bottomInset}px`;
|
|
704
|
+
extent = { content: size.height + bottomInset, viewport: frame.height };
|
|
668
705
|
}
|
|
669
706
|
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
670
|
-
bindScrollEdgeBottom(el, chrome?.['bottomInset']);
|
|
707
|
+
bindScrollEdgeBottom(el, chrome?.['bottomInset'], extent);
|
|
671
708
|
return;
|
|
672
709
|
}
|
|
673
710
|
case 'scrollview': {
|
|
674
711
|
const content = el.querySelector<HTMLElement>(':scope > .sb-scroll-content');
|
|
675
712
|
const size = result.contentSizes.get(node.id);
|
|
676
713
|
const rect = chrome?.['content'];
|
|
714
|
+
let extent: ScrollExtent | undefined;
|
|
677
715
|
if (content && size) {
|
|
678
716
|
const s = content.style;
|
|
679
717
|
const axes = String(node.props['axes'] ?? 'vertical');
|
|
@@ -691,9 +729,12 @@ function applyChrome(
|
|
|
691
729
|
s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
|
|
692
730
|
el.style.scrollPaddingLeft = before ? `${before}px` : '';
|
|
693
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
|
+
}
|
|
694
735
|
}
|
|
695
736
|
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
696
|
-
bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
|
|
737
|
+
bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset'], extent);
|
|
697
738
|
return;
|
|
698
739
|
}
|
|
699
740
|
case 'navscreen': {
|
|
@@ -754,6 +795,13 @@ function applyChrome(
|
|
|
754
795
|
// The bar pads its row 16 (styles.css); a split view's sidebar
|
|
755
796
|
// toggle can push the back button further in (chrome.back).
|
|
756
797
|
const indent = barRect ? Math.max(0, backRect.x - barRect.x - 16) : 0;
|
|
798
|
+
const was = parseFloat(back.style.marginLeft) || 0;
|
|
799
|
+
// A split view's sidebar sliding in or out moves it in with the
|
|
800
|
+
// column (tweened as an offset: it is laid out in the bar's row).
|
|
801
|
+
if (indent !== was && !isNew && chromeChanges) {
|
|
802
|
+
const size = { y: 0, width: backRect.width, height: backRect.height };
|
|
803
|
+
chromeChanges.push({ id: -1, el: back, kind: CHROME_KIND, from: { x: was, ...size }, to: { x: indent, ...size } });
|
|
804
|
+
}
|
|
757
805
|
back.style.marginLeft = indent > 0 ? `${indent}px` : '';
|
|
758
806
|
backLabel.dataset['sbLayoutLabel'] = text;
|
|
759
807
|
if (backLabel.textContent !== text) backLabel.textContent = text;
|
package/src/renderer.ts
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
transitionOf,
|
|
44
44
|
type PassRecord,
|
|
45
45
|
type UpdatedRecord,
|
|
46
|
-
} from './animation';
|
|
46
|
+
type StyleSnapshot } from './animation';
|
|
47
47
|
import {
|
|
48
48
|
CanvasTextMeasurer,
|
|
49
49
|
LayoutPass,
|
|
@@ -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;
|
|
@@ -340,6 +341,14 @@ export class Renderer {
|
|
|
340
341
|
private passAnimates: boolean | null = null;
|
|
341
342
|
/** The animation root's rectangle, read once per pass that animates. */
|
|
342
343
|
private passRootRect: DOMRect | null = null;
|
|
344
|
+
/**
|
|
345
|
+
* What the Animator needs from before the pass, read in one go before its
|
|
346
|
+
* first write (`prereadPass`): the style snapshots of the elements its
|
|
347
|
+
* updates change and the rectangles of those its removes take out. Read
|
|
348
|
+
* op by op, between writes, each forced a style recalculation or layout.
|
|
349
|
+
*/
|
|
350
|
+
private readonly preSnapshots = new Map<HTMLElement, StyleSnapshot | null>();
|
|
351
|
+
private readonly preRects = new Map<HTMLElement, { rect: DOMRect | null; matched: { id: string; el: HTMLElement; rect: DOMRect }[] }>();
|
|
343
352
|
/**
|
|
344
353
|
* The root's size as the engine last read it; null when it must be read
|
|
345
354
|
* again. Reading it after a pass changed the DOM forces a style and layout
|
|
@@ -405,6 +414,8 @@ export class Renderer {
|
|
|
405
414
|
private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
|
|
406
415
|
/** Size last reported for every `geometry` element (whole px). */
|
|
407
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>();
|
|
408
419
|
/** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
|
|
409
420
|
private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
|
|
410
421
|
/** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
|
|
@@ -820,6 +831,7 @@ export class Renderer {
|
|
|
820
831
|
send: (event) => this.onEvent(event),
|
|
821
832
|
nodeOf: (id) => this.nodes.get(id),
|
|
822
833
|
openContextMenu: (anchor, id) => this.openContextMenu(anchor, id),
|
|
834
|
+
revealScreen: (screen) => this.revealScreen(screen),
|
|
823
835
|
isMenuOpen: (id) => this.menu?.anchorId === id,
|
|
824
836
|
closeMenu: () => this.closeMenu(),
|
|
825
837
|
};
|
|
@@ -929,11 +941,16 @@ export class Renderer {
|
|
|
929
941
|
|
|
930
942
|
/** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
|
|
931
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();
|
|
932
948
|
this.applying += 1;
|
|
933
949
|
try {
|
|
934
950
|
this.applyAll(ops);
|
|
935
951
|
} finally {
|
|
936
952
|
this.applying -= 1;
|
|
953
|
+
if (this.applying === 0) discardScrollOffsets();
|
|
937
954
|
}
|
|
938
955
|
// Geometry events go out after the whole batch (one batch per layout
|
|
939
956
|
// pass); Swift's answer re-enters `applyOps` with the next pass.
|
|
@@ -943,7 +960,10 @@ export class Renderer {
|
|
|
943
960
|
private applyAll(ops: readonly Op[]): void {
|
|
944
961
|
for (let index = 0; index < ops.length; index++) {
|
|
945
962
|
const op = ops[index]!;
|
|
946
|
-
if (this.passAnimates === null)
|
|
963
|
+
if (this.passAnimates === null) {
|
|
964
|
+
this.passAnimates = this.passCanAnimate(ops, index);
|
|
965
|
+
if (this.passAnimates) this.prereadPass(ops, index);
|
|
966
|
+
}
|
|
947
967
|
switch (op.op) {
|
|
948
968
|
case 'create':
|
|
949
969
|
this.create(op.id, op.kind, op.props, op);
|
|
@@ -971,8 +991,10 @@ export class Renderer {
|
|
|
971
991
|
this.pass = emptyPass();
|
|
972
992
|
this.passAnimates = null;
|
|
973
993
|
this.passRootRect = null;
|
|
994
|
+
this.preSnapshots.clear();
|
|
995
|
+
this.preRects.clear();
|
|
974
996
|
const changes = this.layout();
|
|
975
|
-
this.animator.
|
|
997
|
+
this.animator.commitNextFrame(pass, op.animation, changes);
|
|
976
998
|
this.commits += 1;
|
|
977
999
|
this.syncFocus();
|
|
978
1000
|
this.performScrolls(op.animation != null);
|
|
@@ -1011,6 +1033,48 @@ export class Renderer {
|
|
|
1011
1033
|
return true;
|
|
1012
1034
|
}
|
|
1013
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* A covered screen is about to show without a pop (a back swipe): the
|
|
1038
|
+
* engine leaves covered screens as they were, so lay this one out now if
|
|
1039
|
+
* the stack resized or its content changed while it was covered.
|
|
1040
|
+
*/
|
|
1041
|
+
private revealScreen(screen: HTMLElement): void {
|
|
1042
|
+
const id = Number(screen.dataset[ID_ATTR]);
|
|
1043
|
+
const node = Number.isInteger(id) ? this.nodes.get(id) : undefined;
|
|
1044
|
+
if (!node || !this.engine) return;
|
|
1045
|
+
this.engine.uncover(node);
|
|
1046
|
+
this.layout();
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Before a pass that can animate writes anything: snapshot the styles of
|
|
1051
|
+
* the elements its updates will change and read the rectangles of those
|
|
1052
|
+
* its removes will take out, so the reads share the one clean layout
|
|
1053
|
+
* instead of each forcing a new one between the writes.
|
|
1054
|
+
*/
|
|
1055
|
+
private prereadPass(ops: readonly Op[], start: number): void {
|
|
1056
|
+
for (let index = start; index < ops.length; index++) {
|
|
1057
|
+
const op = ops[index]!;
|
|
1058
|
+
if (op.op === 'commit') return;
|
|
1059
|
+
if (op.op === 'update') {
|
|
1060
|
+
const el = this.elements.get(op.id);
|
|
1061
|
+
if (el && !this.preSnapshots.has(el)) this.preSnapshots.set(el, this.animator.snapshot(el));
|
|
1062
|
+
} else if (op.op === 'remove') {
|
|
1063
|
+
const el = this.elements.get(op.id);
|
|
1064
|
+
if (!el || !el.parentElement || this.preRects.has(el)) continue;
|
|
1065
|
+
this.passRootRect ??= this.animator.rootRect();
|
|
1066
|
+
const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
|
|
1067
|
+
const candidates = el.dataset['sbMatched'] !== undefined ? [el] : [];
|
|
1068
|
+
candidates.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
|
|
1069
|
+
for (const box of candidates) {
|
|
1070
|
+
const boxRect = this.animator.rectOf(box, this.passRootRect);
|
|
1071
|
+
if (boxRect) matched.push({ id: box.dataset['sbMatched']!, el: box, rect: boxRect });
|
|
1072
|
+
}
|
|
1073
|
+
this.preRects.set(el, { rect: this.animator.rectOf(el, this.passRootRect), matched });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1014
1078
|
/** Detach everything under the root and forget all non-root elements. */
|
|
1015
1079
|
reset(): void {
|
|
1016
1080
|
stopVideos(this.root);
|
|
@@ -1027,8 +1091,11 @@ export class Renderer {
|
|
|
1027
1091
|
this.pass = emptyPass();
|
|
1028
1092
|
this.passAnimates = null;
|
|
1029
1093
|
this.passRootRect = null;
|
|
1094
|
+
this.preSnapshots.clear();
|
|
1095
|
+
this.preRects.clear();
|
|
1030
1096
|
this.closeMenu();
|
|
1031
1097
|
this.reportedGeometry.clear();
|
|
1098
|
+
this.geometryReporters.clear();
|
|
1032
1099
|
this.layoutReporter.clear();
|
|
1033
1100
|
this.pendingGeometry = [];
|
|
1034
1101
|
refreshSheets(this.root);
|
|
@@ -1202,8 +1269,9 @@ export class Renderer {
|
|
|
1202
1269
|
*/
|
|
1203
1270
|
private collectGeometry(result: LayoutResult, framesOnly = false): void {
|
|
1204
1271
|
let rootRect: DOMRect | null = null;
|
|
1205
|
-
for (const
|
|
1206
|
-
|
|
1272
|
+
for (const id of this.geometryReporters) {
|
|
1273
|
+
const node = this.nodes.get(id);
|
|
1274
|
+
if (!node) continue;
|
|
1207
1275
|
const reportsFrame = node.props['reportsFrame'] === true;
|
|
1208
1276
|
if (framesOnly && !reportsFrame) continue;
|
|
1209
1277
|
const frame = result.frames.get(id);
|
|
@@ -1464,8 +1532,10 @@ export class Renderer {
|
|
|
1464
1532
|
el.dataset[KIND_ATTR] = kind;
|
|
1465
1533
|
this.elements.set(id, el);
|
|
1466
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);
|
|
1467
1536
|
applyProps(el, kind, props, this.assets);
|
|
1468
1537
|
this.noteScroll(id, kind, props);
|
|
1538
|
+
if (kind === 'list' || kind === 'scrollview') trackScroller(el);
|
|
1469
1539
|
if (kind === 'lazyspacer') this.lazySpacers.add(id);
|
|
1470
1540
|
if (kind === 'host') {
|
|
1471
1541
|
this.hosts.add(id);
|
|
@@ -1589,8 +1659,11 @@ export class Renderer {
|
|
|
1589
1659
|
// Another Swift view now reads this element's size (a `geometry`
|
|
1590
1660
|
// element kept for a new page's reader): it knows no size yet, so the
|
|
1591
1661
|
// next layout pass reports the size even when it is unchanged.
|
|
1592
|
-
if (
|
|
1593
|
-
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);
|
|
1594
1667
|
}
|
|
1595
1668
|
node.props = next;
|
|
1596
1669
|
// Phase 9: the next layout re-measures this subtree and its ancestors;
|
|
@@ -1604,7 +1677,7 @@ export class Renderer {
|
|
|
1604
1677
|
}
|
|
1605
1678
|
// Remember how the element looked before, in case the pass is animated.
|
|
1606
1679
|
const previous = this.pass.updated.get(el);
|
|
1607
|
-
const before = previous ? previous.before : this.passAnimates !== false ? this.animator.snapshot(el) : null;
|
|
1680
|
+
const before = previous ? previous.before : this.preSnapshots.has(el) ? this.preSnapshots.get(el)! : this.passAnimates !== false ? this.animator.snapshot(el) : null;
|
|
1608
1681
|
const oldToken = el.dataset[ANIMATION_TOKEN];
|
|
1609
1682
|
// `update` replaces the props entirely, so drop whatever the previous
|
|
1610
1683
|
// props produced before applying the new ones. The layout box is kept:
|
|
@@ -1771,10 +1844,14 @@ export class Renderer {
|
|
|
1771
1844
|
const ancestors: HTMLElement[] = [];
|
|
1772
1845
|
for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
|
|
1773
1846
|
let rect: DOMRect | null = null;
|
|
1774
|
-
|
|
1847
|
+
let matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
|
|
1848
|
+
const preread = this.preRects.get(el);
|
|
1775
1849
|
// A bar toolbar's items (and the toolbar) leave with a transition of
|
|
1776
1850
|
// their own in any pass (src/animation.ts, `implicitToolbars`).
|
|
1777
|
-
if (
|
|
1851
|
+
if (preread) {
|
|
1852
|
+
rect = preread.rect;
|
|
1853
|
+
matched = preread.matched;
|
|
1854
|
+
} else if (this.passAnimates !== false || barToolbarOf(el, parent)) {
|
|
1778
1855
|
this.passRootRect ??= this.animator.rootRect();
|
|
1779
1856
|
rect = this.animator.rectOf(el, this.passRootRect);
|
|
1780
1857
|
// `.matchedGeometryEffect`: where the matched boxes in the subtree were.
|
|
@@ -1821,6 +1898,7 @@ export class Renderer {
|
|
|
1821
1898
|
this.boxes.delete(id);
|
|
1822
1899
|
this.dirty.delete(id);
|
|
1823
1900
|
this.reportedGeometry.delete(id);
|
|
1901
|
+
this.geometryReporters.delete(id);
|
|
1824
1902
|
this.layoutReporter.forget(id);
|
|
1825
1903
|
const node = this.nodes.get(id);
|
|
1826
1904
|
const parentId = this.parents.get(id);
|
package/src/scrollEdge.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import type { EdgeEffectProps } from './protocol';
|
|
19
19
|
import type { Rect } from './layout';
|
|
20
|
+
import { runScrollSync, scrollTopOf, type ScrollExtent, type ScrollSync } from './scrollSync';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* How far content has to be under a bar for its edge effect to be at full
|
|
@@ -58,9 +59,10 @@ function ownerOf(scroller: HTMLElement): HTMLElement | null {
|
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* Called for every list and scroll view the layout reaches with the engine's
|
|
61
|
-
* `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.
|
|
62
64
|
*/
|
|
63
|
-
export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined): void {
|
|
65
|
+
export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | undefined, extent?: ScrollExtent): void {
|
|
64
66
|
const owner = ownerOf(scroller);
|
|
65
67
|
if (covered === undefined || covered.height <= 0) {
|
|
66
68
|
if (scroller.dataset['sbUnderBottom'] !== undefined) delete scroller.dataset['sbUnderBottom'];
|
|
@@ -73,17 +75,31 @@ export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | unde
|
|
|
73
75
|
bound.add(scroller);
|
|
74
76
|
scroller.addEventListener('scroll', () => syncScrollEdgeBottom(scroller), { passive: true });
|
|
75
77
|
}
|
|
76
|
-
syncScrollEdgeBottom(scroller);
|
|
78
|
+
syncScrollEdgeBottom(scroller, extent);
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
export function syncScrollEdgeBottom(scroller: HTMLElement): void {
|
|
80
|
-
|
|
81
|
+
export function syncScrollEdgeBottom(scroller: HTMLElement, extent?: ScrollExtent): void {
|
|
82
|
+
runScrollSync(scrollEdgeBottomSync(scroller, extent));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The sync as a read (the scroll metrics) and the writes from it (scrollSync.ts). */
|
|
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;
|
|
93
|
+
return {
|
|
94
|
+
measure: () => (scroller.dataset['sbUnderBottom'] === undefined || !ownerOf(scroller) ? null : remaining()),
|
|
95
|
+
apply: (remaining) => applyScrollEdgeBottom(scroller, remaining),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyScrollEdgeBottom(scroller: HTMLElement, remaining: number | null): void {
|
|
81
100
|
const owner = ownerOf(scroller);
|
|
82
|
-
if (!owner) return;
|
|
101
|
+
if (remaining === null || !owner) return;
|
|
83
102
|
claimedBy.set(owner, scroller);
|
|
84
|
-
// Content is under the bar until the end has scrolled clear of it (the
|
|
85
|
-
// inset is room past the content's end).
|
|
86
|
-
const remaining = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
|
|
87
103
|
setValue(owner, 'sbScrolledBottom', remaining > 1 ? '' : null);
|
|
88
104
|
setProperty(owner, '--sb-edge-bottom', edgeStrength(remaining));
|
|
89
105
|
setValue(owner, 'sbEdgeBottom', scroller.dataset['sbEdgeBottom'] ?? null);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-state syncs batched into one read phase, before the pass writes.
|
|
3
|
+
*
|
|
4
|
+
* Binding a scroll view's under-bar and bottom-edge state (largeTitle.ts,
|
|
5
|
+
* scrollEdge.ts) reads its scroll metrics and writes flags from them. Done
|
|
6
|
+
* in the middle of a layout pass, each read follows DOM writes and forces
|
|
7
|
+
* the browser to lay out again (six times on a split-view toggle with three
|
|
8
|
+
* lists). While a batch is open the syncs are queued instead, and `flush`
|
|
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`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface ScrollSync<T> {
|
|
21
|
+
measure(): T;
|
|
22
|
+
apply(value: T): void;
|
|
23
|
+
}
|
|
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
|
+
|
|
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
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Run `sync` now, or queue it while a batch is open. */
|
|
67
|
+
export function runScrollSync<T>(sync: ScrollSync<T>): void {
|
|
68
|
+
if (batch) batch.push(sync as ScrollSync<unknown>);
|
|
69
|
+
else sync.apply(sync.measure());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Opens a batch, reading the offsets now unless the pass read them before its writes. */
|
|
73
|
+
export function beginScrollSyncBatch(): void {
|
|
74
|
+
batch ??= [];
|
|
75
|
+
if (!offsets) readScrollOffsets();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Measure every queued sync, then apply them all; closes the batch. */
|
|
79
|
+
export function flushScrollSyncBatch(): void {
|
|
80
|
+
const queued = batch;
|
|
81
|
+
batch = null;
|
|
82
|
+
if (!queued) {
|
|
83
|
+
discardScrollOffsets();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const values = queued.map((sync) => sync.measure());
|
|
87
|
+
discardScrollOffsets();
|
|
88
|
+
queued.forEach((sync, i) => sync.apply(values[i]));
|
|
89
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -1059,6 +1059,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1059
1059
|
|
|
1060
1060
|
.sb-navbar-back {
|
|
1061
1061
|
appearance: none;
|
|
1062
|
+
/* Offset by a `left` tween while a split view's sidebar slides (layoutDom). */
|
|
1063
|
+
position: relative;
|
|
1062
1064
|
grid-column: 1;
|
|
1063
1065
|
justify-self: start;
|
|
1064
1066
|
display: inline-flex;
|