@swiftbrowser/web 0.26.1 → 0.26.3
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/app/index.html +9 -3
- package/node/dist/node/apps.d.ts +6 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +3 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +2 -0
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +5 -0
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/plugins/site-layout.d.ts +2 -0
- package/node/dist/plugins/site-layout.d.ts.map +1 -1
- package/node/dist/plugins/site-layout.js.map +1 -1
- package/package.json +1 -1
- package/plugins/site-layout.ts +2 -0
- package/src/deviceFrame.ts +38 -14
- package/src/layout/engine.ts +787 -175
- package/src/layout/types.ts +3 -0
- package/src/layout/typography.ts +27 -6
- package/src/layoutDom.ts +64 -3
- package/src/main.ts +13 -4
- package/src/pathShape.ts +42 -7
- package/src/protocol.ts +41 -1
- package/src/renderer.ts +35 -6
- package/src/styles.css +752 -0
- package/src/typography.ts +11 -10
- package/src/vite-env.d.ts +2 -0
package/src/layout/types.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { AssetCatalog } from '../assets';
|
|
8
|
+
import type { Platform } from '../protocol';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* A size proposal from a parent to a child. `null` means "whatever you want",
|
|
@@ -97,6 +98,8 @@ export interface LayoutEnvironment {
|
|
|
97
98
|
dynamicTypeSize: DynamicTypeSize;
|
|
98
99
|
devicePixelRatio: number;
|
|
99
100
|
colorScheme: 'light' | 'dark';
|
|
101
|
+
/** The platform whose idiom the engine lays out for (docs/notes/macos.md); iOS when absent. */
|
|
102
|
+
platform?: Platform;
|
|
100
103
|
/**
|
|
101
104
|
* Phase 5: the app's asset catalog manifest, for the intrinsic size of
|
|
102
105
|
* named images. Absent (or empty) when the app has no catalog; named
|
package/src/layout/typography.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* leading (body 22): the distance between a paragraph's lines (`LEADINGS`).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { Platform } from '../protocol';
|
|
14
15
|
import type { DynamicTypeSize, FontFamily, LayoutEnvironment, ResolvedFont } from './types';
|
|
15
16
|
|
|
16
17
|
export type TextStyle =
|
|
@@ -63,6 +64,24 @@ export const TEXT_STYLE_SIZES: Record<TextStyle, readonly number[]> = {
|
|
|
63
64
|
caption2: [11, 11, 11, 11, 13, 15, 17, 20, 24, 29, 34, 40],
|
|
64
65
|
};
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The macOS text styles (HIG "Typography" > "macOS built-in text styles"),
|
|
69
|
+
* one size each: the Mac has no Dynamic Type. Body is 13.
|
|
70
|
+
*/
|
|
71
|
+
export const MAC_TEXT_STYLE_SIZES: Readonly<Record<TextStyle, number>> = {
|
|
72
|
+
largeTitle: 26,
|
|
73
|
+
title: 22,
|
|
74
|
+
title2: 17,
|
|
75
|
+
title3: 15,
|
|
76
|
+
headline: 13,
|
|
77
|
+
body: 13,
|
|
78
|
+
callout: 12,
|
|
79
|
+
subheadline: 11,
|
|
80
|
+
footnote: 10,
|
|
81
|
+
caption: 10,
|
|
82
|
+
caption2: 10,
|
|
83
|
+
};
|
|
84
|
+
|
|
66
85
|
/**
|
|
67
86
|
* The HIG's leading for the standard (Large) point sizes: the distance
|
|
68
87
|
* between the baselines of a paragraph's lines, which SwiftUI keeps (a
|
|
@@ -122,8 +141,9 @@ export function sizeCategoryIndex(size: DynamicTypeSize): number {
|
|
|
122
141
|
return index < 0 ? LARGE_INDEX : index;
|
|
123
142
|
}
|
|
124
143
|
|
|
125
|
-
/** Point size of a text style at a Dynamic Type size category. */
|
|
126
|
-
export function textStyleSize(style: TextStyle, size: DynamicTypeSize): number {
|
|
144
|
+
/** Point size of a text style at a Dynamic Type size category (on the Mac, its one size). */
|
|
145
|
+
export function textStyleSize(style: TextStyle, size: DynamicTypeSize, platform: Platform = 'ios'): number {
|
|
146
|
+
if (platform === 'macos') return MAC_TEXT_STYLE_SIZES[style];
|
|
127
147
|
const row = TEXT_STYLE_SIZES[style];
|
|
128
148
|
return row[sizeCategoryIndex(size)] ?? row[LARGE_INDEX] ?? 17;
|
|
129
149
|
}
|
|
@@ -166,9 +186,9 @@ export function makeFont(
|
|
|
166
186
|
return font;
|
|
167
187
|
}
|
|
168
188
|
|
|
169
|
-
/** Body text for the current `dynamicTypeSize` (17/20.33 at Large). */
|
|
189
|
+
/** Body text for the current `dynamicTypeSize` (17/20.33 at Large; 13 on the Mac). */
|
|
170
190
|
export function defaultFont(env: LayoutEnvironment): ResolvedFont {
|
|
171
|
-
return makeFont('system', textStyleSize('body', env.dynamicTypeSize), 400);
|
|
191
|
+
return makeFont('system', textStyleSize('body', env.dynamicTypeSize, env.platform), 400);
|
|
172
192
|
}
|
|
173
193
|
|
|
174
194
|
/**
|
|
@@ -199,13 +219,14 @@ export function resolveFont(
|
|
|
199
219
|
let defaultWeight = 400;
|
|
200
220
|
let isNewFont = false;
|
|
201
221
|
if (isTextStyle(textStyle)) {
|
|
202
|
-
size = textStyleSize(textStyle, env.dynamicTypeSize);
|
|
222
|
+
size = textStyleSize(textStyle, env.dynamicTypeSize, env.platform);
|
|
203
223
|
if (textStyle === 'headline') defaultWeight = 600;
|
|
204
224
|
isNewFont = true;
|
|
205
225
|
} else if (typeof fixedSize === 'number' && Number.isFinite(fixedSize) && fixedSize > 0) {
|
|
206
226
|
size = fixedSize;
|
|
207
227
|
const relativeTo = font['relativeTo'];
|
|
208
|
-
|
|
228
|
+
// The Mac has no Dynamic Type: a fixed size stays as given.
|
|
229
|
+
if (isTextStyle(relativeTo) && env.platform !== 'macos') {
|
|
209
230
|
const base = TEXT_STYLE_SIZES[relativeTo][LARGE_INDEX] ?? 17;
|
|
210
231
|
size = fixedSize * (textStyleSize(relativeTo, env.dynamicTypeSize) / base);
|
|
211
232
|
}
|
package/src/layoutDom.ts
CHANGED
|
@@ -702,6 +702,8 @@ function applyChrome(
|
|
|
702
702
|
// Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
|
|
703
703
|
case 'stepper':
|
|
704
704
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-stepper-control'), chrome?.['control'], box);
|
|
705
|
+
// The Mac idiom: the arrows, up over down (styles.css).
|
|
706
|
+
setFlag(el, 'sbArrows', chrome?.['arrows'] ? '' : null);
|
|
705
707
|
return;
|
|
706
708
|
case 'slider': {
|
|
707
709
|
// The `label` child is hidden (iOS draws none) and names the slider, as VoiceOver reads it.
|
|
@@ -715,6 +717,8 @@ function applyChrome(
|
|
|
715
717
|
el.removeAttribute('aria-labelledby');
|
|
716
718
|
}
|
|
717
719
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-track'), chrome?.['track'], box);
|
|
720
|
+
// The Mac idiom: the round knob (styles.css).
|
|
721
|
+
setFlag(el, 'sbKnob', chrome?.['knob'] ? '' : null);
|
|
718
722
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-fill'), chrome?.['fill'], box);
|
|
719
723
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-thumb'), chrome?.['thumb'], box);
|
|
720
724
|
return;
|
|
@@ -722,6 +726,8 @@ function applyChrome(
|
|
|
722
726
|
case 'datepicker':
|
|
723
727
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-date'), chrome?.['date'], box);
|
|
724
728
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-time'), chrome?.['time'], box);
|
|
729
|
+
// The Mac idiom: the date and time as bordered fields (styles.css).
|
|
730
|
+
setFlag(el, 'sbFields', chrome?.['fields'] ? '' : null);
|
|
725
731
|
return;
|
|
726
732
|
case 'alert': {
|
|
727
733
|
// Phase 8: the card(s), the centered title / message lines and the
|
|
@@ -742,6 +748,8 @@ function applyChrome(
|
|
|
742
748
|
if (message && texts?.['message']) setTextLayout(message, texts['message']);
|
|
743
749
|
placeChrome(part('sb-alert-ok'), chrome?.['ok'], box);
|
|
744
750
|
el.toggleAttribute('data-sb-header', Boolean(chrome?.['title'] || chrome?.['message']));
|
|
751
|
+
// The Mac idiom: the alert as a panel (styles.css).
|
|
752
|
+
setFlag(el, 'sbPanel', chrome?.['panel'] ? '' : null);
|
|
745
753
|
const first = chrome?.['button0'];
|
|
746
754
|
const second = chrome?.['button1'];
|
|
747
755
|
el.dataset['sbLayout'] = first && second && first.y === second.y && !chrome?.['button2'] ? 'row' : 'stack';
|
|
@@ -750,7 +758,34 @@ function applyChrome(
|
|
|
750
758
|
}
|
|
751
759
|
return;
|
|
752
760
|
}
|
|
761
|
+
case 'menu':
|
|
762
|
+
// The Mac idiom: a pull-down button around the label (styles.css).
|
|
763
|
+
setFlag(el, 'sbPulldown', chrome?.['pulldown'] ? '' : null);
|
|
764
|
+
return;
|
|
765
|
+
case 'button': {
|
|
766
|
+
// The Mac idiom: an automatic button the engine bordered (styles.css);
|
|
767
|
+
// in a list row the button is drawn at its own frame on the row
|
|
768
|
+
// (`inset`, the frame in the row's coordinates).
|
|
769
|
+
// The rect the border is drawn at: the engine's (a bar item's slot) or
|
|
770
|
+
// the button's own frame; `inset` when it is not the element's box.
|
|
771
|
+
const bordered = chrome?.['bordered'];
|
|
772
|
+
const rect = bordered ?? frame;
|
|
773
|
+
const inset = bordered !== undefined && (box.x !== rect.x || box.y !== rect.y || box.width !== rect.width || box.height !== rect.height);
|
|
774
|
+
setFlag(el, 'sbBordered', bordered ? (inset ? 'inset' : '') : null);
|
|
775
|
+
const vars: [string, string][] = inset
|
|
776
|
+
? [['--sb-bordered-left', `${rect.x - box.x}px`], ['--sb-bordered-top', `${rect.y - box.y}px`], ['--sb-bordered-width', `${rect.width}px`], ['--sb-bordered-height', `${rect.height}px`]]
|
|
777
|
+
: [['--sb-bordered-left', ''], ['--sb-bordered-top', ''], ['--sb-bordered-width', ''], ['--sb-bordered-height', '']];
|
|
778
|
+
for (const [name, value] of vars) {
|
|
779
|
+
if (el.style.getPropertyValue(name) !== value) {
|
|
780
|
+
if (value) el.style.setProperty(name, value);
|
|
781
|
+
else el.style.removeProperty(name);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
753
786
|
case 'toggle': {
|
|
787
|
+
// The Mac idiom: a checkbox at the switch's rect (styles.css).
|
|
788
|
+
setFlag(el, 'sbCheckbox', chrome?.['checkbox'] ? '' : null);
|
|
754
789
|
const sw = el.querySelector<HTMLElement>(':scope > .sb-switch');
|
|
755
790
|
const rect = chrome?.['switch'] ?? {
|
|
756
791
|
x: frame.x + frame.width - SWITCH_SIZE.width,
|
|
@@ -761,10 +796,22 @@ function applyChrome(
|
|
|
761
796
|
return;
|
|
762
797
|
}
|
|
763
798
|
case 'disclosuregroup': {
|
|
764
|
-
// The chevron at the label's trailing edge, the toggle over the label (the first child's frame).
|
|
765
|
-
const label = node.children[0] ? result.frames.get(node.children[0].id) : undefined;
|
|
766
799
|
const chevron = el.querySelector<HTMLElement>(':scope > .sb-disclosure-chevron');
|
|
767
800
|
const toggle = el.querySelector<HTMLElement>(':scope > .sb-disclosure-toggle');
|
|
801
|
+
// In a list the group is an outline (engine.ts `outlineRows`): its label
|
|
802
|
+
// and content are rows (styles.css draws them as the card's), the toggle
|
|
803
|
+
// lies over the label's row and the chevron sits at that row's trailing
|
|
804
|
+
// edge, centred as a link's is.
|
|
805
|
+
const outline = chrome?.['toggle'];
|
|
806
|
+
setFlag(el, 'sbOutline', outline ? '' : null);
|
|
807
|
+
if (outline) {
|
|
808
|
+
placeChrome(toggle, outline, box);
|
|
809
|
+
const rect = chrome?.['chevron'];
|
|
810
|
+
placeChrome(chevron, rect ? { x: rect.x + (rect.width - CHEVRON_SIZE) / 2, y: rect.y + (rect.height - CHEVRON_SIZE) / 2, width: CHEVRON_SIZE, height: CHEVRON_SIZE } : undefined, box);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
// The chevron at the label's trailing edge, the toggle over the label (the first child's frame).
|
|
814
|
+
const label = node.children[0] ? result.frames.get(node.children[0].id) : undefined;
|
|
768
815
|
if (label) {
|
|
769
816
|
placeChrome(chevron, { x: frame.x + frame.width - CHEVRON_SIZE - 2, y: label.y + (label.height - CHEVRON_SIZE) / 2, width: CHEVRON_SIZE, height: CHEVRON_SIZE }, box);
|
|
770
817
|
placeChrome(toggle, { x: frame.x, y: label.y, width: frame.width, height: label.height }, box);
|
|
@@ -884,11 +931,17 @@ function applyChrome(
|
|
|
884
931
|
bar.style.paddingTop = `${top}px`;
|
|
885
932
|
bar.style.paddingBottom = `${bottom}px`;
|
|
886
933
|
}
|
|
934
|
+
// The Mac idiom: a unified title bar (styles.css lays its title out leading-aligned).
|
|
935
|
+
setFlag(el, 'sbUnified', chrome?.['unified'] ? '' : null);
|
|
936
|
+
// The Mac idiom: a sheet's title bar (the title leading, 13 bold).
|
|
937
|
+
setFlag(el, 'sbSheetBar', chrome?.['sheetBar'] ? '' : null);
|
|
887
938
|
syncBarGlass(result, node, el, chrome, frame, isNew, changes);
|
|
888
939
|
// The `keyboard` toolbars' bar above the keyboard while a text input of the screen is focused.
|
|
889
940
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-keyboard-bar'), chrome?.['keyboardBar'], frame);
|
|
890
941
|
// iOS 26 in a tab view: the search field under the title (styles.css).
|
|
891
942
|
setFlag(el, 'sbSearchTop', chrome?.['searchTop'] ? '' : null);
|
|
943
|
+
// The Mac idiom: the search field in the title bar (styles.css).
|
|
944
|
+
setFlag(el, 'sbSearchBar', chrome?.['searchBar'] ? '' : null);
|
|
892
945
|
const largeRect = chrome?.['largeTitle'];
|
|
893
946
|
if (largeTitle) {
|
|
894
947
|
if (largeRect) {
|
|
@@ -956,6 +1009,8 @@ function applyChrome(
|
|
|
956
1009
|
setFlag(el, 'sbFloating', chrome?.['floating'] ? '' : null);
|
|
957
1010
|
// iPadOS: in a regular width, a centered form sheet (styles.css).
|
|
958
1011
|
setFlag(el, 'sbForm', chrome?.['form'] ? '' : null);
|
|
1012
|
+
// The Mac idiom: a bottom bar with the sheet's actions (styles.css).
|
|
1013
|
+
setFlag(el, 'sbActions', chrome?.['actions'] ? '' : null);
|
|
959
1014
|
return;
|
|
960
1015
|
}
|
|
961
1016
|
case 'tabview': {
|
|
@@ -963,6 +1018,8 @@ function applyChrome(
|
|
|
963
1018
|
const barRect = chrome?.['bar'];
|
|
964
1019
|
if (!bar || !barRect) return;
|
|
965
1020
|
placeChrome(bar, barRect, box);
|
|
1021
|
+
// The Mac idiom: the bar is a segmented control at the top of the content (styles.css).
|
|
1022
|
+
setFlag(el, 'sbTabsTop', chrome?.['top'] ? '' : null);
|
|
966
1023
|
// Items are the 49px row at the top of the bar; the rest is the safe area.
|
|
967
1024
|
const items = Array.from(bar.children) as HTMLElement[];
|
|
968
1025
|
items.forEach((item, i) => placeChrome(item, chrome?.[`item${i}`], barRect));
|
|
@@ -1006,7 +1063,9 @@ function applyChrome(
|
|
|
1006
1063
|
if (node.props['style'] === 'segmented') {
|
|
1007
1064
|
// In a navigation bar item the control is the bar's glass capsule (engine `bar`).
|
|
1008
1065
|
el.toggleAttribute('data-sb-bar', chrome?.['bar'] !== undefined);
|
|
1009
|
-
|
|
1066
|
+
// The Mac idiom: the label before the control (engine `label`, `track`).
|
|
1067
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-segment-track'), chrome?.['track'] ?? frame, box);
|
|
1068
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-picker-label'), chrome?.['label'], box);
|
|
1010
1069
|
const segments = el.querySelectorAll<HTMLElement>(':scope > .sb-segment');
|
|
1011
1070
|
segments.forEach((segment, i) => placeChrome(segment, chrome?.[`segment${i}`], box));
|
|
1012
1071
|
} else if (node.props['style'] === 'inline') {
|
|
@@ -1017,6 +1076,8 @@ function applyChrome(
|
|
|
1017
1076
|
} else {
|
|
1018
1077
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-picker-label'), chrome?.['label'], box);
|
|
1019
1078
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-picker-value'), chrome?.['value'], box);
|
|
1079
|
+
// The Mac idiom: the value in a pop-up button (styles.css).
|
|
1080
|
+
setFlag(el, 'sbPopup', chrome?.['popup'] ? '' : null);
|
|
1020
1081
|
}
|
|
1021
1082
|
return;
|
|
1022
1083
|
}
|
package/src/main.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { easingFor, springEasing, timingFor } from './animation';
|
|
3
3
|
import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
|
|
4
4
|
import { ChromeColor } from './chromeColor';
|
|
5
|
-
import { installDeviceFrame } from './deviceFrame';
|
|
5
|
+
import { frameFor, installDeviceFrame } from './deviceFrame';
|
|
6
6
|
import { applyPageMode, decidePageMode, installViewportReadout, readViewportInsets, watchKeyboard, watchViewport, type PageMode } from './deviceMode';
|
|
7
7
|
import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
|
|
8
8
|
import { createMockApp } from './mock';
|
|
@@ -202,7 +202,7 @@ function currentTypeSize(): DynamicTypeSize {
|
|
|
202
202
|
*/
|
|
203
203
|
function applyTypeSize(size: DynamicTypeSize, persist: boolean): void {
|
|
204
204
|
device.dataset['typeSize'] = size;
|
|
205
|
-
applyTypeSizeVariables(device, size);
|
|
205
|
+
applyTypeSizeVariables(device, size, renderer.platform);
|
|
206
206
|
// The layout engine resolves text styles for this size and re-lays out.
|
|
207
207
|
renderer.setEnvironment({ dynamicTypeSize: size });
|
|
208
208
|
if (typeSizeSelect.value !== size) typeSizeSelect.value = size;
|
|
@@ -237,6 +237,8 @@ function currentSizeClass(): HorizontalSizeClass {
|
|
|
237
237
|
}
|
|
238
238
|
/** The size class last sent in an `environment` event (null before the first). */
|
|
239
239
|
let sentSizeClass: HorizontalSizeClass | null = null;
|
|
240
|
+
/** The platform last sent in an `environment` event (null before the first). */
|
|
241
|
+
let sentPlatform: string | null = null;
|
|
240
242
|
|
|
241
243
|
if (deviceMode) {
|
|
242
244
|
// `#screen` fills the viewport (styles.css); the renderer re-reads its size
|
|
@@ -267,9 +269,12 @@ if (deviceMode) {
|
|
|
267
269
|
// app in an iPhone, an iPad or no device; the renderer re-reads the
|
|
268
270
|
// screen's size when it changes, and Swift hears a new size class.
|
|
269
271
|
const sizeClassMayHaveChanged = (): void => {
|
|
270
|
-
|
|
272
|
+
// The Mac's text styles are its own: the device's variables follow the platform.
|
|
273
|
+
applyTypeSizeVariables(device, currentTypeSize(), renderer.platform);
|
|
274
|
+
if (sentSizeClass !== null && (currentSizeClass() !== sentSizeClass || renderer.platform !== sentPlatform)) sendEnvironment();
|
|
271
275
|
};
|
|
272
|
-
|
|
276
|
+
// An app built for macOS (docs/notes/macos.md) starts in a Mac window.
|
|
277
|
+
installDeviceFrame(renderer, { onChange: sizeClassMayHaveChanged, preferred: frameFor(registeredApp()?.platform) });
|
|
273
278
|
watchViewport(sizeClassMayHaveChanged);
|
|
274
279
|
}
|
|
275
280
|
|
|
@@ -303,12 +308,16 @@ function sendEnvironment(): void {
|
|
|
303
308
|
// Phase 8: every environment event carries the scene phase.
|
|
304
309
|
const scenePhase = currentScenePhase();
|
|
305
310
|
sentScenePhase = scenePhase;
|
|
311
|
+
// The platform the renderer draws (docs/notes/macos.md).
|
|
312
|
+
const platform = renderer.platform;
|
|
313
|
+
sentPlatform = platform;
|
|
306
314
|
dispatch({
|
|
307
315
|
type: 'environment',
|
|
308
316
|
colorScheme: currentTheme(),
|
|
309
317
|
dynamicTypeSize: currentTypeSize(),
|
|
310
318
|
horizontalSizeClass,
|
|
311
319
|
scenePhase,
|
|
320
|
+
platform,
|
|
312
321
|
});
|
|
313
322
|
}
|
|
314
323
|
|
package/src/pathShape.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* `path(in:)`. The Swift side sends the path as SVG path data for the size the
|
|
4
4
|
* element reported (`reportsSize`), so the element holds an `<svg>` whose
|
|
5
5
|
* view box is that size, stretched to the element's box until the path for a
|
|
6
|
-
* new size arrives (docs/ops-protocol.md, "Custom shapes").
|
|
6
|
+
* new size arrives (docs/ops-protocol.md, "Custom shapes"). A path for any
|
|
7
|
+
* size (`affinePath`) is drawn at the size the element is laid out at, which
|
|
8
|
+
* the renderer hands `layOutAffinePath` after each layout.
|
|
7
9
|
*
|
|
8
10
|
* An update whose shape's `animatableData` changed carries `frames`: the
|
|
9
11
|
* paths at fractions of the change. When the commit animates the element,
|
|
@@ -12,7 +14,7 @@
|
|
|
12
14
|
* path; otherwise the final path is drawn at once.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
|
-
import type { Animation, ShapeProps } from './protocol';
|
|
17
|
+
import type { AffinePath, Animation, ShapeProps } from './protocol';
|
|
16
18
|
import { paintFor, type IdSource } from './chartDom';
|
|
17
19
|
import { easedProgress } from './animation';
|
|
18
20
|
|
|
@@ -32,6 +34,9 @@ interface PathState {
|
|
|
32
34
|
frames: PathFrames | null;
|
|
33
35
|
/** The running playback's frame request. */
|
|
34
36
|
request: number | null;
|
|
37
|
+
/** The props last drawn, and the size an `affinePath` was last laid out at (src/renderer.ts). */
|
|
38
|
+
props: ShapeProps | null;
|
|
39
|
+
laidOut: { width: number; height: number } | null;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
const STATE = new WeakMap<HTMLElement, PathState>();
|
|
@@ -48,13 +53,17 @@ export function applyPathShape(el: HTMLElement, props: ShapeProps): void {
|
|
|
48
53
|
const path = doc.createElementNS(SVG_NS, 'path');
|
|
49
54
|
svg.append(doc.createElementNS(SVG_NS, 'defs'), path);
|
|
50
55
|
el.replaceChildren(svg);
|
|
51
|
-
state = { svg, path, final: '', frames: null, request: null };
|
|
56
|
+
state = { svg, path, final: '', frames: null, request: null, props: null, laidOut: null };
|
|
52
57
|
STATE.set(el, state);
|
|
53
58
|
}
|
|
54
59
|
stop(state);
|
|
60
|
+
state.props = props;
|
|
55
61
|
const { svg, path } = state;
|
|
56
|
-
|
|
57
|
-
const
|
|
62
|
+
// A path for any size is drawn at the size the element was laid out at (none before its first layout).
|
|
63
|
+
const affine = props.affinePath ?? null;
|
|
64
|
+
const size = affine ? state.laidOut : props.size;
|
|
65
|
+
const width = Number(size?.width) || 0;
|
|
66
|
+
const height = Number(size?.height) || 0;
|
|
58
67
|
if (width > 0 && height > 0) svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
59
68
|
else svg.removeAttribute('viewBox');
|
|
60
69
|
svg.setAttribute('preserveAspectRatio', 'none');
|
|
@@ -81,11 +90,37 @@ export function applyPathShape(el: HTMLElement, props: ShapeProps): void {
|
|
|
81
90
|
// The stroke is not scaled with a stretched view box.
|
|
82
91
|
path.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
83
92
|
|
|
84
|
-
state.final = typeof props.path === 'string' ? props.path : '';
|
|
85
|
-
state.frames = validFrames(props.frames);
|
|
93
|
+
state.final = affine ? (width > 0 && height > 0 ? affinePathData(affine, width, height) : '') : typeof props.path === 'string' ? props.path : '';
|
|
94
|
+
state.frames = affine ? null : validFrames(props.frames);
|
|
86
95
|
path.setAttribute('d', state.final);
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Draws `el`'s `affinePath` (if it has one) at the `width` by `height` it was
|
|
100
|
+
* laid out at, when that is new: the renderer calls it after each layout.
|
|
101
|
+
*/
|
|
102
|
+
export function layOutAffinePath(el: HTMLElement, width: number, height: number): void {
|
|
103
|
+
const state = STATE.get(el);
|
|
104
|
+
if (!state?.props?.affinePath) return;
|
|
105
|
+
if (state.laidOut && state.laidOut.width === width && state.laidOut.height === height) return;
|
|
106
|
+
state.laidOut = { width, height };
|
|
107
|
+
applyPathShape(el, state.props);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A number in SVG path data, as `Path` (@swiftbrowser/react) writes them. */
|
|
111
|
+
const NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/g;
|
|
112
|
+
|
|
113
|
+
/** `affine`'s SVG path data for a `width` by `height` rectangle, its points rounded to hundredths as `Path` rounds them. */
|
|
114
|
+
export function affinePathData(affine: AffinePath, width: number, height: number): string {
|
|
115
|
+
let i = 0;
|
|
116
|
+
return affine.path.replace(NUMBER, (number) => {
|
|
117
|
+
const value = Number(number) + width * (affine.width[i] ?? 0) + height * (affine.height[i] ?? 0);
|
|
118
|
+
i += 1;
|
|
119
|
+
const rounded = Math.round(value * 100) / 100;
|
|
120
|
+
return Object.is(rounded, -0) ? '0' : String(rounded);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
89
124
|
/** Whether `el` is a path shape whose last update carried frames. */
|
|
90
125
|
export function hasPathFrames(el: HTMLElement): boolean {
|
|
91
126
|
return STATE.get(el)?.frames != null;
|
package/src/protocol.ts
CHANGED
|
@@ -728,6 +728,13 @@ export interface Stroke {
|
|
|
728
728
|
dash?: number[];
|
|
729
729
|
dashPhase?: number;
|
|
730
730
|
}
|
|
731
|
+
/** A path for any size: `ShapeProps.affinePath`. */
|
|
732
|
+
export interface AffinePath {
|
|
733
|
+
path: string;
|
|
734
|
+
width: number[];
|
|
735
|
+
height: number[];
|
|
736
|
+
}
|
|
737
|
+
|
|
731
738
|
/** `fill: null` means the current foreground color; fills its proposal. */
|
|
732
739
|
export interface ShapeProps {
|
|
733
740
|
shape: ShapeKind;
|
|
@@ -748,6 +755,14 @@ export interface ShapeProps {
|
|
|
748
755
|
*/
|
|
749
756
|
path?: string;
|
|
750
757
|
size?: { width: number; height: number };
|
|
758
|
+
/**
|
|
759
|
+
* A custom shape's path for any size (`path` and `size` unused): SVG path
|
|
760
|
+
* data whose numbers are their values for an empty rectangle, the `i`th
|
|
761
|
+
* growing `width[i]` a point of the element's width and `height[i]` a
|
|
762
|
+
* point of its height. Drawn at the element's laid-out size, so from its
|
|
763
|
+
* first layout, with no size reported.
|
|
764
|
+
*/
|
|
765
|
+
affinePath?: AffinePath;
|
|
751
766
|
/** `FillStyle(eoFill: true)`: overlapping parts of the path cancel out. */
|
|
752
767
|
fillRule?: 'evenodd';
|
|
753
768
|
/** The element reports its laid-out size (a custom shape draws its path for it). */
|
|
@@ -1572,7 +1587,21 @@ export type RendererEvent =
|
|
|
1572
1587
|
// The page's undo and redo keys, for the app's undo manager (src/undo.ts)
|
|
1573
1588
|
| UndoEvent
|
|
1574
1589
|
// A registered view's own answer (src/viewRegistry.ts)
|
|
1575
|
-
| RegisteredEvent
|
|
1590
|
+
| RegisteredEvent
|
|
1591
|
+
// A stack's path set whole by the tree that hosts this one (@swiftbrowser/react RemoteTree)
|
|
1592
|
+
| SetPathEvent;
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* A `navstack`'s path set whole: the values its destinations build the
|
|
1596
|
+
* screens from, as its pushed `navscreen`s report them (`route`). Sent by a
|
|
1597
|
+
* host drawing this tree (`RemoteTree`), to reopen the screens a link named;
|
|
1598
|
+
* never by a renderer.
|
|
1599
|
+
*/
|
|
1600
|
+
export interface SetPathEvent {
|
|
1601
|
+
type: 'setpath';
|
|
1602
|
+
id: ElementId;
|
|
1603
|
+
path: unknown[];
|
|
1604
|
+
}
|
|
1576
1605
|
|
|
1577
1606
|
/** What a registered view sent (`context.send(value)`), for the element's `onEvent`. */
|
|
1578
1607
|
export interface RegisteredEvent {
|
|
@@ -1753,6 +1782,17 @@ export function horizontalSizeClassFor(width: number): HorizontalSizeClass {
|
|
|
1753
1782
|
return Number.isFinite(width) && width >= REGULAR_WIDTH_MIN ? 'regular' : 'compact';
|
|
1754
1783
|
}
|
|
1755
1784
|
|
|
1785
|
+
/**
|
|
1786
|
+
* The platform the app is drawn as (docs/notes/macos.md): `ios`, the
|
|
1787
|
+
* default, or `macos`. The renderer draws the platform's idiom and reports
|
|
1788
|
+
* it in the first `environment` event; it never changes in a session.
|
|
1789
|
+
*/
|
|
1790
|
+
export type Platform = 'ios' | 'macos';
|
|
1791
|
+
|
|
1792
|
+
export interface EnvironmentEvent {
|
|
1793
|
+
platform?: Platform;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1756
1796
|
// ---------------------------------------------------------------------------
|
|
1757
1797
|
// Phase 7: charts (docs/ops-protocol.md, "Phase 7"). Swift turns data into
|
|
1758
1798
|
// unit coordinates (0...1 of the plot rectangle, y top → bottom); the renderer
|
package/src/renderer.ts
CHANGED
|
@@ -148,6 +148,7 @@ import {
|
|
|
148
148
|
type CanvasProps,
|
|
149
149
|
type Color,
|
|
150
150
|
type LazySpacerProps,
|
|
151
|
+
type Platform,
|
|
151
152
|
} from './protocol';
|
|
152
153
|
import {
|
|
153
154
|
combineDateSeconds,
|
|
@@ -160,7 +161,7 @@ import {
|
|
|
160
161
|
timeInputValue,
|
|
161
162
|
} from './layout';
|
|
162
163
|
import { filledSymbolName, renderSymbol } from './symbols';
|
|
163
|
-
import { applyPathShape, hasPathFrames, playPathFrames } from './pathShape';
|
|
164
|
+
import { applyPathShape, hasPathFrames, layOutAffinePath, playPathFrames } from './pathShape';
|
|
164
165
|
import { VideoEvents, applyVideo, stopVideos } from './video';
|
|
165
166
|
import { loadableURL } from './hostRequests';
|
|
166
167
|
import { PinchController } from './pinch';
|
|
@@ -361,6 +362,8 @@ const DEFAULT_SAFE_AREA = { top: 59, leading: 0, bottom: 34, trailing: 0 };
|
|
|
361
362
|
export interface RendererEnvironment {
|
|
362
363
|
dynamicTypeSize: DynamicTypeSize;
|
|
363
364
|
colorScheme: ColorScheme;
|
|
365
|
+
/** The platform drawn (docs/notes/macos.md): iOS unless the page says macOS. */
|
|
366
|
+
platform: Platform;
|
|
364
367
|
}
|
|
365
368
|
|
|
366
369
|
export class Renderer {
|
|
@@ -473,7 +476,7 @@ export class Renderer {
|
|
|
473
476
|
*/
|
|
474
477
|
private engine: LayoutPass | null = null;
|
|
475
478
|
private engineEnvKey = '';
|
|
476
|
-
private environment: RendererEnvironment = { dynamicTypeSize: 'large', colorScheme: 'light' };
|
|
479
|
+
private environment: RendererEnvironment = { dynamicTypeSize: 'large', colorScheme: 'light', platform: 'ios' };
|
|
477
480
|
/** Phase 5: the app's asset catalog manifest (empty until `setAssetCatalog`). */
|
|
478
481
|
private assets: AssetCatalog = EMPTY_CATALOG;
|
|
479
482
|
/** The `<style>` holding the catalog colors as custom properties (see `setAssetCatalog`). */
|
|
@@ -494,6 +497,8 @@ export class Renderer {
|
|
|
494
497
|
private readonly reportedGeometry = new Map<ElementId, { width: number; height: number; key: string }>();
|
|
495
498
|
/** The elements whose size is reported (`geometry`, or `reportsSize` props): what `collectGeometry` visits. */
|
|
496
499
|
private readonly geometryReporters = new Set<ElementId>();
|
|
500
|
+
/** The custom shapes with a path for any size (`affinePath`), drawn at their size after each layout. */
|
|
501
|
+
private readonly affinePaths = new Set<ElementId>();
|
|
497
502
|
/** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
|
|
498
503
|
private pendingGeometry: (GeometryEvent | LayoutEvent | ScrollGeometryEvent)[] = [];
|
|
499
504
|
/** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
|
|
@@ -1451,6 +1456,7 @@ export class Renderer {
|
|
|
1451
1456
|
this.closeMenu();
|
|
1452
1457
|
this.reportedGeometry.clear();
|
|
1453
1458
|
this.geometryReporters.clear();
|
|
1459
|
+
this.affinePaths.clear();
|
|
1454
1460
|
this.layoutReporter.clear();
|
|
1455
1461
|
this.pendingGeometry = [];
|
|
1456
1462
|
refreshSheets(this.root);
|
|
@@ -1461,18 +1467,26 @@ export class Renderer {
|
|
|
1461
1467
|
// -------------------------------------------------------------------------
|
|
1462
1468
|
|
|
1463
1469
|
/**
|
|
1464
|
-
* Set the device environment (Dynamic Type size, color scheme)
|
|
1465
|
-
* the tree out. The page calls this on boot and whenever a
|
|
1470
|
+
* Set the device environment (Dynamic Type size, color scheme, platform)
|
|
1471
|
+
* and re-lay the tree out. The page calls this on boot and whenever a
|
|
1472
|
+
* control changes. The platform is also on the document
|
|
1473
|
+
* (`html[data-sb-platform]`), which the stylesheet themes by.
|
|
1466
1474
|
*/
|
|
1467
1475
|
setEnvironment(environment: Partial<RendererEnvironment>): void {
|
|
1468
1476
|
const next = { ...this.environment, ...environment };
|
|
1469
|
-
if (next.dynamicTypeSize === this.environment.dynamicTypeSize && next.colorScheme === this.environment.colorScheme) {
|
|
1477
|
+
if (next.dynamicTypeSize === this.environment.dynamicTypeSize && next.colorScheme === this.environment.colorScheme && next.platform === this.environment.platform) {
|
|
1470
1478
|
return;
|
|
1471
1479
|
}
|
|
1472
1480
|
this.environment = next;
|
|
1481
|
+
this.root.ownerDocument.documentElement.dataset['sbPlatform'] = next.platform;
|
|
1473
1482
|
if (this.commits > 0) this.layout();
|
|
1474
1483
|
}
|
|
1475
1484
|
|
|
1485
|
+
/** The platform drawn (`setEnvironment`). */
|
|
1486
|
+
get platform(): Platform {
|
|
1487
|
+
return this.environment.platform;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1476
1490
|
/**
|
|
1477
1491
|
* Phase 5: give the renderer the app's asset catalog manifest (src/assets.ts).
|
|
1478
1492
|
* Named images take their intrinsic size and `src` from it, and every
|
|
@@ -1586,6 +1600,7 @@ export class Renderer {
|
|
|
1586
1600
|
dynamicTypeSize: this.environment.dynamicTypeSize,
|
|
1587
1601
|
devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
|
|
1588
1602
|
colorScheme: this.environment.colorScheme,
|
|
1603
|
+
platform: this.environment.platform,
|
|
1589
1604
|
assets: this.assets,
|
|
1590
1605
|
...(this.contentHeight ? { contentHeight: true } : {}),
|
|
1591
1606
|
...(this.keyboard > 0 ? { keyboard: this.keyboard } : {}),
|
|
@@ -1786,6 +1801,11 @@ export class Renderer {
|
|
|
1786
1801
|
}
|
|
1787
1802
|
drawScrollTransitions();
|
|
1788
1803
|
drawPinnedViews();
|
|
1804
|
+
for (const id of this.affinePaths) {
|
|
1805
|
+
const frame = result.frames.get(id);
|
|
1806
|
+
const el = this.elements.get(id);
|
|
1807
|
+
if (frame && el) layOutAffinePath(el, frame.width, frame.height);
|
|
1808
|
+
}
|
|
1789
1809
|
this.collectGeometry(result);
|
|
1790
1810
|
this.collectScrollGeometry(result);
|
|
1791
1811
|
this.collectLayouts(result);
|
|
@@ -2119,6 +2139,7 @@ export class Renderer {
|
|
|
2119
2139
|
this.elements.set(id, el);
|
|
2120
2140
|
this.nodes.set(id, { id, kind, props: asRecord(props), children: [] });
|
|
2121
2141
|
if (kind === 'geometry' || (props as { reportsSize?: unknown }).reportsSize === true) this.geometryReporters.add(id);
|
|
2142
|
+
if ((props as { affinePath?: unknown }).affinePath) this.affinePaths.add(id);
|
|
2122
2143
|
applyProps(el, kind, props, this.assets);
|
|
2123
2144
|
this.noteScroll(id, kind, props);
|
|
2124
2145
|
this.noteVisibility(id, kind, props);
|
|
@@ -2529,6 +2550,8 @@ export class Renderer {
|
|
|
2529
2550
|
} else {
|
|
2530
2551
|
this.geometryReporters.delete(id);
|
|
2531
2552
|
}
|
|
2553
|
+
if (next['affinePath']) this.affinePaths.add(id);
|
|
2554
|
+
else this.affinePaths.delete(id);
|
|
2532
2555
|
node.props = next;
|
|
2533
2556
|
// Phase 9: the next layout re-measures this subtree and its ancestors;
|
|
2534
2557
|
// a screen's title also feeds the next screen's back label.
|
|
@@ -2814,6 +2837,7 @@ export class Renderer {
|
|
|
2814
2837
|
this.scrollGeometry.forget(id);
|
|
2815
2838
|
this.offsetSerials.delete(id);
|
|
2816
2839
|
this.geometryReporters.delete(id);
|
|
2840
|
+
this.affinePaths.delete(id);
|
|
2817
2841
|
this.layoutReporter.forget(id);
|
|
2818
2842
|
const node = this.nodes.get(id);
|
|
2819
2843
|
const parentId = this.parents.get(id);
|
|
@@ -4831,8 +4855,13 @@ function applyPicker(el: HTMLElement, props: PickerProps): void {
|
|
|
4831
4855
|
el.removeAttribute('aria-haspopup');
|
|
4832
4856
|
el.removeAttribute('aria-expanded');
|
|
4833
4857
|
// The gray track sits at the control's frame (which, in a list row, is
|
|
4834
|
-
// narrower than the row box the element occupies)
|
|
4858
|
+
// narrower than the row box the element occupies); the label shows only
|
|
4859
|
+
// where the engine places it (the Mac idiom).
|
|
4835
4860
|
el.append(div('sb-segment-track'));
|
|
4861
|
+
const segmentedLabel = span('sb-picker-label');
|
|
4862
|
+
segmentedLabel.textContent = label;
|
|
4863
|
+
segmentedLabel.hidden = true;
|
|
4864
|
+
el.append(segmentedLabel);
|
|
4836
4865
|
options.forEach((option, index) => {
|
|
4837
4866
|
const segment = document.createElement('button');
|
|
4838
4867
|
segment.type = 'button';
|