@swiftbrowser/web 0.17.8 → 0.18.1
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/package.json +1 -1
- package/src/animation.ts +60 -27
- package/src/appFiles.ts +21 -16
- package/src/audioRecorder.ts +392 -0
- package/src/blockedHosts.ts +3 -1
- package/src/corsProxy.ts +3 -1
- package/src/editorSelection.ts +80 -0
- package/src/files.ts +442 -0
- package/src/gestureComposition.ts +92 -0
- package/src/hostRequests.ts +47 -5
- package/src/interaction.ts +108 -27
- package/src/layout/engine.ts +295 -19
- package/src/layout/types.ts +6 -0
- package/src/layoutDom.ts +13 -5
- package/src/main.ts +4 -0
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +304 -13
- package/src/renderer.ts +354 -43
- package/src/richEditor.ts +330 -0
- package/src/scrollGeometry.ts +79 -0
- package/src/scrollTransition.ts +145 -0
- package/src/styles.css +142 -11
- package/src/symbols.ts +22 -0
- package/src/touchPaging.ts +23 -2
- package/src/undo.ts +126 -0
- package/src/video.ts +59 -8
package/src/layout/engine.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface Context {
|
|
|
87
87
|
splitSidebar: boolean;
|
|
88
88
|
/** Inside a leading or trailing item of a navigation bar (a segmented picker there is the bar's own capsule). */
|
|
89
89
|
navBarItem?: boolean;
|
|
90
|
+
/** Inside an item of a bottom toolbar (a bordered or prominent button there takes the bar's sizes). */
|
|
91
|
+
bottomBarItem?: boolean;
|
|
90
92
|
/** Rows of a `sidebar`-style list (iPadOS): its links have no chevron. */
|
|
91
93
|
sidebarList?: boolean;
|
|
92
94
|
/**
|
|
@@ -125,6 +127,8 @@ export interface Context {
|
|
|
125
127
|
* a vertical text field is at least this many lines tall. Absent for 1.
|
|
126
128
|
*/
|
|
127
129
|
minLines?: number;
|
|
130
|
+
/** `.scrollDisabled(true)` above: a `texteditor` is as tall as its lines. */
|
|
131
|
+
scrollDisabled?: boolean;
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
// Constants from the spec.
|
|
@@ -321,6 +325,11 @@ function navSlot(item: LayoutNode, width: number): number {
|
|
|
321
325
|
// more width gives it a little more room (not text's 18 a side). Alone in its
|
|
322
326
|
// group it is a circle only if it fits an icon's slot; wider, a capsule.
|
|
323
327
|
const BOTTOM_GLASS_HEIGHT = 48;
|
|
328
|
+
/** A `keyboard` toolbar's bar (the input accessory view): 44 across the screen, directly above the keyboard, its items 16 in. */
|
|
329
|
+
/** The keyboard bar (iOS 26's Notes): a 48 glass capsule, 10 in from the screen's sides, 10 above the keyboard. */
|
|
330
|
+
const KEYBOARD_BAR_HEIGHT = 48;
|
|
331
|
+
const KEYBOARD_BAR_MARGIN_H = 10;
|
|
332
|
+
const KEYBOARD_BAR_GAP = 10;
|
|
324
333
|
const BOTTOM_GLASS_MARGIN_H = 28;
|
|
325
334
|
const BOTTOM_ICON_SLOT = 50;
|
|
326
335
|
const BOTTOM_TEXT_PAD_H = 18;
|
|
@@ -328,6 +337,58 @@ const BOTTOM_ICON_PAD_H = 8;
|
|
|
328
337
|
const BOTTOM_ITEM_GAP = 6;
|
|
329
338
|
/** A fixed `ToolbarSpacer` between two bottom groups. */
|
|
330
339
|
const BOTTOM_FIXED_SPACER = 8;
|
|
340
|
+
// A bordered button in a bottom bar stays in its capsule, its slot 2 wider
|
|
341
|
+
// and its fill the slot less 10 (5 a side), 34 tall; a prominent or glass
|
|
342
|
+
// one leaves the capsule for a 48 circle of its own, 12 from the next
|
|
343
|
+
// capsule (measured in the iOS 26.2 simulator, Native BarButtonStyleTests).
|
|
344
|
+
const BOTTOM_BORDERED_GROWTH = 2;
|
|
345
|
+
const BOTTOM_BORDERED_INSET = 10;
|
|
346
|
+
const BOTTOM_BORDERED_HEIGHT = 34;
|
|
347
|
+
const BOTTOM_GROUP_GAP = 12;
|
|
348
|
+
// A `.toggleStyle(.button)` toggle in a bottom bar takes a slot 24 wider
|
|
349
|
+
// than a button's, its pill (drawn while on) the slot less 8 and 50 tall,
|
|
350
|
+
// on or off; its capsule is 60 tall about the same center (iOS 26.2,
|
|
351
|
+
// Native ToolbarToggleTests).
|
|
352
|
+
const BOTTOM_TOGGLE_GROWTH = 24;
|
|
353
|
+
const BOTTOM_TOGGLE_INSET = 8;
|
|
354
|
+
const BOTTOM_TOGGLE_HEIGHT = 50;
|
|
355
|
+
const BOTTOM_TOGGLE_GLASS_HEIGHT = 60;
|
|
356
|
+
|
|
357
|
+
/** A toggle drawn as a button (`.toggleStyle(.button)`). */
|
|
358
|
+
function isButtonToggle(node: LayoutNode): boolean {
|
|
359
|
+
return node.kind === 'toggle' && str(node.props['style']) === 'button';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// A toggle of the automatic style in a bar is no switch: it takes a plain
|
|
363
|
+
// button's slot, and while on fills a pill under its label, 10 wider on
|
|
364
|
+
// each side and 36 tall, with the accent (iOS 26.2, Native
|
|
365
|
+
// ToolbarToggleTests: the pen's 42×36, the bar 230 wide on or off).
|
|
366
|
+
const BAR_TOGGLE_PAD_H = 10;
|
|
367
|
+
const BAR_TOGGLE_HEIGHT = 36;
|
|
368
|
+
|
|
369
|
+
/** A toggle the bar draws as its own (any style but `button` and `switch`). */
|
|
370
|
+
function isBarToggle(node: LayoutNode, ctx: Context): boolean {
|
|
371
|
+
if (node.kind !== 'toggle' || !(ctx.bottomBarItem || ctx.navBarItem)) return false;
|
|
372
|
+
const style = str(node.props['style']);
|
|
373
|
+
return style !== 'button' && style !== 'switch';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** A bottom bar item's slot in its capsule, from what it draws. */
|
|
377
|
+
function bottomSlot(node: LayoutNode, size: Size): number {
|
|
378
|
+
if (!hasText(node)) return Math.max(BOTTOM_ICON_SLOT, size.width + 2 * BOTTOM_ICON_PAD_H);
|
|
379
|
+
return size.width < NAV_GLASS_SIZE ? BOTTOM_ICON_SLOT : size.width + 2 * BOTTOM_TEXT_PAD_H;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** How a bottom bar item's button is styled: bordered, prominent (or glass), a button toggle, a bar's own toggle, or none of them. */
|
|
383
|
+
function bottomBarStyle(item: LayoutNode): 'bordered' | 'prominent' | 'toggle' | 'barToggle' | null {
|
|
384
|
+
const inner = unwrapStyled(item);
|
|
385
|
+
if (isButtonToggle(inner)) return 'toggle';
|
|
386
|
+
if (inner.kind === 'toggle' && str(inner.props['style']) !== 'switch') return 'barToggle';
|
|
387
|
+
if (inner.kind !== 'button') return null;
|
|
388
|
+
const style = str(inner.props['style']);
|
|
389
|
+
if (style === 'bordered') return 'bordered';
|
|
390
|
+
return style === 'borderedProminent' || style === 'glass' ? 'prominent' : null;
|
|
391
|
+
}
|
|
331
392
|
const SEARCH_FIELD_HEIGHT = 36;
|
|
332
393
|
const SEARCH_FIELD_MARGIN_H = 16;
|
|
333
394
|
// iOS 26: a navigation stack's search field is a 48-tall glass capsule
|
|
@@ -381,6 +442,8 @@ const DATEPICKER_CAPSULE_GAP = 8;
|
|
|
381
442
|
const DATEPICKER_GAP = 8;
|
|
382
443
|
/** A `texteditor` fills the proposal; 100 high when proposed nil. */
|
|
383
444
|
const TEXTEDITOR_DEFAULT_HEIGHT = 100;
|
|
445
|
+
/** UITextView's insets around a `texteditor`'s lines (`textContainerInset`, `lineFragmentPadding`; styles.css). */
|
|
446
|
+
const TEXTEDITOR_INSET = { horizontal: 5, vertical: 8 };
|
|
384
447
|
/**
|
|
385
448
|
* `alert` style (iOS 26): a 320 wide card centered in the safe area, its
|
|
386
449
|
* title and message leading-aligned 30 in, its buttons 46 tall capsules 16
|
|
@@ -397,6 +460,10 @@ const ALERT_BUTTON_SPACING = 8;
|
|
|
397
460
|
/** Gap between the alert title and its message. */
|
|
398
461
|
const ALERT_TITLE_GAP = 6;
|
|
399
462
|
const ALERT_BUTTON_HEIGHT = 46;
|
|
463
|
+
/** An alert's `textfield` children: 36 tall rows inset as the buttons are, 8 apart, 16 below the message. */
|
|
464
|
+
const ALERT_FIELD_HEIGHT = 36;
|
|
465
|
+
const ALERT_FIELD_SPACING = 8;
|
|
466
|
+
const ALERT_FIELDS_GAP = 16;
|
|
400
467
|
/** Gap between an action sheet's title and its message. */
|
|
401
468
|
const DIALOG_TITLE_GAP = 4;
|
|
402
469
|
/** An action sheet's button labels are inset this much from its rows. */
|
|
@@ -527,6 +594,13 @@ function flexibleFrameLength(proposed: number | null, child: number, min: number
|
|
|
527
594
|
return length;
|
|
528
595
|
}
|
|
529
596
|
|
|
597
|
+
/** Whether `id` is the node or one of its descendants (a screen asking whether the focused text input is its own). */
|
|
598
|
+
function subtreeHas(node: LayoutNode, id: number): boolean {
|
|
599
|
+
if (node.id === id) return true;
|
|
600
|
+
for (const child of node.children) if (subtreeHas(child, id)) return true;
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
|
|
530
604
|
/** Whether a root (through its `styled` wrappers) carries `ignoresKeyboard`: it keeps the keyboardless height. */
|
|
531
605
|
function ignoresKeyboard(node: LayoutNode): boolean {
|
|
532
606
|
let current = node;
|
|
@@ -664,6 +738,7 @@ function listRowVPad(row: LayoutNode, sidebar: boolean): number {
|
|
|
664
738
|
case 'picker':
|
|
665
739
|
return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
|
|
666
740
|
case 'toggle':
|
|
741
|
+
return isButtonToggle(row) ? (sidebar ? LIST_SIDEBAR_ROW_VPAD : LIST_ROW_VPAD) : LIST_ROW_CONTROL_VPAD;
|
|
667
742
|
case 'stepper':
|
|
668
743
|
return LIST_ROW_CONTROL_VPAD;
|
|
669
744
|
default:
|
|
@@ -1562,6 +1637,7 @@ export class LayoutPass {
|
|
|
1562
1637
|
}
|
|
1563
1638
|
const imageScale = str(style['imageScale']);
|
|
1564
1639
|
if (imageScale !== null) next = { ...next, imageScale: IMAGE_SCALES[imageScale] ?? 1 };
|
|
1640
|
+
if (typeof style['scrollDisabled'] === 'boolean') next = { ...next, scrollDisabled: style['scrollDisabled'] };
|
|
1565
1641
|
return next;
|
|
1566
1642
|
}
|
|
1567
1643
|
case 'vstack':
|
|
@@ -1579,8 +1655,9 @@ export class LayoutPass {
|
|
|
1579
1655
|
case 'toolbar': {
|
|
1580
1656
|
const placement = str(node.props['placement']);
|
|
1581
1657
|
const navBarItem = placement === 'leading' || placement === 'trailing';
|
|
1582
|
-
|
|
1583
|
-
|
|
1658
|
+
const bottomBarItem = placement === 'bottom';
|
|
1659
|
+
if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem && ctx.bottomBarItem === bottomBarItem) return ctx;
|
|
1660
|
+
return { ...ctx, stackAxis: null, listRow: false, navBarItem, bottomBarItem };
|
|
1584
1661
|
}
|
|
1585
1662
|
case 'gesture':
|
|
1586
1663
|
case 'swipeactions':
|
|
@@ -1742,7 +1819,9 @@ export class LayoutPass {
|
|
|
1742
1819
|
case 'datepicker':
|
|
1743
1820
|
return this.datePickerPlan(node, ctx, p).size;
|
|
1744
1821
|
case 'texteditor':
|
|
1745
|
-
//
|
|
1822
|
+
// Not scrolling, as tall as its lines; else a textarea filling the
|
|
1823
|
+
// proposal (flexible on an infinite axis, like a shape).
|
|
1824
|
+
if (ctx.scrollDisabled) return this.measureFittingEditor(node, ctx, p);
|
|
1746
1825
|
return { width: p.width ?? TEXTFIELD_DEFAULT_WIDTH, height: p.height ?? TEXTEDITOR_DEFAULT_HEIGHT };
|
|
1747
1826
|
case 'layout':
|
|
1748
1827
|
return this.measureLayout(node, ctx, p);
|
|
@@ -1812,6 +1891,41 @@ export class LayoutPass {
|
|
|
1812
1891
|
return { width, height: textHeight(lines, ctx.font) + 2 * inset.vertical };
|
|
1813
1892
|
}
|
|
1814
1893
|
|
|
1894
|
+
/**
|
|
1895
|
+
* A `texteditor` under `.scrollDisabled(true)`: the proposed width (200
|
|
1896
|
+
* when unconstrained) and its lines, a leading apart (as the DOM spaces
|
|
1897
|
+
* them), inside UITextView's insets; one line when empty.
|
|
1898
|
+
*
|
|
1899
|
+
* Every line is the context font's leading tall, whatever its runs' fonts
|
|
1900
|
+
* (`richTextPlan`'s own per-line heights, a line height then a leading
|
|
1901
|
+
* per line, with a larger run's font making its line taller, are not
|
|
1902
|
+
* used): the DOM gives the editor one line height, which every run's span
|
|
1903
|
+
* inherits (richEditor.ts `layOutRichEditor`), so its lines are evenly
|
|
1904
|
+
* spaced and the box is as tall as the DOM draws it.
|
|
1905
|
+
*/
|
|
1906
|
+
private measureFittingEditor(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1907
|
+
const width = finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH;
|
|
1908
|
+
const runs = node.props['runs'];
|
|
1909
|
+
const lines = Array.isArray(runs)
|
|
1910
|
+
? this.editorRunsPlan(node, ctx, p, runs, width).layout.lines.length
|
|
1911
|
+
: wrapText(str(node.props['text']) ?? '', ctx.font, Math.max(1, width - 2 * TEXTEDITOR_INSET.horizontal), null, this.measurer).lines.length;
|
|
1912
|
+
return { width, height: Math.max(1, lines) * ctx.font.leading + 2 * TEXTEDITOR_INSET.vertical };
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
/**
|
|
1916
|
+
* A rich `texteditor`'s runs wrapped inside the insets of a `width` wide
|
|
1917
|
+
* editor, with no line limit (an editor shows every line). Memoized under
|
|
1918
|
+
* the editor's proposal like a text's plan: the measurement of a fitting
|
|
1919
|
+
* editor and the layout the DOM draws (`place`) wrap the runs once per
|
|
1920
|
+
* layout pass, where they used to wrap them twice.
|
|
1921
|
+
*/
|
|
1922
|
+
private editorRunsPlan(node: LayoutNode, ctx: Context, p: Proposal, runs: unknown[], width: number): TextPlan {
|
|
1923
|
+
return this.plan(node, ctx, p, () => {
|
|
1924
|
+
const inner = Math.max(1, width - 2 * TEXTEDITOR_INSET.horizontal);
|
|
1925
|
+
return this.richTextPlan(runs, { ...ctx, lineLimit: null }, { width: inner, height: null });
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1815
1929
|
private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
|
|
1816
1930
|
return this.plan(node, ctx, p, () => {
|
|
1817
1931
|
const runs = node.props['runs'];
|
|
@@ -1898,6 +2012,28 @@ export class LayoutPass {
|
|
|
1898
2012
|
return { width, height };
|
|
1899
2013
|
}
|
|
1900
2014
|
|
|
2015
|
+
/**
|
|
2016
|
+
* A link row with a badge: iOS draws the count before the chevron
|
|
2017
|
+
* (`Inbox 12 ›`), the chevron at the row's trailing edge. The row was laid
|
|
2018
|
+
* out with the badge's room at the trailing edge, after the link's
|
|
2019
|
+
* chevron; the two trade places.
|
|
2020
|
+
*/
|
|
2021
|
+
private badgeBeforeChevron(rowNode: LayoutNode): void {
|
|
2022
|
+
const rowChrome = rowNode.id >= 0 ? this.chrome.get(rowNode.id) : undefined;
|
|
2023
|
+
const badge = rowChrome?.['badge'];
|
|
2024
|
+
if (!badge) return;
|
|
2025
|
+
// Through the row's wrappers (a context menu's content is its first child).
|
|
2026
|
+
const wrappers = new Set(['styled', 'listrow', 'contextmenu', 'swipeactions', 'gesture']);
|
|
2027
|
+
let link: LayoutNode | undefined = rowNode;
|
|
2028
|
+
while (link && link.kind !== 'navlink') link = wrappers.has(link.kind) ? link.children[0] : undefined;
|
|
2029
|
+
const linkChrome = link && link.id >= 0 ? this.chrome.get(link.id) : undefined;
|
|
2030
|
+
const chevron = linkChrome?.['chevron'];
|
|
2031
|
+
if (!chevron || chevron.x > badge.x) return;
|
|
2032
|
+
const end = badge.x + badge.width;
|
|
2033
|
+
linkChrome['chevron'] = { ...chevron, x: end - chevron.width };
|
|
2034
|
+
rowChrome['badge'] = { ...badge, x: end - chevron.width - badge.width };
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1901
2037
|
private buttonIsBordered(node: LayoutNode): boolean {
|
|
1902
2038
|
const style = str(node.props['style']);
|
|
1903
2039
|
return style === 'bordered' || style === 'borderedProminent' || style === 'glass';
|
|
@@ -1907,6 +2043,14 @@ export class LayoutPass {
|
|
|
1907
2043
|
const label = this.contentOf(node);
|
|
1908
2044
|
if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
|
|
1909
2045
|
const cctx = this.borderedLabelContext(node, ctx);
|
|
2046
|
+
if (ctx.bottomBarItem) {
|
|
2047
|
+
// In a bottom bar (measured in the iOS 26.2 simulator, Native
|
|
2048
|
+
// BarButtonStyleTests): a prominent or glass button is its own 48
|
|
2049
|
+
// circle; a bordered one fills its slot less 10, 34 tall.
|
|
2050
|
+
if (bottomBarStyle(node) === 'prominent') return { width: BOTTOM_GLASS_HEIGHT, height: BOTTOM_GLASS_HEIGHT };
|
|
2051
|
+
const size = label ? this.measure(label, cctx, { width: null, height: null }) : ZERO;
|
|
2052
|
+
return { width: bottomSlot(label ?? node, size) + BOTTOM_BORDERED_GROWTH - BOTTOM_BORDERED_INSET, height: BOTTOM_BORDERED_HEIGHT };
|
|
2053
|
+
}
|
|
1910
2054
|
const { padV, padH, minHeight } = this.borderedMetrics(node);
|
|
1911
2055
|
const inner: Proposal = {
|
|
1912
2056
|
width: shrink(p.width, 2 * padH),
|
|
@@ -1936,6 +2080,20 @@ export class LayoutPass {
|
|
|
1936
2080
|
|
|
1937
2081
|
private measureToggle(node: LayoutNode, ctx: Context, p: Proposal): Size {
|
|
1938
2082
|
const label = this.contentOf(node);
|
|
2083
|
+
if (isBarToggle(node, ctx)) {
|
|
2084
|
+
const size = label ? this.measure(label, this.childContext(node, ctx), { width: null, height: null }) : ZERO;
|
|
2085
|
+
return { width: size.width + 2 * BAR_TOGGLE_PAD_H, height: BAR_TOGGLE_HEIGHT };
|
|
2086
|
+
}
|
|
2087
|
+
if (isButtonToggle(node)) {
|
|
2088
|
+
if (ctx.bottomBarItem) {
|
|
2089
|
+
const size = label ? this.measure(label, this.childContext(node, ctx), { width: null, height: null }) : ZERO;
|
|
2090
|
+
return { width: bottomSlot(label ?? node, size) + BOTTOM_TOGGLE_GROWTH - BOTTOM_TOGGLE_INSET, height: BOTTOM_TOGGLE_HEIGHT };
|
|
2091
|
+
}
|
|
2092
|
+
// sized as a bordered button, on or off
|
|
2093
|
+
const inner: Proposal = { width: shrink(p.width, 2 * BORDERED_PAD_H), height: shrink(p.height, 2 * BORDERED_PAD_V) };
|
|
2094
|
+
const size = label ? this.measure(label, this.childContext(node, ctx), inner) : ZERO;
|
|
2095
|
+
return { width: size.width + 2 * BORDERED_PAD_H, height: Math.max(BORDERED_MIN_HEIGHT, size.height + 2 * BORDERED_PAD_V) };
|
|
2096
|
+
}
|
|
1939
2097
|
const labelSize = label
|
|
1940
2098
|
? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(p))
|
|
1941
2099
|
: ZERO;
|
|
@@ -3002,6 +3160,32 @@ export class LayoutPass {
|
|
|
3002
3160
|
}
|
|
3003
3161
|
case 'toggle': {
|
|
3004
3162
|
const label = this.contentOf(node);
|
|
3163
|
+
if (isBarToggle(node, ctx)) {
|
|
3164
|
+
// the label centered on its pill (the switch's element, filled while on)
|
|
3165
|
+
const labelSize = label ? this.measure(label, cctx, { width: null, height: null }) : ZERO;
|
|
3166
|
+
const { dx, dy } = alignIn(size, labelSize, 'center');
|
|
3167
|
+
if (label) this.place(label, cctx, { width: null, height: null }, x + dx, y + dy);
|
|
3168
|
+
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
3169
|
+
this.setChrome(node, {
|
|
3170
|
+
...existing,
|
|
3171
|
+
label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height },
|
|
3172
|
+
switch: { x, y, width: size.width, height: size.height },
|
|
3173
|
+
});
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
if (isButtonToggle(node)) {
|
|
3177
|
+
// the label centered in the button (its fill, while on, the toggle's frame)
|
|
3178
|
+
if (!label) return;
|
|
3179
|
+
const inner: Proposal = ctx.bottomBarItem
|
|
3180
|
+
? { width: null, height: null }
|
|
3181
|
+
: { width: shrink(p.width, 2 * BORDERED_PAD_H), height: shrink(p.height, 2 * BORDERED_PAD_V) };
|
|
3182
|
+
const labelSize = this.measure(label, cctx, inner);
|
|
3183
|
+
const { dx, dy } = alignIn(size, labelSize, 'center');
|
|
3184
|
+
this.place(label, cctx, inner, x + dx, y + dy);
|
|
3185
|
+
const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
|
|
3186
|
+
this.setChrome(node, { ...existing, label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height } });
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3005
3189
|
const labelProposal = this.toggleLabelProposal(p);
|
|
3006
3190
|
const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
|
|
3007
3191
|
const labelY = y + (size.height - labelSize.height) / 2;
|
|
@@ -3222,8 +3406,14 @@ export class LayoutPass {
|
|
|
3222
3406
|
return;
|
|
3223
3407
|
case 'texteditor':
|
|
3224
3408
|
// It draws in the context font, as a field does (the page's own
|
|
3225
|
-
// font otherwise: a serif where the page sets none)
|
|
3226
|
-
|
|
3409
|
+
// font otherwise: a serif where the page sets none); a rich one's
|
|
3410
|
+
// runs in theirs.
|
|
3411
|
+
if (node.id < 0) return;
|
|
3412
|
+
this.fonts.set(node.id, ctx.font);
|
|
3413
|
+
if (Array.isArray(node.props['runs'])) {
|
|
3414
|
+
// The plan a fitting editor was measured from (`measureFittingEditor`, memoized).
|
|
3415
|
+
this.text.set(node.id, this.editorRunsPlan(node, ctx, p, node.props['runs'], size.width).layout);
|
|
3416
|
+
}
|
|
3227
3417
|
return;
|
|
3228
3418
|
case 'swipeactions':
|
|
3229
3419
|
case 'contextmenu': {
|
|
@@ -3367,6 +3557,7 @@ export class LayoutPass {
|
|
|
3367
3557
|
: undefined;
|
|
3368
3558
|
this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
|
|
3369
3559
|
this.place(row.node, rowCtx, row.proposal, rowX, rowY);
|
|
3560
|
+
if (row.badge) this.badgeBeforeChevron(row.node);
|
|
3370
3561
|
// iOS 26: the separator under a row starts where its first text
|
|
3371
3562
|
// starts (after a Label's icon), else at the row's content.
|
|
3372
3563
|
const placed = row.node.id >= 0 ? this.chrome.get(row.node.id) : undefined;
|
|
@@ -3434,6 +3625,12 @@ export class LayoutPass {
|
|
|
3434
3625
|
const titleItemsHeight = large ? this.largeTitleItemsHeight(parts.toolbars, cctx, size.width) : null;
|
|
3435
3626
|
const largeHeight = !large ? 0 : titleItemsHeight !== null ? LARGE_TITLE_ITEM_TOP + titleItemsHeight : LARGE_TITLE_HEIGHT;
|
|
3436
3627
|
const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
|
|
3628
|
+
// `keyboard` toolbars: a glass capsule floating above the keyboard while a
|
|
3629
|
+
// text input of this screen is being edited (as Notes' format bar on
|
|
3630
|
+
// iOS 26; SwiftUI's input accessory view); nothing otherwise.
|
|
3631
|
+
const keyboardToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'keyboard');
|
|
3632
|
+
const focusedField = this.env.focusedField;
|
|
3633
|
+
const keyboardBarShown = keyboardToolbars.some((t) => !isSpacer(t)) && focusedField !== undefined && subtreeHas(node, focusedField);
|
|
3437
3634
|
const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
|
|
3438
3635
|
const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
|
|
3439
3636
|
? Math.min(size.height, BOTTOM_GLASS_HEIGHT + searchMargin)
|
|
@@ -3447,7 +3644,11 @@ export class LayoutPass {
|
|
|
3447
3644
|
// bottom search field rises above it, as iOS 26 docks it on the
|
|
3448
3645
|
// keyboard, so what is typed into it can be seen; the bars stay put,
|
|
3449
3646
|
// covered.
|
|
3450
|
-
|
|
3647
|
+
// With a keyboard bar the keyboard is the bar and its gap taller to the
|
|
3648
|
+
// content; a keyboard the page did not measure (frame mode) is the bottom
|
|
3649
|
+
// safe area.
|
|
3650
|
+
const keyboardAlone = reachesBottom ? this.keyboard : 0;
|
|
3651
|
+
const keyboard = keyboardBarShown ? KEYBOARD_BAR_HEIGHT + KEYBOARD_BAR_GAP + (keyboardAlone > 0 ? keyboardAlone : reachesBottom ? safeArea.bottom : 0) : keyboardAlone;
|
|
3451
3652
|
const searchLift = searchBottom > 0 ? keyboard : 0;
|
|
3452
3653
|
const contentTop = barHeight + largeHeight + searchRow;
|
|
3453
3654
|
const content: Rect = {
|
|
@@ -3478,6 +3679,9 @@ export class LayoutPass {
|
|
|
3478
3679
|
if (bottomBarHeight > 0) {
|
|
3479
3680
|
chrome['bottomBar'] = { x, y: y + size.height - bottomBarHeight, width: size.width, height: bottomBarHeight };
|
|
3480
3681
|
}
|
|
3682
|
+
if (keyboardBarShown) {
|
|
3683
|
+
chrome['keyboardBar'] = { x: x + KEYBOARD_BAR_MARGIN_H, y: y + size.height - keyboard, width: Math.max(0, size.width - 2 * KEYBOARD_BAR_MARGIN_H), height: KEYBOARD_BAR_HEIGHT };
|
|
3684
|
+
}
|
|
3481
3685
|
const title = str(node.props['title']) ?? '';
|
|
3482
3686
|
const titleFont = makeFont('system', textStyleSize('headline', dynamicTypeSize), 600);
|
|
3483
3687
|
const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize), 700);
|
|
@@ -3498,7 +3702,7 @@ export class LayoutPass {
|
|
|
3498
3702
|
for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
|
|
3499
3703
|
const barToolbars = parts.toolbars.filter((t) => {
|
|
3500
3704
|
const placement = str(t.props['placement']);
|
|
3501
|
-
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && !isSpacer(t);
|
|
3705
|
+
return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
|
|
3502
3706
|
});
|
|
3503
3707
|
const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
|
|
3504
3708
|
const groups: Group[] = barToolbars.map((toolbar) => {
|
|
@@ -3742,6 +3946,9 @@ export class LayoutPass {
|
|
|
3742
3946
|
if (bottomBarHeight > 0) {
|
|
3743
3947
|
this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
|
|
3744
3948
|
}
|
|
3949
|
+
const keyboardBar = chrome['keyboardBar'];
|
|
3950
|
+
if (keyboardBar) this.placeKeyboardToolbars(keyboardToolbars, cctx, keyboardBar);
|
|
3951
|
+
else for (const toolbar of keyboardToolbars) this.hide(toolbar, node);
|
|
3745
3952
|
if (parts.search) {
|
|
3746
3953
|
// The field is the whole capsule (its own measure is a 36pt field).
|
|
3747
3954
|
const field = chrome['searchField'] as Rect;
|
|
@@ -3904,6 +4111,29 @@ export class LayoutPass {
|
|
|
3904
4111
|
* `bottomGlass0` … (drawn under the items); each toolbar's frame spans its
|
|
3905
4112
|
* items.
|
|
3906
4113
|
*/
|
|
4114
|
+
/**
|
|
4115
|
+
* `keyboard` toolbars (`ToolbarItemGroup(placement: .keyboard)`): their
|
|
4116
|
+
* items in one row across the bar, 16 in from either edge, as SwiftUI
|
|
4117
|
+
* lays them out, an `HStack` of the items in the toolbars' order in which
|
|
4118
|
+
* a `Spacer` (a child, or a `ToolbarSpacer` of the placement) takes the
|
|
4119
|
+
* room left over; each toolbar's frame is the bar.
|
|
4120
|
+
*/
|
|
4121
|
+
private placeKeyboardToolbars(toolbars: LayoutNode[], cctx: Context, bar: Rect): void {
|
|
4122
|
+
const items: LayoutNode[] = [];
|
|
4123
|
+
for (const toolbar of toolbars) {
|
|
4124
|
+
if (str(toolbar.props['spacer']) !== null) items.push({ id: -1, kind: 'spacer', props: { minLength: str(toolbar.props['spacer']) === 'fixed' ? BOTTOM_FIXED_SPACER : null }, children: [] });
|
|
4125
|
+
else items.push(...toolbar.children);
|
|
4126
|
+
}
|
|
4127
|
+
const row: LayoutNode = { id: -1, kind: 'hstack', props: { spacing: null, alignment: 'center' }, children: items };
|
|
4128
|
+
const first = toolbars.find((toolbar) => str(toolbar.props['spacer']) === null) ?? (toolbars[0] as LayoutNode);
|
|
4129
|
+
const itemCtx = this.childContext(first, cctx);
|
|
4130
|
+
const inner = Math.max(0, bar.width - 2 * NAV_ITEM_MARGIN);
|
|
4131
|
+
const proposal: Proposal = { width: inner, height: bar.height };
|
|
4132
|
+
const rowSize = this.measure(row, itemCtx, proposal);
|
|
4133
|
+
this.place(row, itemCtx, proposal, bar.x + NAV_ITEM_MARGIN, bar.y + (bar.height - rowSize.height) / 2);
|
|
4134
|
+
for (const toolbar of toolbars) this.setFrame(toolbar, bar.x, bar.y, { width: bar.width, height: bar.height });
|
|
4135
|
+
}
|
|
4136
|
+
|
|
3907
4137
|
private placeBottomToolbars(
|
|
3908
4138
|
toolbars: LayoutNode[],
|
|
3909
4139
|
cctx: Context,
|
|
@@ -3941,28 +4171,45 @@ export class LayoutPass {
|
|
|
3941
4171
|
const itemCtx = this.childContext(toolbar, cctx);
|
|
3942
4172
|
for (const node of toolbar.children) {
|
|
3943
4173
|
const size = this.measure(node, itemCtx, { width: null, height: null });
|
|
3944
|
-
const
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
4174
|
+
const style = bottomBarStyle(node);
|
|
4175
|
+
if (style === 'prominent') {
|
|
4176
|
+
// its own circle, between the capsules of the items around it
|
|
4177
|
+
close();
|
|
4178
|
+
tokens.push({ kind: 'group', items: [{ node, toolbar, size, slot: BOTTOM_GLASS_HEIGHT }], width: BOTTOM_GLASS_HEIGHT });
|
|
4179
|
+
continue;
|
|
4180
|
+
}
|
|
4181
|
+
// A bar's own toggle takes its label's slot, as a plain button does.
|
|
4182
|
+
const slot =
|
|
4183
|
+
style === 'bordered'
|
|
4184
|
+
? size.width + BOTTOM_BORDERED_INSET
|
|
4185
|
+
: style === 'toggle'
|
|
4186
|
+
? size.width + BOTTOM_TOGGLE_INSET
|
|
4187
|
+
: style === 'barToggle'
|
|
4188
|
+
? bottomSlot(node, { width: size.width - 2 * BAR_TOGGLE_PAD_H, height: size.height })
|
|
4189
|
+
: bottomSlot(node, size);
|
|
3949
4190
|
open.push({ node, toolbar, size, slot });
|
|
3950
4191
|
}
|
|
3951
4192
|
}
|
|
3952
4193
|
close();
|
|
3953
4194
|
const inner = Math.max(0, width - 2 * BOTTOM_GLASS_MARGIN_H);
|
|
4195
|
+
// Two capsules with no spacer between them (a prominent item's circle
|
|
4196
|
+
// beside the rest) sit 12 apart.
|
|
4197
|
+
const gapBefore = (index: number): number =>
|
|
4198
|
+
index > 0 && tokens[index]?.kind === 'group' && tokens[index - 1]?.kind === 'group' ? BOTTOM_GROUP_GAP : 0;
|
|
3954
4199
|
let used = 0;
|
|
3955
4200
|
let flexible = 0;
|
|
3956
|
-
|
|
4201
|
+
tokens.forEach((token, index) => {
|
|
4202
|
+
used += gapBefore(index);
|
|
3957
4203
|
if (token.kind === 'group') used += token.width;
|
|
3958
4204
|
else if (token.kind === 'fixed') used += BOTTOM_FIXED_SPACER;
|
|
3959
4205
|
else flexible += 1;
|
|
3960
|
-
}
|
|
4206
|
+
});
|
|
3961
4207
|
const share = flexible > 0 ? Math.max(0, inner - used) / flexible : 0;
|
|
3962
4208
|
let cursor = flexible > 0 ? x + BOTTOM_GLASS_MARGIN_H : x + (width - used) / 2;
|
|
3963
4209
|
const spans = new Map<LayoutNode, { start: number; end: number }>();
|
|
3964
4210
|
let glass = 0;
|
|
3965
|
-
for (const token of tokens) {
|
|
4211
|
+
for (const [index, token] of tokens.entries()) {
|
|
4212
|
+
cursor += gapBefore(index);
|
|
3966
4213
|
if (token.kind === 'flexible') {
|
|
3967
4214
|
cursor += share;
|
|
3968
4215
|
continue;
|
|
@@ -3971,7 +4218,9 @@ export class LayoutPass {
|
|
|
3971
4218
|
cursor += BOTTOM_FIXED_SPACER;
|
|
3972
4219
|
continue;
|
|
3973
4220
|
}
|
|
3974
|
-
|
|
4221
|
+
// A button toggle's capsule is taller, about the row's center.
|
|
4222
|
+
const height = token.items.some((item) => bottomBarStyle(item.node) === 'toggle') ? BOTTOM_TOGGLE_GLASS_HEIGHT : BOTTOM_GLASS_HEIGHT;
|
|
4223
|
+
chrome[`bottomGlass${glass}`] = { x: cursor, y: top - (height - BOTTOM_GLASS_HEIGHT) / 2, width: token.width, height };
|
|
3975
4224
|
glass += 1;
|
|
3976
4225
|
for (const item of token.items) {
|
|
3977
4226
|
const itemCtx = this.childContext(item.toolbar, cctx);
|
|
@@ -4433,6 +4682,21 @@ export class LayoutPass {
|
|
|
4433
4682
|
const chrome: Record<string, Rect> = {};
|
|
4434
4683
|
const texts: Record<string, TextLayout> = {};
|
|
4435
4684
|
const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
|
|
4685
|
+
// `textfield` children (`.alert { TextField(...); Button(...) }`) go in
|
|
4686
|
+
// the card between the message and the buttons, in their order.
|
|
4687
|
+
const fields = node.children.filter((child) => unwrapStyled(child).kind === 'textfield');
|
|
4688
|
+
const buttons = node.children.filter((child) => !fields.includes(child));
|
|
4689
|
+
const placeFields = (top: number, left: number, width: number): void => {
|
|
4690
|
+
fields.forEach((field, i) => {
|
|
4691
|
+
const rect: Rect = { x: left, y: top + i * (ALERT_FIELD_HEIGHT + ALERT_FIELD_SPACING), width, height: ALERT_FIELD_HEIGHT };
|
|
4692
|
+
chrome[`field${i}`] = rect;
|
|
4693
|
+
const proposal: Proposal = { width: rect.width, height: rect.height };
|
|
4694
|
+
const fieldSize = this.measure(field, cctx, proposal);
|
|
4695
|
+
this.place(field, cctx, proposal, rect.x, rect.y + (rect.height - fieldSize.height) / 2);
|
|
4696
|
+
this.setFrame(field, rect.x, rect.y, { width: rect.width, height: rect.height });
|
|
4697
|
+
});
|
|
4698
|
+
};
|
|
4699
|
+
const fieldsHeight = fields.length > 0 ? fields.length * ALERT_FIELD_HEIGHT + (fields.length - 1) * ALERT_FIELD_SPACING : 0;
|
|
4436
4700
|
|
|
4437
4701
|
const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
|
|
4438
4702
|
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
@@ -4463,9 +4727,14 @@ export class LayoutPass {
|
|
|
4463
4727
|
messageBlock = { ...b, top: h };
|
|
4464
4728
|
h += b.height;
|
|
4465
4729
|
}
|
|
4730
|
+
let fieldsTop: number | null = null;
|
|
4731
|
+
if (fields.length > 0) {
|
|
4732
|
+
if (titleBlock || messageBlock) h += ALERT_FIELDS_GAP;
|
|
4733
|
+
fieldsTop = h;
|
|
4734
|
+
h += fieldsHeight;
|
|
4735
|
+
}
|
|
4466
4736
|
h += ALERT_BUTTONS_GAP;
|
|
4467
4737
|
const headerHeight = h;
|
|
4468
|
-
const buttons = node.children;
|
|
4469
4738
|
const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
|
|
4470
4739
|
h += rows * ALERT_BUTTON_HEIGHT + (rows - 1) * ALERT_BUTTON_SPACING + ALERT_PAD_BOTTOM;
|
|
4471
4740
|
// Centered in the safe area, as iOS does.
|
|
@@ -4485,6 +4754,7 @@ export class LayoutPass {
|
|
|
4485
4754
|
const buttonsTop = card.y + headerHeight;
|
|
4486
4755
|
const buttonsX = card.x + ALERT_BUTTON_INSET;
|
|
4487
4756
|
const buttonsWidth = Math.max(0, cardWidth - 2 * ALERT_BUTTON_INSET);
|
|
4757
|
+
if (fieldsTop !== null) placeFields(card.y + fieldsTop, buttonsX, buttonsWidth);
|
|
4488
4758
|
if (buttons.length === 0) {
|
|
4489
4759
|
chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: ALERT_BUTTON_HEIGHT };
|
|
4490
4760
|
} else if (buttons.length === 2) {
|
|
@@ -4534,9 +4804,14 @@ export class LayoutPass {
|
|
|
4534
4804
|
}
|
|
4535
4805
|
h += DIALOG_HEADER_PAD_V;
|
|
4536
4806
|
}
|
|
4807
|
+
let fieldsTop: number | null = null;
|
|
4808
|
+
if (fields.length > 0) {
|
|
4809
|
+
fieldsTop = h;
|
|
4810
|
+
h += fieldsHeight + DIALOG_HEADER_PAD_V;
|
|
4811
|
+
}
|
|
4537
4812
|
const headerHeight = h;
|
|
4538
|
-
const cancel =
|
|
4539
|
-
const others =
|
|
4813
|
+
const cancel = buttons.find((child) => roleOf(child) === 'cancel') ?? null;
|
|
4814
|
+
const others = buttons.filter((child) => child !== cancel);
|
|
4540
4815
|
const rows = others.length === 0 && !cancel ? 1 : others.length;
|
|
4541
4816
|
h += rows * DIALOG_BUTTON_HEIGHT;
|
|
4542
4817
|
const bottom = y + size.height - safeArea.bottom - DIALOG_MARGIN;
|
|
@@ -4559,6 +4834,7 @@ export class LayoutPass {
|
|
|
4559
4834
|
texts['message'] = messageBlock.layout;
|
|
4560
4835
|
}
|
|
4561
4836
|
const buttonsTop = card.y + headerHeight;
|
|
4837
|
+
if (fieldsTop !== null) placeFields(card.y + fieldsTop, card.x + DIALOG_HEADER_PAD_H, textWidth);
|
|
4562
4838
|
if (others.length === 0 && !cancel) {
|
|
4563
4839
|
chrome['ok'] = { x: card.x, y: buttonsTop, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
|
|
4564
4840
|
}
|
package/src/layout/types.ts
CHANGED
|
@@ -118,6 +118,12 @@ export interface LayoutEnvironment {
|
|
|
118
118
|
* under it.
|
|
119
119
|
*/
|
|
120
120
|
keyboard?: number;
|
|
121
|
+
/**
|
|
122
|
+
* The element id of the text input being edited (a `textfield`, a
|
|
123
|
+
* `texteditor` or a `searchfield`), when one is: a `navscreen` holding it
|
|
124
|
+
* shows its `keyboard` toolbars above the keyboard.
|
|
125
|
+
*/
|
|
126
|
+
focusedField?: number;
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
export type TextAlignment = 'leading' | 'center' | 'trailing';
|
package/src/layoutDom.ts
CHANGED
|
@@ -41,6 +41,7 @@ import type { ShapeStyle } from './protocol';
|
|
|
41
41
|
import { applyChartChrome } from './chartDom';
|
|
42
42
|
import { bitmapLaidOut } from './bitmaps';
|
|
43
43
|
import { drawRichText } from './richText';
|
|
44
|
+
import { isRichEditor, layOutRichEditor } from './richEditor';
|
|
44
45
|
import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls, largeTitleTextOf } from './largeTitle';
|
|
45
46
|
import { bindScrollEdgeBottom } from './scrollEdge';
|
|
46
47
|
import { beginScrollSyncBatch, flushScrollSyncBatch, type ScrollExtent } from './scrollSync';
|
|
@@ -480,7 +481,10 @@ function setTextLayout(el: HTMLElement, layout: TextLayout): void {
|
|
|
480
481
|
*/
|
|
481
482
|
function applyBadge(result: LayoutResult, node: LayoutNode, el: HTMLElement, box: Rect): void {
|
|
482
483
|
const badgeRect = result.chrome.get(node.id)?.['badge'];
|
|
483
|
-
|
|
484
|
+
// A swipeable row draws its content on a layer that slides over its
|
|
485
|
+
// actions; the badge is part of that content.
|
|
486
|
+
const host = el.querySelector<HTMLElement>(':scope > .sb-swipe-content') ?? el;
|
|
487
|
+
let badge = host.querySelector<HTMLElement>(':scope > .sb-badge');
|
|
484
488
|
if (!badgeRect) {
|
|
485
489
|
badge?.remove();
|
|
486
490
|
return;
|
|
@@ -489,7 +493,7 @@ function applyBadge(result: LayoutResult, node: LayoutNode, el: HTMLElement, box
|
|
|
489
493
|
badge = el.ownerDocument.createElement('span');
|
|
490
494
|
badge.className = 'sb-badge';
|
|
491
495
|
badge.setAttribute('aria-hidden', 'true');
|
|
492
|
-
|
|
496
|
+
host.append(badge);
|
|
493
497
|
}
|
|
494
498
|
placeChrome(badge, badgeRect, box);
|
|
495
499
|
const layout = result.chromeText.get(node.id)?.['badge'];
|
|
@@ -673,10 +677,10 @@ function applyChrome(
|
|
|
673
677
|
}
|
|
674
678
|
case 'textfield':
|
|
675
679
|
case 'texteditor': {
|
|
676
|
-
// A field draws in its context's font (a vertical one,
|
|
677
|
-
// lines were measured in, a leading apart).
|
|
680
|
+
// A field draws in its context's font (a vertical one, or a rich
|
|
681
|
+
// editor, the font its lines were measured in, a leading apart).
|
|
678
682
|
const font = result.fonts.get(node.id);
|
|
679
|
-
setFont(el, font, el instanceof
|
|
683
|
+
setFont(el, font, el instanceof HTMLInputElement ? 1 : 2);
|
|
680
684
|
// A one-line field's text sits where a centered Text's would (the
|
|
681
685
|
// engine's `text` rect, snapped as a label beside it is): the input
|
|
682
686
|
// centers its line in the frame, so move it by the difference.
|
|
@@ -687,6 +691,8 @@ function applyChrome(
|
|
|
687
691
|
: 0;
|
|
688
692
|
padIntoFrame(el, box, frame, shift);
|
|
689
693
|
if (el instanceof HTMLTextAreaElement && node.kind === 'textfield' && font) fitFieldLines(el, font, box, frame);
|
|
694
|
+
// A rich editor's runs, each in the font it was measured in.
|
|
695
|
+
if (isRichEditor(el)) layOutRichEditor(el, result.text.get(node.id));
|
|
690
696
|
return;
|
|
691
697
|
}
|
|
692
698
|
// Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
|
|
@@ -844,6 +850,8 @@ function applyChrome(
|
|
|
844
850
|
bar.style.paddingBottom = `${bottom}px`;
|
|
845
851
|
}
|
|
846
852
|
syncBarGlass(result, node, el, chrome, frame, isNew, changes);
|
|
853
|
+
// The `keyboard` toolbars' bar above the keyboard while a text input of the screen is focused.
|
|
854
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-keyboard-bar'), chrome?.['keyboardBar'], frame);
|
|
847
855
|
// iOS 26 in a tab view: the search field under the title (styles.css).
|
|
848
856
|
setFlag(el, 'sbSearchTop', chrome?.['searchTop'] ? '' : null);
|
|
849
857
|
const largeRect = chrome?.['largeTitle'];
|
package/src/main.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { withBlockedHosts } from './blockedHosts';
|
|
|
20
20
|
import { withCORSProxy } from './corsProxy';
|
|
21
21
|
import { browserEnvironment, installPushService } from './hostRequests';
|
|
22
22
|
import { locateBundleFiles } from './media';
|
|
23
|
+
import { relayUndo } from './undo';
|
|
23
24
|
import { callbackPageURL } from './webAuth';
|
|
24
25
|
import { bitmapStore } from './bitmaps';
|
|
25
26
|
import { DEFAULT_TYPE_SIZE, applyTypeSizeVariables } from './typography';
|
|
@@ -640,6 +641,9 @@ async function boot(): Promise<void> {
|
|
|
640
641
|
),
|
|
641
642
|
assetURL: (name) => assets.images[name]?.src,
|
|
642
643
|
bitmapFile: (recipe) => bitmapStore.png(recipe),
|
|
644
|
+
// The app's undo manager reports its state in `undo` requests; the
|
|
645
|
+
// keyboard's undo and redo go back to it as events (undo.ts).
|
|
646
|
+
...(browserEnvironment.undoManager ? { undoManager: relayUndo(browserEnvironment.undoManager, dispatch) } : {}),
|
|
643
647
|
},
|
|
644
648
|
onSwiftData: (op) => {
|
|
645
649
|
swiftData?.apply(op).catch((error: unknown) => console.warn('[sb] could not save to IndexedDB', error));
|