@swiftbrowser/web 0.9.11 → 0.9.12
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/renderer.ts +9 -0
- package/src/styles.css +11 -0
- package/src/touchPaging.ts +105 -18
package/package.json
CHANGED
package/src/renderer.ts
CHANGED
|
@@ -1808,10 +1808,19 @@ export class Renderer {
|
|
|
1808
1808
|
.map((id) => ({ id, command: (this.nodes.get(id)?.props as Partial<StyledProps> | undefined)?.style?.scrollTo }))
|
|
1809
1809
|
.filter((entry): entry is { id: ElementId; command: ScrollToCommand } => entry.command !== undefined)
|
|
1810
1810
|
.sort((a, b) => a.command.serial - b.command.serial);
|
|
1811
|
+
// A page a finger set running gives way to the app's commands; a drag in progress carries on across them.
|
|
1812
|
+
const fresh = commands.some(({ command }) => command.serial > this.scrollSerial);
|
|
1813
|
+
if (fresh) this.touchPaging.beforeScrollCommands();
|
|
1811
1814
|
for (const { id, command } of commands) {
|
|
1812
1815
|
if (command.serial <= this.scrollSerial) continue;
|
|
1813
1816
|
scrollToTarget(this, result, id, command, animated);
|
|
1817
|
+
// The scroll views it moved report their position afresh when they next settle, even one they
|
|
1818
|
+
// reported last: the app set its binding itself, and where the view rests may not be it.
|
|
1819
|
+
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) {
|
|
1820
|
+
this.reportedPositions.delete(scroller);
|
|
1821
|
+
}
|
|
1814
1822
|
}
|
|
1823
|
+
if (fresh) this.touchPaging.afterScrollCommands();
|
|
1815
1824
|
for (const { command } of commands) this.scrollSerial = Math.max(this.scrollSerial, command.serial);
|
|
1816
1825
|
}
|
|
1817
1826
|
|
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;
|
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 {
|