@swiftbrowser/web 0.9.11 → 0.9.13
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/browserHistory.ts +77 -5
- package/src/largeTitle.ts +14 -0
- package/src/layout/engine.ts +23 -1
- package/src/layoutDom.ts +23 -3
- package/src/protocol.ts +28 -2
- package/src/renderer.ts +118 -6
- package/src/scrollVisibility.ts +38 -0
- package/src/styles.css +27 -0
- package/src/touchPaging.ts +105 -18
package/package.json
CHANGED
package/src/browserHistory.ts
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* the back button and the scrim send, so the app's own state stays the
|
|
12
12
|
* source of truth; the page's URL is never touched, and an app that maps
|
|
13
13
|
* screens to paths should set them with `replaceState` on the entry the
|
|
14
|
-
* renderer made.
|
|
14
|
+
* renderer made. When the app pops on its own, the renderer's `history.go`
|
|
15
|
+
* back is asynchronous and the app's `replaceState` for the screen it shows
|
|
16
|
+
* now runs before the move lands, on the entry being left; the renderer
|
|
17
|
+
* applies that URL again to the entry it arrives at, so the address bar
|
|
18
|
+
* follows the bar's back button as it follows the browser's.
|
|
15
19
|
*
|
|
16
20
|
* A two-column split view pushes nothing when its sidebar's selection
|
|
17
21
|
* changes, so that is an entry of its own: choosing another sidebar row
|
|
@@ -60,6 +64,13 @@ export class BrowserHistory {
|
|
|
60
64
|
/** A `popstate` asked for the entry's sidebar selection: the change it brings is recorded, not pushed. */
|
|
61
65
|
private restoringSelection = false;
|
|
62
66
|
private enabled = false;
|
|
67
|
+
/** The page's own `replaceState`, while ours stands in for it. */
|
|
68
|
+
private pageReplaceState: History['replaceState'] | null = null;
|
|
69
|
+
private pageReplaceStateWasOwn = false;
|
|
70
|
+
/** A URL the page set while our own `history.go` was on its way: meant for the entry it lands on. */
|
|
71
|
+
private landingUrl: string | URL | null = null;
|
|
72
|
+
/** A level appeared while our own `history.go` was on its way: its entry is pushed once the move lands. */
|
|
73
|
+
private syncOnArrival = false;
|
|
63
74
|
|
|
64
75
|
constructor(
|
|
65
76
|
private readonly renderer: Renderer,
|
|
@@ -75,10 +86,15 @@ export class BrowserHistory {
|
|
|
75
86
|
this.enabled = true;
|
|
76
87
|
const current = stateOf(this.win.history.state);
|
|
77
88
|
if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
|
|
89
|
+
this.watchReplaceState();
|
|
78
90
|
this.renderer.interaction.nativeBackGesture = true;
|
|
79
91
|
this.root.addEventListener('sb:commit', this.onCommit);
|
|
80
92
|
this.win.addEventListener('popstate', this.onPopState);
|
|
81
|
-
|
|
93
|
+
// Enabled before the first commit (a mount, or a remount such as React's
|
|
94
|
+
// StrictMode makes in development) the screen is not drawn yet: fewer
|
|
95
|
+
// levels than the entry records is not the app popping, so only the
|
|
96
|
+
// levels already shown are pushed here and the first commit settles the rest.
|
|
97
|
+
this.sync(true);
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
dispose(): void {
|
|
@@ -87,10 +103,44 @@ export class BrowserHistory {
|
|
|
87
103
|
this.renderer.interaction.nativeBackGesture = false;
|
|
88
104
|
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
89
105
|
this.movingTo = null;
|
|
106
|
+
this.landingUrl = null;
|
|
107
|
+
this.syncOnArrival = false;
|
|
108
|
+
this.unwatchReplaceState();
|
|
90
109
|
this.root.removeEventListener('sb:commit', this.onCommit);
|
|
91
110
|
this.win.removeEventListener('popstate', this.onPopState);
|
|
92
111
|
}
|
|
93
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Stands in for the page's `replaceState`: it still runs at once, and a
|
|
115
|
+
* URL given while our own `go` is on its way is remembered for the entry
|
|
116
|
+
* the move lands on.
|
|
117
|
+
*/
|
|
118
|
+
private watchReplaceState(): void {
|
|
119
|
+
const history = this.win.history;
|
|
120
|
+
const original = history.replaceState;
|
|
121
|
+
this.pageReplaceState = original;
|
|
122
|
+
this.pageReplaceStateWasOwn = Object.prototype.hasOwnProperty.call(history, 'replaceState');
|
|
123
|
+
history.replaceState = (data: unknown, unused: string, url?: string | URL | null): void => {
|
|
124
|
+
if (this.movingTo !== null && url !== undefined && url !== null) this.landingUrl = url;
|
|
125
|
+
original.call(history, data, unused, url);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private unwatchReplaceState(): void {
|
|
130
|
+
const original = this.pageReplaceState;
|
|
131
|
+
if (!original) return;
|
|
132
|
+
const history = this.win.history;
|
|
133
|
+
// A browser's is the prototype's: removing ours uncovers it. A stand-in's own property is put back.
|
|
134
|
+
if (this.pageReplaceStateWasOwn) history.replaceState = original;
|
|
135
|
+
else delete (history as Partial<History>).replaceState;
|
|
136
|
+
this.pageReplaceState = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `replaceState` as the page had it, for our own entries. */
|
|
140
|
+
private replaceEntry(data: unknown, url?: string | URL | null): void {
|
|
141
|
+
(this.pageReplaceState ?? this.win.history.replaceState).call(this.win.history, data, '', url);
|
|
142
|
+
}
|
|
143
|
+
|
|
94
144
|
/** The levels above the base the app shows now: sheets, plus pushed screens in each visible stack of the top layer. */
|
|
95
145
|
depth(): number {
|
|
96
146
|
const sheets = this.sheets();
|
|
@@ -192,7 +242,13 @@ export class BrowserHistory {
|
|
|
192
242
|
this.movingTimer = setTimeout(() => {
|
|
193
243
|
if (this.movingTo === null) return;
|
|
194
244
|
this.movingTo = null;
|
|
245
|
+
// No move (a go past the ends fires nothing): the URL already sits on the entry at hand.
|
|
246
|
+
this.landingUrl = null;
|
|
195
247
|
this.ignorePops = Math.max(0, this.ignorePops - 1);
|
|
248
|
+
if (this.syncOnArrival) {
|
|
249
|
+
this.syncOnArrival = false;
|
|
250
|
+
this.sync();
|
|
251
|
+
}
|
|
196
252
|
}, 1000);
|
|
197
253
|
this.win.history.go(delta);
|
|
198
254
|
}
|
|
@@ -201,12 +257,21 @@ export class BrowserHistory {
|
|
|
201
257
|
this.movingTo = null;
|
|
202
258
|
if (this.movingTimer) clearTimeout(this.movingTimer);
|
|
203
259
|
this.movingTimer = null;
|
|
260
|
+
// The page's URL for what it shows now, set on the entry we left: it belongs here.
|
|
261
|
+
const url = this.landingUrl;
|
|
262
|
+
this.landingUrl = null;
|
|
263
|
+
if (url !== null) this.replaceEntry(this.win.history.state, url);
|
|
264
|
+
// Levels that appeared during the move get their entries now.
|
|
265
|
+
if (this.syncOnArrival) {
|
|
266
|
+
this.syncOnArrival = false;
|
|
267
|
+
this.sync();
|
|
268
|
+
}
|
|
204
269
|
}
|
|
205
270
|
|
|
206
271
|
private readonly onCommit = (): void => this.sync();
|
|
207
272
|
|
|
208
|
-
/** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. */
|
|
209
|
-
sync(): void {
|
|
273
|
+
/** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. `enabling`: nothing is drawn yet, so no level is taken as dismissed. */
|
|
274
|
+
sync(enabling = false): void {
|
|
210
275
|
if (!this.enabled) return;
|
|
211
276
|
const depth = this.depth();
|
|
212
277
|
const recorded = this.recorded();
|
|
@@ -220,8 +285,15 @@ export class BrowserHistory {
|
|
|
220
285
|
return;
|
|
221
286
|
}
|
|
222
287
|
if (depth > recorded) {
|
|
288
|
+
// Under our own pending `go`, a push would land relative to wherever the browser is when it runs (and
|
|
289
|
+
// Chrome moves back past it), leaving the app on an entry below what it shows: push once the move lands.
|
|
290
|
+
if (this.movingTo !== null) {
|
|
291
|
+
this.syncOnArrival = true;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
223
294
|
for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
|
|
224
295
|
} else if (depth < recorded) {
|
|
296
|
+
if (enabling) return;
|
|
225
297
|
// The app popped or dismissed on its own (back button, swipe, code): leave those entries.
|
|
226
298
|
this.go(depth - recorded, depth);
|
|
227
299
|
} else if (this.movingTo === null && selection !== this.recordedSelection()) {
|
|
@@ -231,7 +303,7 @@ export class BrowserHistory {
|
|
|
231
303
|
const history = this.win.history;
|
|
232
304
|
if (this.restoringSelection || !this.recordedSelection()) {
|
|
233
305
|
const { sbSelection: _, ...rest } = (history.state ?? {}) as Partial<HistoryState>;
|
|
234
|
-
|
|
306
|
+
this.replaceEntry({ ...rest, ...this.entry(depth, selection) });
|
|
235
307
|
} else history.pushState(this.entry(depth, selection), '');
|
|
236
308
|
}
|
|
237
309
|
this.restoringSelection = false;
|
package/src/largeTitle.ts
CHANGED
|
@@ -42,6 +42,20 @@ export function largeTitleOf(screen: HTMLElement): HTMLElement | null {
|
|
|
42
42
|
return title;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The large title's text: a span of its own, as the title element also
|
|
47
|
+
* holds a `.largeTitle` toolbar (which then hides the text, styles.css).
|
|
48
|
+
*/
|
|
49
|
+
export function largeTitleTextOf(title: HTMLElement): HTMLElement {
|
|
50
|
+
let text = title.querySelector<HTMLElement>(':scope > .sb-navbar-large-title-text');
|
|
51
|
+
if (!text) {
|
|
52
|
+
text = document.createElement('span');
|
|
53
|
+
text.className = 'sb-navbar-large-title-text';
|
|
54
|
+
title.prepend(text);
|
|
55
|
+
}
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/** Whether a screen's large title currently scrolls inside one of its scroll views. */
|
|
46
60
|
export function largeTitleScrolls(screen: HTMLElement, title: HTMLElement): boolean {
|
|
47
61
|
return title.parentElement !== null && !title.parentElement.classList.contains('sb-navscreen-body') && screen.contains(title);
|
package/src/layout/engine.ts
CHANGED
|
@@ -3429,7 +3429,7 @@ export class LayoutPass {
|
|
|
3429
3429
|
for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
|
|
3430
3430
|
const barToolbars = parts.toolbars.filter((t) => {
|
|
3431
3431
|
const placement = str(t.props['placement']);
|
|
3432
|
-
return placement !== 'bottom' && placement !== 'secondary' && !isSpacer(t);
|
|
3432
|
+
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && !isSpacer(t);
|
|
3433
3433
|
});
|
|
3434
3434
|
const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
|
|
3435
3435
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
@@ -3624,6 +3624,28 @@ export class LayoutPass {
|
|
|
3624
3624
|
if (!inlineSecondary.some((entry) => entry.toolbar === toolbar)) this.hide(toolbar, node);
|
|
3625
3625
|
else for (const item of items) if (collapsedSecondary.some((entry) => entry.item === item)) this.hide(item, node);
|
|
3626
3626
|
}
|
|
3627
|
+
// `.largeTitle` items (iOS 26): in the large title row, in place of the
|
|
3628
|
+
// title's text, from its leading margin and centered in the row; the
|
|
3629
|
+
// renderer moves the toolbar into the title element, so they scroll with
|
|
3630
|
+
// the content as the title does, and the bar shows the title once the row
|
|
3631
|
+
// has gone under it. While the title is inline there is no row: hidden.
|
|
3632
|
+
for (const toolbar of parts.toolbars) {
|
|
3633
|
+
if (str(toolbar.props['placement']) !== 'largeTitle' || isSpacer(toolbar)) continue;
|
|
3634
|
+
const row = chrome['largeTitle'];
|
|
3635
|
+
if (!row) {
|
|
3636
|
+
this.hide(toolbar, node);
|
|
3637
|
+
continue;
|
|
3638
|
+
}
|
|
3639
|
+
const itemCtx = this.childContext(toolbar, cctx);
|
|
3640
|
+
const room: Proposal = { width: Math.max(0, row.width - 2 * NAV_ITEM_MARGIN), height: null };
|
|
3641
|
+
let cursor = row.x + NAV_ITEM_MARGIN;
|
|
3642
|
+
for (const item of toolbar.children) {
|
|
3643
|
+
const itemSize = this.measure(item, itemCtx, room);
|
|
3644
|
+
this.place(item, itemCtx, room, cursor, row.y + (row.height - itemSize.height) / 2);
|
|
3645
|
+
cursor += itemSize.width + NAV_ITEM_SPACING;
|
|
3646
|
+
}
|
|
3647
|
+
this.setFrame(toolbar, row.x, row.y, { width: row.width, height: row.height });
|
|
3648
|
+
}
|
|
3627
3649
|
if (bottomBarHeight > 0) {
|
|
3628
3650
|
this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
|
|
3629
3651
|
}
|
package/src/layoutDom.ts
CHANGED
|
@@ -41,7 +41,7 @@ import type { ShapeStyle } from './protocol';
|
|
|
41
41
|
import { applyChartChrome } from './chartDom';
|
|
42
42
|
import { bitmapLaidOut } from './bitmaps';
|
|
43
43
|
import { drawRichText } from './richText';
|
|
44
|
-
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
|
|
44
|
+
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls, largeTitleTextOf } from './largeTitle';
|
|
45
45
|
import { bindScrollEdgeBottom } from './scrollEdge';
|
|
46
46
|
import { beginScrollSyncBatch, flushScrollSyncBatch, type ScrollExtent } from './scrollSync';
|
|
47
47
|
import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
|
|
@@ -122,7 +122,14 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
|
|
|
122
122
|
/** The element's box relative to the origin its `left`/`top` use; applied when it differs from the last pass's. */
|
|
123
123
|
const applyBox = (id: number, node: LayoutNode, el: HTMLElement, frame: Rect): { box: Rect; moved: boolean; previous: Rect | null } => {
|
|
124
124
|
const box = drawnBox(node, boxOf(ctx, result, node, frame));
|
|
125
|
-
const origin =
|
|
125
|
+
const origin =
|
|
126
|
+
node.kind === 'sheet'
|
|
127
|
+
? { x: 0, y: 0 }
|
|
128
|
+
: isSectionSupplement(node)
|
|
129
|
+
? supplementOrigin(ctx, result, id)
|
|
130
|
+
: isLargeTitleToolbar(node)
|
|
131
|
+
? largeTitleToolbarOrigin(ctx, result, id)
|
|
132
|
+
: originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
|
|
126
133
|
const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
|
|
127
134
|
const previous = ctx.boxes.get(id) ?? null;
|
|
128
135
|
boxes.set(id, relative);
|
|
@@ -233,6 +240,18 @@ function supplementOrigin(ctx: LayoutDomContext, result: LayoutResult, id: numbe
|
|
|
233
240
|
return boxOf(ctx, result, parent, frame);
|
|
234
241
|
}
|
|
235
242
|
|
|
243
|
+
/** A `.largeTitle` toolbar: the renderer keeps it in its screen's large title element. */
|
|
244
|
+
export function isLargeTitleToolbar(node: LayoutNode): boolean {
|
|
245
|
+
return node.kind === 'toolbar' && node.props['placement'] === 'largeTitle';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A `.largeTitle` toolbar sits in the large title element, which is placed at the screen's `largeTitle` row. */
|
|
249
|
+
function largeTitleToolbarOrigin(ctx: LayoutDomContext, result: LayoutResult, id: number): { x: number; y: number } {
|
|
250
|
+
const parentId = ctx.parents.get(id) ?? ROOT_ID;
|
|
251
|
+
const row = result.chrome.get(parentId)?.['largeTitle'];
|
|
252
|
+
return row ?? originOf(ctx, result, parentId);
|
|
253
|
+
}
|
|
254
|
+
|
|
236
255
|
/** Screen-coordinate origin that the children of `parentId` are positioned against. */
|
|
237
256
|
export function originOf(ctx: LayoutDomContext, result: LayoutResult, parentId: number): { x: number; y: number } {
|
|
238
257
|
if (parentId === ROOT_ID) return { x: 0, y: 0 };
|
|
@@ -837,7 +856,8 @@ function applyChrome(
|
|
|
837
856
|
if (texts?.['largeTitle']) {
|
|
838
857
|
setFont(largeTitle, texts['largeTitle'].font);
|
|
839
858
|
const line = texts['largeTitle'].lines.join('\n');
|
|
840
|
-
|
|
859
|
+
const text = largeTitleTextOf(largeTitle);
|
|
860
|
+
if (text.textContent !== line) text.textContent = line;
|
|
841
861
|
}
|
|
842
862
|
} else {
|
|
843
863
|
largeTitle.hidden = true;
|
package/src/protocol.ts
CHANGED
|
@@ -897,7 +897,13 @@ export interface DecoratedProps {
|
|
|
897
897
|
export interface MenuProps {
|
|
898
898
|
deferred?: boolean;
|
|
899
899
|
}
|
|
900
|
-
|
|
900
|
+
/**
|
|
901
|
+
* `largeTitle` (iOS 26 `.largeTitle`): the items take the large title row in
|
|
902
|
+
* place of the title's text, scrolling with the content as the title does;
|
|
903
|
+
* the bar shows the title once the row has gone under it. Hidden while the
|
|
904
|
+
* screen's title is inline.
|
|
905
|
+
*/
|
|
906
|
+
export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom' | 'secondary' | 'largeTitle';
|
|
901
907
|
/**
|
|
902
908
|
* `.toolbar { ToolbarItem(placement:) { ... } }`, an extra child of a
|
|
903
909
|
* `navscreen` after its content. The children are the items, laid out at
|
|
@@ -1884,6 +1890,16 @@ export interface Style {
|
|
|
1884
1890
|
hitTesting?: boolean;
|
|
1885
1891
|
}
|
|
1886
1892
|
|
|
1893
|
+
export interface Style {
|
|
1894
|
+
/**
|
|
1895
|
+
* `.onScrollVisibilityChange(threshold:_:)`: the page sends
|
|
1896
|
+
* `scrollvisibility` to the box when the share of it inside its scroll
|
|
1897
|
+
* view's visible area (below a bar the view scrolls under, above a bar
|
|
1898
|
+
* covering its bottom) crosses `threshold` (src/scrollVisibility.ts).
|
|
1899
|
+
*/
|
|
1900
|
+
scrollVisibility?: { threshold: number };
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1887
1903
|
export interface Style {
|
|
1888
1904
|
/** `.blendMode`: the CSS `mix-blend-mode` the box is composited with. */
|
|
1889
1905
|
blendMode?: string;
|
|
@@ -1957,6 +1973,16 @@ export interface ScrollPositionEvent {
|
|
|
1957
1973
|
type: 'scrollposition';
|
|
1958
1974
|
id: ElementId;
|
|
1959
1975
|
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Sent to a `styled` box with `scrollVisibility`: the share of it inside its
|
|
1978
|
+
* scroll view's visible area came to the threshold or above (`visible`), or
|
|
1979
|
+
* fell below it. The first check after the box appears says where it is.
|
|
1980
|
+
*/
|
|
1981
|
+
export interface ScrollVisibilityEvent {
|
|
1982
|
+
type: 'scrollvisibility';
|
|
1983
|
+
id: ElementId;
|
|
1984
|
+
visible: boolean;
|
|
1985
|
+
}
|
|
1960
1986
|
/**
|
|
1961
1987
|
* A movable `listrow` was dragged to a new place: `to` is the index among
|
|
1962
1988
|
* its container's children (the `list`'s, or its `section`'s) before which
|
|
@@ -2002,7 +2028,7 @@ export interface DropEvent {
|
|
|
2002
2028
|
}
|
|
2003
2029
|
|
|
2004
2030
|
/** The Phase 8 events (merged into `RendererEvent` above). */
|
|
2005
|
-
export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
|
|
2031
|
+
export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | ScrollVisibilityEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
|
|
2006
2032
|
// Phase 8: alerts, controls, effects
|
|
2007
2033
|
// (docs/ops-protocol.md, "Phase 8: interaction"; gestures, swipe actions,
|
|
2008
2034
|
// context menus, list rows and refresh live in their own block)
|
package/src/renderer.ts
CHANGED
|
@@ -153,13 +153,14 @@ import { applyCanvas, createCanvasElement } from './canvasDrawing';
|
|
|
153
153
|
import { scrollToTarget } from './scrollTo';
|
|
154
154
|
import { POSITION_SETTLE_MS, POSITION_SNAP_WAITS, anchorPoint, positionTarget, restsOnSnapPoint, type PositionCandidate } from './scrollPosition';
|
|
155
155
|
import { TouchPaging } from './touchPaging';
|
|
156
|
+
import { isScrollVisible } from './scrollVisibility';
|
|
156
157
|
import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
|
|
157
158
|
import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
|
|
158
159
|
import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
|
|
159
160
|
import { hostContent, onHostContentChange } from './hostContent';
|
|
160
161
|
import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
|
|
161
162
|
import { colorMultiplyFilter } from './colorMultiply';
|
|
162
|
-
import { largeTitleOf } from './largeTitle';
|
|
163
|
+
import { largeTitleOf, largeTitleTextOf } from './largeTitle';
|
|
163
164
|
import { applyEdgeEffect } from './scrollEdge';
|
|
164
165
|
import { discardScrollOffsets, readScrollOffsets, trackScroller } from './scrollSync';
|
|
165
166
|
import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
|
|
@@ -440,6 +441,10 @@ export class Renderer {
|
|
|
440
441
|
private pendingScrolls: ElementId[] = [];
|
|
441
442
|
/** Lazy rows: the `lazyspacer`s on the page, and those already reported near the visible area. */
|
|
442
443
|
private lazySpacers = new Set<ElementId>();
|
|
444
|
+
/** `styled` boxes with `scrollVisibility`, and what each was last told (src/scrollVisibility.ts). */
|
|
445
|
+
private readonly visibilityTargets = new Map<ElementId, number>();
|
|
446
|
+
private readonly visibilityReported = new Map<ElementId, boolean>();
|
|
447
|
+
private visibilityCheckQueued = false;
|
|
443
448
|
private lazyReported = new Set<ElementId>();
|
|
444
449
|
private lazyCheckQueued = false;
|
|
445
450
|
/** The highest `scrollTo` serial seen: a command keeps its serial until the reader re-runs, and runs once. */
|
|
@@ -864,6 +869,7 @@ export class Renderer {
|
|
|
864
869
|
root.addEventListener('focusout', this.onFocusChange);
|
|
865
870
|
root.addEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
866
871
|
root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
|
|
872
|
+
root.addEventListener('scroll', this.queueVisibilityCheck, { capture: true, passive: true });
|
|
867
873
|
root.addEventListener('scroll', this.onPositionScroll, { capture: true, passive: true });
|
|
868
874
|
root.addEventListener('scrollend', this.onPositionScrollEnd, { capture: true, passive: true });
|
|
869
875
|
root.addEventListener('touchstart', this.onPositionTouchStart, { capture: true, passive: true });
|
|
@@ -1335,6 +1341,7 @@ export class Renderer {
|
|
|
1335
1341
|
this.collectGeometry(result);
|
|
1336
1342
|
this.collectLayouts(result);
|
|
1337
1343
|
if (this.applying === 0) this.flushGeometry();
|
|
1344
|
+
this.queueVisibilityCheck();
|
|
1338
1345
|
this.afterLayout?.();
|
|
1339
1346
|
return changes;
|
|
1340
1347
|
}
|
|
@@ -1585,6 +1592,7 @@ export class Renderer {
|
|
|
1585
1592
|
this.root.removeEventListener('focusin', this.onFocusChange);
|
|
1586
1593
|
this.root.removeEventListener('focusout', this.onFocusChange);
|
|
1587
1594
|
this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
1595
|
+
this.root.removeEventListener('scroll', this.queueVisibilityCheck, true);
|
|
1588
1596
|
this.root.removeEventListener('scroll', this.onPositionScroll, true);
|
|
1589
1597
|
this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
|
|
1590
1598
|
this.root.removeEventListener('touchstart', this.onPositionTouchStart, true);
|
|
@@ -1626,6 +1634,7 @@ export class Renderer {
|
|
|
1626
1634
|
if (kind === 'geometry' || (props as { reportsSize?: unknown }).reportsSize === true) this.geometryReporters.add(id);
|
|
1627
1635
|
applyProps(el, kind, props, this.assets);
|
|
1628
1636
|
this.noteScroll(id, kind, props);
|
|
1637
|
+
this.noteVisibility(id, kind, props);
|
|
1629
1638
|
if (kind === 'list' || kind === 'scrollview') trackScroller(el);
|
|
1630
1639
|
if (kind === 'lazyspacer') this.lazySpacers.add(id);
|
|
1631
1640
|
if (kind === 'host') {
|
|
@@ -1634,6 +1643,18 @@ export class Renderer {
|
|
|
1634
1643
|
}
|
|
1635
1644
|
}
|
|
1636
1645
|
|
|
1646
|
+
/** A toolbar whose placement changed to or from `largeTitle` moves between its screen's content slot and large title. */
|
|
1647
|
+
private rehomeToolbar(id: ElementId, el: HTMLElement, op: Op): void {
|
|
1648
|
+
const parentId = this.parents.get(id);
|
|
1649
|
+
const parent = parentId === undefined ? undefined : this.elements.get(parentId);
|
|
1650
|
+
if (parentId === undefined || !parent || !parent.classList.contains('sb-navscreen') || !el.isConnected) return;
|
|
1651
|
+
const expected = el.dataset['sbPlacement'] === 'largeTitle' ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
|
|
1652
|
+
if (el.parentElement === expected) return;
|
|
1653
|
+
const node = this.nodes.get(id);
|
|
1654
|
+
const index = node ? (this.nodes.get(parentId)?.children.indexOf(node) ?? -1) : -1;
|
|
1655
|
+
if (index >= 0) this.insert(parentId, id, index, op);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1637
1658
|
/** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
|
|
1638
1659
|
private adoptHostContent(id: ElementId, el: HTMLElement, props: HostProps): void {
|
|
1639
1660
|
const content = hostContent(props.content);
|
|
@@ -1680,6 +1701,64 @@ export class Renderer {
|
|
|
1680
1701
|
}
|
|
1681
1702
|
}
|
|
1682
1703
|
|
|
1704
|
+
// -------------------------------------------------------------------------
|
|
1705
|
+
// `.onScrollVisibilityChange` (src/scrollVisibility.ts)
|
|
1706
|
+
// -------------------------------------------------------------------------
|
|
1707
|
+
|
|
1708
|
+
/** A `styled` box with `scrollVisibility` is watched; any other forgets it was. */
|
|
1709
|
+
private noteVisibility(id: ElementId, kind: ElementKind, props: ElementProps): void {
|
|
1710
|
+
const spec = kind === 'styled' ? (props as Partial<StyledProps>)?.style?.scrollVisibility : undefined;
|
|
1711
|
+
if (spec && typeof spec.threshold === 'number') {
|
|
1712
|
+
this.visibilityTargets.set(id, spec.threshold);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
this.visibilityTargets.delete(id);
|
|
1716
|
+
this.visibilityReported.delete(id);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/** After a scroll or a layout, the watched boxes are checked on the next frame (once per frame). */
|
|
1720
|
+
private readonly queueVisibilityCheck = (): void => {
|
|
1721
|
+
if (this.visibilityCheckQueued || this.visibilityTargets.size === 0) return;
|
|
1722
|
+
this.visibilityCheckQueued = true;
|
|
1723
|
+
requestAnimationFrame(() => {
|
|
1724
|
+
this.visibilityCheckQueued = false;
|
|
1725
|
+
this.checkVisibility();
|
|
1726
|
+
});
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Each watched box against its scroll view's visible area (the view's box
|
|
1731
|
+
* less the bars it runs under), or the root's outside one: told when it
|
|
1732
|
+
* comes into view or leaves it, and once when first checked.
|
|
1733
|
+
*/
|
|
1734
|
+
private checkVisibility(): void {
|
|
1735
|
+
for (const [id, threshold] of this.visibilityTargets) {
|
|
1736
|
+
const el = this.elements.get(id);
|
|
1737
|
+
if (!el || !el.isConnected) {
|
|
1738
|
+
this.visibilityTargets.delete(id);
|
|
1739
|
+
this.visibilityReported.delete(id);
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
const box = el.getBoundingClientRect();
|
|
1743
|
+
if (box.width === 0 && box.height === 0) continue;
|
|
1744
|
+
const scroller = el.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list') ?? null;
|
|
1745
|
+
const area = (scroller ?? this.root).getBoundingClientRect();
|
|
1746
|
+
const viewport = { x: area.left, y: area.top, width: area.width, height: area.height };
|
|
1747
|
+
if (scroller) {
|
|
1748
|
+
const chrome = this.layoutResult?.chrome.get(Number(scroller.dataset[ID_ATTR]));
|
|
1749
|
+
const top = chrome?.['topInset']?.height ?? 0;
|
|
1750
|
+
const covered = chrome?.['bottomInset'];
|
|
1751
|
+
viewport.y += top;
|
|
1752
|
+
viewport.height -= top;
|
|
1753
|
+
if (covered) viewport.height = Math.max(0, viewport.height - covered.height);
|
|
1754
|
+
}
|
|
1755
|
+
const visible = isScrollVisible({ x: box.left, y: box.top, width: box.width, height: box.height }, viewport, threshold);
|
|
1756
|
+
if (this.visibilityReported.get(id) === visible) continue;
|
|
1757
|
+
this.visibilityReported.set(id, visible);
|
|
1758
|
+
this.onEvent({ type: 'scrollvisibility', id, visible });
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1683
1762
|
// -------------------------------------------------------------------------
|
|
1684
1763
|
// `.scrollPosition(id:)` reading back (src/scrollPosition.ts)
|
|
1685
1764
|
// -------------------------------------------------------------------------
|
|
@@ -1808,10 +1887,19 @@ export class Renderer {
|
|
|
1808
1887
|
.map((id) => ({ id, command: (this.nodes.get(id)?.props as Partial<StyledProps> | undefined)?.style?.scrollTo }))
|
|
1809
1888
|
.filter((entry): entry is { id: ElementId; command: ScrollToCommand } => entry.command !== undefined)
|
|
1810
1889
|
.sort((a, b) => a.command.serial - b.command.serial);
|
|
1890
|
+
// A page a finger set running gives way to the app's commands; a drag in progress carries on across them.
|
|
1891
|
+
const fresh = commands.some(({ command }) => command.serial > this.scrollSerial);
|
|
1892
|
+
if (fresh) this.touchPaging.beforeScrollCommands();
|
|
1811
1893
|
for (const { id, command } of commands) {
|
|
1812
1894
|
if (command.serial <= this.scrollSerial) continue;
|
|
1813
1895
|
scrollToTarget(this, result, id, command, animated);
|
|
1896
|
+
// The scroll views it moved report their position afresh when they next settle, even one they
|
|
1897
|
+
// reported last: the app set its binding itself, and where the view rests may not be it.
|
|
1898
|
+
for (let scroller = this.elements.get(id)?.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list'); scroller; scroller = scroller.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list') ?? null) {
|
|
1899
|
+
this.reportedPositions.delete(scroller);
|
|
1900
|
+
}
|
|
1814
1901
|
}
|
|
1902
|
+
if (fresh) this.touchPaging.afterScrollCommands();
|
|
1815
1903
|
for (const { command } of commands) this.scrollSerial = Math.max(this.scrollSerial, command.serial);
|
|
1816
1904
|
}
|
|
1817
1905
|
|
|
@@ -1901,7 +1989,9 @@ export class Renderer {
|
|
|
1901
1989
|
if (kind === 'text') el.textContent = '';
|
|
1902
1990
|
applyProps(el, kind, props, this.assets);
|
|
1903
1991
|
this.noteScroll(id, kind, props);
|
|
1992
|
+
this.noteVisibility(id, kind, props);
|
|
1904
1993
|
if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
|
|
1994
|
+
if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
|
|
1905
1995
|
const newToken = el.dataset[ANIMATION_TOKEN];
|
|
1906
1996
|
const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
|
|
1907
1997
|
const record: UpdatedRecord = { el, before, tokenChanged };
|
|
@@ -1942,7 +2032,9 @@ export class Renderer {
|
|
|
1942
2032
|
// A section's view header / footer lives beside its rows' card, in the
|
|
1943
2033
|
// section element itself; the rows after a header are one DOM slot lower.
|
|
1944
2034
|
const supplement = isSectionSupplement(el);
|
|
1945
|
-
|
|
2035
|
+
// A `.largeTitle` toolbar lives in its screen's large title element, which scrolls with the content.
|
|
2036
|
+
const inLargeTitle = el.dataset['sbPlacement'] === 'largeTitle' && parent.classList.contains('sb-navscreen');
|
|
2037
|
+
const container = supplement && parent.classList.contains('sb-section') ? parent : inLargeTitle ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
|
|
1946
2038
|
// Moves: `index` is the element's position in the parent's child list
|
|
1947
2039
|
// *after* the op is applied. Detaching first makes the index arithmetic
|
|
1948
2040
|
// identical for fresh inserts and for moves within the same parent.
|
|
@@ -1954,9 +2046,21 @@ export class Renderer {
|
|
|
1954
2046
|
const first = this.nodes.get(parentId)?.children.find((child) => child.id !== id);
|
|
1955
2047
|
if (first?.kind === 'sectionheader') slotIndex -= 1;
|
|
1956
2048
|
}
|
|
2049
|
+
// A screen's children may live in two containers (its content slot and
|
|
2050
|
+
// its large title): the next sibling in the same container is the reference.
|
|
2051
|
+
const screenReference = (): Element | null => {
|
|
2052
|
+
const siblings = (this.nodes.get(parentId)?.children ?? []).filter((child) => child.id !== id);
|
|
2053
|
+
for (const sibling of siblings.slice(index)) {
|
|
2054
|
+
const sib = this.elements.get(sibling.id);
|
|
2055
|
+
if (sib && sib.parentElement === container) return sib;
|
|
2056
|
+
}
|
|
2057
|
+
return null;
|
|
2058
|
+
};
|
|
1957
2059
|
const reference = supplement
|
|
1958
2060
|
? el.classList.contains('sb-sectionheader') ? container.firstChild : null
|
|
1959
|
-
: (
|
|
2061
|
+
: parent.classList.contains('sb-navscreen')
|
|
2062
|
+
? screenReference()
|
|
2063
|
+
: (container.children[slotIndex] ?? null);
|
|
1960
2064
|
container.insertBefore(el, reference);
|
|
1961
2065
|
if (!wasAttached) this.pass.inserted.add(el);
|
|
1962
2066
|
// Mirror the move in the layout tree.
|
|
@@ -3225,7 +3329,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3225
3329
|
const placement = (props as ToolbarProps).placement;
|
|
3226
3330
|
// `secondary` items shown in the bar look like the trailing ones (glass capsules).
|
|
3227
3331
|
el.dataset['sbPlacement'] =
|
|
3228
|
-
placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
|
|
3332
|
+
placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' ? placement : 'trailing';
|
|
3229
3333
|
// A `ToolbarSpacer`: draws nothing.
|
|
3230
3334
|
el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
|
|
3231
3335
|
break;
|
|
@@ -3855,9 +3959,17 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
|
|
|
3855
3959
|
const largeTitle = largeTitleOf(el);
|
|
3856
3960
|
// A large title that scrolls away collapses into the inline one (shown
|
|
3857
3961
|
// with `data-sb-collapsed`, src/largeTitle.ts).
|
|
3858
|
-
if (inlineTitle)
|
|
3962
|
+
if (inlineTitle) {
|
|
3963
|
+
const appearing = inlineTitle.textContent === '' && title !== '' && inlineTitle.isConnected;
|
|
3964
|
+
inlineTitle.textContent = title;
|
|
3965
|
+
// An inline title given to an empty bar (a header row that has scrolled
|
|
3966
|
+
// under it handing the bar its name) fades in as a collapsing large title does.
|
|
3967
|
+
if (appearing && !large && typeof inlineTitle.animate === 'function') {
|
|
3968
|
+
inlineTitle.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150, easing: 'ease' });
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3859
3971
|
if (largeTitle) {
|
|
3860
|
-
largeTitle.textContent = large ? title : '';
|
|
3972
|
+
largeTitleTextOf(largeTitle).textContent = large ? title : '';
|
|
3861
3973
|
largeTitle.hidden = !large;
|
|
3862
3974
|
}
|
|
3863
3975
|
el.setAttribute('aria-label', title);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.onScrollVisibilityChange(threshold:_:)` (iOS 18; docs/ops-protocol.md,
|
|
3
|
+
* "Scroll visibility"): a `styled` box with `scrollVisibility` is told when
|
|
4
|
+
* the share of it inside its scroll view's visible area crosses its
|
|
5
|
+
* threshold. The visible area is the scroll view's box less a bar it scrolls
|
|
6
|
+
* under (its `topInset`) and a bar covering its bottom (`bottomInset`), as
|
|
7
|
+
* content under a bar is not in view; outside a scroll view it is the root.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface Span {
|
|
11
|
+
start: number;
|
|
12
|
+
length: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The share (0...1) of `target` along an axis inside `viewport`; 0 for a target of no extent. */
|
|
16
|
+
export function visibleShare(target: Span, viewport: Span): number {
|
|
17
|
+
if (target.length <= 0) return 0;
|
|
18
|
+
const start = Math.max(target.start, viewport.start);
|
|
19
|
+
const end = Math.min(target.start + target.length, viewport.start + viewport.length);
|
|
20
|
+
return Math.max(0, end - start) / target.length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a box is visible: some of it is in view, and at least `threshold`
|
|
25
|
+
* of it along the scroll axes (both shares multiplied for a view scrolling
|
|
26
|
+
* both ways, the other axis counted too, as a box off to the side is not in
|
|
27
|
+
* view).
|
|
28
|
+
*/
|
|
29
|
+
export function isScrollVisible(
|
|
30
|
+
target: { x: number; y: number; width: number; height: number },
|
|
31
|
+
viewport: { x: number; y: number; width: number; height: number },
|
|
32
|
+
threshold: number,
|
|
33
|
+
): boolean {
|
|
34
|
+
const horizontal = visibleShare({ start: target.x, length: target.width }, { start: viewport.x, length: viewport.width });
|
|
35
|
+
const vertical = visibleShare({ start: target.y, length: target.height }, { start: viewport.y, length: viewport.height });
|
|
36
|
+
const share = horizontal * vertical;
|
|
37
|
+
return share > 0 && share >= Math.min(1, Math.max(0, threshold));
|
|
38
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -751,6 +751,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
751
751
|
touch-action: pan-x pinch-zoom;
|
|
752
752
|
}
|
|
753
753
|
|
|
754
|
+
/* Nothing inside widens the pans the browser may make along the paging axis:
|
|
755
|
+
a button's `manipulation` would let Safari pan the view itself under a
|
|
756
|
+
quick swipe that starts on it. */
|
|
757
|
+
.sb-scrollview[data-sb-snap="paging"][data-sb-axes="horizontal"] * {
|
|
758
|
+
touch-action: pan-y pinch-zoom;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.sb-scrollview[data-sb-snap="paging"][data-sb-axes="vertical"] * {
|
|
762
|
+
touch-action: pan-x pinch-zoom;
|
|
763
|
+
}
|
|
764
|
+
|
|
754
765
|
.sb-scroll-content,
|
|
755
766
|
.sb-list-content {
|
|
756
767
|
position: relative;
|
|
@@ -1242,6 +1253,22 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1242
1253
|
display: none;
|
|
1243
1254
|
}
|
|
1244
1255
|
|
|
1256
|
+
/* A `.largeTitle` toolbar (iOS 26) takes the row in place of the title's
|
|
1257
|
+
text, which the bar still shows once the row has scrolled under it. Its
|
|
1258
|
+
items are content, drawn as anywhere else and answering touches. */
|
|
1259
|
+
.sb-navbar-large-title:has(> .sb-toolbar[data-sb-placement="largeTitle"]:not([hidden])) > .sb-navbar-large-title-text {
|
|
1260
|
+
visibility: hidden;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
.sb-navbar-large-title > .sb-toolbar {
|
|
1264
|
+
font-size: var(--sb-font-size-body);
|
|
1265
|
+
font-weight: 400;
|
|
1266
|
+
line-height: normal;
|
|
1267
|
+
letter-spacing: normal;
|
|
1268
|
+
white-space: normal;
|
|
1269
|
+
pointer-events: auto;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1245
1272
|
/* The large title scrolls with the screen's root list or scroll view
|
|
1246
1273
|
(src/largeTitle.ts moves it into it); drags on it scroll that view. There
|
|
1247
1274
|
it is content, under the bar. */
|
package/src/touchPaging.ts
CHANGED
|
@@ -18,8 +18,14 @@ export const FLICK_VELOCITY = 0.3;
|
|
|
18
18
|
export const FLICK_WINDOW_MS = 100;
|
|
19
19
|
/** How far a flick carries on inside a page longer than the viewport (its velocity times this). */
|
|
20
20
|
export const FLICK_RUN_MS = 250;
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* A touch is a drag once it moved this far; the larger axis of that first
|
|
23
|
+
* move decides whose gesture it is. Small, and under Safari's own threshold
|
|
24
|
+
* for starting a pan: a move left to the browser lets it begin a pan, after
|
|
25
|
+
* which it cancels the touch for the page, and a drag of ours would end in
|
|
26
|
+
* a run from wherever the finger was.
|
|
27
|
+
*/
|
|
28
|
+
export const DIRECTION_SLOP = 3;
|
|
23
29
|
|
|
24
30
|
/** Where a scroll view may rest for one page: at its start, or anywhere a page longer than the viewport fills it (`start === end` for a page the viewport's size). */
|
|
25
31
|
export interface RestInterval {
|
|
@@ -120,19 +126,37 @@ interface Gesture {
|
|
|
120
126
|
/** The finger position (along the axis) where our drag took over. */
|
|
121
127
|
fromPos: number;
|
|
122
128
|
samples: TouchSample[];
|
|
129
|
+
/** The page under the leading edge as of the last move, to find again after the app's scroll commands. */
|
|
130
|
+
page: Page | null;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
interface Run {
|
|
126
134
|
el: HTMLElement;
|
|
127
135
|
frame: number;
|
|
128
|
-
|
|
129
|
-
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Where a drag was when the app's scroll commands began: the offset, and the page under it to find again after. */
|
|
139
|
+
interface Held {
|
|
140
|
+
el: HTMLElement;
|
|
141
|
+
offset: number;
|
|
142
|
+
page: Page | null;
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
const PAGING_SELECTOR = '.sb-scrollview[data-sb-snap="paging"]';
|
|
133
146
|
|
|
147
|
+
export interface Page {
|
|
148
|
+
element: Element;
|
|
149
|
+
start: number;
|
|
150
|
+
length: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The page whose extent holds `offset` (the leading edge), if any. */
|
|
154
|
+
function pageAt(el: HTMLElement, horizontal: boolean, offset: number): Page | null {
|
|
155
|
+
return pagesOf(el, horizontal).find((p) => p.start <= offset + 1 && offset < p.start + p.length) ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
/** Pages of a paging scroll view in scroll coordinates along its axis: the children of its `scrollTargetLayout` stack, else of its content. */
|
|
135
|
-
export function pagesOf(el: HTMLElement, horizontal: boolean):
|
|
159
|
+
export function pagesOf(el: HTMLElement, horizontal: boolean): Page[] {
|
|
136
160
|
const targets = [...el.querySelectorAll<HTMLElement>('[data-sb-scroll-target] > *')].filter((t) => t.parentElement?.closest('.sb-scrollview, .sb-list') === el);
|
|
137
161
|
const children = targets.length > 0 ? targets : [...((el.firstElementChild?.children ?? []) as Iterable<Element>)];
|
|
138
162
|
const box = el.getBoundingClientRect();
|
|
@@ -141,13 +165,14 @@ export function pagesOf(el: HTMLElement, horizontal: boolean): { start: number;
|
|
|
141
165
|
const offset = horizontal ? el.scrollLeft : el.scrollTop;
|
|
142
166
|
return children.map((child) => {
|
|
143
167
|
const rect = child.getBoundingClientRect();
|
|
144
|
-
return horizontal ? { start: rect.left - box.left - padding + offset, length: rect.width } : { start: rect.top - box.top - padding + offset, length: rect.height };
|
|
168
|
+
return horizontal ? { element: child, start: rect.left - box.left - padding + offset, length: rect.width } : { element: child, start: rect.top - box.top - padding + offset, length: rect.height };
|
|
145
169
|
});
|
|
146
170
|
}
|
|
147
171
|
|
|
148
172
|
export class TouchPaging {
|
|
149
173
|
private gesture: Gesture | null = null;
|
|
150
174
|
private run: Run | null = null;
|
|
175
|
+
private held: Held | null = null;
|
|
151
176
|
|
|
152
177
|
/**
|
|
153
178
|
* `onRest(el)`: the view came to rest on a page after a gesture of ours
|
|
@@ -159,7 +184,9 @@ export class TouchPaging {
|
|
|
159
184
|
private readonly onRest: (el: HTMLElement) => void = () => {},
|
|
160
185
|
) {
|
|
161
186
|
root.addEventListener('touchstart', this.onTouchStart, { passive: true });
|
|
162
|
-
|
|
187
|
+
// Not passive: a drag of ours cancels the browser's own handling of the moves (Safari pans a
|
|
188
|
+
// scroll view under a finger whatever `touch-action` says, and the two would fight over it).
|
|
189
|
+
root.addEventListener('touchmove', this.onTouchMove, { passive: false });
|
|
163
190
|
root.addEventListener('touchend', this.onTouchEnd, { passive: true });
|
|
164
191
|
root.addEventListener('touchcancel', this.onTouchEnd, { passive: true });
|
|
165
192
|
}
|
|
@@ -169,6 +196,67 @@ export class TouchPaging {
|
|
|
169
196
|
return (this.gesture?.el === el && this.gesture.ours === true) || this.run?.el === el;
|
|
170
197
|
}
|
|
171
198
|
|
|
199
|
+
/**
|
|
200
|
+
* The app is about to scroll (`scrollTo` commands): a run still going
|
|
201
|
+
* gives way to them, and a drag in progress notes where the view is and
|
|
202
|
+
* which page is under the finger, to carry on from there once the
|
|
203
|
+
* commands are done (`afterScrollCommands`).
|
|
204
|
+
*/
|
|
205
|
+
beforeScrollCommands(): void {
|
|
206
|
+
const run = this.run;
|
|
207
|
+
if (run) {
|
|
208
|
+
this.stopRun();
|
|
209
|
+
this.onRest(run.el);
|
|
210
|
+
}
|
|
211
|
+
const g = this.gesture;
|
|
212
|
+
this.held = null;
|
|
213
|
+
if (!g?.ours) return;
|
|
214
|
+
// The page as of the drag's last move: the commands' pass has already changed the tree by now.
|
|
215
|
+
this.held = { el: g.el, offset: g.horizontal ? g.el.scrollLeft : g.el.scrollTop, page: g.page };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The app's scroll commands are done. A drag in progress keeps the
|
|
220
|
+
* content under the finger where it was: an app re-centring its pages
|
|
221
|
+
* on the one arrived at moves the content a page and scrolls the same
|
|
222
|
+
* page, which the command's own offset would show aligned, dropping the
|
|
223
|
+
* drag so far. The page's new places reach the DOM on the next frame
|
|
224
|
+
* (the renderer commits its layout then), so the drag carries on from
|
|
225
|
+
* them at the next frame or the next move, whichever comes first.
|
|
226
|
+
*/
|
|
227
|
+
afterScrollCommands(): void {
|
|
228
|
+
const held = this.held;
|
|
229
|
+
if (!held) return;
|
|
230
|
+
const g = this.gesture;
|
|
231
|
+
if (!g?.ours || g.el !== held.el) {
|
|
232
|
+
this.held = null;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
requestAnimationFrame(() => this.carryOn());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The page that was under the finger is found again and the offset
|
|
240
|
+
* moved by what it moved; without it (a page that is gone), the drag
|
|
241
|
+
* carries on from the offset the commands set.
|
|
242
|
+
*/
|
|
243
|
+
private carryOn(): void {
|
|
244
|
+
const held = this.held;
|
|
245
|
+
this.held = null;
|
|
246
|
+
const g = this.gesture;
|
|
247
|
+
if (!held || !g?.ours || g.el !== held.el) return;
|
|
248
|
+
const now = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
|
|
249
|
+
const page = held.page ? pagesOf(g.el, g.horizontal).find((p) => p.element === held.page!.element) : undefined;
|
|
250
|
+
const shift = page ? page.start - held.page!.start : now - held.offset;
|
|
251
|
+
g.from += shift;
|
|
252
|
+
if (page) {
|
|
253
|
+
const max = g.horizontal ? g.el.scrollWidth - g.el.clientWidth : g.el.scrollHeight - g.el.clientHeight;
|
|
254
|
+
const offset = Math.min(max, Math.max(0, held.offset + shift));
|
|
255
|
+
if (g.horizontal) g.el.scrollLeft = offset;
|
|
256
|
+
else g.el.scrollTop = offset;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
172
260
|
dispose(): void {
|
|
173
261
|
this.root.removeEventListener('touchstart', this.onTouchStart);
|
|
174
262
|
this.root.removeEventListener('touchmove', this.onTouchMove);
|
|
@@ -186,7 +274,7 @@ export class TouchPaging {
|
|
|
186
274
|
const axes = el.dataset['sbAxes'];
|
|
187
275
|
if (axes !== 'horizontal' && axes !== 'vertical') return;
|
|
188
276
|
const horizontal = axes === 'horizontal';
|
|
189
|
-
this.gesture = { el, horizontal, startX: touch.clientX, startY: touch.clientY, ours: null, from: 0, fromPos: 0, samples: [] };
|
|
277
|
+
this.gesture = { el, horizontal, startX: touch.clientX, startY: touch.clientY, ours: null, from: 0, fromPos: 0, samples: [], page: null };
|
|
190
278
|
// A finger on a page still running to its place catches it: the drag is
|
|
191
279
|
// ours from here, and the lift runs it on to a page.
|
|
192
280
|
if (this.run?.el === el) {
|
|
@@ -202,6 +290,7 @@ export class TouchPaging {
|
|
|
202
290
|
const g = this.gesture;
|
|
203
291
|
const touch = event.touches[0];
|
|
204
292
|
if (!g || !touch) return;
|
|
293
|
+
if (this.held) this.carryOn();
|
|
205
294
|
const pos = g.horizontal ? touch.clientX : touch.clientY;
|
|
206
295
|
if (g.ours === null) {
|
|
207
296
|
const dx = touch.clientX - g.startX;
|
|
@@ -218,10 +307,12 @@ export class TouchPaging {
|
|
|
218
307
|
g.from = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
|
|
219
308
|
g.fromPos = pos;
|
|
220
309
|
}
|
|
310
|
+
if (event.cancelable) event.preventDefault();
|
|
221
311
|
const max = g.horizontal ? g.el.scrollWidth - g.el.clientWidth : g.el.scrollHeight - g.el.clientHeight;
|
|
222
312
|
const offset = Math.min(max, Math.max(0, g.from - (pos - g.fromPos)));
|
|
223
313
|
if (g.horizontal) g.el.scrollLeft = offset;
|
|
224
314
|
else g.el.scrollTop = offset;
|
|
315
|
+
g.page = pageAt(g.el, g.horizontal, offset);
|
|
225
316
|
g.samples.push({ t: event.timeStamp, pos });
|
|
226
317
|
if (g.samples.length > 20) g.samples.shift();
|
|
227
318
|
};
|
|
@@ -230,6 +321,7 @@ export class TouchPaging {
|
|
|
230
321
|
const g = this.gesture;
|
|
231
322
|
if (!g) return;
|
|
232
323
|
if (event.touches.length > 0) return;
|
|
324
|
+
if (this.held) this.carryOn();
|
|
233
325
|
this.gesture = null;
|
|
234
326
|
if (!g.ours) return;
|
|
235
327
|
const el = g.el;
|
|
@@ -253,15 +345,10 @@ export class TouchPaging {
|
|
|
253
345
|
}
|
|
254
346
|
const duration = runDuration(distance);
|
|
255
347
|
const started = performance.now();
|
|
348
|
+
// The run writes the offset every frame and the last write is the page itself, whatever
|
|
349
|
+
// else moved the view meanwhile (the offset Safari reads back can trail a write); only an
|
|
350
|
+
// app's own scroll command ends it early (`interrupt`).
|
|
256
351
|
const step = (now: number): void => {
|
|
257
|
-
const run = this.run;
|
|
258
|
-
const current = horizontal ? el.scrollLeft : el.scrollTop;
|
|
259
|
-
// Another writer moved the view (an app re-centring on the page): the run is over.
|
|
260
|
-
if (run && Math.abs(current - run.written) > 1) {
|
|
261
|
-
this.stopRun();
|
|
262
|
-
this.onRest(el);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
352
|
const t = (now - started) / duration;
|
|
266
353
|
const offset = t >= 1 ? to : from + distance * easeOut(t);
|
|
267
354
|
if (horizontal) el.scrollLeft = offset;
|
|
@@ -272,9 +359,9 @@ export class TouchPaging {
|
|
|
272
359
|
this.onRest(el);
|
|
273
360
|
return;
|
|
274
361
|
}
|
|
275
|
-
this.run = { el, frame: requestAnimationFrame(step)
|
|
362
|
+
this.run = { el, frame: requestAnimationFrame(step) };
|
|
276
363
|
};
|
|
277
|
-
this.run = { el, frame: requestAnimationFrame(step)
|
|
364
|
+
this.run = { el, frame: requestAnimationFrame(step) };
|
|
278
365
|
}
|
|
279
366
|
|
|
280
367
|
private stopRun(): void {
|