@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.
@@ -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. Not a grid item:
1166
- an absolutely positioned grid child would be placed against its grid
1167
- area, not the bar's padding box. */
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;