@swiftbrowser/web 0.9.3 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -1
- package/package.json +1 -1
- package/src/browserHistory.ts +268 -0
- package/src/deviceMode.ts +239 -0
- package/src/interaction.ts +10 -1
- package/src/layout/engine.ts +68 -20
- package/src/layout/measure.ts +24 -2
- package/src/layout/types.ts +10 -0
- package/src/layout/typography.ts +13 -3
- package/src/layoutDom.ts +31 -9
- package/src/main.ts +12 -1
- package/src/protocol.ts +15 -0
- package/src/renderer.ts +32 -1
- package/src/richText.ts +5 -2
- package/src/scrollAnchor.ts +110 -0
- package/src/styles.css +20 -4
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.defaultScrollAnchor(_:)` on a `ScrollView` (docs/ops-protocol.md, "Default
|
|
3
|
+
* scroll anchor"): the scroll view carries `defaultScrollAnchor`, a unit
|
|
4
|
+
* point. The content starts there (`.bottom`: the end is in view, as a chat
|
|
5
|
+
* opens on its latest message), and when the content or the viewport changes
|
|
6
|
+
* size in a pass, the anchor is kept *if the viewer was at it* before the
|
|
7
|
+
* pass: new lines at the end of a chat keep the end in view, while a viewer
|
|
8
|
+
* who scrolled up to read stays where they are. The offset before the pass
|
|
9
|
+
* is the one scrollSync.ts read for it, so a pass's own writes do not count
|
|
10
|
+
* as the viewer's scrolling.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { UnitPoint } from './protocol';
|
|
14
|
+
import { scrollTopOf } from './scrollSync';
|
|
15
|
+
|
|
16
|
+
/** The scroll offset on one axis that puts the anchor fraction of the content at the same fraction of the viewport. */
|
|
17
|
+
export function anchoredOffset(content: number, viewport: number, anchor: number): number {
|
|
18
|
+
return Math.max(0, Math.round((content - viewport) * anchor));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Within a couple of pixels of the anchor: browsers round offsets, and a smooth scroll may stop short. */
|
|
22
|
+
export const ANCHOR_TOLERANCE = 2;
|
|
23
|
+
|
|
24
|
+
export function isAtAnchor(offset: number, content: number, viewport: number, anchor: number): boolean {
|
|
25
|
+
return Math.abs(offset - anchoredOffset(content, viewport, anchor)) <= ANCHOR_TOLERANCE;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What `keepAnchor` reads and writes on a scrolling element; `HTMLElement` fits. */
|
|
29
|
+
export interface AnchoredScroller {
|
|
30
|
+
scrollTop: number;
|
|
31
|
+
scrollLeft: number;
|
|
32
|
+
scrollHeight: number;
|
|
33
|
+
scrollWidth: number;
|
|
34
|
+
clientHeight: number;
|
|
35
|
+
clientWidth: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The extents a scroller had after the last pass. */
|
|
39
|
+
interface Extents {
|
|
40
|
+
content: { width: number; height: number };
|
|
41
|
+
viewport: { width: number; height: number };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* After a pass laid the scroller out: on its first pass the content goes to
|
|
46
|
+
* the anchor; after that, an axis whose extents changed keeps the anchor
|
|
47
|
+
* when `offsetBefore` (the viewer's position before the pass) was at it.
|
|
48
|
+
* Returns the extents to remember for the next pass.
|
|
49
|
+
*/
|
|
50
|
+
export function keepAnchor(
|
|
51
|
+
el: AnchoredScroller,
|
|
52
|
+
anchor: UnitPoint,
|
|
53
|
+
axes: { horizontal: boolean; vertical: boolean },
|
|
54
|
+
before: Extents | undefined,
|
|
55
|
+
offsetBefore: { top: number; left: number },
|
|
56
|
+
): Extents {
|
|
57
|
+
const now: Extents = { content: { width: el.scrollWidth, height: el.scrollHeight }, viewport: { width: el.clientWidth, height: el.clientHeight } };
|
|
58
|
+
if (axes.vertical) {
|
|
59
|
+
const changed = !before || before.content.height !== now.content.height || before.viewport.height !== now.viewport.height;
|
|
60
|
+
const wasAt = !before || isAtAnchor(offsetBefore.top, before.content.height, before.viewport.height, anchor.y);
|
|
61
|
+
if (changed && wasAt) el.scrollTop = anchoredOffset(now.content.height, now.viewport.height, anchor.y);
|
|
62
|
+
}
|
|
63
|
+
if (axes.horizontal) {
|
|
64
|
+
const changed = !before || before.content.width !== now.content.width || before.viewport.width !== now.viewport.width;
|
|
65
|
+
const wasAt = !before || isAtAnchor(offsetBefore.left, before.content.width, before.viewport.width, anchor.x);
|
|
66
|
+
if (changed && wasAt) el.scrollLeft = anchoredOffset(now.content.width, now.viewport.width, anchor.x);
|
|
67
|
+
}
|
|
68
|
+
return now;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const anchors = new WeakMap<HTMLElement, UnitPoint>();
|
|
72
|
+
const extents = new WeakMap<HTMLElement, Extents>();
|
|
73
|
+
const anchored = new Set<HTMLElement>();
|
|
74
|
+
|
|
75
|
+
/** A scroll view's `defaultScrollAnchor` prop, kept for the passes to come (none: the view scrolls as any other). */
|
|
76
|
+
export function applyDefaultScrollAnchor(el: HTMLElement, anchor: UnitPoint | undefined): void {
|
|
77
|
+
if (anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number') {
|
|
78
|
+
anchors.set(el, { x: anchor.x, y: anchor.y });
|
|
79
|
+
anchored.add(el);
|
|
80
|
+
} else {
|
|
81
|
+
anchors.delete(el);
|
|
82
|
+
extents.delete(el);
|
|
83
|
+
anchored.delete(el);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The axes a `scrollview` element scrolls on, from its `data-sb-axes`. */
|
|
88
|
+
function axesOf(el: HTMLElement): { horizontal: boolean; vertical: boolean } {
|
|
89
|
+
const axes = el.dataset['sbAxes'] ?? 'vertical';
|
|
90
|
+
return { horizontal: axes === 'horizontal' || axes === 'both', vertical: axes !== 'horizontal' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Called by the renderer after each pass's frames are applied: every anchored scroll view keeps (or takes) its anchor. */
|
|
94
|
+
export function keepScrollAnchors(): void {
|
|
95
|
+
for (const el of anchored) {
|
|
96
|
+
if (!el.isConnected) {
|
|
97
|
+
anchored.delete(el);
|
|
98
|
+
extents.delete(el);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const anchor = anchors.get(el);
|
|
102
|
+
if (!anchor) continue;
|
|
103
|
+
extents.set(el, keepAnchor(el, anchor, axesOf(el), extents.get(el), { top: scrollTopOf(el), left: el.scrollLeft }));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** For tests: forget every anchored scroller. */
|
|
108
|
+
export function resetScrollAnchors(): void {
|
|
109
|
+
anchored.clear();
|
|
110
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -396,6 +396,22 @@ html[data-sb-mode="device"] .device {
|
|
|
396
396
|
box-shadow: none;
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
+
/* `?debug=viewport` (src/deviceMode.ts installViewportReadout): the viewport
|
|
400
|
+
numbers the keyboard measurement reads, over the app, under the status bar. */
|
|
401
|
+
.sb-viewport-readout {
|
|
402
|
+
position: fixed;
|
|
403
|
+
top: calc(var(--sb-inset-top) + 4px);
|
|
404
|
+
left: 8px;
|
|
405
|
+
z-index: 1000;
|
|
406
|
+
margin: 0;
|
|
407
|
+
padding: 4px 6px;
|
|
408
|
+
font: 11px/1.3 ui-monospace, Menlo, monospace;
|
|
409
|
+
color: #fff;
|
|
410
|
+
background: rgba(0, 0, 0, 0.7);
|
|
411
|
+
border-radius: 6px;
|
|
412
|
+
pointer-events: none;
|
|
413
|
+
}
|
|
414
|
+
|
|
399
415
|
html[data-sb-mode="device"] .device::before,
|
|
400
416
|
html[data-sb-mode="device"] .device::after,
|
|
401
417
|
html[data-sb-mode="device"] .dynamic-island,
|
|
@@ -1162,12 +1178,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1162
1178
|
|
|
1163
1179
|
.sb-navbar-title {
|
|
1164
1180
|
/* Positioned at the engine's `title` rect (Phase 6), centered and truncated
|
|
1165
|
-
to the room the back button and the toolbar items leave
|
|
1166
|
-
|
|
1167
|
-
|
|
1181
|
+
to the room the back button and the toolbar items leave; the rect is the
|
|
1182
|
+
only limit on its width (a wide bar on an iPad gives a long title the
|
|
1183
|
+
room). Not a grid item: an absolutely positioned grid child would be
|
|
1184
|
+
placed against its grid area, not the bar's padding box. */
|
|
1168
1185
|
position: absolute;
|
|
1169
1186
|
display: block;
|
|
1170
|
-
max-width: 220px;
|
|
1171
1187
|
overflow: hidden;
|
|
1172
1188
|
text-overflow: ellipsis;
|
|
1173
1189
|
white-space: nowrap;
|