@swiftbrowser/web 0.3.1 → 0.4.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 +3 -1
- package/package.json +4 -2
- package/src/animation.ts +154 -3
- package/src/dragDrop.ts +549 -0
- package/src/hostContent.ts +37 -0
- package/src/interaction.ts +128 -22
- package/src/largeTitle.ts +95 -54
- package/src/layout/engine.ts +415 -88
- package/src/layout/index.ts +5 -2
- package/src/layout/measure.ts +2 -0
- package/src/layout/text.ts +6 -3
- package/src/layout/types.ts +16 -1
- package/src/layout/typography.ts +30 -10
- package/src/layoutDom.ts +66 -16
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +188 -9
- package/src/renderer.ts +316 -15
- package/src/scrollEdge.ts +87 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +413 -16
- package/src/typography.ts +5 -2
package/src/layout/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* | `section` | `rows` (the inset card), `header`, `footer` (when present) |
|
|
17
17
|
* | list rows | `row` (the full-width row rect inside the card); `navlink` rows add `label` and `chevron` (20px at the trailing edge); rows with a Phase 6 `badge` style add `badge` (the text's rect at the trailing inset, full row height) |
|
|
18
18
|
* | `scrollview` | `content` (the content rect at scroll offset 0) |
|
|
19
|
-
* | `list`/`scrollview` | `bottomInset` (Phase 4, only under a tab bar that reaches the screen bottom: the part of the frame the bar covers; the DOM adds its height as scrollable content inset) |
|
|
19
|
+
* | `list`/`scrollview` | `bottomInset` (Phase 4, only under a tab bar that reaches the screen bottom, or a bottom toolbar or search field reaching its screen's bottom: the part of the frame the bar covers; the DOM adds its height as scrollable content inset), `topInset` / `largeTitle` (the screen's bar and the large title row it runs under: its screen's content, or a vertical one in it touching the bar) |
|
|
20
20
|
* | `tabview` | `bar` (49 + bottom safe area at the bottom), `item0` … `itemN-1` (equal-width 49px items in the bar), `content` (above the bar) |
|
|
21
21
|
* | `tab` | `content` (the selected tab's safe content area: below the status bar, above the bar); non-selected tabs are in `hidden` |
|
|
22
22
|
* | `picker` | segmented: `segment0` … `segmentN-1` (equal widths, 32 high); menu: `label` (leading), `value` (the selected option plus chevron, trailing) |
|
|
@@ -74,7 +74,10 @@ export {
|
|
|
74
74
|
makeFont,
|
|
75
75
|
DYNAMIC_TYPE_SIZES,
|
|
76
76
|
TEXT_STYLE_SIZES,
|
|
77
|
-
|
|
77
|
+
LINE_HEIGHT_RATIO,
|
|
78
|
+
LEADINGS,
|
|
79
|
+
leadingFor,
|
|
80
|
+
textHeight,
|
|
78
81
|
FONT_FAMILIES,
|
|
79
82
|
FONT_WEIGHTS,
|
|
80
83
|
type TextStyle,
|
package/src/layout/measure.ts
CHANGED
|
@@ -20,6 +20,8 @@ function canvasFont(font: ResolvedFont): string {
|
|
|
20
20
|
* is available (Node), it falls back to the monospace estimate.
|
|
21
21
|
*/
|
|
22
22
|
export class CanvasTextMeasurer implements TextMeasurer {
|
|
23
|
+
/** Set by the renderer: measures a `host` element's content in the DOM. */
|
|
24
|
+
measureHost?: TextMeasurer['measureHost'];
|
|
23
25
|
private context: Canvas2D | null | undefined;
|
|
24
26
|
private readonly cache = new Map<string, number>();
|
|
25
27
|
private readonly fallback = new MonospaceTextMeasurer();
|
package/src/layout/text.ts
CHANGED
|
@@ -262,12 +262,15 @@ export function wrapRuns(
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
const merged = lines.map(mergePieces);
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
// The first line is a line tall, each further one a leading (the
|
|
266
|
+
// distance from the line above's baseline).
|
|
267
|
+
const heights = merged.map((line, index) => {
|
|
268
|
+
const metric = (font: ResolvedFont): number => (index === 0 ? font.lineHeight : font.leading);
|
|
269
|
+
if (line.length === 0) return metric(base);
|
|
267
270
|
let height = 0;
|
|
268
271
|
for (const piece of line) {
|
|
269
272
|
const font = (runs[piece.run] as TextRunInput).font;
|
|
270
|
-
height = Math.max(height, piece.height ?? font
|
|
273
|
+
height = Math.max(height, piece.height ?? metric(font), metric(font));
|
|
271
274
|
}
|
|
272
275
|
return height;
|
|
273
276
|
});
|
package/src/layout/types.ts
CHANGED
|
@@ -62,8 +62,10 @@ export interface ResolvedFont {
|
|
|
62
62
|
size: number;
|
|
63
63
|
/** CSS numeric weight, 100...900. */
|
|
64
64
|
weight: number;
|
|
65
|
-
/**
|
|
65
|
+
/** A line's height in CSS px (a multiple of 1/3: a 3x device pixel). */
|
|
66
66
|
lineHeight: number;
|
|
67
|
+
/** The distance between a paragraph's baselines (the HIG leading); an SF Symbol image is this square. */
|
|
68
|
+
leading: number;
|
|
67
69
|
/** A CSS `font` shorthand (`weight size/lineHeight family`) usable verbatim. */
|
|
68
70
|
css: string;
|
|
69
71
|
/**
|
|
@@ -79,6 +81,12 @@ export interface ResolvedFont {
|
|
|
79
81
|
export interface TextMeasurer {
|
|
80
82
|
/** Width of one line of text in CSS px when drawn with `font`. */
|
|
81
83
|
measureWidth(text: string, font: ResolvedFont): number;
|
|
84
|
+
/**
|
|
85
|
+
* A `host` element's content (src/hostContent.ts, by key) laid out at
|
|
86
|
+
* `width` CSS px, or at its own ideal width when null: its size. Absent in
|
|
87
|
+
* headless layouts, where a host measures as a 10×10 placeholder.
|
|
88
|
+
*/
|
|
89
|
+
measureHost?: ((content: string, width: number | null) => Size) | undefined;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
export interface LayoutEnvironment {
|
|
@@ -93,6 +101,13 @@ export interface LayoutEnvironment {
|
|
|
93
101
|
* images then measure as unknown (44×44 placeholders).
|
|
94
102
|
*/
|
|
95
103
|
assets?: AssetCatalog;
|
|
104
|
+
/**
|
|
105
|
+
* The root's height is its content's, not the screen's: a SwiftUI island
|
|
106
|
+
* in a page's own layout (@swiftbrowser/react's `SwiftUIView`). A plain
|
|
107
|
+
* root is proposed the screen width and no height and placed at the top;
|
|
108
|
+
* navigation and tab roots still fill the screen.
|
|
109
|
+
*/
|
|
110
|
+
contentHeight?: boolean;
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
export type TextAlignment = 'leading' | 'center' | 'trailing';
|
package/src/layout/typography.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* Point sizes come from Apple's Human Interface Guidelines, "Typography" >
|
|
5
5
|
* "Specifications" > "iOS, iPadOS Dynamic Type sizes" and "Larger
|
|
6
6
|
* accessibility type sizes" (https://developer.apple.com/design/human-interface-guidelines/typography).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* A line is as tall as SwiftUI makes a `Text`: SF Pro's ascender plus
|
|
8
|
+
* descender (`UIFont.lineHeight`, 1.1934 × the point size), rounded up to a
|
|
9
|
+
* device pixel on a 3x iPhone (body 17 → 20.33, large title 34 → 40.67;
|
|
10
|
+
* measured in the iOS 26.2 simulator). The HIG's tables list a taller
|
|
11
|
+
* leading (body 22): the distance between a paragraph's lines (`LEADINGS`).
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import type { DynamicTypeSize, FontFamily, LayoutEnvironment, ResolvedFont } from './types';
|
|
@@ -60,8 +63,13 @@ export const TEXT_STYLE_SIZES: Record<TextStyle, readonly number[]> = {
|
|
|
60
63
|
caption2: [11, 11, 11, 11, 13, 15, 17, 20, 24, 29, 34, 40],
|
|
61
64
|
};
|
|
62
65
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The HIG's leading for the standard (Large) point sizes: the distance
|
|
68
|
+
* between the baselines of a paragraph's lines, which SwiftUI keeps (a
|
|
69
|
+
* multi-line body `Text` is 20.33 + 22 per further line); other sizes use
|
|
70
|
+
* round(size * 1.2). An SF Symbol image takes a square of it.
|
|
71
|
+
*/
|
|
72
|
+
export const LEADINGS: Readonly<Record<number, number>> = {
|
|
65
73
|
34: 41,
|
|
66
74
|
28: 34,
|
|
67
75
|
22: 28,
|
|
@@ -120,10 +128,22 @@ export function textStyleSize(style: TextStyle, size: DynamicTypeSize): number {
|
|
|
120
128
|
return row[sizeCategoryIndex(size)] ?? row[LARGE_INDEX] ?? 17;
|
|
121
129
|
}
|
|
122
130
|
|
|
123
|
-
/**
|
|
131
|
+
/** SF Pro's ascender plus descender per point (1950 + 494 of 2048 units per em). */
|
|
132
|
+
export const LINE_HEIGHT_RATIO = 2444 / 2048;
|
|
133
|
+
|
|
134
|
+
/** A line's height at a point size: `UIFont.lineHeight`, rounded up to a 3x pixel. */
|
|
124
135
|
export function lineHeightFor(size: number): number {
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
return Math.ceil(size * LINE_HEIGHT_RATIO * 3 - 1e-6) / 3;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The distance between a paragraph's baselines at a point size. */
|
|
140
|
+
export function leadingFor(size: number): number {
|
|
141
|
+
return LEADINGS[size] ?? Math.round(size * 1.2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The height of `lines` lines of text in `font`: the first line, then its leading per further line. */
|
|
145
|
+
export function textHeight(lines: number, font: ResolvedFont): number {
|
|
146
|
+
return lines > 0 ? font.lineHeight + (lines - 1) * font.leading : 0;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
function formatPx(value: number): string {
|
|
@@ -138,14 +158,14 @@ export function makeFont(
|
|
|
138
158
|
modifiers: { weightModifier?: number | undefined; familyModifier?: FontFamily | undefined } = {},
|
|
139
159
|
): ResolvedFont {
|
|
140
160
|
const lineHeight = lineHeightFor(size);
|
|
141
|
-
const css = `${weight} ${formatPx(size)}/${lineHeight}
|
|
142
|
-
const font: ResolvedFont = { family, size, weight, lineHeight, css };
|
|
161
|
+
const css = `${weight} ${formatPx(size)}/${formatPx(lineHeight)} ${FONT_FAMILIES[family]}`;
|
|
162
|
+
const font: ResolvedFont = { family, size, weight, lineHeight, leading: leadingFor(size), css };
|
|
143
163
|
if (modifiers.weightModifier !== undefined) font.weightModifier = modifiers.weightModifier;
|
|
144
164
|
if (modifiers.familyModifier !== undefined) font.familyModifier = modifiers.familyModifier;
|
|
145
165
|
return font;
|
|
146
166
|
}
|
|
147
167
|
|
|
148
|
-
/** Body text for the current `dynamicTypeSize` (17/
|
|
168
|
+
/** Body text for the current `dynamicTypeSize` (17/20.33 at Large). */
|
|
149
169
|
export function defaultFont(env: LayoutEnvironment): ResolvedFont {
|
|
150
170
|
return makeFont('system', textStyleSize('body', env.dynamicTypeSize), 400);
|
|
151
171
|
}
|
package/src/layoutDom.ts
CHANGED
|
@@ -37,7 +37,8 @@ import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, T
|
|
|
37
37
|
import { applyChartChrome } from './chartDom';
|
|
38
38
|
import { bitmapLaidOut } from './bitmaps';
|
|
39
39
|
import { drawRichText } from './richText';
|
|
40
|
-
import {
|
|
40
|
+
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
|
|
41
|
+
import { bindScrollEdgeBottom } from './scrollEdge';
|
|
41
42
|
|
|
42
43
|
export interface LayoutDomContext {
|
|
43
44
|
root: HTMLElement;
|
|
@@ -236,6 +237,27 @@ function positionBox(el: HTMLElement, box: Rect, origin: { x: number; y: number
|
|
|
236
237
|
}
|
|
237
238
|
|
|
238
239
|
/** Position a chrome node inside its element: `rect` is in screen coordinates. */
|
|
240
|
+
/**
|
|
241
|
+
* An edit-mode control fades in when it first appears (the list started
|
|
242
|
+
* editing), marked `data-sb-appear` for the animation's duration. A control
|
|
243
|
+
* that is laid out again while shown, or whose row is moved in the DOM (a
|
|
244
|
+
* reorder re-inserts the row, which would restart a CSS animation on it),
|
|
245
|
+
* is not marked and stays put.
|
|
246
|
+
*/
|
|
247
|
+
function placeEditControl(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
|
|
248
|
+
if (!node) return;
|
|
249
|
+
const wasHidden = node.hidden;
|
|
250
|
+
placeChrome(node, rect, origin);
|
|
251
|
+
if (wasHidden && !node.hidden) {
|
|
252
|
+
node.dataset['sbAppear'] = '';
|
|
253
|
+
const settle = (): void => {
|
|
254
|
+
delete node.dataset['sbAppear'];
|
|
255
|
+
};
|
|
256
|
+
node.addEventListener('animationend', settle, { once: true });
|
|
257
|
+
node.addEventListener('animationcancel', settle, { once: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
239
261
|
function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
|
|
240
262
|
if (!node) return;
|
|
241
263
|
if (!rect) {
|
|
@@ -261,16 +283,21 @@ function positionSheetCard(sheet: HTMLElement, frame: Rect): void {
|
|
|
261
283
|
s.height = `${frame.height}px`;
|
|
262
284
|
}
|
|
263
285
|
|
|
264
|
-
|
|
286
|
+
/**
|
|
287
|
+
* `lines` > 1: a paragraph, whose lines are a leading apart (the engine makes
|
|
288
|
+
* it the first line's height plus a leading per further line).
|
|
289
|
+
*/
|
|
290
|
+
function setFont(el: HTMLElement, font: ResolvedFont | undefined, lines = 1): void {
|
|
265
291
|
if (!font) return;
|
|
266
|
-
|
|
292
|
+
const css = lines > 1 ? font.css.replace(/\/[\d.]+px /, `/${Math.round(font.leading * 100) / 100}px `) : font.css;
|
|
293
|
+
if (el.style.font !== css) el.style.font = css;
|
|
267
294
|
}
|
|
268
295
|
|
|
269
296
|
const TEXT_ALIGN: Record<string, string> = { leading: 'left', center: 'center', trailing: 'right' };
|
|
270
297
|
|
|
271
298
|
/** Render the engine's exact lines into a text node (`white-space: pre` keeps them). */
|
|
272
299
|
function setTextLayout(el: HTMLElement, layout: TextLayout): void {
|
|
273
|
-
setFont(el, layout.font);
|
|
300
|
+
setFont(el, layout.font, layout.lines.length);
|
|
274
301
|
const align = TEXT_ALIGN[layout.alignment] ?? 'left';
|
|
275
302
|
if (el.style.textAlign !== align) el.style.textAlign = align;
|
|
276
303
|
if (layout.rich && el.dataset['sbRich'] !== undefined) {
|
|
@@ -373,8 +400,9 @@ function applyChrome(
|
|
|
373
400
|
}
|
|
374
401
|
case 'textfield':
|
|
375
402
|
case 'texteditor':
|
|
376
|
-
// A vertical text field draws in the font its lines were measured in
|
|
377
|
-
|
|
403
|
+
// A vertical text field draws in the font its lines were measured in,
|
|
404
|
+
// a leading apart.
|
|
405
|
+
setFont(el, result.fonts.get(node.id), el instanceof HTMLTextAreaElement ? 2 : 1);
|
|
378
406
|
padIntoFrame(el, box, frame);
|
|
379
407
|
return;
|
|
380
408
|
// Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
|
|
@@ -463,7 +491,7 @@ function applyChrome(
|
|
|
463
491
|
if (header && texts?.['header']) setTextLayout(header, texts['header']);
|
|
464
492
|
if (footer && texts?.['footer']) {
|
|
465
493
|
// The prop text in the engine's font and rect, wrapped there.
|
|
466
|
-
setFont(footer, texts['footer'].font);
|
|
494
|
+
setFont(footer, texts['footer'].font, texts['footer'].lines.length);
|
|
467
495
|
footer.style.textAlign = 'left';
|
|
468
496
|
footer.style.whiteSpace = 'pre-wrap';
|
|
469
497
|
const original = typeof node.props['footer'] === 'string' ? node.props['footer'] : texts['footer'].lines.join('\n');
|
|
@@ -479,7 +507,8 @@ function applyChrome(
|
|
|
479
507
|
// Extra scrollable space at the end (the engine's frames are unchanged).
|
|
480
508
|
content.style.height = `${size.height + bottomInset}px`;
|
|
481
509
|
}
|
|
482
|
-
|
|
510
|
+
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
511
|
+
bindScrollEdgeBottom(el, chrome?.['bottomInset']);
|
|
483
512
|
return;
|
|
484
513
|
}
|
|
485
514
|
case 'scrollview': {
|
|
@@ -494,24 +523,33 @@ function applyChrome(
|
|
|
494
523
|
s.marginLeft = `${rect ? rect.x - frame.x : 0}px`;
|
|
495
524
|
s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
|
|
496
525
|
}
|
|
497
|
-
|
|
526
|
+
bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
|
|
527
|
+
bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
|
|
498
528
|
return;
|
|
499
529
|
}
|
|
500
530
|
case 'navscreen': {
|
|
501
531
|
const bar = el.querySelector<HTMLElement>(':scope > .sb-navbar');
|
|
502
|
-
const largeTitle = el
|
|
532
|
+
const largeTitle = largeTitleOf(el);
|
|
503
533
|
const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
|
|
504
534
|
const texts = result.chromeText.get(node.id);
|
|
505
535
|
const barRect = chrome?.['bar'];
|
|
506
536
|
if (bar && barRect) {
|
|
507
537
|
placeChrome(bar, barRect, frame);
|
|
508
|
-
// The 44px bar row sits under the status bar portion of the rect
|
|
509
|
-
bar
|
|
538
|
+
// The 44px bar row sits under the status bar portion of the rect
|
|
539
|
+
// (iOS 26: an inline bar keeps 10 more below it).
|
|
540
|
+
const row = chrome?.['barRow'];
|
|
541
|
+
const top = row ? row.y - barRect.y : Math.max(0, barRect.height - NAV_BAR_HEIGHT);
|
|
542
|
+
const bottom = row ? Math.max(0, barRect.y + barRect.height - (row.y + row.height)) : 0;
|
|
543
|
+
bar.style.paddingTop = `${top}px`;
|
|
544
|
+
bar.style.paddingBottom = `${bottom}px`;
|
|
510
545
|
}
|
|
511
546
|
const largeRect = chrome?.['largeTitle'];
|
|
512
547
|
if (largeTitle) {
|
|
513
548
|
if (largeRect) {
|
|
514
|
-
|
|
549
|
+
// A title that scrolls with the screen's scroll view is placed by
|
|
550
|
+
// it (src/largeTitle.ts).
|
|
551
|
+
if (largeTitleScrolls(el, largeTitle)) largeTitle.hidden = false;
|
|
552
|
+
else placeChrome(largeTitle, largeRect, frame);
|
|
515
553
|
if (texts?.['largeTitle']) {
|
|
516
554
|
setFont(largeTitle, texts['largeTitle'].font);
|
|
517
555
|
const line = texts['largeTitle'].lines.join('\n');
|
|
@@ -539,6 +577,10 @@ function applyChrome(
|
|
|
539
577
|
if (back && backLabel && backRect && texts?.['back']) {
|
|
540
578
|
const text = texts['back'].lines[0] ?? '';
|
|
541
579
|
back.style.maxWidth = `${backRect.width}px`;
|
|
580
|
+
// The bar pads its row 16 (styles.css); a split view's sidebar
|
|
581
|
+
// toggle can push the back button further in (chrome.back).
|
|
582
|
+
const indent = barRect ? Math.max(0, backRect.x - barRect.x - 16) : 0;
|
|
583
|
+
back.style.marginLeft = indent > 0 ? `${indent}px` : '';
|
|
542
584
|
backLabel.dataset['sbLayoutLabel'] = text;
|
|
543
585
|
if (backLabel.textContent !== text) backLabel.textContent = text;
|
|
544
586
|
setFont(back, texts['back'].font);
|
|
@@ -563,9 +605,15 @@ function applyChrome(
|
|
|
563
605
|
items.forEach((item, i) => placeChrome(item, chrome?.[`item${i}`], barRect));
|
|
564
606
|
return;
|
|
565
607
|
}
|
|
608
|
+
case 'inspector': {
|
|
609
|
+
setFlag(el, 'sbPresented', node.props['presented'] === true ? '' : null);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
566
612
|
case 'navsplit': {
|
|
567
|
-
// Phase 7: the hairline between the two columns.
|
|
613
|
+
// Phase 7: the hairline between the two columns, and the sidebar toggle.
|
|
568
614
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-separator'), chrome?.['separator'], box);
|
|
615
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-toggle'), chrome?.['toggle'], box);
|
|
616
|
+
setFlag(el, 'sbSidebarHidden', node.props['sidebarVisible'] === false ? '' : null);
|
|
569
617
|
return;
|
|
570
618
|
}
|
|
571
619
|
case 'gauge': {
|
|
@@ -583,8 +631,10 @@ function applyChrome(
|
|
|
583
631
|
return;
|
|
584
632
|
case 'listrow':
|
|
585
633
|
// Phase 8: the edit-mode control (selection circle / minus) at the
|
|
586
|
-
// engine's `editControl` rect
|
|
587
|
-
|
|
634
|
+
// engine's `editControl` rect and the reorder grip at `reorderControl`;
|
|
635
|
+
// hidden when the list is not editing.
|
|
636
|
+
placeEditControl(el.querySelector<HTMLElement>(':scope > .sb-listrow-control'), chrome?.['editControl'], box);
|
|
637
|
+
placeEditControl(el.querySelector<HTMLElement>(':scope > .sb-swipe-content > .sb-listrow-grip'), chrome?.['reorderControl'], box);
|
|
588
638
|
return;
|
|
589
639
|
case 'picker': {
|
|
590
640
|
setFont(el, result.fonts.get(node.id));
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a page with a mouse adds to a phone's UI: `hover` events (`.onHover`),
|
|
3
|
+
* tooltips (`.help`), cursors (`.pointerStyle`), and SF Symbols' effects
|
|
4
|
+
* (`.symbolEffect`) drawn with CSS. The Swift shim draws an iPhone and sends
|
|
5
|
+
* none of these; producers in the page (@swiftbrowser/react) do.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ElementId, Style, SymbolEffectStyle } from './protocol';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Hover
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const HOVER_ATTR = 'sbHover';
|
|
15
|
+
|
|
16
|
+
/** Sends `hover` as a mouse enters and leaves the boxes marked `data-sb-hover` under `root`. */
|
|
17
|
+
export class HoverTracker {
|
|
18
|
+
private readonly hovered = new Set<HTMLElement>();
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly root: HTMLElement,
|
|
22
|
+
private readonly send: (id: ElementId, value: boolean) => void,
|
|
23
|
+
) {
|
|
24
|
+
root.addEventListener('pointerover', this.onOver);
|
|
25
|
+
root.addEventListener('pointerout', this.onOut);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
dispose(): void {
|
|
29
|
+
this.root.removeEventListener('pointerover', this.onOver);
|
|
30
|
+
this.root.removeEventListener('pointerout', this.onOut);
|
|
31
|
+
this.hovered.clear();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private readonly onOver = (event: PointerEvent): void => {
|
|
35
|
+
if (event.pointerType === 'touch') return;
|
|
36
|
+
for (let el = event.target as HTMLElement | null; el && el !== this.root; el = el.parentElement) {
|
|
37
|
+
if (el.dataset[HOVER_ATTR] === undefined || this.hovered.has(el)) continue;
|
|
38
|
+
this.hovered.add(el);
|
|
39
|
+
this.report(el, true);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
private readonly onOut = (event: PointerEvent): void => {
|
|
44
|
+
if (event.pointerType === 'touch') return;
|
|
45
|
+
const next = event.relatedTarget as Node | null;
|
|
46
|
+
for (const el of [...this.hovered]) {
|
|
47
|
+
if (next && el.contains(next)) continue;
|
|
48
|
+
this.hovered.delete(el);
|
|
49
|
+
this.report(el, false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
private report(el: HTMLElement, value: boolean): void {
|
|
54
|
+
const id = Number(el.dataset['sbId']);
|
|
55
|
+
if (Number.isInteger(id) && el.isConnected) this.send(id, value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Style fields
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** One run of each effect at speed 1 (seconds) and its keyframes on the CSS `scale`, `rotate`, `translate` and `opacity` properties. */
|
|
64
|
+
const EFFECTS: Record<string, { duration: number; frames: Keyframe[] }> = {
|
|
65
|
+
bounce: {
|
|
66
|
+
duration: 0.5,
|
|
67
|
+
frames: [
|
|
68
|
+
{ scale: '1', translate: '0 0', offset: 0 },
|
|
69
|
+
{ scale: '1.18', translate: '0 -12%', offset: 0.35 },
|
|
70
|
+
{ scale: '0.94', translate: '0 0', offset: 0.7 },
|
|
71
|
+
{ scale: '1', translate: '0 0', offset: 1 },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
pulse: { duration: 1.2, frames: [{ opacity: '1' }, { opacity: '0.35' }, { opacity: '1' }] },
|
|
75
|
+
variableColor: { duration: 1.2, frames: [{ opacity: '1' }, { opacity: '0.5', offset: 0.33 }, { opacity: '0.75', offset: 0.66 }, { opacity: '1' }] },
|
|
76
|
+
rotate: { duration: 1, frames: [{ rotate: '0deg' }, { rotate: '360deg' }] },
|
|
77
|
+
wiggle: {
|
|
78
|
+
duration: 0.6,
|
|
79
|
+
frames: [{ rotate: '0deg' }, { rotate: '-14deg', offset: 0.25 }, { rotate: '14deg', offset: 0.6 }, { rotate: '0deg' }],
|
|
80
|
+
},
|
|
81
|
+
breathe: { duration: 2, frames: [{ scale: '1', opacity: '1' }, { scale: '1.1', opacity: '0.7' }, { scale: '1', opacity: '1' }] },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const playing = new WeakMap<HTMLElement, { key: string; animation: globalThis.Animation }>();
|
|
85
|
+
const triggers = new WeakMap<HTMLElement, number>();
|
|
86
|
+
|
|
87
|
+
/** Apply the pointer and effect fields of a `styled` element's style (called on create and update). */
|
|
88
|
+
export function applyPointerAndEffects(el: HTMLElement, style: Style): void {
|
|
89
|
+
if (style.hover) el.dataset[HOVER_ATTR] = '';
|
|
90
|
+
if (style.help) {
|
|
91
|
+
el.title = style.help;
|
|
92
|
+
el.dataset['sbHelp'] = '';
|
|
93
|
+
} else if (el.hasAttribute('title') && el.classList.contains('sb-styled')) {
|
|
94
|
+
el.removeAttribute('title');
|
|
95
|
+
}
|
|
96
|
+
if (style.pointerStyle) el.style.cursor = style.pointerStyle;
|
|
97
|
+
applySymbolEffect(el, style.symbolEffect);
|
|
98
|
+
if (style.matchedGeometry) el.dataset['sbMatched'] = style.matchedGeometry;
|
|
99
|
+
if (style.contentTransition) {
|
|
100
|
+
el.dataset['sbContentTransition'] = style.contentTransition.kind;
|
|
101
|
+
const { countsDown } = style.contentTransition;
|
|
102
|
+
if (countsDown === undefined) delete el.dataset['sbCountsDown'];
|
|
103
|
+
else el.dataset['sbCountsDown'] = String(countsDown);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function applySymbolEffect(el: HTMLElement, effect: SymbolEffectStyle | undefined): void {
|
|
108
|
+
const s = el.style;
|
|
109
|
+
if (!effect) {
|
|
110
|
+
stopEffect(el);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
el.dataset['sbSymbolEffect'] = effect.effect;
|
|
114
|
+
// State effects: a look kept while the style carries them.
|
|
115
|
+
if (effect.effect === 'scale') {
|
|
116
|
+
s.transition = 'scale 0.25s';
|
|
117
|
+
s.scale = effect.direction === 'down' ? '0.85' : '1.2';
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (effect.effect === 'appear' || effect.effect === 'disappear') {
|
|
121
|
+
s.transition = 'opacity 0.25s, scale 0.25s';
|
|
122
|
+
const shown = effect.effect === 'appear';
|
|
123
|
+
s.opacity = shown ? '1' : '0';
|
|
124
|
+
s.scale = shown ? '1' : '0.6';
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const spec = EFFECTS[effect.effect];
|
|
128
|
+
if (!spec || typeof el.animate !== 'function') return;
|
|
129
|
+
const duration = (spec.duration / Math.max(effect.speed ?? 1, 0.01)) * 1000;
|
|
130
|
+
const repeat = effect.repeat === 'forever' ? Infinity : Math.max(1, effect.repeat ?? 1);
|
|
131
|
+
if (effect.trigger !== undefined) {
|
|
132
|
+
// A discrete effect plays once per new trigger (never for the first value).
|
|
133
|
+
const last = triggers.get(el);
|
|
134
|
+
triggers.set(el, effect.trigger);
|
|
135
|
+
if (last === undefined || last === effect.trigger) return;
|
|
136
|
+
playing.get(el)?.animation.cancel();
|
|
137
|
+
playing.set(el, { key: `once:${effect.trigger}`, animation: el.animate(spec.frames, { duration, iterations: repeat, easing: 'ease-in-out' }) });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// An indefinite effect keeps running across updates that leave it alone.
|
|
141
|
+
const key = `${effect.effect}:${duration}:${repeat === Infinity ? 'forever' : repeat}`;
|
|
142
|
+
const current = playing.get(el);
|
|
143
|
+
if (current && current.key === key && current.animation.playState !== 'finished') return;
|
|
144
|
+
current?.animation.cancel();
|
|
145
|
+
const iterations = effect.repeat === undefined ? Infinity : repeat;
|
|
146
|
+
playing.set(el, { key, animation: el.animate(spec.frames, { duration, iterations, easing: 'ease-in-out' }) });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stopEffect(el: HTMLElement): void {
|
|
150
|
+
const current = playing.get(el);
|
|
151
|
+
if (current && !current.key.startsWith('once:')) {
|
|
152
|
+
current.animation.cancel();
|
|
153
|
+
playing.delete(el);
|
|
154
|
+
}
|
|
155
|
+
triggers.delete(el);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Geometry readers that report their frame
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export interface SafeAreaInsets {
|
|
163
|
+
top: number;
|
|
164
|
+
leading: number;
|
|
165
|
+
bottom: number;
|
|
166
|
+
trailing: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** How far the device's safe area reaches into a box at `rect` in a root of `size`. */
|
|
170
|
+
export function safeAreaOverlap(
|
|
171
|
+
rect: { x: number; y: number; width: number; height: number },
|
|
172
|
+
size: { width: number; height: number },
|
|
173
|
+
safeArea: SafeAreaInsets,
|
|
174
|
+
): SafeAreaInsets {
|
|
175
|
+
const clamp = (value: number, max: number): number => Math.round(Math.min(Math.max(value, 0), max));
|
|
176
|
+
return {
|
|
177
|
+
top: clamp(safeArea.top - rect.y, rect.height),
|
|
178
|
+
bottom: clamp(rect.y + rect.height - (size.height - safeArea.bottom), rect.height),
|
|
179
|
+
leading: clamp(safeArea.leading - rect.x, rect.width),
|
|
180
|
+
trailing: clamp(rect.x + rect.width - (size.width - safeArea.trailing), rect.width),
|
|
181
|
+
};
|
|
182
|
+
}
|