@swiftbrowser/web 0.1.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.
- package/README.md +600 -0
- package/app/index.html +125 -0
- package/index.html +56 -0
- package/node/dist/node/apps.d.ts +58 -0
- package/node/dist/node/apps.d.ts.map +1 -0
- package/node/dist/node/apps.js +60 -0
- package/node/dist/node/apps.js.map +1 -0
- package/node/dist/node/config.d.ts +42 -0
- package/node/dist/node/config.d.ts.map +1 -0
- package/node/dist/node/config.js +99 -0
- package/node/dist/node/config.js.map +1 -0
- package/node/dist/node/examples.d.ts +39 -0
- package/node/dist/node/examples.d.ts.map +1 -0
- package/node/dist/node/examples.js +116 -0
- package/node/dist/node/examples.js.map +1 -0
- package/node/dist/node/index.d.ts +47 -0
- package/node/dist/node/index.d.ts.map +1 -0
- package/node/dist/node/index.js +52 -0
- package/node/dist/node/index.js.map +1 -0
- package/node/dist/plugins/app-manifests.d.ts +61 -0
- package/node/dist/plugins/app-manifests.d.ts.map +1 -0
- package/node/dist/plugins/app-manifests.js +168 -0
- package/node/dist/plugins/app-manifests.js.map +1 -0
- package/node/dist/plugins/asset-catalogs.d.ts +119 -0
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
- package/node/dist/plugins/asset-catalogs.js +488 -0
- package/node/dist/plugins/asset-catalogs.js.map +1 -0
- package/node/dist/plugins/bundle-resources.d.ts +49 -0
- package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
- package/node/dist/plugins/bundle-resources.js +172 -0
- package/node/dist/plugins/bundle-resources.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +33 -0
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
- package/node/dist/plugins/swift-wasm.js +241 -0
- package/node/dist/plugins/swift-wasm.js.map +1 -0
- package/node/dist/src/assets.d.ts +60 -0
- package/node/dist/src/assets.d.ts.map +1 -0
- package/node/dist/src/assets.js +54 -0
- package/node/dist/src/assets.js.map +1 -0
- package/node/dist/src/devEvents.d.ts +31 -0
- package/node/dist/src/devEvents.d.ts.map +1 -0
- package/node/dist/src/devEvents.js +9 -0
- package/node/dist/src/devEvents.js.map +1 -0
- package/package.json +56 -0
- package/plugins/app-manifests.ts +192 -0
- package/plugins/asset-catalogs.ts +558 -0
- package/plugins/bundle-resources.ts +195 -0
- package/plugins/swift-wasm.ts +259 -0
- package/public/_headers +23 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/icon.svg +14 -0
- package/public/manifest.webmanifest +14 -0
- package/src/animation.ts +521 -0
- package/src/assets.ts +86 -0
- package/src/chartDom.ts +481 -0
- package/src/devEvents.ts +35 -0
- package/src/deviceMode.ts +80 -0
- package/src/ghost.ts +25 -0
- package/src/interaction.ts +1113 -0
- package/src/landing.css +261 -0
- package/src/landing.ts +141 -0
- package/src/layout/chart.ts +477 -0
- package/src/layout/controls.ts +116 -0
- package/src/layout/engine.ts +3581 -0
- package/src/layout/index.ts +91 -0
- package/src/layout/measure.ts +86 -0
- package/src/layout/text.ts +124 -0
- package/src/layout/types.ts +153 -0
- package/src/layout/typography.ts +188 -0
- package/src/layoutDom.ts +549 -0
- package/src/layoutEvents.ts +110 -0
- package/src/locale.ts +64 -0
- package/src/main.ts +554 -0
- package/src/mock.ts +752 -0
- package/src/nodeQueries.ts +60 -0
- package/src/protocol.ts +1421 -0
- package/src/renderer.ts +2882 -0
- package/src/styles.css +2834 -0
- package/src/styles.ts +373 -0
- package/src/swipe.ts +110 -0
- package/src/symbols.ts +595 -0
- package/src/typography.ts +76 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wasm.ts +400 -0
package/src/layoutDom.ts
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies a `LayoutResult` from the layout engine (src/layout) to the DOM.
|
|
3
|
+
*
|
|
4
|
+
* Every protocol element is `position: absolute`. The engine reports frames
|
|
5
|
+
* in screen coordinates; an element is positioned relative to the *origin* of
|
|
6
|
+
* its parent element's box, which is the parent's frame except for:
|
|
7
|
+
*
|
|
8
|
+
* - `scrollview`: children live in `.sb-scroll-content` (sized from
|
|
9
|
+
* `contentSizes`, offset by `chrome.content`) so the browser scrolls it;
|
|
10
|
+
* - `list`: children live in `.sb-list-content`, sized from `contentSizes`;
|
|
11
|
+
* - `section`: rows live in `.sb-section-rows`, the inset card (`chrome.rows`);
|
|
12
|
+
* - `sheet`: the element itself is a full-screen layer (scrim); the engine's
|
|
13
|
+
* frame is applied to its card and children are relative to the card;
|
|
14
|
+
* - `tabview`: tabs live in `.sb-tabview-content`; the tab bar (`chrome.bar`,
|
|
15
|
+
* items at `chrome.item<i>`) is drawn on top. Tabs in `result.hidden` get
|
|
16
|
+
* `data-sb-tab-hidden` (visibility hidden, inert) so their DOM and scroll
|
|
17
|
+
* positions survive; their subtrees have no frames and are left alone.
|
|
18
|
+
*
|
|
19
|
+
* A list row's DOM box is the full-width row (`chrome.row`) rather than its
|
|
20
|
+
* content frame, so the row background, separator and hit area match iOS;
|
|
21
|
+
* `text`, `image` and `textfield` rows pad their content back into place.
|
|
22
|
+
*
|
|
23
|
+
* Phase 6: the items of a `menu` (every child but the first) are in
|
|
24
|
+
* `result.hidden` without frames; they get the `hidden` attribute so they
|
|
25
|
+
* never show inline (the renderer lists them in a popover). A list row whose
|
|
26
|
+
* `styled` carries a `badge` gets a `.sb-badge` span at `chrome.badge`; an
|
|
27
|
+
* inline `picker` positions its label/option rows; a navscreen's inline title
|
|
28
|
+
* is positioned at `chrome.title` so it truncates around the bar items.
|
|
29
|
+
*
|
|
30
|
+
* Phase 7: the candidates a `viewthatfits` did not choose are in
|
|
31
|
+
* `result.hidden` without frames and get the `hidden` attribute like menu
|
|
32
|
+
* items (and lose it when chosen later); a `navsplit` positions its
|
|
33
|
+
* separator hairline at `chrome.separator`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
|
|
37
|
+
import { applyChartChrome } from './chartDom';
|
|
38
|
+
|
|
39
|
+
export interface LayoutDomContext {
|
|
40
|
+
root: HTMLElement;
|
|
41
|
+
elements: Map<number, HTMLElement>;
|
|
42
|
+
nodes: Map<number, LayoutNode>;
|
|
43
|
+
parents: Map<number, number>;
|
|
44
|
+
/**
|
|
45
|
+
* Boxes applied by the previous layout, by id, relative to the origin of
|
|
46
|
+
* the parent element (the coordinates the inline `left`/`top` use). A child
|
|
47
|
+
* that moves only because its parent moved therefore reads as unchanged:
|
|
48
|
+
* it is neither repositioned nor animated, the parent is.
|
|
49
|
+
*/
|
|
50
|
+
boxes: Map<number, Rect>;
|
|
51
|
+
/** Elements whose inline style was reset since the previous layout. */
|
|
52
|
+
dirty: Set<number>;
|
|
53
|
+
/** The environment the result was laid out for (screen size, safe areas). */
|
|
54
|
+
env: LayoutEnvironment;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An element whose DOM box moved or resized (`from` is null for a new
|
|
59
|
+
* element). Boxes are relative to the parent's origin, so the delta is the
|
|
60
|
+
* element's own motion and not its ancestors'.
|
|
61
|
+
*/
|
|
62
|
+
export interface BoxChange {
|
|
63
|
+
id: number;
|
|
64
|
+
el: HTMLElement;
|
|
65
|
+
kind: string;
|
|
66
|
+
from: Rect | null;
|
|
67
|
+
to: Rect;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ROOT_ID = 0;
|
|
71
|
+
const SWITCH_SIZE = { width: 51, height: 31 };
|
|
72
|
+
const CHEVRON_SIZE = 14;
|
|
73
|
+
const NAV_BAR_HEIGHT = 44;
|
|
74
|
+
|
|
75
|
+
export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxChange[] {
|
|
76
|
+
const changes: BoxChange[] = [];
|
|
77
|
+
// Phase 9: an incremental result names what it touched; everything else
|
|
78
|
+
// kept its frame, chrome and box. A one-off result is visited whole.
|
|
79
|
+
const boxes = result.changed ? ctx.boxes : new Map<number, Rect>();
|
|
80
|
+
let ids: Iterable<number>;
|
|
81
|
+
if (result.changed) {
|
|
82
|
+
const visit = new Set(result.changed);
|
|
83
|
+
for (const id of ctx.dirty) visit.add(id);
|
|
84
|
+
ids = visit;
|
|
85
|
+
} else {
|
|
86
|
+
ids = result.frames.keys();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const id of ids) {
|
|
90
|
+
const frame = result.frames.get(id);
|
|
91
|
+
const el = ctx.elements.get(id);
|
|
92
|
+
const node = ctx.nodes.get(id);
|
|
93
|
+
if (!el || !node) continue;
|
|
94
|
+
if (!frame) {
|
|
95
|
+
boxes.delete(id);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const box = boxOf(ctx, result, node, frame);
|
|
99
|
+
const origin = node.kind === 'sheet' ? { x: 0, y: 0 } : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
|
|
100
|
+
const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
|
|
101
|
+
const previous = ctx.boxes.get(id) ?? null;
|
|
102
|
+
boxes.set(id, relative);
|
|
103
|
+
const moved = !previous || !sameRect(previous, relative);
|
|
104
|
+
if (moved) changes.push({ id, el, kind: node.kind, from: previous, to: relative });
|
|
105
|
+
if (moved || ctx.dirty.has(id)) {
|
|
106
|
+
if (node.kind === 'sheet') positionSheetCard(el, box);
|
|
107
|
+
else positionBox(el, box, origin);
|
|
108
|
+
}
|
|
109
|
+
// A former menu item that is laid out again (moved out of the menu), or a
|
|
110
|
+
// `viewthatfits` candidate chosen again, shows again.
|
|
111
|
+
if (el.hidden) el.hidden = false;
|
|
112
|
+
applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame));
|
|
113
|
+
if (node.kind === 'tab') setTabHidden(el, result.hidden.has(id));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Phase 6: menu items are hidden without a frame (tabs keep theirs, above);
|
|
117
|
+
// Phase 7: so are the unchosen candidates of a `viewthatfits`.
|
|
118
|
+
for (const id of result.hidden) {
|
|
119
|
+
const el = ctx.elements.get(id);
|
|
120
|
+
const node = ctx.nodes.get(id);
|
|
121
|
+
if (!el || !node || node.kind === 'tab' || result.frames.has(id)) continue;
|
|
122
|
+
if (!el.hidden) el.hidden = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
ctx.boxes = boxes;
|
|
126
|
+
ctx.dirty.clear();
|
|
127
|
+
return changes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const LIST_ROW_MIN_HEIGHT = 44;
|
|
131
|
+
const LIST_ROW_VPAD = 11;
|
|
132
|
+
|
|
133
|
+
/** Hide a non-selected tab without dropping its DOM (or its scroll positions). */
|
|
134
|
+
export function setTabHidden(tab: HTMLElement, hidden: boolean): void {
|
|
135
|
+
tab.toggleAttribute('data-sb-tab-hidden', hidden);
|
|
136
|
+
tab.toggleAttribute('inert', hidden);
|
|
137
|
+
tab.setAttribute('aria-hidden', String(hidden));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The DOM box of an element: its full row inside a list, else its frame.
|
|
142
|
+
* Rows whose own chrome replaced the engine's `row` key (toggles, bordered
|
|
143
|
+
* buttons) get the row rebuilt from the card and the engine's row rule
|
|
144
|
+
* (content centered in max(44, content + 2 × 11)).
|
|
145
|
+
*/
|
|
146
|
+
export function boxOf(ctx: LayoutDomContext, result: LayoutResult, node: LayoutNode, frame: Rect): Rect {
|
|
147
|
+
const row = result.chrome.get(node.id)?.['row'];
|
|
148
|
+
if (row) return row;
|
|
149
|
+
if (node.kind === 'section') return frame;
|
|
150
|
+
const parentId = ctx.parents.get(node.id);
|
|
151
|
+
const parent = parentId !== undefined ? ctx.nodes.get(parentId) : undefined;
|
|
152
|
+
if (!parent || (parent.kind !== 'section' && parent.kind !== 'list')) return frame;
|
|
153
|
+
const card = parent.kind === 'section' ? result.chrome.get(parent.id)?.['rows'] : result.frames.get(parent.id);
|
|
154
|
+
if (!card) return frame;
|
|
155
|
+
const height = Math.max(LIST_ROW_MIN_HEIGHT, frame.height + 2 * LIST_ROW_VPAD);
|
|
156
|
+
return { x: card.x, y: frame.y - (height - frame.height) / 2, width: card.width, height };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Screen-coordinate origin that the children of `parentId` are positioned against. */
|
|
160
|
+
export function originOf(ctx: LayoutDomContext, result: LayoutResult, parentId: number): { x: number; y: number } {
|
|
161
|
+
if (parentId === ROOT_ID) return { x: 0, y: 0 };
|
|
162
|
+
const node = ctx.nodes.get(parentId);
|
|
163
|
+
const frame = result.frames.get(parentId);
|
|
164
|
+
if (!node || !frame) return { x: 0, y: 0 };
|
|
165
|
+
const chrome = result.chrome.get(parentId);
|
|
166
|
+
switch (node.kind) {
|
|
167
|
+
case 'scrollview':
|
|
168
|
+
return chrome?.['content'] ?? frame;
|
|
169
|
+
case 'list':
|
|
170
|
+
case 'sheet':
|
|
171
|
+
return frame;
|
|
172
|
+
case 'section':
|
|
173
|
+
return chrome?.['rows'] ?? frame;
|
|
174
|
+
default:
|
|
175
|
+
return boxOf(ctx, result, node, frame);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* iOS adds the bottom safe area as a content inset to scrolling views that
|
|
181
|
+
* reach the bottom of the screen, so the last row can scroll clear of the
|
|
182
|
+
* home indicator. Inside a sheet the card already ends above it (0).
|
|
183
|
+
*/
|
|
184
|
+
function scrollBottomInset(ctx: LayoutDomContext, result: LayoutResult, node: LayoutNode, frame: Rect): number {
|
|
185
|
+
if (node.kind !== 'list' && node.kind !== 'scrollview') return 0;
|
|
186
|
+
// Under a tab bar the engine reports the covered part of the frame; the
|
|
187
|
+
// bar (49 + safe area) replaces the plain safe-area inset.
|
|
188
|
+
const covered = result.chrome.get(node.id)?.['bottomInset'];
|
|
189
|
+
if (covered) return covered.height;
|
|
190
|
+
if (frame.y + frame.height < ctx.env.screen.height) return 0;
|
|
191
|
+
for (let id = ctx.parents.get(node.id); id !== undefined && id !== ROOT_ID; id = ctx.parents.get(id)) {
|
|
192
|
+
if (ctx.nodes.get(id)?.kind === 'sheet') return 0;
|
|
193
|
+
}
|
|
194
|
+
return ctx.env.safeArea.bottom;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sameRect(a: Rect, b: Rect): boolean {
|
|
198
|
+
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function positionBox(el: HTMLElement, box: Rect, origin: { x: number; y: number }): void {
|
|
202
|
+
const s = el.style;
|
|
203
|
+
s.left = `${box.x - origin.x}px`;
|
|
204
|
+
s.top = `${box.y - origin.y}px`;
|
|
205
|
+
s.width = `${box.width}px`;
|
|
206
|
+
s.height = `${box.height}px`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Position a chrome node inside its element: `rect` is in screen coordinates. */
|
|
210
|
+
function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
|
|
211
|
+
if (!node) return;
|
|
212
|
+
if (!rect) {
|
|
213
|
+
node.hidden = true;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
node.hidden = false;
|
|
217
|
+
const s = node.style;
|
|
218
|
+
s.position = 'absolute';
|
|
219
|
+
s.left = `${rect.x - origin.x}px`;
|
|
220
|
+
s.top = `${rect.y - origin.y}px`;
|
|
221
|
+
s.width = `${rect.width}px`;
|
|
222
|
+
s.height = `${rect.height}px`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function positionSheetCard(sheet: HTMLElement, frame: Rect): void {
|
|
226
|
+
const card = sheet.querySelector<HTMLElement>(':scope > .sb-sheet-card');
|
|
227
|
+
if (!card) return;
|
|
228
|
+
const s = card.style;
|
|
229
|
+
s.left = `${frame.x}px`;
|
|
230
|
+
s.top = `${frame.y}px`;
|
|
231
|
+
s.width = `${frame.width}px`;
|
|
232
|
+
s.height = `${frame.height}px`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function setFont(el: HTMLElement, font: ResolvedFont | undefined): void {
|
|
236
|
+
if (!font) return;
|
|
237
|
+
if (el.style.font !== font.css) el.style.font = font.css;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const TEXT_ALIGN: Record<string, string> = { leading: 'left', center: 'center', trailing: 'right' };
|
|
241
|
+
|
|
242
|
+
/** Render the engine's exact lines into a text node (`white-space: pre` keeps them). */
|
|
243
|
+
function setTextLayout(el: HTMLElement, layout: TextLayout): void {
|
|
244
|
+
setFont(el, layout.font);
|
|
245
|
+
const align = TEXT_ALIGN[layout.alignment] ?? 'left';
|
|
246
|
+
if (el.style.textAlign !== align) el.style.textAlign = align;
|
|
247
|
+
const content = layout.lines.join('\n');
|
|
248
|
+
if (el.textContent !== content) el.textContent = content;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Phase 6 `.badge`: the trailing text of a list row, drawn by the row element
|
|
253
|
+
* (a `styled` row, or a `navlink` whose label carries the badge) at
|
|
254
|
+
* `chrome.badge`; removed when the row has no badge.
|
|
255
|
+
*/
|
|
256
|
+
function applyBadge(result: LayoutResult, node: LayoutNode, el: HTMLElement, box: Rect): void {
|
|
257
|
+
const badgeRect = result.chrome.get(node.id)?.['badge'];
|
|
258
|
+
let badge = el.querySelector<HTMLElement>(':scope > .sb-badge');
|
|
259
|
+
if (!badgeRect) {
|
|
260
|
+
badge?.remove();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!badge) {
|
|
264
|
+
badge = el.ownerDocument.createElement('span');
|
|
265
|
+
badge.className = 'sb-badge';
|
|
266
|
+
badge.setAttribute('aria-hidden', 'true');
|
|
267
|
+
el.append(badge);
|
|
268
|
+
}
|
|
269
|
+
placeChrome(badge, badgeRect, box);
|
|
270
|
+
const layout = result.chromeText.get(node.id)?.['badge'];
|
|
271
|
+
if (layout) {
|
|
272
|
+
setFont(badge, layout.font);
|
|
273
|
+
const text = layout.lines[0] ?? '';
|
|
274
|
+
if (badge.textContent !== text) badge.textContent = text;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** For leaf rows: pad the content frame back into place inside the row box. */
|
|
279
|
+
function padIntoFrame(el: HTMLElement, box: Rect, frame: Rect): void {
|
|
280
|
+
const s = el.style;
|
|
281
|
+
const left = frame.x - box.x;
|
|
282
|
+
const top = frame.y - box.y;
|
|
283
|
+
const right = box.x + box.width - (frame.x + frame.width);
|
|
284
|
+
const bottom = box.y + box.height - (frame.y + frame.height);
|
|
285
|
+
if (left || top || right || bottom) {
|
|
286
|
+
s.padding = `${Math.max(0, top)}px ${Math.max(0, right)}px ${Math.max(0, bottom)}px ${Math.max(0, left)}px`;
|
|
287
|
+
} else if (s.padding) {
|
|
288
|
+
s.padding = '';
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function applyChrome(
|
|
293
|
+
result: LayoutResult,
|
|
294
|
+
node: LayoutNode,
|
|
295
|
+
el: HTMLElement,
|
|
296
|
+
box: Rect,
|
|
297
|
+
frame: Rect,
|
|
298
|
+
bottomInset: number,
|
|
299
|
+
): void {
|
|
300
|
+
const chrome = result.chrome.get(node.id);
|
|
301
|
+
switch (node.kind) {
|
|
302
|
+
case 'styled':
|
|
303
|
+
applyBadge(result, node, el, box);
|
|
304
|
+
return;
|
|
305
|
+
case 'text': {
|
|
306
|
+
const layout = result.text.get(node.id);
|
|
307
|
+
if (layout) setTextLayout(el, layout);
|
|
308
|
+
padIntoFrame(el, box, frame);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
case 'image': {
|
|
312
|
+
const font = result.fonts.get(node.id);
|
|
313
|
+
if (font) {
|
|
314
|
+
const size = `${font.size}px`;
|
|
315
|
+
if (el.style.fontSize !== size) el.style.fontSize = size;
|
|
316
|
+
}
|
|
317
|
+
padIntoFrame(el, box, frame);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
case 'textfield':
|
|
321
|
+
case 'texteditor':
|
|
322
|
+
padIntoFrame(el, box, frame);
|
|
323
|
+
return;
|
|
324
|
+
// Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
|
|
325
|
+
case 'stepper':
|
|
326
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-stepper-control'), chrome?.['control'], box);
|
|
327
|
+
return;
|
|
328
|
+
case 'slider':
|
|
329
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-track'), chrome?.['track'], box);
|
|
330
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-fill'), chrome?.['fill'], box);
|
|
331
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-thumb'), chrome?.['thumb'], box);
|
|
332
|
+
return;
|
|
333
|
+
case 'datepicker':
|
|
334
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-date'), chrome?.['date'], box);
|
|
335
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-time'), chrome?.['time'], box);
|
|
336
|
+
return;
|
|
337
|
+
case 'alert': {
|
|
338
|
+
// Phase 8: the card(s), the centered title / message lines and the
|
|
339
|
+
// renderer-drawn OK; the button children have ordinary frames. Two
|
|
340
|
+
// buttons in one row get `data-sb-layout="row"` (a hairline between
|
|
341
|
+
// them); the appear animation scales the layer about the card's center.
|
|
342
|
+
const layer = el.querySelector<HTMLElement>(':scope > .sb-alert-layer');
|
|
343
|
+
const part = (cls: string): HTMLElement | null => layer?.querySelector<HTMLElement>(`:scope > .${cls}`) ?? null;
|
|
344
|
+
const texts = result.chromeText.get(node.id);
|
|
345
|
+
const card = chrome?.['card'];
|
|
346
|
+
placeChrome(part('sb-alert-card'), card, box);
|
|
347
|
+
placeChrome(part('sb-alert-cancel-card'), chrome?.['cancelCard'], box);
|
|
348
|
+
const title = part('sb-alert-title');
|
|
349
|
+
placeChrome(title, chrome?.['title'], box);
|
|
350
|
+
if (title && texts?.['title']) setTextLayout(title, texts['title']);
|
|
351
|
+
const message = part('sb-alert-message');
|
|
352
|
+
placeChrome(message, chrome?.['message'], box);
|
|
353
|
+
if (message && texts?.['message']) setTextLayout(message, texts['message']);
|
|
354
|
+
placeChrome(part('sb-alert-ok'), chrome?.['ok'], box);
|
|
355
|
+
el.toggleAttribute('data-sb-header', Boolean(chrome?.['title'] || chrome?.['message']));
|
|
356
|
+
const first = chrome?.['button0'];
|
|
357
|
+
const second = chrome?.['button1'];
|
|
358
|
+
el.dataset['sbLayout'] = first && second && first.y === second.y && !chrome?.['button2'] ? 'row' : 'stack';
|
|
359
|
+
if (layer && card) {
|
|
360
|
+
layer.style.transformOrigin = `${card.x + card.width / 2 - box.x}px ${card.y + card.height / 2 - box.y}px`;
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
case 'toggle': {
|
|
365
|
+
const sw = el.querySelector<HTMLElement>(':scope > .sb-switch');
|
|
366
|
+
const rect = chrome?.['switch'] ?? {
|
|
367
|
+
x: frame.x + frame.width - SWITCH_SIZE.width,
|
|
368
|
+
y: frame.y + (frame.height - SWITCH_SIZE.height) / 2,
|
|
369
|
+
...SWITCH_SIZE,
|
|
370
|
+
};
|
|
371
|
+
placeChrome(sw, rect, box);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
case 'navlink': {
|
|
375
|
+
// A link row's badge sits on its label (`NavigationLink { row.badge(n) }`).
|
|
376
|
+
applyBadge(result, node, el, box);
|
|
377
|
+
const chevron = el.querySelector<HTMLElement>(':scope > .sb-navlink-chevron');
|
|
378
|
+
const rect = chrome?.['chevron'];
|
|
379
|
+
if (chevron) {
|
|
380
|
+
if (rect) {
|
|
381
|
+
chevron.removeAttribute('hidden');
|
|
382
|
+
placeChrome(chevron, {
|
|
383
|
+
x: rect.x + (rect.width - CHEVRON_SIZE) / 2,
|
|
384
|
+
y: rect.y + (rect.height - CHEVRON_SIZE) / 2,
|
|
385
|
+
width: CHEVRON_SIZE,
|
|
386
|
+
height: CHEVRON_SIZE,
|
|
387
|
+
}, box);
|
|
388
|
+
} else {
|
|
389
|
+
chevron.toggleAttribute('hidden', true); // an SVG: the `hidden` property is not an attribute
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
case 'section': {
|
|
395
|
+
const rows = el.querySelector<HTMLElement>(':scope > .sb-section-rows');
|
|
396
|
+
const header = el.querySelector<HTMLElement>(':scope > .sb-section-header');
|
|
397
|
+
const footer = el.querySelector<HTMLElement>(':scope > .sb-section-footer');
|
|
398
|
+
const texts = result.chromeText.get(node.id);
|
|
399
|
+
if (chrome?.['rows']) placeChrome(rows, chrome['rows'], frame);
|
|
400
|
+
else if (rows) {
|
|
401
|
+
// Outside a list the section is a plain container.
|
|
402
|
+
rows.hidden = false;
|
|
403
|
+
rows.removeAttribute('style');
|
|
404
|
+
}
|
|
405
|
+
placeChrome(header, chrome?.['header'], frame);
|
|
406
|
+
placeChrome(footer, chrome?.['footer'], frame);
|
|
407
|
+
if (header && texts?.['header']) setTextLayout(header, texts['header']);
|
|
408
|
+
if (footer && texts?.['footer']) {
|
|
409
|
+
// The engine measures footers upper-cased like headers; iOS footers
|
|
410
|
+
// keep their case, so draw the prop text in the engine's font and
|
|
411
|
+
// rect and let it wrap there.
|
|
412
|
+
setFont(footer, texts['footer'].font);
|
|
413
|
+
footer.style.textAlign = 'left';
|
|
414
|
+
footer.style.whiteSpace = 'pre-wrap';
|
|
415
|
+
const original = typeof node.props['footer'] === 'string' ? node.props['footer'] : texts['footer'].lines.join('\n');
|
|
416
|
+
if (footer.textContent !== original) footer.textContent = original;
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
case 'list': {
|
|
421
|
+
const content = el.querySelector<HTMLElement>(':scope > .sb-list-content');
|
|
422
|
+
const size = result.contentSizes.get(node.id);
|
|
423
|
+
if (content && size) {
|
|
424
|
+
content.style.width = `${size.width}px`;
|
|
425
|
+
// Extra scrollable space at the end (the engine's frames are unchanged).
|
|
426
|
+
content.style.height = `${size.height + bottomInset}px`;
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
case 'scrollview': {
|
|
431
|
+
const content = el.querySelector<HTMLElement>(':scope > .sb-scroll-content');
|
|
432
|
+
const size = result.contentSizes.get(node.id);
|
|
433
|
+
const rect = chrome?.['content'];
|
|
434
|
+
if (content && size) {
|
|
435
|
+
const s = content.style;
|
|
436
|
+
const axes = String(node.props['axes'] ?? 'vertical');
|
|
437
|
+
s.width = `${size.width}px`;
|
|
438
|
+
s.height = `${size.height + (axes === 'horizontal' ? 0 : bottomInset)}px`;
|
|
439
|
+
s.marginLeft = `${rect ? rect.x - frame.x : 0}px`;
|
|
440
|
+
s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
|
|
441
|
+
}
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
case 'navscreen': {
|
|
445
|
+
const bar = el.querySelector<HTMLElement>(':scope > .sb-navbar');
|
|
446
|
+
const largeTitle = el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-navbar-large-title');
|
|
447
|
+
const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
|
|
448
|
+
const texts = result.chromeText.get(node.id);
|
|
449
|
+
const barRect = chrome?.['bar'];
|
|
450
|
+
if (bar && barRect) {
|
|
451
|
+
placeChrome(bar, barRect, frame);
|
|
452
|
+
// The 44px bar row sits under the status bar portion of the rect.
|
|
453
|
+
bar.style.paddingTop = `${Math.max(0, barRect.height - NAV_BAR_HEIGHT)}px`;
|
|
454
|
+
}
|
|
455
|
+
const largeRect = chrome?.['largeTitle'];
|
|
456
|
+
if (largeTitle) {
|
|
457
|
+
if (largeRect) {
|
|
458
|
+
placeChrome(largeTitle, largeRect, frame);
|
|
459
|
+
if (texts?.['largeTitle']) {
|
|
460
|
+
setFont(largeTitle, texts['largeTitle'].font);
|
|
461
|
+
const line = texts['largeTitle'].lines.join('\n');
|
|
462
|
+
if (largeTitle.textContent !== line) largeTitle.textContent = line;
|
|
463
|
+
}
|
|
464
|
+
} else {
|
|
465
|
+
largeTitle.hidden = true;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (inlineTitle) {
|
|
469
|
+
// Phase 6: the engine centers the inline title in the room the back
|
|
470
|
+
// button and the toolbar items leave (no rect: large title or a
|
|
471
|
+
// `principal` toolbar replaces it).
|
|
472
|
+
const titleRect = chrome?.['title'];
|
|
473
|
+
placeChrome(inlineTitle, titleRect, frame);
|
|
474
|
+
if (titleRect) {
|
|
475
|
+
if (texts?.['title']) setFont(inlineTitle, texts['title'].font);
|
|
476
|
+
const lineHeight = `${titleRect.height}px`;
|
|
477
|
+
if (inlineTitle.style.lineHeight !== lineHeight) inlineTitle.style.lineHeight = lineHeight;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const back = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-back');
|
|
481
|
+
const backLabel = back?.querySelector<HTMLElement>('.sb-navbar-back-label');
|
|
482
|
+
const backRect = chrome?.['back'];
|
|
483
|
+
if (back && backLabel && backRect && texts?.['back']) {
|
|
484
|
+
const text = texts['back'].lines[0] ?? '';
|
|
485
|
+
back.style.maxWidth = `${backRect.width}px`;
|
|
486
|
+
backLabel.dataset['sbLayoutLabel'] = text;
|
|
487
|
+
if (backLabel.textContent !== text) backLabel.textContent = text;
|
|
488
|
+
setFont(back, texts['back'].font);
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
case 'sheet': {
|
|
493
|
+
const card = el.querySelector<HTMLElement>(':scope > .sb-sheet-card');
|
|
494
|
+
const grabber = card?.querySelector<HTMLElement>(':scope > .sb-sheet-grabber') ?? null;
|
|
495
|
+
placeChrome(grabber, chrome?.['grabber'], frame);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
case 'tabview': {
|
|
499
|
+
const bar = el.querySelector<HTMLElement>(':scope > .sb-tabbar');
|
|
500
|
+
const barRect = chrome?.['bar'];
|
|
501
|
+
if (!bar || !barRect) return;
|
|
502
|
+
placeChrome(bar, barRect, box);
|
|
503
|
+
// Items are the 49px row at the top of the bar; the rest is the safe area.
|
|
504
|
+
const items = Array.from(bar.children) as HTMLElement[];
|
|
505
|
+
items.forEach((item, i) => placeChrome(item, chrome?.[`item${i}`], barRect));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
case 'navsplit': {
|
|
509
|
+
// Phase 7: the hairline between the two columns.
|
|
510
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-separator'), chrome?.['separator'], box);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
case 'gauge': {
|
|
514
|
+
// The track and fill are chrome, positioned from the element's own box.
|
|
515
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-track'), chrome?.['track'], box);
|
|
516
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-fill'), chrome?.['fill'], box);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
case 'chart':
|
|
520
|
+
// Phase 7: the plot SVG, ticks, text labels and legend are rebuilt
|
|
521
|
+
// from the props and the engine's `plot` / label / legend rects.
|
|
522
|
+
applyChartChrome(el, node, result, box);
|
|
523
|
+
return;
|
|
524
|
+
case 'listrow':
|
|
525
|
+
// Phase 8: the edit-mode control (selection circle / minus) at the
|
|
526
|
+
// engine's `editControl` rect; hidden when the list is not editing.
|
|
527
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-listrow-control'), chrome?.['editControl'], box);
|
|
528
|
+
return;
|
|
529
|
+
case 'picker': {
|
|
530
|
+
setFont(el, result.fonts.get(node.id));
|
|
531
|
+
if (node.props['style'] === 'segmented') {
|
|
532
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-segment-track'), frame, box);
|
|
533
|
+
const segments = el.querySelectorAll<HTMLElement>(':scope > .sb-segment');
|
|
534
|
+
segments.forEach((segment, i) => placeChrome(segment, chrome?.[`segment${i}`], box));
|
|
535
|
+
} else if (node.props['style'] === 'inline') {
|
|
536
|
+
// Phase 6: a label row (when the label is non-empty) and one 44px row per option.
|
|
537
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-picker-inline-label'), chrome?.['label'], box);
|
|
538
|
+
const options = el.querySelectorAll<HTMLElement>(':scope > .sb-picker-option');
|
|
539
|
+
options.forEach((option, i) => placeChrome(option, chrome?.[`option${i}`], box));
|
|
540
|
+
} else {
|
|
541
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-picker-label'), chrome?.['label'], box);
|
|
542
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-picker-value'), chrome?.['value'], box);
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
default:
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 6: `layout` events (docs/phase6/layout-containers.md).
|
|
3
|
+
*
|
|
4
|
+
* A `layout` element is a custom SwiftUI `Layout` evaluated on the Swift side
|
|
5
|
+
* with host-measured subviews. After every layout pass the engine leaves, per
|
|
6
|
+
* `layout` element, the proposal it was placed with and its children's min /
|
|
7
|
+
* ideal / max sizes (`LayoutResult.layouts`). `LayoutReporter` turns those
|
|
8
|
+
* into `layout` events and sends one only when the record differs from the
|
|
9
|
+
* last one sent for that id, so Swift's answer (an `update` of the element's
|
|
10
|
+
* `size` and `frames`) can trigger another pass without looping forever.
|
|
11
|
+
*
|
|
12
|
+
* DOM-free so it can be unit-tested next to the engine.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { LayoutRecord, LayoutResult, Size } from './layout';
|
|
16
|
+
import type { Dimension, ElementId, LayoutChildMeasurement, LayoutEvent, LayoutProposal } from './protocol';
|
|
17
|
+
|
|
18
|
+
/** Round to 2 decimals; `Infinity` (an unbounded axis) serializes as `"infinity"`. */
|
|
19
|
+
export function layoutDimension(value: number): Dimension {
|
|
20
|
+
if (!Number.isFinite(value)) return value > 0 ? 'infinity' : 0;
|
|
21
|
+
return Math.round(value * 100) / 100;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function layoutSize(size: Size): { width: Dimension; height: Dimension } {
|
|
25
|
+
return { width: layoutDimension(size.width), height: layoutDimension(size.height) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The `layout` event for an engine record. */
|
|
29
|
+
export function layoutEventFor(id: ElementId, record: LayoutRecord): LayoutEvent {
|
|
30
|
+
const axis = (value: number | null): LayoutProposal['width'] => (value === null ? null : layoutDimension(value));
|
|
31
|
+
const children: LayoutChildMeasurement[] = record.children.map((child) => ({
|
|
32
|
+
min: layoutSize(child.min),
|
|
33
|
+
ideal: layoutSize(child.ideal),
|
|
34
|
+
max: layoutSize(child.max),
|
|
35
|
+
}));
|
|
36
|
+
return {
|
|
37
|
+
type: 'layout',
|
|
38
|
+
id,
|
|
39
|
+
proposal: { width: axis(record.proposal.width), height: axis(record.proposal.height) },
|
|
40
|
+
children,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How long (ms) the cycle guard remembers a record sent for an element. */
|
|
45
|
+
export const CYCLE_WINDOW_MS = 1000;
|
|
46
|
+
|
|
47
|
+
interface SentRecord {
|
|
48
|
+
json: string;
|
|
49
|
+
at: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class LayoutReporter {
|
|
53
|
+
/** The last event sent for every `layout` element. */
|
|
54
|
+
readonly reported = new Map<ElementId, LayoutEvent>();
|
|
55
|
+
/** The records sent per element within the cycle window (newest last). */
|
|
56
|
+
private readonly history = new Map<ElementId, SentRecord[]>();
|
|
57
|
+
/** Elements whose records cycled; each is warned about once. */
|
|
58
|
+
private readonly cycled = new Set<ElementId>();
|
|
59
|
+
|
|
60
|
+
constructor(private readonly now: () => number = () => performance.now()) {}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The events to send for a pass: one per `layout` element whose record
|
|
64
|
+
* differs from the last one sent. Elements without a frame (inside a hidden
|
|
65
|
+
* tab) are skipped, like `geometry`.
|
|
66
|
+
*/
|
|
67
|
+
collect(result: LayoutResult): LayoutEvent[] {
|
|
68
|
+
const events: LayoutEvent[] = [];
|
|
69
|
+
const at = this.now();
|
|
70
|
+
for (const [id, record] of result.layouts) {
|
|
71
|
+
if (!result.frames.has(id)) continue;
|
|
72
|
+
const event = layoutEventFor(id, record);
|
|
73
|
+
const json = JSON.stringify(event);
|
|
74
|
+
const last = this.reported.get(id);
|
|
75
|
+
if (last && JSON.stringify(last) === json) continue;
|
|
76
|
+
// Cycle guard: a record already sent within the window means the host
|
|
77
|
+
// and Swift are trading answers (nested layouts measuring each other).
|
|
78
|
+
// Stop re-asking until the window passes; Swift's latest answer stands.
|
|
79
|
+
// The window keeps a legitimate repeat (data that changes back) from
|
|
80
|
+
// being mistaken for a cycle and frozen for good.
|
|
81
|
+
const recent = (this.history.get(id) ?? []).filter((r) => at - r.at < CYCLE_WINDOW_MS);
|
|
82
|
+
if (recent.some((r) => r.json === json)) {
|
|
83
|
+
this.history.set(id, recent);
|
|
84
|
+
if (!this.cycled.has(id)) {
|
|
85
|
+
this.cycled.add(id);
|
|
86
|
+
console.warn(`[sb] layout ${id}: measurements cycle; keeping the last answer`);
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
recent.push({ json, at });
|
|
91
|
+
this.history.set(id, recent);
|
|
92
|
+
this.reported.set(id, event);
|
|
93
|
+
events.push(event);
|
|
94
|
+
}
|
|
95
|
+
return events;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The element was removed: a re-created element with the same id reports afresh. */
|
|
99
|
+
forget(id: ElementId): void {
|
|
100
|
+
this.reported.delete(id);
|
|
101
|
+
this.history.delete(id);
|
|
102
|
+
this.cycled.delete(id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
clear(): void {
|
|
106
|
+
this.reported.clear();
|
|
107
|
+
this.history.clear();
|
|
108
|
+
this.cycled.clear();
|
|
109
|
+
}
|
|
110
|
+
}
|