@swiftbrowser/web 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +4 -2
- package/src/animation.ts +154 -3
- package/src/dragDrop.ts +549 -0
- package/src/hostContent.ts +37 -0
- package/src/interaction.ts +128 -22
- package/src/largeTitle.ts +95 -54
- package/src/layout/engine.ts +415 -88
- package/src/layout/index.ts +5 -2
- package/src/layout/measure.ts +2 -0
- package/src/layout/text.ts +6 -3
- package/src/layout/types.ts +16 -1
- package/src/layout/typography.ts +30 -10
- package/src/layoutDom.ts +66 -16
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +188 -9
- package/src/renderer.ts +316 -15
- package/src/scrollEdge.ts +87 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +413 -16
- package/src/typography.ts +5 -2
package/src/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 {
|
|
@@ -86,8 +87,10 @@ import {
|
|
|
86
87
|
type LayoutEvent,
|
|
87
88
|
type LazyVGridProps,
|
|
88
89
|
type ImageProps,
|
|
90
|
+
type InspectorProps,
|
|
89
91
|
type ListProps,
|
|
90
92
|
type ListRowProps,
|
|
93
|
+
type DropDestinationProps,
|
|
91
94
|
type NavLinkProps,
|
|
92
95
|
type TapGestureProps,
|
|
93
96
|
type NavScreenProps,
|
|
@@ -105,6 +108,7 @@ import {
|
|
|
105
108
|
type SwipeActionsProps,
|
|
106
109
|
type TabProps,
|
|
107
110
|
type TabViewProps,
|
|
111
|
+
type HostProps,
|
|
108
112
|
type TextFieldProps,
|
|
109
113
|
type TextProps,
|
|
110
114
|
type TextRun,
|
|
@@ -144,7 +148,11 @@ import { applyCanvas, createCanvasElement } from './canvasDrawing';
|
|
|
144
148
|
import { scrollToTarget } from './scrollTo';
|
|
145
149
|
import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
|
|
146
150
|
import { colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
|
|
151
|
+
import { hostContent, onHostContentChange } from './hostContent';
|
|
152
|
+
import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
|
|
147
153
|
import { colorMultiplyFilter } from './colorMultiply';
|
|
154
|
+
import { largeTitleOf } from './largeTitle';
|
|
155
|
+
import { applyEdgeEffect } from './scrollEdge';
|
|
148
156
|
import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
|
|
149
157
|
|
|
150
158
|
export type EventCallback = (event: RendererEvent) => void;
|
|
@@ -161,6 +169,23 @@ const ID_ATTR = 'sbId';
|
|
|
161
169
|
/** `animationToken` of a `styled` box, kept across `update` to detect changes. */
|
|
162
170
|
const ANIMATION_TOKEN = 'sbAnimationToken';
|
|
163
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Kinds whose own changes animate even outside `withAnimation`: their token
|
|
174
|
+
* changes with what they present (an inspector's `presented`).
|
|
175
|
+
*/
|
|
176
|
+
const IMPLICITLY_ANIMATED: ReadonlySet<ElementKind> = new Set(['inspector']);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The animation token an element of `kind` has with `props`: a `styled`
|
|
180
|
+
* box's `animationToken` (`.animation(_:value:)`), an inspector's
|
|
181
|
+
* `presented`. An `update` that changes it animates its subtree.
|
|
182
|
+
*/
|
|
183
|
+
function animationTokenOf(kind: ElementKind | undefined, props: unknown): string | undefined {
|
|
184
|
+
if (kind === 'inspector') return (props as InspectorProps | null)?.presented === true ? 'shown' : 'hidden';
|
|
185
|
+
const token = (props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
|
|
186
|
+
return token == null ? undefined : String(token);
|
|
187
|
+
}
|
|
188
|
+
|
|
164
189
|
/** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
|
|
165
190
|
export const NAV_TRANSITION_MS = 400;
|
|
166
191
|
/** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
|
|
@@ -169,6 +194,7 @@ export const SHEET_TRANSITION_MS = 400;
|
|
|
169
194
|
export { SHEET_DISMISS_DRAG } from './interaction';
|
|
170
195
|
|
|
171
196
|
const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
197
|
+
'host',
|
|
172
198
|
'text',
|
|
173
199
|
'vstack',
|
|
174
200
|
'hstack',
|
|
@@ -205,6 +231,8 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
|
205
231
|
'tapgesture',
|
|
206
232
|
// Phase 7
|
|
207
233
|
'navsplit',
|
|
234
|
+
'inspector',
|
|
235
|
+
'inspectorpanel',
|
|
208
236
|
'viewthatfits',
|
|
209
237
|
'chart',
|
|
210
238
|
'chartitem',
|
|
@@ -297,11 +325,31 @@ export class Renderer {
|
|
|
297
325
|
*/
|
|
298
326
|
private rootSize: { width: number; height: number } | null = null;
|
|
299
327
|
private readonly resizeObserver: ResizeObserver | null = null;
|
|
328
|
+
/**
|
|
329
|
+
* `host` elements (src/hostContent.ts): their content elements, observed
|
|
330
|
+
* for size changes, and the measurements of the current layout pass.
|
|
331
|
+
*/
|
|
332
|
+
private readonly hostObserver: ResizeObserver | null = null;
|
|
333
|
+
/** `hover` events for boxes whose style asks for them (pointerEffects.ts). */
|
|
334
|
+
private readonly hover: HoverTracker;
|
|
335
|
+
/** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
|
|
336
|
+
private frameCheckQueued = false;
|
|
337
|
+
private readonly hostContents = new Map<ElementId, HTMLElement>();
|
|
338
|
+
private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
|
|
339
|
+
private readonly hostMeasures = new Map<string, { width: number; height: number }>();
|
|
340
|
+
private readonly stopHostChanges: () => void;
|
|
341
|
+
/**
|
|
342
|
+
* The root takes its content's height (`LayoutEnvironment.contentHeight`):
|
|
343
|
+
* a SwiftUI island in a page's own layout. Set before the first commit.
|
|
344
|
+
*/
|
|
345
|
+
contentHeight = false;
|
|
300
346
|
/**
|
|
301
347
|
* Phase 8: owns every pointer gesture (drags, long presses, swipe rows,
|
|
302
348
|
* swipe back, the sheet drag, pull to refresh) and arbitrates between them.
|
|
303
349
|
*/
|
|
304
350
|
readonly interaction: InteractionController;
|
|
351
|
+
/** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
|
|
352
|
+
private readonly externalDrops: ExternalDrops;
|
|
305
353
|
/** `video` elements' media events, as `video` events (src/video.ts). */
|
|
306
354
|
private readonly videoEvents: VideoEvents;
|
|
307
355
|
private readonly measurer = new CanvasTextMeasurer();
|
|
@@ -329,7 +377,7 @@ export class Renderer {
|
|
|
329
377
|
/** Lazy menus: a menu the viewer opened whose items Swift is building. */
|
|
330
378
|
private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
|
|
331
379
|
/** Size last reported for every `geometry` element (whole px). */
|
|
332
|
-
private readonly reportedGeometry = new Map<ElementId, { width: number; height: number }>();
|
|
380
|
+
private readonly reportedGeometry = new Map<ElementId, { width: number; height: number; key: string }>();
|
|
333
381
|
/** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
|
|
334
382
|
private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
|
|
335
383
|
/** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
|
|
@@ -349,7 +397,7 @@ export class Renderer {
|
|
|
349
397
|
// `closest` picks the innermost control, so a button inside a
|
|
350
398
|
// `tapgesture` (or a menu label) handles the click alone.
|
|
351
399
|
const target = (event.target as HTMLElement | null)?.closest<HTMLElement>(
|
|
352
|
-
'.sb-button, .sb-navlink, .sb-navbar-back, .sb-switch, .sb-sheet-scrim, ' +
|
|
400
|
+
'.sb-button, .sb-navlink, .sb-navbar-back, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
|
|
353
401
|
'.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
|
|
354
402
|
'.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
|
|
355
403
|
// Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
|
|
@@ -450,6 +498,14 @@ export class Renderer {
|
|
|
450
498
|
this.onEvent({ type: 'tap', id });
|
|
451
499
|
return;
|
|
452
500
|
}
|
|
501
|
+
if (target.classList.contains('sb-navsplit-toggle')) {
|
|
502
|
+
const split = target.closest<HTMLElement>('.sb-navsplit');
|
|
503
|
+
const id = Number(split?.dataset[ID_ATTR]);
|
|
504
|
+
if (!split || !Number.isInteger(id)) return;
|
|
505
|
+
event.preventDefault();
|
|
506
|
+
this.onEvent({ type: 'tap', id });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
453
509
|
if (target.classList.contains('sb-navbar-back')) {
|
|
454
510
|
const id = Number(target.dataset['sbTarget']);
|
|
455
511
|
if (!Number.isInteger(id)) return;
|
|
@@ -728,6 +784,7 @@ export class Renderer {
|
|
|
728
784
|
closeMenu: () => this.closeMenu(),
|
|
729
785
|
};
|
|
730
786
|
this.interaction = new InteractionController(host);
|
|
787
|
+
this.externalDrops = new ExternalDrops(host);
|
|
731
788
|
root.addEventListener('change', this.onChange);
|
|
732
789
|
root.addEventListener('pointerdown', this.onSliderPointerDown);
|
|
733
790
|
if (typeof ResizeObserver !== 'undefined') {
|
|
@@ -740,6 +797,83 @@ export class Renderer {
|
|
|
740
797
|
});
|
|
741
798
|
this.resizeObserver.observe(root);
|
|
742
799
|
}
|
|
800
|
+
this.measurer.measureHost = (content, width) => this.measureHostContent(content, width);
|
|
801
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
802
|
+
this.hostObserver = new ResizeObserver((entries) => {
|
|
803
|
+
let changed = false;
|
|
804
|
+
for (const entry of entries) {
|
|
805
|
+
const el = entry.target as HTMLElement;
|
|
806
|
+
const size = { width: el.offsetWidth, height: el.offsetHeight };
|
|
807
|
+
const last = this.hostSizes.get(el);
|
|
808
|
+
if (last && last.width === size.width && last.height === size.height) continue;
|
|
809
|
+
this.hostSizes.set(el, size);
|
|
810
|
+
changed = true;
|
|
811
|
+
}
|
|
812
|
+
if (changed) this.hostContentChanged();
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
this.stopHostChanges = onHostContentChange(() => this.hostContentChanged());
|
|
816
|
+
this.hover = new HoverTracker(root, (id, value) => this.onEvent({ type: 'hover', id, value }));
|
|
817
|
+
root.addEventListener('scroll', this.queueFrameCheck, { capture: true, passive: true });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Geometry readers with `reportsFrame` report where they are on screen,
|
|
822
|
+
* which scrolling changes without a layout pass: after a scroll their
|
|
823
|
+
* frames are checked once per animation frame.
|
|
824
|
+
*/
|
|
825
|
+
private readonly queueFrameCheck = (): void => {
|
|
826
|
+
if (this.frameCheckQueued || this.commits === 0) return;
|
|
827
|
+
this.frameCheckQueued = true;
|
|
828
|
+
requestAnimationFrame(() => {
|
|
829
|
+
this.frameCheckQueued = false;
|
|
830
|
+
if (!this.layoutResult) return;
|
|
831
|
+
this.collectGeometry(this.layoutResult, true);
|
|
832
|
+
if (this.applying === 0) this.flushGeometry();
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* A `host` element's content changed (the page said so, or it resized):
|
|
838
|
+
* the hosts are measured again and the tree laid out.
|
|
839
|
+
*/
|
|
840
|
+
private hostContentChanged(): void {
|
|
841
|
+
if (this.commits === 0 || this.hostContents.size === 0) return;
|
|
842
|
+
for (const id of this.hostContents.keys()) {
|
|
843
|
+
const node = this.nodes.get(id);
|
|
844
|
+
if (node) this.engine?.invalidate(node);
|
|
845
|
+
}
|
|
846
|
+
this.layout();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The host measurer (src/layout, `measureHost`): the content element laid
|
|
851
|
+
* out at `width`, or at its own ideal width when null. Measured in place,
|
|
852
|
+
* so the content sees the device's tokens and fonts; the styles it needs
|
|
853
|
+
* for the measurement are restored right after.
|
|
854
|
+
*/
|
|
855
|
+
private measureHostContent(content: string, width: number | null): { width: number; height: number } {
|
|
856
|
+
const key = `${content}\u0000${width ?? ''}`;
|
|
857
|
+
const cached = this.hostMeasures.get(key);
|
|
858
|
+
if (cached) return cached;
|
|
859
|
+
const el = hostContent(content);
|
|
860
|
+
if (!el) return { width: 0, height: 0 };
|
|
861
|
+
const { style } = el;
|
|
862
|
+
const saved = { width: style.width, height: style.height, maxWidth: style.maxWidth };
|
|
863
|
+
style.height = 'auto';
|
|
864
|
+
if (width === null) {
|
|
865
|
+
style.width = 'max-content';
|
|
866
|
+
style.maxWidth = 'none';
|
|
867
|
+
} else {
|
|
868
|
+
style.width = `${width}px`;
|
|
869
|
+
}
|
|
870
|
+
const rect = el.getBoundingClientRect();
|
|
871
|
+
const size = { width: Math.ceil(rect.width), height: Math.ceil(rect.height) };
|
|
872
|
+
style.width = saved.width;
|
|
873
|
+
style.height = saved.height;
|
|
874
|
+
style.maxWidth = saved.maxWidth;
|
|
875
|
+
this.hostMeasures.set(key, size);
|
|
876
|
+
return size;
|
|
743
877
|
}
|
|
744
878
|
|
|
745
879
|
/** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
|
|
@@ -818,10 +952,10 @@ export class Renderer {
|
|
|
818
952
|
const op = ops[index]!;
|
|
819
953
|
if (op.op === 'commit') return op.animation != null;
|
|
820
954
|
if (op.op !== 'update') continue;
|
|
821
|
-
const token = (op.props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
|
|
822
|
-
if (token == null) continue;
|
|
823
955
|
const el = this.elements.get(op.id);
|
|
824
|
-
|
|
956
|
+
const token = animationTokenOf(el?.dataset[KIND_ATTR] as ElementKind | undefined, op.props);
|
|
957
|
+
if (token == null) continue;
|
|
958
|
+
if (!el || el.dataset[ANIMATION_TOKEN] !== token) return true;
|
|
825
959
|
}
|
|
826
960
|
return true;
|
|
827
961
|
}
|
|
@@ -946,6 +1080,7 @@ export class Renderer {
|
|
|
946
1080
|
devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
|
|
947
1081
|
colorScheme: this.environment.colorScheme,
|
|
948
1082
|
assets: this.assets,
|
|
1083
|
+
...(this.contentHeight ? { contentHeight: true } : {}),
|
|
949
1084
|
};
|
|
950
1085
|
}
|
|
951
1086
|
|
|
@@ -968,6 +1103,7 @@ export class Renderer {
|
|
|
968
1103
|
layout(): BoxChange[] {
|
|
969
1104
|
const root = this.nodes.get(ROOT_ID);
|
|
970
1105
|
if (!root) return [];
|
|
1106
|
+
this.hostMeasures.clear();
|
|
971
1107
|
const env = this.layoutEnvironment();
|
|
972
1108
|
const envKey = JSON.stringify({ ...env, assets: undefined });
|
|
973
1109
|
if (!this.engine || envKey !== this.engineEnvKey) {
|
|
@@ -991,6 +1127,15 @@ export class Renderer {
|
|
|
991
1127
|
};
|
|
992
1128
|
const changes = applyLayout(ctx, result);
|
|
993
1129
|
this.boxes = ctx.boxes;
|
|
1130
|
+
if (this.contentHeight) {
|
|
1131
|
+
// The island's root is as tall as its content.
|
|
1132
|
+
let bottom = 0;
|
|
1133
|
+
for (const child of root.children) {
|
|
1134
|
+
const frame = result.frames.get(child.id);
|
|
1135
|
+
if (frame) bottom = Math.max(bottom, frame.y + frame.height);
|
|
1136
|
+
}
|
|
1137
|
+
this.root.style.height = `${bottom + env.safeArea.bottom}px`;
|
|
1138
|
+
}
|
|
994
1139
|
this.collectGeometry(result);
|
|
995
1140
|
this.collectLayouts(result);
|
|
996
1141
|
if (this.applying === 0) this.flushGeometry();
|
|
@@ -1004,17 +1149,35 @@ export class Renderer {
|
|
|
1004
1149
|
* (whole px) differs from the one last reported. Nothing is queued for an
|
|
1005
1150
|
* unchanged size, which is what lets the Swift re-render loop converge.
|
|
1006
1151
|
*/
|
|
1007
|
-
private collectGeometry(result: LayoutResult): void {
|
|
1152
|
+
private collectGeometry(result: LayoutResult, framesOnly = false): void {
|
|
1153
|
+
let rootRect: DOMRect | null = null;
|
|
1008
1154
|
for (const [id, node] of this.nodes) {
|
|
1009
1155
|
if (node.kind !== 'geometry' && node.props['reportsSize'] !== true) continue;
|
|
1156
|
+
const reportsFrame = node.props['reportsFrame'] === true;
|
|
1157
|
+
if (framesOnly && !reportsFrame) continue;
|
|
1010
1158
|
const frame = result.frames.get(id);
|
|
1011
1159
|
if (!frame) continue; // e.g. inside a hidden tab
|
|
1012
1160
|
const width = Math.round(frame.width);
|
|
1013
1161
|
const height = Math.round(frame.height);
|
|
1162
|
+
const event: GeometryEvent = { type: 'geometry', id, width, height };
|
|
1163
|
+
let key = `${width}x${height}`;
|
|
1164
|
+
if (reportsFrame) {
|
|
1165
|
+
// Where it is drawn now (scrolled), in the root's coordinates.
|
|
1166
|
+
const el = this.elements.get(id);
|
|
1167
|
+
rootRect ??= this.root.getBoundingClientRect();
|
|
1168
|
+
const rect = el?.getBoundingClientRect();
|
|
1169
|
+
const x = rect ? Math.round(rect.left - rootRect.left) : Math.round(frame.x);
|
|
1170
|
+
const y = rect ? Math.round(rect.top - rootRect.top) : Math.round(frame.y);
|
|
1171
|
+
const env = this.layoutEnvironment();
|
|
1172
|
+
event.x = x;
|
|
1173
|
+
event.y = y;
|
|
1174
|
+
event.safeArea = safeAreaOverlap({ x, y, width, height }, env.screen, env.safeArea);
|
|
1175
|
+
key += `@${x},${y}/${event.safeArea.top},${event.safeArea.leading},${event.safeArea.bottom},${event.safeArea.trailing}`;
|
|
1176
|
+
}
|
|
1014
1177
|
const last = this.reportedGeometry.get(id);
|
|
1015
|
-
if (last && last.
|
|
1016
|
-
this.reportedGeometry.set(id, { width, height });
|
|
1017
|
-
this.pendingGeometry.push(
|
|
1178
|
+
if (last && last.key === key) continue;
|
|
1179
|
+
this.reportedGeometry.set(id, { width, height, key });
|
|
1180
|
+
this.pendingGeometry.push(event);
|
|
1018
1181
|
}
|
|
1019
1182
|
}
|
|
1020
1183
|
|
|
@@ -1193,8 +1356,13 @@ export class Renderer {
|
|
|
1193
1356
|
this.root.removeEventListener('focusout', this.onFocusChange);
|
|
1194
1357
|
this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
1195
1358
|
this.interaction.dispose();
|
|
1359
|
+
this.externalDrops.dispose();
|
|
1196
1360
|
this.videoEvents.dispose();
|
|
1197
1361
|
this.resizeObserver?.disconnect();
|
|
1362
|
+
this.hostObserver?.disconnect();
|
|
1363
|
+
this.stopHostChanges();
|
|
1364
|
+
this.hover.dispose();
|
|
1365
|
+
this.root.removeEventListener('scroll', this.queueFrameCheck, true);
|
|
1198
1366
|
this.root.removeEventListener('change', this.onChange);
|
|
1199
1367
|
this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
|
|
1200
1368
|
this.reset();
|
|
@@ -1218,6 +1386,24 @@ export class Renderer {
|
|
|
1218
1386
|
applyProps(el, kind, props, this.assets);
|
|
1219
1387
|
this.noteScroll(id, kind, props);
|
|
1220
1388
|
if (kind === 'lazyspacer') this.lazySpacers.add(id);
|
|
1389
|
+
if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
|
|
1393
|
+
private adoptHostContent(id: ElementId, el: HTMLElement, props: HostProps): void {
|
|
1394
|
+
const content = hostContent(props.content);
|
|
1395
|
+
const previous = this.hostContents.get(id);
|
|
1396
|
+
if (previous && previous !== content) {
|
|
1397
|
+
this.hostObserver?.unobserve(previous);
|
|
1398
|
+
previous.remove();
|
|
1399
|
+
this.hostContents.delete(id);
|
|
1400
|
+
}
|
|
1401
|
+
if (!content) return;
|
|
1402
|
+
if (content.parentElement !== el) el.appendChild(content);
|
|
1403
|
+
if (previous !== content) {
|
|
1404
|
+
this.hostContents.set(id, content);
|
|
1405
|
+
this.hostObserver?.observe(content);
|
|
1406
|
+
}
|
|
1221
1407
|
}
|
|
1222
1408
|
|
|
1223
1409
|
/** After a scroll, the lazy spacers are checked on the next frame (once per frame). */
|
|
@@ -1291,6 +1477,24 @@ export class Renderer {
|
|
|
1291
1477
|
if (!el) throw new RendererError(`Cannot update unknown element ${id}`, op);
|
|
1292
1478
|
const kind = el.dataset[KIND_ATTR] as ElementKind;
|
|
1293
1479
|
const node = this.nodes.get(id);
|
|
1480
|
+
// `.contentTransition`: a text changing under one keeps a copy of how it
|
|
1481
|
+
// looked, for the Animator to play the change with (when the pass animates).
|
|
1482
|
+
if (kind === 'text' && node && this.passAnimates !== false && (props as TextProps).text !== node.props['text']) {
|
|
1483
|
+
const host = el.closest<HTMLElement>('[data-sb-content-transition]');
|
|
1484
|
+
if (host && this.root.contains(host)) {
|
|
1485
|
+
this.passRootRect ??= this.animator.rootRect();
|
|
1486
|
+
const rect = this.animator.rectOf(el, this.passRootRect);
|
|
1487
|
+
if (rect) {
|
|
1488
|
+
this.pass.contentChanges.push({
|
|
1489
|
+
el,
|
|
1490
|
+
clone: ghostClone(el),
|
|
1491
|
+
rect,
|
|
1492
|
+
kind: host.dataset['sbContentTransition'] as 'numericText' | 'opacity' | 'interpolate',
|
|
1493
|
+
countsDown: countsDown(host.dataset['sbCountsDown'], String(node.props['text'] ?? ''), String((props as TextProps).text ?? '')),
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1294
1498
|
if (node) {
|
|
1295
1499
|
node.props = asRecord(props);
|
|
1296
1500
|
// Phase 9: the next layout re-measures this subtree and its ancestors;
|
|
@@ -1324,9 +1528,11 @@ export class Renderer {
|
|
|
1324
1528
|
if (kind === 'text') el.textContent = '';
|
|
1325
1529
|
applyProps(el, kind, props, this.assets);
|
|
1326
1530
|
this.noteScroll(id, kind, props);
|
|
1531
|
+
if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
|
|
1327
1532
|
const newToken = el.dataset[ANIMATION_TOKEN];
|
|
1328
1533
|
const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
|
|
1329
1534
|
const record: UpdatedRecord = { el, before, tokenChanged };
|
|
1535
|
+
if (IMPLICITLY_ANIMATED.has(kind)) record.implicit = true;
|
|
1330
1536
|
const animation = ANIMATIONS.get(el);
|
|
1331
1537
|
if (animation) record.animation = animation;
|
|
1332
1538
|
this.pass.updated.set(el, record);
|
|
@@ -1450,11 +1656,19 @@ export class Renderer {
|
|
|
1450
1656
|
const ancestors: HTMLElement[] = [];
|
|
1451
1657
|
for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
|
|
1452
1658
|
let rect: DOMRect | null = null;
|
|
1659
|
+
const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
|
|
1453
1660
|
if (this.passAnimates !== false) {
|
|
1454
1661
|
this.passRootRect ??= this.animator.rootRect();
|
|
1455
1662
|
rect = this.animator.rectOf(el, this.passRootRect);
|
|
1663
|
+
// `.matchedGeometryEffect`: where the matched boxes in the subtree were.
|
|
1664
|
+
const candidates = el.dataset['sbMatched'] !== undefined ? [el] : [];
|
|
1665
|
+
candidates.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
|
|
1666
|
+
for (const box of candidates) {
|
|
1667
|
+
const boxRect = this.animator.rectOf(box, this.passRootRect);
|
|
1668
|
+
if (boxRect) matched.push({ id: box.dataset['sbMatched']!, el: box, rect: boxRect });
|
|
1669
|
+
}
|
|
1456
1670
|
}
|
|
1457
|
-
this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el) });
|
|
1671
|
+
this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el), ...(matched.length ? { matched } : {}) });
|
|
1458
1672
|
}
|
|
1459
1673
|
this.pass.inserted.delete(el);
|
|
1460
1674
|
this.pass.updated.delete(el);
|
|
@@ -1480,6 +1694,11 @@ export class Renderer {
|
|
|
1480
1694
|
|
|
1481
1695
|
/** Forget an element in every map; detaches its node from the tree. */
|
|
1482
1696
|
private forget(id: ElementId): void {
|
|
1697
|
+
const content = this.hostContents.get(id);
|
|
1698
|
+
if (content) {
|
|
1699
|
+
this.hostObserver?.unobserve(content);
|
|
1700
|
+
this.hostContents.delete(id);
|
|
1701
|
+
}
|
|
1483
1702
|
this.elements.delete(id);
|
|
1484
1703
|
this.boxes.delete(id);
|
|
1485
1704
|
this.dirty.delete(id);
|
|
@@ -1705,6 +1924,9 @@ function div(className: string): HTMLDivElement {
|
|
|
1705
1924
|
/** Create the element and any chrome it owns; props are applied separately. */
|
|
1706
1925
|
function createShell(kind: ElementKind): HTMLElement {
|
|
1707
1926
|
switch (kind) {
|
|
1927
|
+
case 'host':
|
|
1928
|
+
// The page's content element is adopted by the renderer (`adoptHostContent`).
|
|
1929
|
+
return div('sb-host');
|
|
1708
1930
|
case 'button': {
|
|
1709
1931
|
const el = document.createElement('button');
|
|
1710
1932
|
el.type = 'button';
|
|
@@ -1912,6 +2134,15 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
1912
2134
|
}
|
|
1913
2135
|
case 'tapgesture':
|
|
1914
2136
|
return document.createElement('div');
|
|
2137
|
+
case 'inspector': {
|
|
2138
|
+
// The content and the panel go into the slot; the panel draws the
|
|
2139
|
+
// hairline before it (styles.css), so it slides with the panel.
|
|
2140
|
+
const el = document.createElement('div');
|
|
2141
|
+
const slot = div('sb-slot sb-inspector-columns');
|
|
2142
|
+
el.append(slot);
|
|
2143
|
+
SLOTS.set(el, slot);
|
|
2144
|
+
return el;
|
|
2145
|
+
}
|
|
1915
2146
|
case 'navsplit': {
|
|
1916
2147
|
// Phase 7: the two columns go into the slot; the separator is chrome
|
|
1917
2148
|
// positioned from the engine's `separator` rect.
|
|
@@ -1919,15 +2150,25 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
1919
2150
|
const slot = div('sb-slot sb-navsplit-columns');
|
|
1920
2151
|
const separator = div('sb-navsplit-separator');
|
|
1921
2152
|
separator.setAttribute('aria-hidden', 'true');
|
|
1922
|
-
|
|
2153
|
+
// iPadOS: the sidebar toggle, at the engine's `toggle` rect; a tap
|
|
2154
|
+
// asks Swift to show or hide the sidebar.
|
|
2155
|
+
const toggle = document.createElement('button');
|
|
2156
|
+
toggle.type = 'button';
|
|
2157
|
+
toggle.className = 'sb-navsplit-toggle';
|
|
2158
|
+
toggle.setAttribute('aria-label', 'Toggle Sidebar');
|
|
2159
|
+
const symbol = renderSymbol('sidebar.left');
|
|
2160
|
+
if (symbol) toggle.append(symbol);
|
|
2161
|
+
el.append(slot, separator, toggle);
|
|
1923
2162
|
SLOTS.set(el, slot);
|
|
1924
2163
|
return el;
|
|
1925
2164
|
}
|
|
1926
2165
|
case 'viewthatfits':
|
|
1927
2166
|
return document.createElement('div');
|
|
1928
|
-
// Phase 8: gestures, rows, navigation
|
|
2167
|
+
// Phase 8: gestures, rows, navigation; drag and drop
|
|
1929
2168
|
case 'gesture':
|
|
1930
2169
|
case 'contextmenu':
|
|
2170
|
+
case 'draggable':
|
|
2171
|
+
case 'dropdestination':
|
|
1931
2172
|
return document.createElement('div');
|
|
1932
2173
|
case 'swipeactions':
|
|
1933
2174
|
case 'listrow': {
|
|
@@ -1958,6 +2199,15 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
1958
2199
|
}
|
|
1959
2200
|
control.append(circle);
|
|
1960
2201
|
el.append(control);
|
|
2202
|
+
// The reorder grip of a movable row (`chrome.reorderControl`), at the
|
|
2203
|
+
// trailing edge: three lines, as iOS draws it. It lives in the content
|
|
2204
|
+
// slot, so it slides with the content when the minus (or a swipe)
|
|
2205
|
+
// reveals Delete; the control above stays, as on iOS.
|
|
2206
|
+
const grip = div('sb-listrow-grip');
|
|
2207
|
+
grip.hidden = true;
|
|
2208
|
+
grip.setAttribute('aria-label', 'Reorder');
|
|
2209
|
+
grip.append(gripGlyph());
|
|
2210
|
+
slot.append(grip);
|
|
1961
2211
|
}
|
|
1962
2212
|
return el;
|
|
1963
2213
|
}
|
|
@@ -2064,6 +2314,22 @@ function isSectionSupplement(el: HTMLElement): boolean {
|
|
|
2064
2314
|
return el.classList.contains('sb-sectionheader') || el.classList.contains('sb-sectionfooter');
|
|
2065
2315
|
}
|
|
2066
2316
|
|
|
2317
|
+
/** The reorder grip as iOS draws it: three 21 pt lines, 1.33 thick, 4.67 apart. */
|
|
2318
|
+
function gripGlyph(): SVGSVGElement {
|
|
2319
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
2320
|
+
svg.setAttribute('viewBox', '0 0 21 11');
|
|
2321
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
2322
|
+
for (const y of [0.67, 5.33, 10]) {
|
|
2323
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
2324
|
+
line.setAttribute('x1', '0');
|
|
2325
|
+
line.setAttribute('x2', '21');
|
|
2326
|
+
line.setAttribute('y1', String(y));
|
|
2327
|
+
line.setAttribute('y2', String(y));
|
|
2328
|
+
svg.append(line);
|
|
2329
|
+
}
|
|
2330
|
+
return svg;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2067
2333
|
function span(className: string): HTMLSpanElement {
|
|
2068
2334
|
const el = document.createElement('span');
|
|
2069
2335
|
el.className = className;
|
|
@@ -2413,6 +2679,9 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
|
|
|
2413
2679
|
|
|
2414
2680
|
function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
|
|
2415
2681
|
switch (kind) {
|
|
2682
|
+
case 'host':
|
|
2683
|
+
el.dataset['sbSizing'] = (props as HostProps).sizing;
|
|
2684
|
+
break;
|
|
2416
2685
|
case 'text':
|
|
2417
2686
|
applyText(el, props as TextProps, assets);
|
|
2418
2687
|
break;
|
|
@@ -2447,8 +2716,15 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2447
2716
|
case 'navstack':
|
|
2448
2717
|
case 'geometry':
|
|
2449
2718
|
case 'menu':
|
|
2719
|
+
case 'inspector':
|
|
2720
|
+
// The engine reads `presented`. Showing or hiding the panel animates
|
|
2721
|
+
// by default, as in SwiftUI (`animationTokenOf`).
|
|
2722
|
+
el.dataset[ANIMATION_TOKEN] = animationTokenOf(kind, props)!;
|
|
2723
|
+
break;
|
|
2724
|
+
case 'inspectorpanel':
|
|
2725
|
+
break;
|
|
2450
2726
|
case 'navsplit':
|
|
2451
|
-
// No props.
|
|
2727
|
+
// No props (a navsplit's `sidebarVisible` is read by the engine and layoutDom).
|
|
2452
2728
|
break;
|
|
2453
2729
|
case 'navlink':
|
|
2454
2730
|
// Phase 7: a selected row (inside a `List(selection:)`) gets a rounded fill.
|
|
@@ -2477,6 +2753,7 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2477
2753
|
// `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
|
|
2478
2754
|
if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
|
|
2479
2755
|
if (list.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
2756
|
+
applyEdgeEffect(el, list.edgeEffect);
|
|
2480
2757
|
break;
|
|
2481
2758
|
}
|
|
2482
2759
|
case 'tabview': {
|
|
@@ -2505,6 +2782,7 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2505
2782
|
if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
|
|
2506
2783
|
else delete el.dataset['sbSnap'];
|
|
2507
2784
|
if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
2785
|
+
applyEdgeEffect(el, scroll.edgeEffect);
|
|
2508
2786
|
break;
|
|
2509
2787
|
}
|
|
2510
2788
|
case 'section':
|
|
@@ -2587,11 +2865,19 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2587
2865
|
}
|
|
2588
2866
|
case 'contextmenu':
|
|
2589
2867
|
break;
|
|
2868
|
+
case 'draggable':
|
|
2869
|
+
break;
|
|
2870
|
+
case 'dropdestination': {
|
|
2871
|
+
const accepts = (props as DropDestinationProps | undefined)?.accepts;
|
|
2872
|
+
el.dataset['sbAccepts'] = Array.isArray(accepts) ? accepts.filter((a) => typeof a === 'string').join(' ') : '';
|
|
2873
|
+
break;
|
|
2874
|
+
}
|
|
2590
2875
|
case 'listrow': {
|
|
2591
2876
|
const row = (props as ListRowProps | undefined) ?? {};
|
|
2592
2877
|
el.toggleAttribute('data-sb-selectable', row.selectable === true);
|
|
2593
2878
|
el.toggleAttribute('data-sb-selected', row.selected === true);
|
|
2594
2879
|
el.toggleAttribute('data-sb-deletable', row.deletable === true);
|
|
2880
|
+
el.toggleAttribute('data-sb-movable', row.movable === true);
|
|
2595
2881
|
if (row.selectable) el.setAttribute('aria-selected', String(row.selected === true));
|
|
2596
2882
|
else el.removeAttribute('aria-selected');
|
|
2597
2883
|
const control = el.querySelector<HTMLElement>(':scope > .sb-listrow-control');
|
|
@@ -3051,7 +3337,7 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
|
|
|
3051
3337
|
el.dataset['sbDepth'] = String(depth);
|
|
3052
3338
|
el.dataset['sbDisplay'] = large ? 'large' : 'inline';
|
|
3053
3339
|
const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
|
|
3054
|
-
const largeTitle = el
|
|
3340
|
+
const largeTitle = largeTitleOf(el);
|
|
3055
3341
|
// A large title that scrolls away collapses into the inline one (shown
|
|
3056
3342
|
// with `data-sb-collapsed`, src/largeTitle.ts).
|
|
3057
3343
|
if (inlineTitle) inlineTitle.textContent = title;
|
|
@@ -3201,7 +3487,7 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
3201
3487
|
// typography.ts); the fallbacks are the Large sizes.
|
|
3202
3488
|
const large = resolveTextStyle(textStyle, DEFAULT_TYPE_SIZE);
|
|
3203
3489
|
s.fontSize = `var(${textStyleVar(textStyle, 'size')}, ${large.fontSize}px)`;
|
|
3204
|
-
s.lineHeight = `var(${textStyleVar(textStyle, 'lh')}, ${large.lineHeight}px)`;
|
|
3490
|
+
s.lineHeight = `var(${textStyleVar(textStyle, 'lh')}, ${Math.round(large.lineHeight * 100) / 100}px)`;
|
|
3205
3491
|
el.dataset['sbTextStyle'] = textStyle;
|
|
3206
3492
|
const implied = TEXT_STYLE_WEIGHT[textStyle];
|
|
3207
3493
|
if (!weight && implied) s.fontWeight = FONT_WEIGHT[implied];
|
|
@@ -3309,6 +3595,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
3309
3595
|
s.pointerEvents = 'none';
|
|
3310
3596
|
el.dataset['sbHitTesting'] = 'false';
|
|
3311
3597
|
}
|
|
3598
|
+
// Hover, tooltips, cursors, symbol effects and matched geometry ids (pointerEffects.ts).
|
|
3599
|
+
applyPointerAndEffects(el, style);
|
|
3312
3600
|
|
|
3313
3601
|
let opacity = style.opacity;
|
|
3314
3602
|
if (style.disabled) {
|
|
@@ -3439,3 +3727,16 @@ function lazySpacerIsNear(el: HTMLElement): boolean {
|
|
|
3439
3727
|
}
|
|
3440
3728
|
return rect.top < view.bottom + view.height && rect.bottom > view.top - view.height;
|
|
3441
3729
|
}
|
|
3730
|
+
|
|
3731
|
+
/**
|
|
3732
|
+
* Which way a `.numericText` change rolls: the direction it names, or else
|
|
3733
|
+
* down when the new text reads as a smaller number than the old one (as
|
|
3734
|
+
* SwiftUI's `numericText(value:)` decides from the value).
|
|
3735
|
+
*/
|
|
3736
|
+
export function countsDown(named: string | undefined, before: string, after: string): boolean {
|
|
3737
|
+
if (named !== undefined) return named === 'true';
|
|
3738
|
+
const number = (text: string): number => Number.parseFloat(text.replace(/[^\d.+-]/g, ''));
|
|
3739
|
+
const from = number(before);
|
|
3740
|
+
const to = number(after);
|
|
3741
|
+
return Number.isFinite(from) && Number.isFinite(to) && to < from;
|
|
3742
|
+
}
|