@swiftbrowser/web 0.6.2 → 0.6.4
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 +37 -6
- package/src/interaction.ts +3 -0
- package/src/largeTitle.ts +19 -10
- package/src/layout/engine.ts +33 -1
- package/src/layoutDom.ts +50 -8
- package/src/renderer.ts +67 -5
- package/src/scrollEdge.ts +17 -5
- package/src/scrollSync.ts +36 -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)';
|
|
@@ -534,6 +539,25 @@ export class Animator {
|
|
|
534
539
|
const matchedPairs = this.matchedPairs(pass);
|
|
535
540
|
const matchedTo = new Map<RemovedRecord, { from: DOMRect; to: DOMRect }>();
|
|
536
541
|
|
|
542
|
+
// What a screen below the top of its stack holds is not drawn once the
|
|
543
|
+
// push has settled (styles.css: `visibility: hidden`), so it is not
|
|
544
|
+
// animated: it jumps to its new boxes, as the rows of a list a pushed
|
|
545
|
+
// screen covers do when a split view's sidebar slides. During a push or
|
|
546
|
+
// pop the lower screen shows, sliding, so then it animates as usual.
|
|
547
|
+
const hiddenScreens = new Map<HTMLElement, boolean>();
|
|
548
|
+
const hidden = (el: HTMLElement): boolean => {
|
|
549
|
+
for (let screen = el.closest<HTMLElement>(BELOW_SCREEN); screen; screen = screen.parentElement?.closest<HTMLElement>(BELOW_SCREEN) ?? null) {
|
|
550
|
+
let settled = hiddenScreens.get(screen);
|
|
551
|
+
if (settled === undefined) {
|
|
552
|
+
const stack = screen.closest<HTMLElement>('.sb-navstack');
|
|
553
|
+
settled = stack !== null && stack.querySelector(NAV_IN_FLIGHT) === null;
|
|
554
|
+
hiddenScreens.set(screen, settled);
|
|
555
|
+
}
|
|
556
|
+
if (settled) return true;
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
};
|
|
560
|
+
|
|
537
561
|
const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
|
|
538
562
|
let depth = 0;
|
|
539
563
|
for (const el of chain) {
|
|
@@ -549,7 +573,7 @@ export class Animator {
|
|
|
549
573
|
|
|
550
574
|
// Style changes.
|
|
551
575
|
for (const [el, rec] of pass.updated) {
|
|
552
|
-
if (!rec.before || !el.isConnected) continue;
|
|
576
|
+
if (!rec.before || !el.isConnected || hidden(el)) continue;
|
|
553
577
|
const animation = animationFor(ancestorsAndSelf(el));
|
|
554
578
|
if (animation === false) continue;
|
|
555
579
|
this.animateChanges(el, rec.before, animation);
|
|
@@ -558,7 +582,7 @@ export class Animator {
|
|
|
558
582
|
// What elements animate themselves (a custom shape's path).
|
|
559
583
|
if (this.playOwnFrames) {
|
|
560
584
|
for (const el of pass.updated.keys()) {
|
|
561
|
-
if (!el.isConnected) continue;
|
|
585
|
+
if (!el.isConnected || hidden(el)) continue;
|
|
562
586
|
const animation = animationFor(ancestorsAndSelf(el));
|
|
563
587
|
if (animation !== false) this.playOwnFrames(el, animation);
|
|
564
588
|
}
|
|
@@ -572,6 +596,12 @@ export class Animator {
|
|
|
572
596
|
const { el, from, to } = change;
|
|
573
597
|
if (!from || !el.isConnected || pass.inserted.has(el)) continue;
|
|
574
598
|
if (this.selfAnimating.has(change.kind)) continue;
|
|
599
|
+
if (hidden(el)) {
|
|
600
|
+
// Not drawn: a tween still running on it (from a pass before the
|
|
601
|
+
// push settled) carries on to the new box; otherwise it jumps.
|
|
602
|
+
this.retargetFrame(change, null);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
575
605
|
let animation = animationFor(ancestorsAndSelf(el));
|
|
576
606
|
if (animation === false && change.kind === BAR_GLASS_KIND && glassScreens.has(el.parentElement)) {
|
|
577
607
|
animation = TOOLBAR_ANIMATION;
|
|
@@ -590,8 +620,9 @@ export class Animator {
|
|
|
590
620
|
// change still moves with an ancestor that jumped.
|
|
591
621
|
const changed = new Set(frames.map((change) => change.el));
|
|
592
622
|
for (const [el, animation] of tokenRoots) {
|
|
593
|
-
if (changed.has(el) || !el.isConnected || pass.inserted.has(el)) continue;
|
|
594
|
-
|
|
623
|
+
if (changed.has(el) || !el.isConnected || pass.inserted.has(el) || hidden(el)) continue;
|
|
624
|
+
// (the inline frame the layout applied: no layout read mid-commit)
|
|
625
|
+
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
626
|
tweens.push({ change: { el, kind: el.dataset['sbKind'] ?? '', from: box, to: box }, from: box, animation: animation ?? { type: 'default' }, moved: false });
|
|
596
627
|
}
|
|
597
628
|
// An animated element under an ancestor that jumps (outside the animated
|
|
@@ -626,7 +657,7 @@ export class Animator {
|
|
|
626
657
|
// Inserted elements: only subtree roots (a nested fade would compound).
|
|
627
658
|
for (const el of pass.inserted) {
|
|
628
659
|
if (!el.isConnected || pass.updated.has(el)) continue;
|
|
629
|
-
if (hasAncestorIn(el, pass.inserted)) continue;
|
|
660
|
+
if (hasAncestorIn(el, pass.inserted) || hidden(el)) continue;
|
|
630
661
|
if (this.selfAnimating.has(el.dataset['sbKind'] ?? '')) continue;
|
|
631
662
|
const animation = animationFor(ancestorsAndSelf(el));
|
|
632
663
|
if (animation === false) continue;
|
|
@@ -635,7 +666,7 @@ export class Animator {
|
|
|
635
666
|
|
|
636
667
|
// Content transitions: the old text leaves as the new one arrives.
|
|
637
668
|
for (const change of pass.contentChanges) {
|
|
638
|
-
if (!change.el.isConnected) continue;
|
|
669
|
+
if (!change.el.isConnected || hidden(change.el)) continue;
|
|
639
670
|
const animation = animationFor(ancestorsAndSelf(change.el));
|
|
640
671
|
if (animation === false) continue;
|
|
641
672
|
this.transitionContent(change, animation);
|
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, 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 : scroller.scrollTop),
|
|
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 } 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 {
|
|
@@ -754,6 +789,13 @@ function applyChrome(
|
|
|
754
789
|
// The bar pads its row 16 (styles.css); a split view's sidebar
|
|
755
790
|
// toggle can push the back button further in (chrome.back).
|
|
756
791
|
const indent = barRect ? Math.max(0, backRect.x - barRect.x - 16) : 0;
|
|
792
|
+
const was = parseFloat(back.style.marginLeft) || 0;
|
|
793
|
+
// A split view's sidebar sliding in or out moves it in with the
|
|
794
|
+
// column (tweened as an offset: it is laid out in the bar's row).
|
|
795
|
+
if (indent !== was && !isNew && chromeChanges) {
|
|
796
|
+
const size = { y: 0, width: backRect.width, height: backRect.height };
|
|
797
|
+
chromeChanges.push({ id: -1, el: back, kind: CHROME_KIND, from: { x: was, ...size }, to: { x: indent, ...size } });
|
|
798
|
+
}
|
|
757
799
|
back.style.marginLeft = indent > 0 ? `${indent}px` : '';
|
|
758
800
|
backLabel.dataset['sbLayoutLabel'] = text;
|
|
759
801
|
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,
|
|
@@ -340,6 +340,14 @@ export class Renderer {
|
|
|
340
340
|
private passAnimates: boolean | null = null;
|
|
341
341
|
/** The animation root's rectangle, read once per pass that animates. */
|
|
342
342
|
private passRootRect: DOMRect | null = null;
|
|
343
|
+
/**
|
|
344
|
+
* What the Animator needs from before the pass, read in one go before its
|
|
345
|
+
* first write (`prereadPass`): the style snapshots of the elements its
|
|
346
|
+
* updates change and the rectangles of those its removes take out. Read
|
|
347
|
+
* op by op, between writes, each forced a style recalculation or layout.
|
|
348
|
+
*/
|
|
349
|
+
private readonly preSnapshots = new Map<HTMLElement, StyleSnapshot | null>();
|
|
350
|
+
private readonly preRects = new Map<HTMLElement, { rect: DOMRect | null; matched: { id: string; el: HTMLElement; rect: DOMRect }[] }>();
|
|
343
351
|
/**
|
|
344
352
|
* The root's size as the engine last read it; null when it must be read
|
|
345
353
|
* again. Reading it after a pass changed the DOM forces a style and layout
|
|
@@ -820,6 +828,7 @@ export class Renderer {
|
|
|
820
828
|
send: (event) => this.onEvent(event),
|
|
821
829
|
nodeOf: (id) => this.nodes.get(id),
|
|
822
830
|
openContextMenu: (anchor, id) => this.openContextMenu(anchor, id),
|
|
831
|
+
revealScreen: (screen) => this.revealScreen(screen),
|
|
823
832
|
isMenuOpen: (id) => this.menu?.anchorId === id,
|
|
824
833
|
closeMenu: () => this.closeMenu(),
|
|
825
834
|
};
|
|
@@ -943,7 +952,10 @@ export class Renderer {
|
|
|
943
952
|
private applyAll(ops: readonly Op[]): void {
|
|
944
953
|
for (let index = 0; index < ops.length; index++) {
|
|
945
954
|
const op = ops[index]!;
|
|
946
|
-
if (this.passAnimates === null)
|
|
955
|
+
if (this.passAnimates === null) {
|
|
956
|
+
this.passAnimates = this.passCanAnimate(ops, index);
|
|
957
|
+
if (this.passAnimates) this.prereadPass(ops, index);
|
|
958
|
+
}
|
|
947
959
|
switch (op.op) {
|
|
948
960
|
case 'create':
|
|
949
961
|
this.create(op.id, op.kind, op.props, op);
|
|
@@ -971,6 +983,8 @@ export class Renderer {
|
|
|
971
983
|
this.pass = emptyPass();
|
|
972
984
|
this.passAnimates = null;
|
|
973
985
|
this.passRootRect = null;
|
|
986
|
+
this.preSnapshots.clear();
|
|
987
|
+
this.preRects.clear();
|
|
974
988
|
const changes = this.layout();
|
|
975
989
|
this.animator.commit(pass, op.animation, changes);
|
|
976
990
|
this.commits += 1;
|
|
@@ -1011,6 +1025,48 @@ export class Renderer {
|
|
|
1011
1025
|
return true;
|
|
1012
1026
|
}
|
|
1013
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* A covered screen is about to show without a pop (a back swipe): the
|
|
1030
|
+
* engine leaves covered screens as they were, so lay this one out now if
|
|
1031
|
+
* the stack resized or its content changed while it was covered.
|
|
1032
|
+
*/
|
|
1033
|
+
private revealScreen(screen: HTMLElement): void {
|
|
1034
|
+
const id = Number(screen.dataset[ID_ATTR]);
|
|
1035
|
+
const node = Number.isInteger(id) ? this.nodes.get(id) : undefined;
|
|
1036
|
+
if (!node || !this.engine) return;
|
|
1037
|
+
this.engine.uncover(node);
|
|
1038
|
+
this.layout();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Before a pass that can animate writes anything: snapshot the styles of
|
|
1043
|
+
* the elements its updates will change and read the rectangles of those
|
|
1044
|
+
* its removes will take out, so the reads share the one clean layout
|
|
1045
|
+
* instead of each forcing a new one between the writes.
|
|
1046
|
+
*/
|
|
1047
|
+
private prereadPass(ops: readonly Op[], start: number): void {
|
|
1048
|
+
for (let index = start; index < ops.length; index++) {
|
|
1049
|
+
const op = ops[index]!;
|
|
1050
|
+
if (op.op === 'commit') return;
|
|
1051
|
+
if (op.op === 'update') {
|
|
1052
|
+
const el = this.elements.get(op.id);
|
|
1053
|
+
if (el && !this.preSnapshots.has(el)) this.preSnapshots.set(el, this.animator.snapshot(el));
|
|
1054
|
+
} else if (op.op === 'remove') {
|
|
1055
|
+
const el = this.elements.get(op.id);
|
|
1056
|
+
if (!el || !el.parentElement || this.preRects.has(el)) continue;
|
|
1057
|
+
this.passRootRect ??= this.animator.rootRect();
|
|
1058
|
+
const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
|
|
1059
|
+
const candidates = el.dataset['sbMatched'] !== undefined ? [el] : [];
|
|
1060
|
+
candidates.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
|
|
1061
|
+
for (const box of candidates) {
|
|
1062
|
+
const boxRect = this.animator.rectOf(box, this.passRootRect);
|
|
1063
|
+
if (boxRect) matched.push({ id: box.dataset['sbMatched']!, el: box, rect: boxRect });
|
|
1064
|
+
}
|
|
1065
|
+
this.preRects.set(el, { rect: this.animator.rectOf(el, this.passRootRect), matched });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1014
1070
|
/** Detach everything under the root and forget all non-root elements. */
|
|
1015
1071
|
reset(): void {
|
|
1016
1072
|
stopVideos(this.root);
|
|
@@ -1027,6 +1083,8 @@ export class Renderer {
|
|
|
1027
1083
|
this.pass = emptyPass();
|
|
1028
1084
|
this.passAnimates = null;
|
|
1029
1085
|
this.passRootRect = null;
|
|
1086
|
+
this.preSnapshots.clear();
|
|
1087
|
+
this.preRects.clear();
|
|
1030
1088
|
this.closeMenu();
|
|
1031
1089
|
this.reportedGeometry.clear();
|
|
1032
1090
|
this.layoutReporter.clear();
|
|
@@ -1604,7 +1662,7 @@ export class Renderer {
|
|
|
1604
1662
|
}
|
|
1605
1663
|
// Remember how the element looked before, in case the pass is animated.
|
|
1606
1664
|
const previous = this.pass.updated.get(el);
|
|
1607
|
-
const before = previous ? previous.before : this.passAnimates !== false ? this.animator.snapshot(el) : null;
|
|
1665
|
+
const before = previous ? previous.before : this.preSnapshots.has(el) ? this.preSnapshots.get(el)! : this.passAnimates !== false ? this.animator.snapshot(el) : null;
|
|
1608
1666
|
const oldToken = el.dataset[ANIMATION_TOKEN];
|
|
1609
1667
|
// `update` replaces the props entirely, so drop whatever the previous
|
|
1610
1668
|
// props produced before applying the new ones. The layout box is kept:
|
|
@@ -1771,10 +1829,14 @@ export class Renderer {
|
|
|
1771
1829
|
const ancestors: HTMLElement[] = [];
|
|
1772
1830
|
for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
|
|
1773
1831
|
let rect: DOMRect | null = null;
|
|
1774
|
-
|
|
1832
|
+
let matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
|
|
1833
|
+
const preread = this.preRects.get(el);
|
|
1775
1834
|
// A bar toolbar's items (and the toolbar) leave with a transition of
|
|
1776
1835
|
// their own in any pass (src/animation.ts, `implicitToolbars`).
|
|
1777
|
-
if (
|
|
1836
|
+
if (preread) {
|
|
1837
|
+
rect = preread.rect;
|
|
1838
|
+
matched = preread.matched;
|
|
1839
|
+
} else if (this.passAnimates !== false || barToolbarOf(el, parent)) {
|
|
1778
1840
|
this.passRootRect ??= this.animator.rootRect();
|
|
1779
1841
|
rect = this.animator.rectOf(el, this.passRootRect);
|
|
1780
1842
|
// `.matchedGeometryEffect`: where the matched boxes in the subtree were.
|
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, 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
|
|
@@ -77,13 +78,24 @@ export function bindScrollEdgeBottom(scroller: HTMLElement, covered: Rect | unde
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export function syncScrollEdgeBottom(scroller: HTMLElement): void {
|
|
80
|
-
|
|
81
|
+
runScrollSync(scrollEdgeBottomSync(scroller));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The sync as a read (the scroll metrics) and the writes from it (scrollSync.ts). */
|
|
85
|
+
function scrollEdgeBottomSync(scroller: HTMLElement): ScrollSync<number | null> {
|
|
86
|
+
return {
|
|
87
|
+
// Content is under the bar until the end has scrolled clear of it (the
|
|
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,
|
|
91
|
+
apply: (remaining) => applyScrollEdgeBottom(scroller, remaining),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function applyScrollEdgeBottom(scroller: HTMLElement, remaining: number | null): void {
|
|
81
96
|
const owner = ownerOf(scroller);
|
|
82
|
-
if (!owner) return;
|
|
97
|
+
if (remaining === null || !owner) return;
|
|
83
98
|
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
99
|
setValue(owner, 'sbScrolledBottom', remaining > 1 ? '' : null);
|
|
88
100
|
setProperty(owner, '--sb-edge-bottom', edgeStrength(remaining));
|
|
89
101
|
setValue(owner, 'sbEdgeBottom', scroller.dataset['sbEdgeBottom'] ?? null);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-state syncs batched into one read phase.
|
|
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, so they share one layout.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ScrollSync<T> {
|
|
13
|
+
measure(): T;
|
|
14
|
+
apply(value: T): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let batch: ScrollSync<unknown>[] | null = null;
|
|
18
|
+
|
|
19
|
+
/** Run `sync` now, or queue it while a batch is open. */
|
|
20
|
+
export function runScrollSync<T>(sync: ScrollSync<T>): void {
|
|
21
|
+
if (batch) batch.push(sync as ScrollSync<unknown>);
|
|
22
|
+
else sync.apply(sync.measure());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function beginScrollSyncBatch(): void {
|
|
26
|
+
batch ??= [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Measure every queued sync, then apply them all; closes the batch. */
|
|
30
|
+
export function flushScrollSyncBatch(): void {
|
|
31
|
+
const queued = batch;
|
|
32
|
+
batch = null;
|
|
33
|
+
if (!queued) return;
|
|
34
|
+
const values = queued.map((sync) => sync.measure());
|
|
35
|
+
queued.forEach((sync, i) => sync.apply(values[i]));
|
|
36
|
+
}
|
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;
|