@swiftbrowser/web 0.3.1 → 0.5.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 +4 -1
- package/app/index.html +1 -0
- package/package.json +5 -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 +98 -54
- package/src/layout/engine.ts +582 -135
- package/src/layout/index.ts +8 -5
- 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 +87 -16
- package/src/page.css +234 -0
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +219 -9
- package/src/renderer.ts +355 -15
- package/src/scrollEdge.ts +109 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +457 -251
- package/src/symbols.ts +3 -1
- package/src/typography.ts +5 -2
package/src/renderer.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { bitmapStore, showBitmap } from './bitmaps';
|
|
|
56
56
|
import { clearRichText, runTemplates } from './richText';
|
|
57
57
|
import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
|
|
58
58
|
import { ghostClone } from './ghost';
|
|
59
|
+
import { ExternalDrops } from './dragDrop';
|
|
59
60
|
import { InteractionController, type InteractionHost } from './interaction';
|
|
60
61
|
import { symbolOf, textOf, unwrapStyledNode } from './nodeQueries';
|
|
61
62
|
import {
|
|
@@ -85,9 +86,12 @@ import {
|
|
|
85
86
|
type HStackProps,
|
|
86
87
|
type LayoutEvent,
|
|
87
88
|
type LazyVGridProps,
|
|
89
|
+
type LazyHGridProps,
|
|
88
90
|
type ImageProps,
|
|
91
|
+
type InspectorProps,
|
|
89
92
|
type ListProps,
|
|
90
93
|
type ListRowProps,
|
|
94
|
+
type DropDestinationProps,
|
|
91
95
|
type NavLinkProps,
|
|
92
96
|
type TapGestureProps,
|
|
93
97
|
type NavScreenProps,
|
|
@@ -105,6 +109,7 @@ import {
|
|
|
105
109
|
type SwipeActionsProps,
|
|
106
110
|
type TabProps,
|
|
107
111
|
type TabViewProps,
|
|
112
|
+
type HostProps,
|
|
108
113
|
type TextFieldProps,
|
|
109
114
|
type TextProps,
|
|
110
115
|
type TextRun,
|
|
@@ -144,7 +149,11 @@ import { applyCanvas, createCanvasElement } from './canvasDrawing';
|
|
|
144
149
|
import { scrollToTarget } from './scrollTo';
|
|
145
150
|
import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
|
|
146
151
|
import { colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
|
|
152
|
+
import { hostContent, onHostContentChange } from './hostContent';
|
|
153
|
+
import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
|
|
147
154
|
import { colorMultiplyFilter } from './colorMultiply';
|
|
155
|
+
import { largeTitleOf } from './largeTitle';
|
|
156
|
+
import { applyEdgeEffect } from './scrollEdge';
|
|
148
157
|
import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
|
|
149
158
|
|
|
150
159
|
export type EventCallback = (event: RendererEvent) => void;
|
|
@@ -161,6 +170,23 @@ const ID_ATTR = 'sbId';
|
|
|
161
170
|
/** `animationToken` of a `styled` box, kept across `update` to detect changes. */
|
|
162
171
|
const ANIMATION_TOKEN = 'sbAnimationToken';
|
|
163
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Kinds whose own changes animate even outside `withAnimation`: their token
|
|
175
|
+
* changes with what they present (an inspector's `presented`).
|
|
176
|
+
*/
|
|
177
|
+
const IMPLICITLY_ANIMATED: ReadonlySet<ElementKind> = new Set(['inspector']);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The animation token an element of `kind` has with `props`: a `styled`
|
|
181
|
+
* box's `animationToken` (`.animation(_:value:)`), an inspector's
|
|
182
|
+
* `presented`. An `update` that changes it animates its subtree.
|
|
183
|
+
*/
|
|
184
|
+
function animationTokenOf(kind: ElementKind | undefined, props: unknown): string | undefined {
|
|
185
|
+
if (kind === 'inspector') return (props as InspectorProps | null)?.presented === true ? 'shown' : 'hidden';
|
|
186
|
+
const token = (props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
|
|
187
|
+
return token == null ? undefined : String(token);
|
|
188
|
+
}
|
|
189
|
+
|
|
164
190
|
/** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
|
|
165
191
|
export const NAV_TRANSITION_MS = 400;
|
|
166
192
|
/** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
|
|
@@ -169,6 +195,7 @@ export const SHEET_TRANSITION_MS = 400;
|
|
|
169
195
|
export { SHEET_DISMISS_DRAG } from './interaction';
|
|
170
196
|
|
|
171
197
|
const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
198
|
+
'host',
|
|
172
199
|
'text',
|
|
173
200
|
'vstack',
|
|
174
201
|
'hstack',
|
|
@@ -197,6 +224,7 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
|
197
224
|
'grid',
|
|
198
225
|
'gridrow',
|
|
199
226
|
'lazyvgrid',
|
|
227
|
+
'lazyhgrid',
|
|
200
228
|
'gauge',
|
|
201
229
|
'decorated',
|
|
202
230
|
'menu',
|
|
@@ -205,6 +233,8 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
|
205
233
|
'tapgesture',
|
|
206
234
|
// Phase 7
|
|
207
235
|
'navsplit',
|
|
236
|
+
'inspector',
|
|
237
|
+
'inspectorpanel',
|
|
208
238
|
'viewthatfits',
|
|
209
239
|
'chart',
|
|
210
240
|
'chartitem',
|
|
@@ -297,11 +327,33 @@ export class Renderer {
|
|
|
297
327
|
*/
|
|
298
328
|
private rootSize: { width: number; height: number } | null = null;
|
|
299
329
|
private readonly resizeObserver: ResizeObserver | null = null;
|
|
330
|
+
/**
|
|
331
|
+
* `host` elements (src/hostContent.ts): their content elements, observed
|
|
332
|
+
* for size changes, and the measurements of the current layout pass.
|
|
333
|
+
*/
|
|
334
|
+
private readonly hostObserver: ResizeObserver | null = null;
|
|
335
|
+
/** `hover` events for boxes whose style asks for them (pointerEffects.ts). */
|
|
336
|
+
private readonly hover: HoverTracker;
|
|
337
|
+
/** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
|
|
338
|
+
private frameCheckQueued = false;
|
|
339
|
+
private readonly hostContents = new Map<ElementId, HTMLElement>();
|
|
340
|
+
/** The `host` elements, adopted or still waiting for their content. */
|
|
341
|
+
private readonly hosts = new Set<ElementId>();
|
|
342
|
+
private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
|
|
343
|
+
private readonly hostMeasures = new Map<string, { width: number; height: number }>();
|
|
344
|
+
private readonly stopHostChanges: () => void;
|
|
345
|
+
/**
|
|
346
|
+
* The root takes its content's height (`LayoutEnvironment.contentHeight`):
|
|
347
|
+
* a SwiftUI island in a page's own layout. Set before the first commit.
|
|
348
|
+
*/
|
|
349
|
+
contentHeight = false;
|
|
300
350
|
/**
|
|
301
351
|
* Phase 8: owns every pointer gesture (drags, long presses, swipe rows,
|
|
302
352
|
* swipe back, the sheet drag, pull to refresh) and arbitrates between them.
|
|
303
353
|
*/
|
|
304
354
|
readonly interaction: InteractionController;
|
|
355
|
+
/** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
|
|
356
|
+
private readonly externalDrops: ExternalDrops;
|
|
305
357
|
/** `video` elements' media events, as `video` events (src/video.ts). */
|
|
306
358
|
private readonly videoEvents: VideoEvents;
|
|
307
359
|
private readonly measurer = new CanvasTextMeasurer();
|
|
@@ -329,7 +381,7 @@ export class Renderer {
|
|
|
329
381
|
/** Lazy menus: a menu the viewer opened whose items Swift is building. */
|
|
330
382
|
private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
|
|
331
383
|
/** Size last reported for every `geometry` element (whole px). */
|
|
332
|
-
private readonly reportedGeometry = new Map<ElementId, { width: number; height: number }>();
|
|
384
|
+
private readonly reportedGeometry = new Map<ElementId, { width: number; height: number; key: string }>();
|
|
333
385
|
/** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
|
|
334
386
|
private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
|
|
335
387
|
/** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
|
|
@@ -349,7 +401,7 @@ export class Renderer {
|
|
|
349
401
|
// `closest` picks the innermost control, so a button inside a
|
|
350
402
|
// `tapgesture` (or a menu label) handles the click alone.
|
|
351
403
|
const target = (event.target as HTMLElement | null)?.closest<HTMLElement>(
|
|
352
|
-
'.sb-button, .sb-navlink, .sb-navbar-back, .sb-switch, .sb-sheet-scrim, ' +
|
|
404
|
+
'.sb-button, .sb-navlink, .sb-navbar-back, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
|
|
353
405
|
'.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
|
|
354
406
|
'.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
|
|
355
407
|
// Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
|
|
@@ -450,6 +502,14 @@ export class Renderer {
|
|
|
450
502
|
this.onEvent({ type: 'tap', id });
|
|
451
503
|
return;
|
|
452
504
|
}
|
|
505
|
+
if (target.classList.contains('sb-navsplit-toggle')) {
|
|
506
|
+
const split = target.closest<HTMLElement>('.sb-navsplit');
|
|
507
|
+
const id = Number(split?.dataset[ID_ATTR]);
|
|
508
|
+
if (!split || !Number.isInteger(id)) return;
|
|
509
|
+
event.preventDefault();
|
|
510
|
+
this.onEvent({ type: 'tap', id });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
453
513
|
if (target.classList.contains('sb-navbar-back')) {
|
|
454
514
|
const id = Number(target.dataset['sbTarget']);
|
|
455
515
|
if (!Number.isInteger(id)) return;
|
|
@@ -728,6 +788,7 @@ export class Renderer {
|
|
|
728
788
|
closeMenu: () => this.closeMenu(),
|
|
729
789
|
};
|
|
730
790
|
this.interaction = new InteractionController(host);
|
|
791
|
+
this.externalDrops = new ExternalDrops(host);
|
|
731
792
|
root.addEventListener('change', this.onChange);
|
|
732
793
|
root.addEventListener('pointerdown', this.onSliderPointerDown);
|
|
733
794
|
if (typeof ResizeObserver !== 'undefined') {
|
|
@@ -740,6 +801,92 @@ export class Renderer {
|
|
|
740
801
|
});
|
|
741
802
|
this.resizeObserver.observe(root);
|
|
742
803
|
}
|
|
804
|
+
this.measurer.measureHost = (content, width) => this.measureHostContent(content, width);
|
|
805
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
806
|
+
this.hostObserver = new ResizeObserver((entries) => {
|
|
807
|
+
let changed = false;
|
|
808
|
+
for (const entry of entries) {
|
|
809
|
+
const el = entry.target as HTMLElement;
|
|
810
|
+
const size = { width: el.offsetWidth, height: el.offsetHeight };
|
|
811
|
+
const last = this.hostSizes.get(el);
|
|
812
|
+
if (last && last.width === size.width && last.height === size.height) continue;
|
|
813
|
+
this.hostSizes.set(el, size);
|
|
814
|
+
changed = true;
|
|
815
|
+
}
|
|
816
|
+
if (changed) this.hostContentChanged();
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
this.stopHostChanges = onHostContentChange(() => this.hostContentChanged());
|
|
820
|
+
this.hover = new HoverTracker(root, (id, value) => this.onEvent({ type: 'hover', id, value }));
|
|
821
|
+
root.addEventListener('scroll', this.queueFrameCheck, { capture: true, passive: true });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Geometry readers with `reportsFrame` report where they are on screen,
|
|
826
|
+
* which scrolling changes without a layout pass: after a scroll their
|
|
827
|
+
* frames are checked once per animation frame.
|
|
828
|
+
*/
|
|
829
|
+
private readonly queueFrameCheck = (): void => {
|
|
830
|
+
if (this.frameCheckQueued || this.commits === 0) return;
|
|
831
|
+
this.frameCheckQueued = true;
|
|
832
|
+
requestAnimationFrame(() => {
|
|
833
|
+
this.frameCheckQueued = false;
|
|
834
|
+
if (!this.layoutResult) return;
|
|
835
|
+
this.collectGeometry(this.layoutResult, true);
|
|
836
|
+
if (this.applying === 0) this.flushGeometry();
|
|
837
|
+
});
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* A `host` element's content changed (the page said so, or it resized):
|
|
842
|
+
* the hosts are measured again and the tree laid out. A host whose content
|
|
843
|
+
* was not registered when its element was created (a page that registers
|
|
844
|
+
* from an effect, after its commit) adopts it now.
|
|
845
|
+
*/
|
|
846
|
+
private hostContentChanged(): void {
|
|
847
|
+
if (this.commits === 0) return;
|
|
848
|
+
for (const id of this.hosts) {
|
|
849
|
+
if (this.hostContents.has(id)) continue;
|
|
850
|
+
const el = this.elements.get(id);
|
|
851
|
+
const node = this.nodes.get(id);
|
|
852
|
+
if (el && node) this.adoptHostContent(id, el, node.props as unknown as HostProps);
|
|
853
|
+
}
|
|
854
|
+
if (this.hostContents.size === 0) return;
|
|
855
|
+
for (const id of this.hostContents.keys()) {
|
|
856
|
+
const node = this.nodes.get(id);
|
|
857
|
+
if (node) this.engine?.invalidate(node);
|
|
858
|
+
}
|
|
859
|
+
this.layout();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* The host measurer (src/layout, `measureHost`): the content element laid
|
|
864
|
+
* out at `width`, or at its own ideal width when null. Measured in place,
|
|
865
|
+
* so the content sees the device's tokens and fonts; the styles it needs
|
|
866
|
+
* for the measurement are restored right after.
|
|
867
|
+
*/
|
|
868
|
+
private measureHostContent(content: string, width: number | null): { width: number; height: number } {
|
|
869
|
+
const key = `${content}\u0000${width ?? ''}`;
|
|
870
|
+
const cached = this.hostMeasures.get(key);
|
|
871
|
+
if (cached) return cached;
|
|
872
|
+
const el = hostContent(content);
|
|
873
|
+
if (!el) return { width: 0, height: 0 };
|
|
874
|
+
const { style } = el;
|
|
875
|
+
const saved = { width: style.width, height: style.height, maxWidth: style.maxWidth };
|
|
876
|
+
style.height = 'auto';
|
|
877
|
+
if (width === null) {
|
|
878
|
+
style.width = 'max-content';
|
|
879
|
+
style.maxWidth = 'none';
|
|
880
|
+
} else {
|
|
881
|
+
style.width = `${width}px`;
|
|
882
|
+
}
|
|
883
|
+
const rect = el.getBoundingClientRect();
|
|
884
|
+
const size = { width: Math.ceil(rect.width), height: Math.ceil(rect.height) };
|
|
885
|
+
style.width = saved.width;
|
|
886
|
+
style.height = saved.height;
|
|
887
|
+
style.maxWidth = saved.maxWidth;
|
|
888
|
+
this.hostMeasures.set(key, size);
|
|
889
|
+
return size;
|
|
743
890
|
}
|
|
744
891
|
|
|
745
892
|
/** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
|
|
@@ -818,10 +965,10 @@ export class Renderer {
|
|
|
818
965
|
const op = ops[index]!;
|
|
819
966
|
if (op.op === 'commit') return op.animation != null;
|
|
820
967
|
if (op.op !== 'update') continue;
|
|
821
|
-
const token = (op.props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
|
|
822
|
-
if (token == null) continue;
|
|
823
968
|
const el = this.elements.get(op.id);
|
|
824
|
-
|
|
969
|
+
const token = animationTokenOf(el?.dataset[KIND_ATTR] as ElementKind | undefined, op.props);
|
|
970
|
+
if (token == null) continue;
|
|
971
|
+
if (!el || el.dataset[ANIMATION_TOKEN] !== token) return true;
|
|
825
972
|
}
|
|
826
973
|
return true;
|
|
827
974
|
}
|
|
@@ -946,6 +1093,7 @@ export class Renderer {
|
|
|
946
1093
|
devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
|
|
947
1094
|
colorScheme: this.environment.colorScheme,
|
|
948
1095
|
assets: this.assets,
|
|
1096
|
+
...(this.contentHeight ? { contentHeight: true } : {}),
|
|
949
1097
|
};
|
|
950
1098
|
}
|
|
951
1099
|
|
|
@@ -968,6 +1116,7 @@ export class Renderer {
|
|
|
968
1116
|
layout(): BoxChange[] {
|
|
969
1117
|
const root = this.nodes.get(ROOT_ID);
|
|
970
1118
|
if (!root) return [];
|
|
1119
|
+
this.hostMeasures.clear();
|
|
971
1120
|
const env = this.layoutEnvironment();
|
|
972
1121
|
const envKey = JSON.stringify({ ...env, assets: undefined });
|
|
973
1122
|
if (!this.engine || envKey !== this.engineEnvKey) {
|
|
@@ -991,6 +1140,15 @@ export class Renderer {
|
|
|
991
1140
|
};
|
|
992
1141
|
const changes = applyLayout(ctx, result);
|
|
993
1142
|
this.boxes = ctx.boxes;
|
|
1143
|
+
if (this.contentHeight) {
|
|
1144
|
+
// The island's root is as tall as its content.
|
|
1145
|
+
let bottom = 0;
|
|
1146
|
+
for (const child of root.children) {
|
|
1147
|
+
const frame = result.frames.get(child.id);
|
|
1148
|
+
if (frame) bottom = Math.max(bottom, frame.y + frame.height);
|
|
1149
|
+
}
|
|
1150
|
+
this.root.style.height = `${bottom + env.safeArea.bottom}px`;
|
|
1151
|
+
}
|
|
994
1152
|
this.collectGeometry(result);
|
|
995
1153
|
this.collectLayouts(result);
|
|
996
1154
|
if (this.applying === 0) this.flushGeometry();
|
|
@@ -1004,17 +1162,49 @@ export class Renderer {
|
|
|
1004
1162
|
* (whole px) differs from the one last reported. Nothing is queued for an
|
|
1005
1163
|
* unchanged size, which is what lets the Swift re-render loop converge.
|
|
1006
1164
|
*/
|
|
1007
|
-
private collectGeometry(result: LayoutResult): void {
|
|
1165
|
+
private collectGeometry(result: LayoutResult, framesOnly = false): void {
|
|
1166
|
+
let rootRect: DOMRect | null = null;
|
|
1008
1167
|
for (const [id, node] of this.nodes) {
|
|
1009
1168
|
if (node.kind !== 'geometry' && node.props['reportsSize'] !== true) continue;
|
|
1169
|
+
const reportsFrame = node.props['reportsFrame'] === true;
|
|
1170
|
+
if (framesOnly && !reportsFrame) continue;
|
|
1010
1171
|
const frame = result.frames.get(id);
|
|
1011
1172
|
if (!frame) continue; // e.g. inside a hidden tab
|
|
1012
1173
|
const width = Math.round(frame.width);
|
|
1013
1174
|
const height = Math.round(frame.height);
|
|
1175
|
+
const event: GeometryEvent = { type: 'geometry', id, width, height };
|
|
1176
|
+
let key = `${width}x${height}`;
|
|
1177
|
+
if (reportsFrame) {
|
|
1178
|
+
// Where it is drawn now (scrolled), in the root's coordinates.
|
|
1179
|
+
const el = this.elements.get(id);
|
|
1180
|
+
rootRect ??= this.root.getBoundingClientRect();
|
|
1181
|
+
const rect = el?.getBoundingClientRect();
|
|
1182
|
+
const x = rect ? Math.round(rect.left - rootRect.left) : Math.round(frame.x);
|
|
1183
|
+
const y = rect ? Math.round(rect.top - rootRect.top) : Math.round(frame.y);
|
|
1184
|
+
const env = this.layoutEnvironment();
|
|
1185
|
+
event.x = x;
|
|
1186
|
+
event.y = y;
|
|
1187
|
+
event.safeArea = safeAreaOverlap({ x, y, width, height }, env.screen, env.safeArea);
|
|
1188
|
+
key += `@${x},${y}/${event.safeArea.top},${event.safeArea.leading},${event.safeArea.bottom},${event.safeArea.trailing}`;
|
|
1189
|
+
// The visible rectangle of the scroll view (or list) the reader is in.
|
|
1190
|
+
const scroller = el?.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list');
|
|
1191
|
+
if (scroller) {
|
|
1192
|
+
const view = scroller.getBoundingClientRect();
|
|
1193
|
+
event.scrollView = {
|
|
1194
|
+
x: Math.round(view.left - rootRect.left),
|
|
1195
|
+
y: Math.round(view.top - rootRect.top),
|
|
1196
|
+
width: Math.round(view.width),
|
|
1197
|
+
height: Math.round(view.height),
|
|
1198
|
+
axis: scroller.dataset['sbAxes'] === 'horizontal' ? 'horizontal' : 'vertical',
|
|
1199
|
+
};
|
|
1200
|
+
const v = event.scrollView;
|
|
1201
|
+
key += `/${v.x},${v.y},${v.width},${v.height},${v.axis}`;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1014
1204
|
const last = this.reportedGeometry.get(id);
|
|
1015
|
-
if (last && last.
|
|
1016
|
-
this.reportedGeometry.set(id, { width, height });
|
|
1017
|
-
this.pendingGeometry.push(
|
|
1205
|
+
if (last && last.key === key) continue;
|
|
1206
|
+
this.reportedGeometry.set(id, { width, height, key });
|
|
1207
|
+
this.pendingGeometry.push(event);
|
|
1018
1208
|
}
|
|
1019
1209
|
}
|
|
1020
1210
|
|
|
@@ -1193,8 +1383,13 @@ export class Renderer {
|
|
|
1193
1383
|
this.root.removeEventListener('focusout', this.onFocusChange);
|
|
1194
1384
|
this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
1195
1385
|
this.interaction.dispose();
|
|
1386
|
+
this.externalDrops.dispose();
|
|
1196
1387
|
this.videoEvents.dispose();
|
|
1197
1388
|
this.resizeObserver?.disconnect();
|
|
1389
|
+
this.hostObserver?.disconnect();
|
|
1390
|
+
this.stopHostChanges();
|
|
1391
|
+
this.hover.dispose();
|
|
1392
|
+
this.root.removeEventListener('scroll', this.queueFrameCheck, true);
|
|
1198
1393
|
this.root.removeEventListener('change', this.onChange);
|
|
1199
1394
|
this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
|
|
1200
1395
|
this.reset();
|
|
@@ -1218,6 +1413,27 @@ export class Renderer {
|
|
|
1218
1413
|
applyProps(el, kind, props, this.assets);
|
|
1219
1414
|
this.noteScroll(id, kind, props);
|
|
1220
1415
|
if (kind === 'lazyspacer') this.lazySpacers.add(id);
|
|
1416
|
+
if (kind === 'host') {
|
|
1417
|
+
this.hosts.add(id);
|
|
1418
|
+
this.adoptHostContent(id, el, props as HostProps);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
|
|
1423
|
+
private adoptHostContent(id: ElementId, el: HTMLElement, props: HostProps): void {
|
|
1424
|
+
const content = hostContent(props.content);
|
|
1425
|
+
const previous = this.hostContents.get(id);
|
|
1426
|
+
if (previous && previous !== content) {
|
|
1427
|
+
this.hostObserver?.unobserve(previous);
|
|
1428
|
+
previous.remove();
|
|
1429
|
+
this.hostContents.delete(id);
|
|
1430
|
+
}
|
|
1431
|
+
if (!content) return;
|
|
1432
|
+
if (content.parentElement !== el) el.appendChild(content);
|
|
1433
|
+
if (previous !== content) {
|
|
1434
|
+
this.hostContents.set(id, content);
|
|
1435
|
+
this.hostObserver?.observe(content);
|
|
1436
|
+
}
|
|
1221
1437
|
}
|
|
1222
1438
|
|
|
1223
1439
|
/** After a scroll, the lazy spacers are checked on the next frame (once per frame). */
|
|
@@ -1291,6 +1507,24 @@ export class Renderer {
|
|
|
1291
1507
|
if (!el) throw new RendererError(`Cannot update unknown element ${id}`, op);
|
|
1292
1508
|
const kind = el.dataset[KIND_ATTR] as ElementKind;
|
|
1293
1509
|
const node = this.nodes.get(id);
|
|
1510
|
+
// `.contentTransition`: a text changing under one keeps a copy of how it
|
|
1511
|
+
// looked, for the Animator to play the change with (when the pass animates).
|
|
1512
|
+
if (kind === 'text' && node && this.passAnimates !== false && (props as TextProps).text !== node.props['text']) {
|
|
1513
|
+
const host = el.closest<HTMLElement>('[data-sb-content-transition]');
|
|
1514
|
+
if (host && this.root.contains(host)) {
|
|
1515
|
+
this.passRootRect ??= this.animator.rootRect();
|
|
1516
|
+
const rect = this.animator.rectOf(el, this.passRootRect);
|
|
1517
|
+
if (rect) {
|
|
1518
|
+
this.pass.contentChanges.push({
|
|
1519
|
+
el,
|
|
1520
|
+
clone: ghostClone(el),
|
|
1521
|
+
rect,
|
|
1522
|
+
kind: host.dataset['sbContentTransition'] as 'numericText' | 'opacity' | 'interpolate',
|
|
1523
|
+
countsDown: countsDown(host.dataset['sbCountsDown'], String(node.props['text'] ?? ''), String((props as TextProps).text ?? '')),
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1294
1528
|
if (node) {
|
|
1295
1529
|
node.props = asRecord(props);
|
|
1296
1530
|
// Phase 9: the next layout re-measures this subtree and its ancestors;
|
|
@@ -1324,9 +1558,11 @@ export class Renderer {
|
|
|
1324
1558
|
if (kind === 'text') el.textContent = '';
|
|
1325
1559
|
applyProps(el, kind, props, this.assets);
|
|
1326
1560
|
this.noteScroll(id, kind, props);
|
|
1561
|
+
if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
|
|
1327
1562
|
const newToken = el.dataset[ANIMATION_TOKEN];
|
|
1328
1563
|
const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
|
|
1329
1564
|
const record: UpdatedRecord = { el, before, tokenChanged };
|
|
1565
|
+
if (IMPLICITLY_ANIMATED.has(kind)) record.implicit = true;
|
|
1330
1566
|
const animation = ANIMATIONS.get(el);
|
|
1331
1567
|
if (animation) record.animation = animation;
|
|
1332
1568
|
this.pass.updated.set(el, record);
|
|
@@ -1450,11 +1686,19 @@ export class Renderer {
|
|
|
1450
1686
|
const ancestors: HTMLElement[] = [];
|
|
1451
1687
|
for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
|
|
1452
1688
|
let rect: DOMRect | null = null;
|
|
1689
|
+
const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
|
|
1453
1690
|
if (this.passAnimates !== false) {
|
|
1454
1691
|
this.passRootRect ??= this.animator.rootRect();
|
|
1455
1692
|
rect = this.animator.rectOf(el, this.passRootRect);
|
|
1693
|
+
// `.matchedGeometryEffect`: where the matched boxes in the subtree were.
|
|
1694
|
+
const candidates = el.dataset['sbMatched'] !== undefined ? [el] : [];
|
|
1695
|
+
candidates.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
|
|
1696
|
+
for (const box of candidates) {
|
|
1697
|
+
const boxRect = this.animator.rectOf(box, this.passRootRect);
|
|
1698
|
+
if (boxRect) matched.push({ id: box.dataset['sbMatched']!, el: box, rect: boxRect });
|
|
1699
|
+
}
|
|
1456
1700
|
}
|
|
1457
|
-
this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el) });
|
|
1701
|
+
this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el), ...(matched.length ? { matched } : {}) });
|
|
1458
1702
|
}
|
|
1459
1703
|
this.pass.inserted.delete(el);
|
|
1460
1704
|
this.pass.updated.delete(el);
|
|
@@ -1480,6 +1724,12 @@ export class Renderer {
|
|
|
1480
1724
|
|
|
1481
1725
|
/** Forget an element in every map; detaches its node from the tree. */
|
|
1482
1726
|
private forget(id: ElementId): void {
|
|
1727
|
+
const content = this.hostContents.get(id);
|
|
1728
|
+
if (content) {
|
|
1729
|
+
this.hostObserver?.unobserve(content);
|
|
1730
|
+
this.hostContents.delete(id);
|
|
1731
|
+
}
|
|
1732
|
+
this.hosts.delete(id);
|
|
1483
1733
|
this.elements.delete(id);
|
|
1484
1734
|
this.boxes.delete(id);
|
|
1485
1735
|
this.dirty.delete(id);
|
|
@@ -1705,6 +1955,9 @@ function div(className: string): HTMLDivElement {
|
|
|
1705
1955
|
/** Create the element and any chrome it owns; props are applied separately. */
|
|
1706
1956
|
function createShell(kind: ElementKind): HTMLElement {
|
|
1707
1957
|
switch (kind) {
|
|
1958
|
+
case 'host':
|
|
1959
|
+
// The page's content element is adopted by the renderer (`adoptHostContent`).
|
|
1960
|
+
return div('sb-host');
|
|
1708
1961
|
case 'button': {
|
|
1709
1962
|
const el = document.createElement('button');
|
|
1710
1963
|
el.type = 'button';
|
|
@@ -1912,6 +2165,15 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
1912
2165
|
}
|
|
1913
2166
|
case 'tapgesture':
|
|
1914
2167
|
return document.createElement('div');
|
|
2168
|
+
case 'inspector': {
|
|
2169
|
+
// The content and the panel go into the slot; the panel draws the
|
|
2170
|
+
// hairline before it (styles.css), so it slides with the panel.
|
|
2171
|
+
const el = document.createElement('div');
|
|
2172
|
+
const slot = div('sb-slot sb-inspector-columns');
|
|
2173
|
+
el.append(slot);
|
|
2174
|
+
SLOTS.set(el, slot);
|
|
2175
|
+
return el;
|
|
2176
|
+
}
|
|
1915
2177
|
case 'navsplit': {
|
|
1916
2178
|
// Phase 7: the two columns go into the slot; the separator is chrome
|
|
1917
2179
|
// positioned from the engine's `separator` rect.
|
|
@@ -1919,15 +2181,25 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
1919
2181
|
const slot = div('sb-slot sb-navsplit-columns');
|
|
1920
2182
|
const separator = div('sb-navsplit-separator');
|
|
1921
2183
|
separator.setAttribute('aria-hidden', 'true');
|
|
1922
|
-
|
|
2184
|
+
// iPadOS: the sidebar toggle, at the engine's `toggle` rect; a tap
|
|
2185
|
+
// asks Swift to show or hide the sidebar.
|
|
2186
|
+
const toggle = document.createElement('button');
|
|
2187
|
+
toggle.type = 'button';
|
|
2188
|
+
toggle.className = 'sb-navsplit-toggle';
|
|
2189
|
+
toggle.setAttribute('aria-label', 'Toggle Sidebar');
|
|
2190
|
+
const symbol = renderSymbol('sidebar.left');
|
|
2191
|
+
if (symbol) toggle.append(symbol);
|
|
2192
|
+
el.append(slot, separator, toggle);
|
|
1923
2193
|
SLOTS.set(el, slot);
|
|
1924
2194
|
return el;
|
|
1925
2195
|
}
|
|
1926
2196
|
case 'viewthatfits':
|
|
1927
2197
|
return document.createElement('div');
|
|
1928
|
-
// Phase 8: gestures, rows, navigation
|
|
2198
|
+
// Phase 8: gestures, rows, navigation; drag and drop
|
|
1929
2199
|
case 'gesture':
|
|
1930
2200
|
case 'contextmenu':
|
|
2201
|
+
case 'draggable':
|
|
2202
|
+
case 'dropdestination':
|
|
1931
2203
|
return document.createElement('div');
|
|
1932
2204
|
case 'swipeactions':
|
|
1933
2205
|
case 'listrow': {
|
|
@@ -1958,6 +2230,15 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
1958
2230
|
}
|
|
1959
2231
|
control.append(circle);
|
|
1960
2232
|
el.append(control);
|
|
2233
|
+
// The reorder grip of a movable row (`chrome.reorderControl`), at the
|
|
2234
|
+
// trailing edge: three lines, as iOS draws it. It lives in the content
|
|
2235
|
+
// slot, so it slides with the content when the minus (or a swipe)
|
|
2236
|
+
// reveals Delete; the control above stays, as on iOS.
|
|
2237
|
+
const grip = div('sb-listrow-grip');
|
|
2238
|
+
grip.hidden = true;
|
|
2239
|
+
grip.setAttribute('aria-label', 'Reorder');
|
|
2240
|
+
grip.append(gripGlyph());
|
|
2241
|
+
slot.append(grip);
|
|
1961
2242
|
}
|
|
1962
2243
|
return el;
|
|
1963
2244
|
}
|
|
@@ -2064,6 +2345,22 @@ function isSectionSupplement(el: HTMLElement): boolean {
|
|
|
2064
2345
|
return el.classList.contains('sb-sectionheader') || el.classList.contains('sb-sectionfooter');
|
|
2065
2346
|
}
|
|
2066
2347
|
|
|
2348
|
+
/** The reorder grip as iOS draws it: three 21 pt lines, 1.33 thick, 4.67 apart. */
|
|
2349
|
+
function gripGlyph(): SVGSVGElement {
|
|
2350
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
2351
|
+
svg.setAttribute('viewBox', '0 0 21 11');
|
|
2352
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
2353
|
+
for (const y of [0.67, 5.33, 10]) {
|
|
2354
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
2355
|
+
line.setAttribute('x1', '0');
|
|
2356
|
+
line.setAttribute('x2', '21');
|
|
2357
|
+
line.setAttribute('y1', String(y));
|
|
2358
|
+
line.setAttribute('y2', String(y));
|
|
2359
|
+
svg.append(line);
|
|
2360
|
+
}
|
|
2361
|
+
return svg;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2067
2364
|
function span(className: string): HTMLSpanElement {
|
|
2068
2365
|
const el = document.createElement('span');
|
|
2069
2366
|
el.className = className;
|
|
@@ -2413,6 +2710,9 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
|
|
|
2413
2710
|
|
|
2414
2711
|
function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
|
|
2415
2712
|
switch (kind) {
|
|
2713
|
+
case 'host':
|
|
2714
|
+
el.dataset['sbSizing'] = (props as HostProps).sizing;
|
|
2715
|
+
break;
|
|
2416
2716
|
case 'text':
|
|
2417
2717
|
applyText(el, props as TextProps, assets);
|
|
2418
2718
|
break;
|
|
@@ -2447,8 +2747,15 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2447
2747
|
case 'navstack':
|
|
2448
2748
|
case 'geometry':
|
|
2449
2749
|
case 'menu':
|
|
2750
|
+
case 'inspector':
|
|
2751
|
+
// The engine reads `presented`. Showing or hiding the panel animates
|
|
2752
|
+
// by default, as in SwiftUI (`animationTokenOf`).
|
|
2753
|
+
el.dataset[ANIMATION_TOKEN] = animationTokenOf(kind, props)!;
|
|
2754
|
+
break;
|
|
2755
|
+
case 'inspectorpanel':
|
|
2756
|
+
break;
|
|
2450
2757
|
case 'navsplit':
|
|
2451
|
-
// No props.
|
|
2758
|
+
// No props (a navsplit's `sidebarVisible` is read by the engine and layoutDom).
|
|
2452
2759
|
break;
|
|
2453
2760
|
case 'navlink':
|
|
2454
2761
|
// Phase 7: a selected row (inside a `List(selection:)`) gets a rounded fill.
|
|
@@ -2463,6 +2770,8 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2463
2770
|
const placement = (props as ToolbarProps).placement;
|
|
2464
2771
|
el.dataset['sbPlacement'] =
|
|
2465
2772
|
placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
|
|
2773
|
+
// A `ToolbarSpacer`: draws nothing.
|
|
2774
|
+
el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
|
|
2466
2775
|
break;
|
|
2467
2776
|
}
|
|
2468
2777
|
case 'searchfield':
|
|
@@ -2477,6 +2786,7 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2477
2786
|
// `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
|
|
2478
2787
|
if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
|
|
2479
2788
|
if (list.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
2789
|
+
applyEdgeEffect(el, list.edgeEffect);
|
|
2480
2790
|
break;
|
|
2481
2791
|
}
|
|
2482
2792
|
case 'tabview': {
|
|
@@ -2505,6 +2815,7 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2505
2815
|
if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
|
|
2506
2816
|
else delete el.dataset['sbSnap'];
|
|
2507
2817
|
if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
2818
|
+
applyEdgeEffect(el, scroll.edgeEffect);
|
|
2508
2819
|
break;
|
|
2509
2820
|
}
|
|
2510
2821
|
case 'section':
|
|
@@ -2547,6 +2858,12 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2547
2858
|
el.dataset['sbColumns'] = String(Array.isArray(grid.columns) ? grid.columns.length : 0);
|
|
2548
2859
|
break;
|
|
2549
2860
|
}
|
|
2861
|
+
case 'lazyhgrid': {
|
|
2862
|
+
const grid = props as LazyHGridProps;
|
|
2863
|
+
el.dataset['sbAlignment'] = grid.alignment ?? 'center';
|
|
2864
|
+
el.dataset['sbRows'] = String(Array.isArray(grid.rows) ? grid.rows.length : 0);
|
|
2865
|
+
break;
|
|
2866
|
+
}
|
|
2550
2867
|
case 'gauge':
|
|
2551
2868
|
applyGauge(el, props as GaugeProps);
|
|
2552
2869
|
break;
|
|
@@ -2587,11 +2904,19 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2587
2904
|
}
|
|
2588
2905
|
case 'contextmenu':
|
|
2589
2906
|
break;
|
|
2907
|
+
case 'draggable':
|
|
2908
|
+
break;
|
|
2909
|
+
case 'dropdestination': {
|
|
2910
|
+
const accepts = (props as DropDestinationProps | undefined)?.accepts;
|
|
2911
|
+
el.dataset['sbAccepts'] = Array.isArray(accepts) ? accepts.filter((a) => typeof a === 'string').join(' ') : '';
|
|
2912
|
+
break;
|
|
2913
|
+
}
|
|
2590
2914
|
case 'listrow': {
|
|
2591
2915
|
const row = (props as ListRowProps | undefined) ?? {};
|
|
2592
2916
|
el.toggleAttribute('data-sb-selectable', row.selectable === true);
|
|
2593
2917
|
el.toggleAttribute('data-sb-selected', row.selected === true);
|
|
2594
2918
|
el.toggleAttribute('data-sb-deletable', row.deletable === true);
|
|
2919
|
+
el.toggleAttribute('data-sb-movable', row.movable === true);
|
|
2595
2920
|
if (row.selectable) el.setAttribute('aria-selected', String(row.selected === true));
|
|
2596
2921
|
else el.removeAttribute('aria-selected');
|
|
2597
2922
|
const control = el.querySelector<HTMLElement>(':scope > .sb-listrow-control');
|
|
@@ -3051,7 +3376,7 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
|
|
|
3051
3376
|
el.dataset['sbDepth'] = String(depth);
|
|
3052
3377
|
el.dataset['sbDisplay'] = large ? 'large' : 'inline';
|
|
3053
3378
|
const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
|
|
3054
|
-
const largeTitle = el
|
|
3379
|
+
const largeTitle = largeTitleOf(el);
|
|
3055
3380
|
// A large title that scrolls away collapses into the inline one (shown
|
|
3056
3381
|
// with `data-sb-collapsed`, src/largeTitle.ts).
|
|
3057
3382
|
if (inlineTitle) inlineTitle.textContent = title;
|
|
@@ -3201,7 +3526,7 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
3201
3526
|
// typography.ts); the fallbacks are the Large sizes.
|
|
3202
3527
|
const large = resolveTextStyle(textStyle, DEFAULT_TYPE_SIZE);
|
|
3203
3528
|
s.fontSize = `var(${textStyleVar(textStyle, 'size')}, ${large.fontSize}px)`;
|
|
3204
|
-
s.lineHeight = `var(${textStyleVar(textStyle, 'lh')}, ${large.lineHeight}px)`;
|
|
3529
|
+
s.lineHeight = `var(${textStyleVar(textStyle, 'lh')}, ${Math.round(large.lineHeight * 100) / 100}px)`;
|
|
3205
3530
|
el.dataset['sbTextStyle'] = textStyle;
|
|
3206
3531
|
const implied = TEXT_STYLE_WEIGHT[textStyle];
|
|
3207
3532
|
if (!weight && implied) s.fontWeight = FONT_WEIGHT[implied];
|
|
@@ -3309,6 +3634,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
3309
3634
|
s.pointerEvents = 'none';
|
|
3310
3635
|
el.dataset['sbHitTesting'] = 'false';
|
|
3311
3636
|
}
|
|
3637
|
+
// Hover, tooltips, cursors, symbol effects and matched geometry ids (pointerEffects.ts).
|
|
3638
|
+
applyPointerAndEffects(el, style);
|
|
3312
3639
|
|
|
3313
3640
|
let opacity = style.opacity;
|
|
3314
3641
|
if (style.disabled) {
|
|
@@ -3439,3 +3766,16 @@ function lazySpacerIsNear(el: HTMLElement): boolean {
|
|
|
3439
3766
|
}
|
|
3440
3767
|
return rect.top < view.bottom + view.height && rect.bottom > view.top - view.height;
|
|
3441
3768
|
}
|
|
3769
|
+
|
|
3770
|
+
/**
|
|
3771
|
+
* Which way a `.numericText` change rolls: the direction it names, or else
|
|
3772
|
+
* down when the new text reads as a smaller number than the old one (as
|
|
3773
|
+
* SwiftUI's `numericText(value:)` decides from the value).
|
|
3774
|
+
*/
|
|
3775
|
+
export function countsDown(named: string | undefined, before: string, after: string): boolean {
|
|
3776
|
+
if (named !== undefined) return named === 'true';
|
|
3777
|
+
const number = (text: string): number => Number.parseFloat(text.replace(/[^\d.+-]/g, ''));
|
|
3778
|
+
const from = number(before);
|
|
3779
|
+
const to = number(after);
|
|
3780
|
+
return Number.isFinite(from) && Number.isFinite(to) && to < from;
|
|
3781
|
+
}
|