@swiftbrowser/web 0.1.0 → 0.2.0

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.
Files changed (98) hide show
  1. package/README.md +43 -6
  2. package/app/index.html +11 -0
  3. package/node/dist/node/apps.d.ts +16 -0
  4. package/node/dist/node/apps.d.ts.map +1 -1
  5. package/node/dist/node/apps.js +9 -0
  6. package/node/dist/node/apps.js.map +1 -1
  7. package/node/dist/node/config.d.ts +14 -1
  8. package/node/dist/node/config.d.ts.map +1 -1
  9. package/node/dist/node/config.js +20 -3
  10. package/node/dist/node/config.js.map +1 -1
  11. package/node/dist/node/examples.d.ts +2 -1
  12. package/node/dist/node/examples.d.ts.map +1 -1
  13. package/node/dist/node/examples.js +11 -2
  14. package/node/dist/node/examples.js.map +1 -1
  15. package/node/dist/plugins/app-manifests.d.ts +4 -1
  16. package/node/dist/plugins/app-manifests.d.ts.map +1 -1
  17. package/node/dist/plugins/app-manifests.js +3 -2
  18. package/node/dist/plugins/app-manifests.js.map +1 -1
  19. package/node/dist/plugins/asset-catalogs.d.ts +15 -3
  20. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
  21. package/node/dist/plugins/asset-catalogs.js +111 -13
  22. package/node/dist/plugins/asset-catalogs.js.map +1 -1
  23. package/node/dist/plugins/included-sites.d.ts +26 -0
  24. package/node/dist/plugins/included-sites.d.ts.map +1 -0
  25. package/node/dist/plugins/included-sites.js +70 -0
  26. package/node/dist/plugins/included-sites.js.map +1 -0
  27. package/node/dist/plugins/pdfImages.d.ts +27 -0
  28. package/node/dist/plugins/pdfImages.d.ts.map +1 -0
  29. package/node/dist/plugins/pdfImages.js +145 -0
  30. package/node/dist/plugins/pdfImages.js.map +1 -0
  31. package/node/dist/plugins/swift-wasm.d.ts +19 -1
  32. package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
  33. package/node/dist/plugins/swift-wasm.js +45 -7
  34. package/node/dist/plugins/swift-wasm.js.map +1 -1
  35. package/node/dist/plugins/symbolTemplates.d.ts +22 -0
  36. package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
  37. package/node/dist/plugins/symbolTemplates.js +83 -0
  38. package/node/dist/plugins/symbolTemplates.js.map +1 -0
  39. package/package.json +4 -2
  40. package/plugins/app-manifests.ts +3 -2
  41. package/plugins/asset-catalogs.ts +119 -14
  42. package/plugins/included-sites.ts +74 -0
  43. package/plugins/pdfImages.ts +163 -0
  44. package/plugins/swift-wasm.ts +52 -6
  45. package/plugins/symbolTemplates.ts +88 -0
  46. package/public/_headers +7 -0
  47. package/public/app/oauth-callback.html +62 -0
  48. package/src/animation.ts +235 -8
  49. package/src/appFiles.ts +201 -0
  50. package/src/base64.ts +43 -0
  51. package/src/bitmapPlan.ts +143 -0
  52. package/src/bitmapStore.ts +206 -0
  53. package/src/bitmapWorker.ts +41 -0
  54. package/src/bitmapWorkerProtocol.ts +15 -0
  55. package/src/bitmaps.ts +283 -0
  56. package/src/blockedHosts.ts +29 -0
  57. package/src/canvasDrawing.ts +361 -0
  58. package/src/chartDom.ts +2 -2
  59. package/src/chromeColor.ts +179 -0
  60. package/src/clipboard.ts +78 -0
  61. package/src/colorMultiply.ts +93 -0
  62. package/src/colorPicker.ts +44 -0
  63. package/src/corsProxy.ts +45 -0
  64. package/src/gaugeRing.ts +74 -0
  65. package/src/gif.ts +297 -0
  66. package/src/hostRequests.ts +426 -0
  67. package/src/imageOps.ts +592 -0
  68. package/src/interaction.ts +18 -1
  69. package/src/landing.ts +2 -2
  70. package/src/layout/chart.ts +9 -3
  71. package/src/layout/engine.ts +279 -25
  72. package/src/layout/index.ts +3 -1
  73. package/src/layout/text.ts +217 -1
  74. package/src/layout/types.ts +31 -0
  75. package/src/layout/typography.ts +24 -7
  76. package/src/layout/video.ts +24 -0
  77. package/src/layoutDom.ts +53 -3
  78. package/src/main.ts +124 -4
  79. package/src/media.ts +132 -0
  80. package/src/mock.ts +2 -1
  81. package/src/notifications.ts +109 -0
  82. package/src/pathShape.ts +156 -0
  83. package/src/protocol.ts +761 -12
  84. package/src/renderer.ts +584 -36
  85. package/src/richText.ts +65 -0
  86. package/src/scanner.ts +234 -0
  87. package/src/scrollTo.ts +123 -0
  88. package/src/styles.css +212 -7
  89. package/src/styles.ts +31 -0
  90. package/src/swiftData.ts +128 -0
  91. package/src/symbols.ts +564 -21
  92. package/src/translation.ts +126 -0
  93. package/src/video.ts +299 -0
  94. package/src/vite-env.d.ts +6 -0
  95. package/src/wasm.ts +227 -32
  96. package/src/webAuth.ts +267 -0
  97. package/src/webSocket.ts +148 -0
  98. package/src/zoneinfo.ts +269 -0
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Rich text on the page (a `text` with `runs`; docs/ops-protocol.md, "Rich
3
+ * text"). The renderer turns each run into a template when the props arrive:
4
+ * a `span` carrying the run's color, lines, kerning, baseline offset and link,
5
+ * or the `image` element of an inline image. The layout pass then draws the
6
+ * engine's lines out of them: a block per line, the height the engine gave
7
+ * it, holding a clone of its run's span (with the run's font) per text
8
+ * fragment and the image elements themselves.
9
+ */
10
+
11
+ import type { RichTextLayout } from './layout';
12
+ import { bitmapLaidOut } from './bitmaps';
13
+
14
+ /** The templates of a rich text's runs, by its element. */
15
+ export const runTemplates = new WeakMap<HTMLElement, HTMLElement[]>();
16
+
17
+ /** What a text element last drew, to skip redrawing the same lines. */
18
+ const drawn = new WeakMap<HTMLElement, { templates: HTMLElement[]; key: string }>();
19
+
20
+ /** Draws the engine's lines of a rich text into its element. */
21
+ export function drawRichText(el: HTMLElement, rich: RichTextLayout): void {
22
+ const templates = runTemplates.get(el) ?? [];
23
+ const key = JSON.stringify([rich.lines, rich.heights, rich.fonts.map((font) => font.css)]);
24
+ const last = drawn.get(el);
25
+ if (last && last.templates === templates && last.key === key) return;
26
+ drawn.set(el, { templates, key });
27
+ const doc = el.ownerDocument;
28
+ const rows = rich.lines.map((line, index) => {
29
+ const row = doc.createElement('span');
30
+ row.className = 'sb-text-line';
31
+ const height = `${rich.heights[index] ?? 0}px`;
32
+ row.style.height = height;
33
+ row.style.lineHeight = height;
34
+ for (const fragment of line) {
35
+ const template = templates[fragment.run];
36
+ if (fragment.text === undefined) {
37
+ // The image element itself: an image run is never split.
38
+ if (!template) continue;
39
+ const s = template.style;
40
+ s.width = `${fragment.width}px`;
41
+ s.height = `${fragment.height ?? 0}px`;
42
+ // A symbol's glyph is sized by its run's font.
43
+ const font = rich.fonts[fragment.run];
44
+ if (font) s.fontSize = `${font.size}px`;
45
+ row.append(template);
46
+ bitmapLaidOut(template, fragment.width, fragment.height ?? 0);
47
+ continue;
48
+ }
49
+ const span = (template?.cloneNode(false) as HTMLElement | undefined) ?? doc.createElement('span');
50
+ const font = rich.fonts[fragment.run];
51
+ if (font) span.style.font = font.css;
52
+ // Every fragment shares the line's baseline (its height is the tallest run's).
53
+ span.style.lineHeight = 'inherit';
54
+ span.textContent = fragment.text;
55
+ row.append(span);
56
+ }
57
+ return row;
58
+ });
59
+ el.replaceChildren(...rows);
60
+ }
61
+
62
+ /** Forget what the element drew (it shows plain text now). */
63
+ export function clearRichText(el: HTMLElement): void {
64
+ drawn.delete(el);
65
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The page side of AVFoundation's capture sessions (docs/ops-protocol.md,
3
+ * "Code scanning"). Where the browser has `BarcodeDetector` and a camera
4
+ * (Chrome on Android and macOS, today), `scan` opens the camera full screen
5
+ * over the page and answers with the first code it reads and the frame it
6
+ * read it in. Elsewhere, or when there is no camera, it asks the viewer to
7
+ * type or paste the code; a refused camera fails.
8
+ */
9
+
10
+ import type { ScannerRequestParams } from './protocol';
11
+
12
+ /** A code the camera read (corners in video pixels), or one the viewer typed (no corners). */
13
+ export interface ScannedCode {
14
+ string: string;
15
+ format: string;
16
+ corners: { x: number; y: number }[];
17
+ /** The camera frame the code was read in, a base64 JPEG. */
18
+ image?: string;
19
+ }
20
+
21
+ export type ScannerResult = { camera: boolean } | ScannedCode | { cancelled: true };
22
+
23
+ export interface ScanOptions {
24
+ torch: boolean;
25
+ vibrate: boolean;
26
+ facing: 'environment' | 'user';
27
+ }
28
+
29
+ /** The browser's camera and barcode detection; tests substitute them. */
30
+ export interface ScannerHost {
31
+ /** Whether there is a camera and a detector for at least one of `formats`. */
32
+ available(formats: readonly string[]): Promise<boolean>;
33
+ /**
34
+ * Shows the camera (or asks for a typed code) until a code of `formats` is
35
+ * found (resolves with it) or the viewer closes it (null). Rejects with
36
+ * `permission: …` when the camera is refused.
37
+ */
38
+ scan(formats: readonly string[], options: ScanOptions): Promise<ScannedCode | null>;
39
+ }
40
+
41
+ export async function performScanner(params: ScannerRequestParams, host: ScannerHost | undefined): Promise<ScannerResult> {
42
+ switch (params.action) {
43
+ case 'probe':
44
+ return { camera: (await host?.available(params.formats).catch(() => false)) ?? false };
45
+ case 'scan': {
46
+ if (!host) throw new Error('unsupported: this page cannot scan codes');
47
+ const options: ScanOptions = { torch: params.torch ?? false, vibrate: params.vibrate ?? false, facing: params.facing ?? 'environment' };
48
+ const code = await host.scan(params.formats, options);
49
+ return code ?? { cancelled: true };
50
+ }
51
+ default:
52
+ throw new Error(`unsupported: scanner action ${String((params as { action: unknown }).action)}`);
53
+ }
54
+ }
55
+
56
+ interface DetectedBarcode {
57
+ rawValue: string;
58
+ format: string;
59
+ cornerPoints: { x: number; y: number }[];
60
+ }
61
+
62
+ interface BarcodeDetectorLike {
63
+ detect(source: HTMLVideoElement): Promise<DetectedBarcode[]>;
64
+ }
65
+
66
+ interface BarcodeDetectorClass {
67
+ new (options: { formats: string[] }): BarcodeDetectorLike;
68
+ getSupportedFormats(): Promise<string[]>;
69
+ }
70
+
71
+ /** The camera with `BarcodeDetector` where the browser has both, else a typed code; nothing outside a browser. */
72
+ export function browserScannerHost(): ScannerHost | undefined {
73
+ if (typeof window === 'undefined' || typeof document === 'undefined') return undefined;
74
+ const Detector = (window as unknown as { BarcodeDetector?: BarcodeDetectorClass }).BarcodeDetector;
75
+ const canCapture = typeof navigator.mediaDevices?.getUserMedia === 'function';
76
+ const supported = async (formats: readonly string[]): Promise<string[]> => {
77
+ if (!Detector) return [];
78
+ const known = await Detector.getSupportedFormats();
79
+ return formats.filter((format) => known.includes(format));
80
+ };
81
+ return {
82
+ available: async (formats) => {
83
+ if (!canCapture || (await supported(formats)).length === 0) return false;
84
+ const devices = await navigator.mediaDevices.enumerateDevices();
85
+ return devices.some((device) => device.kind === 'videoinput');
86
+ },
87
+ scan: async (formats, options) => {
88
+ const readable = await supported(formats);
89
+ if (!Detector || !canCapture || readable.length === 0) return typedCode(formats);
90
+ let stream: MediaStream;
91
+ try {
92
+ stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: options.facing }, audio: false });
93
+ } catch (error) {
94
+ const name = error instanceof Error ? error.name : '';
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ if (name === 'NotAllowedError' || name === 'SecurityError') throw new Error(`permission: ${message}`);
97
+ // No camera after all (or one that would not start): the viewer types the code.
98
+ return typedCode(formats);
99
+ }
100
+ const detector = new Detector({ formats: readable });
101
+ if (options.torch) {
102
+ const [track] = stream.getVideoTracks();
103
+ await track?.applyConstraints({ advanced: [{ torch: true } as MediaTrackConstraintSet] }).catch(() => undefined);
104
+ }
105
+ return cameraOverlay(stream, detector, options.vibrate);
106
+ },
107
+ };
108
+ }
109
+
110
+ const OVERLAY_STYLE = 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center';
111
+ const FONT = '-apple-system,system-ui,sans-serif';
112
+
113
+ function pillButton(label: string, primary: boolean): HTMLButtonElement {
114
+ const button = document.createElement('button');
115
+ button.type = 'button';
116
+ button.textContent = label;
117
+ button.style.cssText = `padding:12px 28px;border:0;border-radius:999px;font:600 17px ${FONT};color:#fff;background:${
118
+ primary ? '#007aff' : 'rgba(255,255,255,.25)'
119
+ };backdrop-filter:blur(12px)`;
120
+ return button;
121
+ }
122
+
123
+ /** The video's current frame as a base64 JPEG (no prefix); undefined when it cannot be read. */
124
+ function frameJPEG(video: HTMLVideoElement): string | undefined {
125
+ try {
126
+ const canvas = document.createElement('canvas');
127
+ canvas.width = video.videoWidth;
128
+ canvas.height = video.videoHeight;
129
+ if (canvas.width === 0 || canvas.height === 0) return undefined;
130
+ canvas.getContext('2d')?.drawImage(video, 0, 0);
131
+ return canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
137
+ /** The camera full screen with a viewfinder and a Cancel button, polling the detector. */
138
+ function cameraOverlay(stream: MediaStream, detector: BarcodeDetectorLike, vibrate: boolean): Promise<ScannedCode | null> {
139
+ const overlay = document.createElement('div');
140
+ overlay.dataset['sbScanner'] = 'camera';
141
+ overlay.style.cssText = `${OVERLAY_STYLE};background:#000`;
142
+ const video = document.createElement('video');
143
+ video.muted = true;
144
+ video.playsInline = true;
145
+ video.autoplay = true;
146
+ video.srcObject = stream;
147
+ video.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
148
+ const finder = document.createElement('div');
149
+ finder.style.cssText = 'position:relative;width:min(70vw,70vh);aspect-ratio:1;border:3px solid rgba(255,255,255,.9);border-radius:24px;box-shadow:0 0 0 100vmax rgba(0,0,0,.35)';
150
+ const cancel = pillButton('Cancel', false);
151
+ cancel.style.cssText += ';position:absolute;bottom:calc(env(safe-area-inset-bottom) + 32px);left:50%;transform:translateX(-50%)';
152
+ overlay.append(video, finder, cancel);
153
+ document.body.append(overlay);
154
+ void video.play().catch(() => undefined);
155
+
156
+ return new Promise((resolve) => {
157
+ let done = false;
158
+ const finish = (code: ScannedCode | null): void => {
159
+ if (done) return;
160
+ done = true;
161
+ for (const track of stream.getTracks()) track.stop();
162
+ overlay.remove();
163
+ resolve(code);
164
+ };
165
+ cancel.addEventListener('click', () => finish(null));
166
+ const tick = async (): Promise<void> => {
167
+ if (done) return;
168
+ if (video.readyState >= 2) {
169
+ try {
170
+ const [found] = await detector.detect(video);
171
+ if (found) {
172
+ if (vibrate) navigator.vibrate?.(100);
173
+ const image = frameJPEG(video);
174
+ finish({
175
+ string: found.rawValue,
176
+ format: found.format,
177
+ corners: found.cornerPoints.map(({ x, y }) => ({ x, y })),
178
+ ...(image ? { image } : {}),
179
+ });
180
+ return;
181
+ }
182
+ } catch {
183
+ // A frame the detector could not read; try the next one.
184
+ }
185
+ }
186
+ setTimeout(() => void tick(), 120);
187
+ };
188
+ void tick();
189
+ });
190
+ }
191
+
192
+ /** Where the browser cannot read codes: a dialog over the page asking for the code's contents. */
193
+ export function typedCode(formats: readonly string[]): Promise<ScannedCode | null> {
194
+ const overlay = document.createElement('div');
195
+ overlay.dataset['sbScanner'] = 'typed';
196
+ overlay.style.cssText = `${OVERLAY_STYLE};background:rgba(0,0,0,.6);padding:16px`;
197
+ const panel = document.createElement('form');
198
+ panel.style.cssText = `width:min(420px,100%);display:flex;flex-direction:column;gap:12px;padding:20px;border-radius:20px;background:#1c1c1e;color:#fff;font:15px ${FONT}`;
199
+ const title = document.createElement('h2');
200
+ title.textContent = 'Enter a Code';
201
+ title.style.cssText = 'margin:0;font-size:20px;font-weight:600';
202
+ const note = document.createElement('p');
203
+ note.textContent = "This browser can't read codes with the camera. Type or paste what the code holds.";
204
+ note.style.cssText = 'margin:0;color:rgba(255,255,255,.7)';
205
+ const field = document.createElement('textarea');
206
+ field.rows = 3;
207
+ field.setAttribute('aria-label', 'Code contents');
208
+ field.style.cssText = `resize:vertical;padding:10px;border-radius:10px;border:1px solid rgba(255,255,255,.2);background:#2c2c2e;color:#fff;font:15px ${FONT}`;
209
+ const buttons = document.createElement('div');
210
+ buttons.style.cssText = 'display:flex;gap:12px;justify-content:flex-end';
211
+ const cancel = pillButton('Cancel', false);
212
+ const done = pillButton('Done', true);
213
+ done.type = 'submit';
214
+ buttons.append(cancel, done);
215
+ panel.append(title, note, field, buttons);
216
+ overlay.append(panel);
217
+ document.body.append(overlay);
218
+ field.focus();
219
+
220
+ return new Promise((resolve) => {
221
+ const finish = (code: ScannedCode | null): void => {
222
+ overlay.remove();
223
+ resolve(code);
224
+ };
225
+ cancel.addEventListener('click', () => finish(null));
226
+ panel.addEventListener('submit', (event) => {
227
+ event.preventDefault();
228
+ // Line breaks are kept: a QR code holds several lines as often as one.
229
+ const string = field.value.replace(/\r\n/g, '\n');
230
+ if (string.length === 0) return;
231
+ finish({ string, format: formats[0] ?? 'qr_code', corners: [] });
232
+ });
233
+ });
234
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `ScrollViewProxy.scrollTo(_:anchor:)` (docs/ops-protocol.md, "Scrolling to
3
+ * a view"). Swift marks the target's `styled` box with `scrollTo` in exactly
4
+ * one pass; after laying that pass out the renderer calls `scrollToTarget`,
5
+ * which scrolls every scrolling ancestor (`scrollview`, `list`) along its
6
+ * axes so the target's anchor point meets the same point of the visible
7
+ * area, or, with no anchor, as little as makes the target visible.
8
+ *
9
+ * Geometry comes from the layout result, whose frames are screen
10
+ * coordinates with every scroll view at offset 0, so the wanted offset is a
11
+ * difference of frames and independent of where the views are scrolled now
12
+ * (and of any CSS scaling of the device frame).
13
+ */
14
+
15
+ import type { LayoutNode, LayoutResult, Rect } from './layout';
16
+ import type { ScrollToCommand, UnitPoint } from './protocol';
17
+
18
+ /** One axis of a target and a viewport (start and length), in the same coordinates. */
19
+ export interface AxisSpan {
20
+ start: number;
21
+ length: number;
22
+ }
23
+
24
+ /**
25
+ * The scroll offset on one axis that puts the target where the command
26
+ * wants it: `anchor` (0 = start, 1 = end) aligns that fraction of the target
27
+ * with the same fraction of the viewport; `null` keeps `current` when the
28
+ * target is fully visible, else aligns the nearer edge (the start when the
29
+ * target is longer than the viewport). Never negative; the browser clamps
30
+ * the far end.
31
+ */
32
+ export function scrollOffset(target: AxisSpan, viewport: AxisSpan, anchor: number | null, current: number): number {
33
+ const top = target.start - viewport.start;
34
+ let offset: number;
35
+ if (anchor !== null) {
36
+ offset = top - anchor * (viewport.length - target.length);
37
+ } else if (top < current || target.length > viewport.length) {
38
+ offset = top;
39
+ } else if (top + target.length > current + viewport.length) {
40
+ offset = top + target.length - viewport.length;
41
+ } else {
42
+ offset = current;
43
+ }
44
+ return Math.max(0, offset);
45
+ }
46
+
47
+ /** What `scrollToTarget` needs from the renderer. */
48
+ export interface ScrollContext {
49
+ nodes: Map<number, LayoutNode>;
50
+ parents: Map<number, number>;
51
+ elements: Map<number, HTMLElement>;
52
+ }
53
+
54
+ function anchorOf(command: ScrollToCommand | undefined): UnitPoint | null {
55
+ const anchor = command?.anchor;
56
+ if (!anchor || typeof anchor.x !== 'number' || typeof anchor.y !== 'number') return null;
57
+ return { x: anchor.x, y: anchor.y };
58
+ }
59
+
60
+ /** The axes a scrolling element scrolls on, or null for anything else. */
61
+ function scrollAxes(node: LayoutNode): { horizontal: boolean; vertical: boolean } | null {
62
+ if (node.kind === 'list') return { horizontal: false, vertical: true };
63
+ if (node.kind !== 'scrollview') return null;
64
+ const axes = node.props['axes'];
65
+ return { horizontal: axes === 'horizontal' || axes === 'both', vertical: axes !== 'horizontal' };
66
+ }
67
+
68
+ /**
69
+ * Scroll the scrolling ancestors of element `id` to it. Each axis is
70
+ * scrolled by the nearest ancestor that scrolls on it; the visible area of
71
+ * a scroll view a tab bar covers ends at the bar (chrome `bottomInset`).
72
+ * Returns how many scroll positions it set (for tests).
73
+ */
74
+ export function scrollToTarget(
75
+ ctx: ScrollContext,
76
+ result: LayoutResult,
77
+ id: number,
78
+ command: ScrollToCommand | undefined,
79
+ smooth: boolean,
80
+ ): number {
81
+ // A list row's box is the whole row, not its content.
82
+ const target = result.chrome.get(id)?.['row'] ?? result.frames.get(id);
83
+ if (!target) return 0;
84
+ const anchor = anchorOf(command);
85
+ let horizontalDone = false;
86
+ let verticalDone = false;
87
+ let scrolled = 0;
88
+ for (let at = ctx.parents.get(id); at !== undefined && at !== 0; at = ctx.parents.get(at)) {
89
+ const node = ctx.nodes.get(at);
90
+ const el = ctx.elements.get(at);
91
+ const frame = result.frames.get(at);
92
+ if (!node || !el || !frame) continue;
93
+ const axes = scrollAxes(node);
94
+ if (!axes) continue;
95
+ const viewport: Rect = { ...frame };
96
+ const covered = result.chrome.get(at)?.['bottomInset'];
97
+ if (covered) viewport.height = Math.max(0, covered.y - frame.y);
98
+ let left = el.scrollLeft;
99
+ let top = el.scrollTop;
100
+ let changed = false;
101
+ if (axes.horizontal && !horizontalDone) {
102
+ left = scrollOffset({ start: target.x, length: target.width }, { start: viewport.x, length: viewport.width }, anchor?.x ?? null, el.scrollLeft);
103
+ horizontalDone = true;
104
+ changed = true;
105
+ }
106
+ if (axes.vertical && !verticalDone) {
107
+ top = scrollOffset({ start: target.y, length: target.height }, { start: viewport.y, length: viewport.height }, anchor?.y ?? null, el.scrollTop);
108
+ verticalDone = true;
109
+ changed = true;
110
+ }
111
+ if (changed) {
112
+ if (smooth && typeof el.scrollTo === 'function') {
113
+ el.scrollTo({ left, top, behavior: 'smooth' });
114
+ } else {
115
+ el.scrollLeft = left;
116
+ el.scrollTop = top;
117
+ }
118
+ scrolled += 1;
119
+ }
120
+ if (horizontalDone && verticalDone) break;
121
+ }
122
+ return scrolled;
123
+ }