@ng-native/fabric 0.1.3 → 0.3.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/dist/engine.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * inherent and is what React's own Fabric renderer does.
9
9
  */
10
10
  import { StyleResolver, } from "./css.js";
11
- import { animationEvent, bezier, interpolate, sample, step, tracksOf, } from "./transition.js";
11
+ import { animationEvent, bezier, interpolate, readsInheritedColour, sample, step, tracksOf, } from "./transition.js";
12
12
  import { isFormsInput } from "./forms-inputs.js";
13
13
  import { transformList } from "./inline-transform.js";
14
14
  import { tokenFromValue } from "./inline-token.js";
@@ -22,14 +22,41 @@ import { FontFaces } from "./font-faces.js";
22
22
  * and this runs for every node that commits.
23
23
  */
24
24
  function animationOf(props) {
25
- const spec = props['$animation'];
25
+ let spec = props['$animation'];
26
26
  if (spec !== undefined)
27
27
  delete props['$animation'];
28
- const playState = props['$playState'];
29
- if (playState === undefined)
30
- return spec;
31
- delete props['$playState'];
32
- return spec && { ...spec, paused: playState === 'paused' };
28
+ for (const [key, field] of Object.entries(ANIMATION_PARTS)) {
29
+ if (!(key in props))
30
+ continue;
31
+ const value = props[key];
32
+ delete props[key];
33
+ if (spec && value !== null)
34
+ spec = { ...spec, ...animationPart(field, value) };
35
+ }
36
+ return spec;
37
+ }
38
+ const ANIMATION_PARTS = {
39
+ $animationDuration: 'duration',
40
+ $animationDelay: 'delay',
41
+ $animationEasing: 'easing',
42
+ $animationIterations: 'iterations',
43
+ $animationDirection: 'direction',
44
+ $animationFill: 'fill',
45
+ $playState: 'paused',
46
+ };
47
+ function animationPart(field, value) {
48
+ switch (field) {
49
+ case 'iterations':
50
+ return { iterations: value === 'infinite' ? null : value };
51
+ case 'direction':
52
+ return {
53
+ direction: value === 'normal' ? undefined : value,
54
+ };
55
+ case 'paused':
56
+ return { paused: value === 'paused' ? true : undefined };
57
+ default:
58
+ return { [field]: value };
59
+ }
33
60
  }
34
61
  /**
35
62
  * Whether two specs ask for the same animation. By what they say rather than which object they
@@ -53,23 +80,24 @@ const SPEC_FIELDS = [
53
80
  'direction',
54
81
  'timeline',
55
82
  ];
83
+ /**
84
+ * The scroll events that mean a finger is dragging the list rather than tapping something in it.
85
+ *
86
+ * `topScrollBeginDrag` is the precise one; `topScroll` is here because a scroll view configured
87
+ * without drag events still reports movement, and a press surviving that is the bug either way.
88
+ */
89
+ const SCROLL_CANCELS_PRESS = new Set(['topScroll', 'topScrollBeginDrag']);
56
90
  /**
57
91
  * RN's `directEventTypes`: delivered to the target only, never up the tree. Everything else
58
92
  * bubbles, which matches RN's `bubblingEventTypes` for the touch and input events that matter.
93
+ * The web host's engine reads this set too, so both hosts deliver an event the same way.
59
94
  *
60
95
  * ponytail: a hand-kept set rather than real ViewConfigs. It is wrong only for a nested
61
96
  * scroll view, where an ancestor listening for the same direct event would not fire (correct)
62
97
  * but a third-party component's custom direct event would bubble (harmless in practice).
63
98
  * Swap it for per-component ViewConfig metadata if that ever matters.
64
99
  */
65
- /**
66
- * The scroll events that mean a finger is dragging the list rather than tapping something in it.
67
- *
68
- * `topScrollBeginDrag` is the precise one; `topScroll` is here because a scroll view configured
69
- * without drag events still reports movement, and a press surviving that is the bug either way.
70
- */
71
- const SCROLL_CANCELS_PRESS = new Set(['topScroll', 'topScrollBeginDrag']);
72
- const DIRECT_EVENTS = new Set([
100
+ export const DIRECT_EVENTS = new Set([
73
101
  'topScroll',
74
102
  'topScrollBeginDrag',
75
103
  'topScrollEndDrag',
@@ -108,9 +136,11 @@ const DIRECT_EVENTS = new Set([
108
136
  */
109
137
  export class SyntheticEvent {
110
138
  nativeEvent;
139
+ target;
111
140
  stopped = false;
112
- constructor(nativeEvent) {
141
+ constructor(nativeEvent, target = null) {
113
142
  this.nativeEvent = nativeEvent;
143
+ this.target = target;
114
144
  }
115
145
  stopPropagation() {
116
146
  this.stopped = true;
@@ -256,6 +286,11 @@ export const ANDROID_VIEW_NAMES = {
256
286
  'safe-area-view': 'View',
257
287
  'input-accessory-view': 'View',
258
288
  };
289
+ /**
290
+ * iOS's names for the elements Android names differently, so reporting iOS after Android puts
291
+ * them back.
292
+ */
293
+ const IOS_VIEW_NAMES = Object.fromEntries(Object.keys(ANDROID_VIEW_NAMES).map((element) => [element, VIEW_NAMES[element]]));
259
294
  /**
260
295
  * The platform the host reported at startup. Defaults to iOS, which is what the view-name table
261
296
  * above assumes too.
@@ -274,11 +309,9 @@ export function nativePlatform() {
274
309
  */
275
310
  export function registerPlatformComponents(platform) {
276
311
  platformOS = platform;
277
- if (platform !== 'android')
278
- return;
279
- for (const [element, viewName] of Object.entries(ANDROID_VIEW_NAMES)) {
312
+ const names = platform === 'android' ? ANDROID_VIEW_NAMES : IOS_VIEW_NAMES;
313
+ for (const [element, viewName] of Object.entries(names))
280
314
  registerViewName(element, viewName);
281
- }
282
315
  }
283
316
  /**
284
317
  * Props RN's JS wrappers apply before the caller's, per native view name.
@@ -318,6 +351,88 @@ const DEFAULT_VIEW = 'View';
318
351
  /** A top-level text, the one view that aligns its lines. A nested text is a `VirtualText`. */
319
352
  const PARAGRAPH = 'Paragraph';
320
353
  const textDirection = (value) => value === 'ltr' || value === 'rtl' ? value : undefined;
354
+ /**
355
+ * What iOS scales a text's `lineHeight` by: the system text size, capped by the text's own
356
+ * `maxFontSizeMultiplier` where it is at least 1, and 1 without font scaling
357
+ * (`RCTEffectiveFontSizeMultiplierFromTextAttributes`).
358
+ */
359
+ function textScale(props, fontScale = 1) {
360
+ if (props['allowFontScaling'] === false)
361
+ return 1;
362
+ const cap = props['maxFontSizeMultiplier'];
363
+ return typeof cap === 'number' && cap >= 1 ? Math.min(fontScale, cap) : fontScale;
364
+ }
365
+ /**
366
+ * Centre the text of a single-line text field that has a line height, as Chrome centres an
367
+ * input's, keeping the height the line height gives it.
368
+ *
369
+ * Where a `height` in points sizes the field, the line height has nothing left to do, and is left
370
+ * out on both platforms: on Android, `EditText` centres a line box taller than the font's own
371
+ * about 1.7pt high of centre in a 44pt field, and centres the font's own exactly. A percentage
372
+ * height sizes the field only where its parent's height is definite, which is known at layout
373
+ * alone, so such a field is left as it was. Elsewhere Android sizes the field by the line height
374
+ * and centres the text in it itself, and keeps it.
375
+ *
376
+ * On iOS, React Native's field sets `lineHeight` as the paragraph's minimum and maximum line
377
+ * height and, unlike a paragraph (`RCTApplyBaselineOffset`), never offsets the baseline, so the
378
+ * glyphs sit at the bottom of a line box taller than the font. One line has nothing to space, so
379
+ * the line height is left out. What it does in Chrome, and on Android, is set the field's height:
380
+ * line height, padding and border. That is kept as a `minHeight`, the larger of it and the field's
381
+ * own, within its `maxHeight`: the whole sum for a border-box field, Yoga's default, and the line
382
+ * height alone for a content-box one. A null `height` is no height. A value that is not a number
383
+ * cannot be added up, and leaves the field as it was.
384
+ */
385
+ function centreSingleLine(viewName, props, fontScale) {
386
+ const lineHeight = props['lineHeight'];
387
+ if (!TEXT_INPUTS.has(viewName) || typeof lineHeight !== 'number')
388
+ return;
389
+ if (props['multiline'] === true)
390
+ return;
391
+ const height = props['height'];
392
+ if (typeof height === 'number') {
393
+ delete props['lineHeight'];
394
+ return;
395
+ }
396
+ // A percentage is a fixed height only where the parent's is definite, known at layout alone.
397
+ if (isSet(height) && height !== 'auto')
398
+ return;
399
+ // Android sizes the field by its line height, and centres the text in it, itself.
400
+ if (viewName !== 'TextInput')
401
+ return;
402
+ const edge = (side, logical) => [
403
+ props[`padding${side}`] ??
404
+ props[`paddingBlock${logical}`] ??
405
+ props['paddingBlock'] ??
406
+ props['paddingVertical'] ??
407
+ props['padding'] ??
408
+ 0,
409
+ props[`border${side}Width`] ?? props['borderWidth'] ?? 0,
410
+ ];
411
+ const line = lineHeight * textScale(props, fontScale);
412
+ const box = [line, ...edge('Top', 'Start'), ...edge('Bottom', 'End')];
413
+ const own = props['minHeight'] ?? 0;
414
+ const max = props['maxHeight'] ?? Infinity;
415
+ if (![...box, own, max].every((part) => typeof part === 'number'))
416
+ return;
417
+ delete props['lineHeight'];
418
+ // A content-box field's minHeight is its content's: Yoga adds the padding and border itself.
419
+ const content = props['boxSizing'] === 'content-box'
420
+ ? line
421
+ : box.reduce((sum, part) => sum + part, 0);
422
+ props['minHeight'] = Math.max(own, Math.min(content, max));
423
+ }
424
+ /**
425
+ * Start a multiline Android text field's text at the top, as iOS and a `<textarea>` do.
426
+ *
427
+ * React Native maps an unset `textAlignVertical` to `Gravity.NO_GRAVITY`, which `ReactEditText`
428
+ * reads as the `EditText`'s own gravity, `center_vertical`. One the app set, through the input or
429
+ * a CSS `vertical-align`, stays.
430
+ */
431
+ function alignMultiline(viewName, props) {
432
+ if (viewName !== 'AndroidTextInput' || props['multiline'] !== true)
433
+ return;
434
+ props['textAlignVertical'] ??= 'top';
435
+ }
321
436
  /**
322
437
  * Resolve a paragraph's `text-align` against its direction, as CSS does.
323
438
  *
@@ -361,13 +476,45 @@ const MODAL_HOST = 'ModalHostView';
361
476
  * and skipping it is not cosmetic: `RNSScreenStackHeaderConfig` without its `position: absolute`
362
477
  * joins the screen's flex layout and pushes every page down by the height of the header.
363
478
  */
364
- export function registerViewName(elementName, viewName, defaultProps) {
479
+ export function registerViewName(elementName, viewName, defaultProps, options) {
365
480
  VIEW_NAMES[elementName] = viewName;
366
- if (!defaultProps)
367
- return;
368
481
  for (const name of typeof viewName === 'string' ? [viewName] : [viewName.ios, viewName.android]) {
369
- DEFAULT_PROPS[name] = defaultProps;
482
+ if (defaultProps)
483
+ DEFAULT_PROPS[name] = defaultProps;
484
+ if (options?.textContent)
485
+ TEXT_CONTENT_PROPS[name] = options.textContent;
486
+ }
487
+ }
488
+ /** The prop each view that takes its text content as a prop reads it from. See `ViewNameOptions`. */
489
+ const TEXT_CONTENT_PROPS = {};
490
+ /** Whether `node` takes its text content as a prop rather than as text children. */
491
+ function takesTextAsProp(node) {
492
+ return node.kind === 'element' && TEXT_CONTENT_PROPS[viewNameOf(node)] !== undefined;
493
+ }
494
+ /** Sets a view's text prop from its content, unless the prop is set explicitly. */
495
+ function withTextContent(node, viewName, props) {
496
+ const prop = TEXT_CONTENT_PROPS[viewName];
497
+ if (!prop || node.props[prop] !== undefined)
498
+ return;
499
+ const text = textContent(node);
500
+ if (text)
501
+ props[prop] = text;
502
+ }
503
+ /**
504
+ * The text written inside a view that takes it as a prop: every run joined, with the whitespace at
505
+ * the two ends dropped as a paragraph drops it, and kept inside. A nested view is drawn after the
506
+ * text, as SwiftUI's `Text` through `@expo/ui` draws a child `Text`, so the space before it stays.
507
+ */
508
+ function textContent(node) {
509
+ let text = '';
510
+ let nested = false;
511
+ for (const child of node.children) {
512
+ if (child.kind === 'text')
513
+ text += child.text;
514
+ else if (child.kind === 'element')
515
+ nested = true;
370
516
  }
517
+ return nested ? text.trimStart() : text.trim();
371
518
  }
372
519
  /** An element's registered view name, for the platform the host reported. */
373
520
  function registeredViewName(elementName) {
@@ -949,15 +1096,19 @@ export class Engine {
949
1096
  * for them. Only those in the tree: see `releaseDetached`.
950
1097
  */
951
1098
  hoisted = new Set();
952
- /** Whether a child list has lost a member since the last commit. See `releaseDetached`. */
953
- removedSinceCommit = false;
1099
+ /** The nodes taken out of a child list since the last commit. See `releaseDetached`. */
1100
+ removedSinceCommit = new Set();
954
1101
  /**
955
- * `@keyframes` by name, across every sheet seen so far. One registry rather than one per sheet,
1102
+ * `@keyframes` by name, across every sheet registered. One registry rather than one per sheet,
956
1103
  * because that is the scope CSS gives them: a name defined in a global stylesheet is usable
957
1104
  * from a component's. The cost is that two components defining the same name collide, last one
958
1105
  * in wins, exactly as two stylesheets in a document would.
959
1106
  */
960
1107
  keyframes = new Map();
1108
+ /** Every sheet registered, in the order a document would hold them. See `sheetReplaced`. */
1109
+ sheetOrder = [];
1110
+ /** The `@keyframes` each playing animation was started from. See `startPlaying`. */
1111
+ playedFrames = new WeakMap();
961
1112
  /** Element names already reported as unclaimed, so a list of a thousand rows reports once. */
962
1113
  reported = new Set();
963
1114
  processColor;
@@ -973,12 +1124,15 @@ export class Engine {
973
1124
  const conditions = options.conditions ?? { width: 0, height: 0, colorScheme: 'light' };
974
1125
  this.styles = new StyleResolver(options.globalStyles ?? null, conditions);
975
1126
  this.viewportSize = { width: conditions.width, height: conditions.height };
1127
+ this.fontScale = conditions.fontScale;
976
1128
  if (options.tokens)
977
1129
  this.styles.setRootTokens(options.tokens);
978
1130
  this.structuralSheets = options.globalStyles?.structural === true;
979
1131
  this.dev = options.dev ?? globalThis.__DEV__ === true;
980
- if (this.dev)
1132
+ if (this.dev) {
981
1133
  this.styles.onUndefinedToken = (name, props) => this.reportUndefinedToken(name, props);
1134
+ this.styles.onUnreadDisplay = (name, value) => this.reportUnreadDisplay(name, value);
1135
+ }
982
1136
  this.now = options.now ?? (() => globalThis.performance?.now?.() ?? Date.now());
983
1137
  this.onDirty = options.onDirty;
984
1138
  this.onError = options.onError;
@@ -1050,6 +1204,35 @@ export class Engine {
1050
1204
  * a caller owns the properties it names and nothing else. Each owner writes all of its own at
1051
1205
  * once - four insets together, never one - so nothing is left over from a previous rotation.
1052
1206
  */
1207
+ /**
1208
+ * Match `sheet` against every node from now on, after the global sheet the engine was made
1209
+ * with, and restyle everything; false, and nothing done, when it already is. How a platform
1210
+ * applies a component whose CSS is not scoped, such as Angular's `ViewEncapsulation.None`.
1211
+ * `replacing` is a sheet added before that this one takes the place of, after a hot swap.
1212
+ */
1213
+ addGlobalSheet(sheet, replacing) {
1214
+ if (!this.styles.addGlobalSheet(sheet, replacing))
1215
+ return false;
1216
+ if (replacing)
1217
+ this.sheetReplaced(replacing, sheet);
1218
+ else
1219
+ this.registerSheet(sheet);
1220
+ if (sheet.structural)
1221
+ this.structuralSheets = true;
1222
+ this.markPath(this.root);
1223
+ return true;
1224
+ }
1225
+ /**
1226
+ * Stop matching a sheet `addGlobalSheet` added, and restyle everything; false, and nothing
1227
+ * done, when it is not one. A hot swap that leaves a None component no rules, or makes it scoped.
1228
+ */
1229
+ removeGlobalSheet(sheet) {
1230
+ if (!this.styles.removeGlobalSheet(sheet))
1231
+ return false;
1232
+ this.sheetReplaced(sheet, null);
1233
+ this.markPath(this.root);
1234
+ return true;
1235
+ }
1053
1236
  updateTokens(next) {
1054
1237
  this.styles.setRootTokens({ ...this.styles.rootTokens, ...next });
1055
1238
  this.root.subtreeDirty = true;
@@ -1189,13 +1372,33 @@ export class Engine {
1189
1372
  this.markProps(node, false);
1190
1373
  this.commit();
1191
1374
  }
1375
+ /**
1376
+ * A node's component rules are its creating sheet's, so this matches it against `like`'s, or
1377
+ * against the one it was created with once `like` is null.
1378
+ */
1379
+ adoptScope(node, like) {
1380
+ if (like && !this.ownSheets.has(node))
1381
+ this.ownSheets.set(node, node.sheet);
1382
+ const sheet = like ? like.sheet : this.ownSheets.get(node);
1383
+ if (sheet === undefined || node.sheet === sheet)
1384
+ return;
1385
+ node.sheet = sheet;
1386
+ if (sheet?.structural)
1387
+ this.structuralSheets = true;
1388
+ this.markProps(node);
1389
+ }
1390
+ /** The sheet each node given another's scope was created with. */
1391
+ ownSheets = new WeakMap();
1192
1392
  /** The window's size, from the conditions media queries use. Zero until the platform says. */
1193
1393
  get viewport() {
1194
1394
  return this.viewportSize;
1195
1395
  }
1196
1396
  viewportSize;
1397
+ /** The system text size, from the conditions. See `Conditions.fontScale`. */
1398
+ fontScale;
1197
1399
  updateConditions(next) {
1198
1400
  this.viewportSize = { width: next.width, height: next.height };
1401
+ this.fontScale = next.fontScale;
1199
1402
  this.styles.setConditions(next);
1200
1403
  this.root.subtreeDirty = true;
1201
1404
  // Committed here rather than left to the next change-detection pass, because there may not
@@ -1220,6 +1423,26 @@ export class Engine {
1220
1423
  node.classes?.delete(name);
1221
1424
  this.markProps(node);
1222
1425
  }
1426
+ /**
1427
+ * The sheet of the component `node` hosts, whose `:host` rules it matches. Set again when a hot
1428
+ * swap replaces the component's sheet, which recreates the views inside but not the host, so the
1429
+ * host is styled afresh here.
1430
+ */
1431
+ setHostSheet(node, sheet) {
1432
+ if (node.hostSheet === sheet)
1433
+ return;
1434
+ node.hostSheet = sheet;
1435
+ this.markProps(node);
1436
+ }
1437
+ /**
1438
+ * Style for `node` that any matched rule or inline style overrides, as a browser's own default
1439
+ * for an element is. `mount` gives the root component's host the surface's height this way, as
1440
+ * the web's mount point has, unless the app's `:host` sizes it otherwise.
1441
+ */
1442
+ setDefaultStyle(node, style) {
1443
+ node.defaultStyle = style;
1444
+ this.markProps(node);
1445
+ }
1223
1446
  /** `class="a b"` from a template. Replaces the set rather than adding to it. */
1224
1447
  setClasses(node, value) {
1225
1448
  const names = value.split(/\s+/).filter(Boolean);
@@ -1242,6 +1465,7 @@ export class Engine {
1242
1465
  if (child.dormantHoists)
1243
1466
  this.wakeHoists(child);
1244
1467
  this.markStructure(parent);
1468
+ this.markTextContent(parent, child);
1245
1469
  }
1246
1470
  insertBefore(parent, child, ref) {
1247
1471
  this.detach(child);
@@ -1254,6 +1478,7 @@ export class Engine {
1254
1478
  if (child.dormantHoists)
1255
1479
  this.wakeHoists(child);
1256
1480
  this.markStructure(parent);
1481
+ this.markTextContent(parent, child);
1257
1482
  }
1258
1483
  removeChild(parent, child) {
1259
1484
  const target = parent ?? child.parent;
@@ -1264,8 +1489,17 @@ export class Engine {
1264
1489
  return;
1265
1490
  target.children.splice(at, 1);
1266
1491
  child.parent = null;
1267
- this.removedSinceCommit = true;
1492
+ this.removedSinceCommit.add(child);
1268
1493
  this.markStructure(target);
1494
+ this.markTextContent(target, child);
1495
+ }
1496
+ /**
1497
+ * A child came or went under a view that takes its text as a prop: that prop changed. A span
1498
+ * changes it too, by keeping or dropping the space before it.
1499
+ */
1500
+ markTextContent(parent, child) {
1501
+ if (child.kind !== 'anchor' && takesTextAsProp(parent))
1502
+ this.markProps(parent);
1269
1503
  }
1270
1504
  /** A subtree that went out of the tree is back in: its hoisted nodes commit again. */
1271
1505
  wakeHoists(node) {
@@ -1288,12 +1522,17 @@ export class Engine {
1288
1522
  * whole. So a hoisted node moves to the root of the subtree it left with, which is garbage along
1289
1523
  * with it if the subtree never returns, and is handed back when it does. An animation on a node
1290
1524
  * out of the tree stops rather than running a frame loop for something nobody can see, and
1291
- * starts again if the node returns.
1525
+ * starts again if the node returns. And a node out of the tree when the commit runs forgets its
1526
+ * committed views, as a withheld modal does: the commit unmounts them, Fabric never re-enables an
1527
+ * unmounted view's events (`EventEmitter::setEnabled`), and committed again they would ignore
1528
+ * every touch. It comes back created afresh, as React creates an element it mounts again. One
1529
+ * put back before the commit never left, and keeps its views.
1292
1530
  *
1293
- * Once per commit, and only after a removal: the walks are the depth of a handful of nodes.
1531
+ * Once per commit, and only after a removal: the walks are the depth of a handful of nodes, and
1532
+ * forgetting walks each subtree that left once.
1294
1533
  */
1295
1534
  releaseDetached() {
1296
- this.removedSinceCommit = false;
1535
+ this.forgetDetached();
1297
1536
  for (const node of this.hoisted) {
1298
1537
  const top = this.topOf(node);
1299
1538
  if (top === this.root)
@@ -1319,6 +1558,14 @@ export class Engine {
1319
1558
  if (this.focusedNode && this.topOf(this.focusedNode) !== this.root)
1320
1559
  this.focusedNode = null;
1321
1560
  }
1561
+ /** Forget the committed views of each node removed since the last commit that is still out. */
1562
+ forgetDetached() {
1563
+ for (const node of this.removedSinceCommit) {
1564
+ if (node.committed && this.topOf(node) !== this.root)
1565
+ this.forgetCommitted(node);
1566
+ }
1567
+ this.removedSinceCommit.clear();
1568
+ }
1322
1569
  /** The root of the tree a node is in: the engine's root while it is attached. */
1323
1570
  topOf(node) {
1324
1571
  let top = node;
@@ -1421,6 +1668,8 @@ export class Engine {
1421
1668
  setText(node, value) {
1422
1669
  node.text = value;
1423
1670
  this.markProps(node);
1671
+ if (node.parent)
1672
+ this.markTextContent(node.parent, node);
1424
1673
  }
1425
1674
  setEventListener(node, topLevelType, fn) {
1426
1675
  this.registerEventHandler();
@@ -1494,7 +1743,7 @@ export class Engine {
1494
1743
  * optimisation.
1495
1744
  */
1496
1745
  commit() {
1497
- if (this.removedSinceCommit)
1746
+ if (this.removedSinceCommit.size)
1498
1747
  this.releaseDetached();
1499
1748
  // The root is never reconciled itself, so it has no `committed` record and `isClean` would
1500
1749
  // always say dirty. Ask the flags directly, or every change-detection pass ends in a
@@ -1511,6 +1760,8 @@ export class Engine {
1511
1760
  this.facesAdded = false;
1512
1761
  const set = this.fabric.createChildSet(this.rootTag);
1513
1762
  for (const child of this.visibleChildren(this.root)) {
1763
+ if (this.withheld(child))
1764
+ continue;
1514
1765
  this.fabric.appendChildToSet(set, this.reconcileUnder(this.root, child));
1515
1766
  }
1516
1767
  this.clearFlags(this.root);
@@ -1599,12 +1850,14 @@ export class Engine {
1599
1850
  }
1600
1851
  /** Anchors take part in sibling ordering but never reach Fabric. */
1601
1852
  visibleChildren(node) {
1602
- return node.children.filter((child) => child.kind !== 'anchor');
1853
+ // A view that takes its text as a prop has no text children to commit. See `ViewNameOptions`.
1854
+ const textAsProp = takesTextAsProp(node);
1855
+ return node.children.filter((child) => child.kind !== 'anchor' && !(textAsProp && child.kind === 'text'));
1603
1856
  }
1604
1857
  /**
1605
- * Everything a node renders with, unprocessed. Precedence, weakest first: native defaults,
1606
- * matched CSS, explicit props, inline style, a component's `styleOverride`. Inline wins over CSS for the same reason it does
1607
- * on the web.
1858
+ * Everything a node renders with, unprocessed. Precedence, weakest first: native defaults, the
1859
+ * node's `defaultStyle`, matched CSS, explicit props, inline style, a component's
1860
+ * `styleOverride`. Inline wins over CSS for the same reason it does on the web.
1608
1861
  *
1609
1862
  * Raw, because this is what the next commit diffs against. Colours and asset ids are converted
1610
1863
  * on the way out instead (`processed`), and only for the keys that changed: the converters
@@ -1618,7 +1871,7 @@ export class Engine {
1618
1871
  this.checkProps(node);
1619
1872
  this.registerSheet(node.sheet);
1620
1873
  this.registerSheet(node.hostSheet);
1621
- const props = { ...DEFAULT_PROPS[viewName] };
1874
+ const props = { ...DEFAULT_PROPS[viewName], ...node.defaultStyle };
1622
1875
  Object.assign(props, this.styles.resolve(node, this.styleEpoch).style);
1623
1876
  for (const key of Object.keys(node.props)) {
1624
1877
  // No native prop has a hyphen. `data-*` and `aria-*` attributes stay on the node for
@@ -1630,6 +1883,7 @@ export class Engine {
1630
1883
  props[key] = node.props[key];
1631
1884
  }
1632
1885
  }
1886
+ withTextContent(node, viewName, props);
1633
1887
  const style = flattenStyle(node.props['style'], props);
1634
1888
  const intrinsic = node.props[INTRINSIC_SIZE];
1635
1889
  if (intrinsic)
@@ -1640,7 +1894,10 @@ export class Engine {
1640
1894
  alignText(style, this.directionOf(node, style));
1641
1895
  if (this.fontsRefreshed)
1642
1896
  this.capForFonts(node, style);
1643
- return composeTransform(node, this.animated(node, this.transitioned(node, style)));
1897
+ alignMultiline(viewName, style);
1898
+ const merged = composeTransform(node, this.animated(node, this.transitioned(node, style)));
1899
+ centreSingleLine(viewName, merged, this.fontScale);
1900
+ return merged;
1644
1901
  }
1645
1902
  /**
1646
1903
  * The text size cap that lays a paragraph or text input out again once a face it names has
@@ -1698,12 +1955,59 @@ export class Engine {
1698
1955
  if (!sheet || this.knownSheets.has(sheet))
1699
1956
  return;
1700
1957
  this.knownSheets.add(sheet);
1958
+ this.sheetOrder.push(sheet);
1701
1959
  for (const name of Object.keys(sheet.keyframes ?? {})) {
1702
1960
  this.keyframes.set(name, sheet.keyframes[name]);
1703
1961
  }
1704
1962
  if (sheet.fonts && this.fontFaces.add(sheet.fonts))
1705
1963
  this.facesAdded = true;
1706
1964
  }
1965
+ /**
1966
+ * A hot swap edited a component's sheet into `next`, or took it away with null: `sheet`'s
1967
+ * `@keyframes` go, and `next`'s take its place among the others, as an edited stylesheet keeps
1968
+ * its place in a document. A name another sheet also defines falls back to that one. Every
1969
+ * animation re-resolves the name it plays, so one whose keyframes went stops, as in a browser.
1970
+ * A `sheet` already gone, such as a global sheet removed, leaves `next` registered last.
1971
+ */
1972
+ sheetReplaced(sheet, next) {
1973
+ if (sheet === next && this.knownSheets.has(sheet))
1974
+ return;
1975
+ const at = this.sheetOrder.indexOf(sheet);
1976
+ if (at === -1) {
1977
+ this.registerSheet(next);
1978
+ return;
1979
+ }
1980
+ this.knownSheets.delete(sheet);
1981
+ if (next && !this.knownSheets.has(next)) {
1982
+ this.knownSheets.add(next);
1983
+ this.sheetOrder[at] = next;
1984
+ if (next.fonts && this.fontFaces.add(next.fonts))
1985
+ this.facesAdded = true;
1986
+ }
1987
+ else {
1988
+ this.sheetOrder.splice(at, 1);
1989
+ }
1990
+ if (sheet.keyframes || next?.keyframes)
1991
+ this.reindexKeyframes();
1992
+ }
1993
+ /** Every sheet's `@keyframes` again, in order, and every animation told to look them up. */
1994
+ reindexKeyframes() {
1995
+ this.keyframes.clear();
1996
+ for (const sheet of this.sheetOrder) {
1997
+ for (const name of Object.keys(sheet.keyframes ?? {})) {
1998
+ this.keyframes.set(name, sheet.keyframes[name]);
1999
+ }
2000
+ }
2001
+ this.markAnimated(this.root);
2002
+ }
2003
+ /** Mark every node under `node` with an animation, so it looks its keyframes up again. */
2004
+ markAnimated(node) {
2005
+ for (const child of node.children) {
2006
+ if (child.playing || child.scrolled)
2007
+ this.markProps(child, false);
2008
+ this.markAnimated(child);
2009
+ }
2010
+ }
1707
2011
  /** Every `@font-face` seen, global as on the web: a face declared in one sheet serves all. */
1708
2012
  fontFaces = new FontFaces();
1709
2013
  /** Whether a sheet first used in this commit declared a face. See `rematchFonts`. */
@@ -1747,6 +2051,10 @@ export class Engine {
1747
2051
  }
1748
2052
  const frames = this.keyframes.get(spec.name);
1749
2053
  if (!frames) {
2054
+ // Keyframes a hot swap deleted, from under an animation that was playing them.
2055
+ node.playing = undefined;
2056
+ this.playing.delete(node);
2057
+ this.stopScrolled(node);
1750
2058
  if (this.dev)
1751
2059
  this.reportMissingKeyframes(spec.name);
1752
2060
  return props;
@@ -1759,10 +2067,12 @@ export class Engine {
1759
2067
  /** Start the clock on an animation, unless the node is already playing this one. */
1760
2068
  startPlaying(node, spec, frames, props) {
1761
2069
  const current = node.playing;
2070
+ const inherited = this.inheritedColour(node, frames);
1762
2071
  if (!current || !sameAnimation(current.spec, spec)) {
1763
2072
  const started = {
1764
2073
  spec,
1765
- tracks: tracksOf(frames, props),
2074
+ tracks: tracksOf(frames, props, inherited),
2075
+ inherited,
1766
2076
  start: this.now(),
1767
2077
  values: {},
1768
2078
  done: false,
@@ -1775,6 +2085,7 @@ export class Engine {
1775
2085
  // resting style: `animation-fill-mode: backwards` behaviour, always. It is what an entrance
1776
2086
  // wants, and the alternative is the flash again.
1777
2087
  started.values = sample(started, this.now()).values;
2088
+ this.playedFrames.set(started, frames);
1778
2089
  node.playing = started;
1779
2090
  if (spec.paused)
1780
2091
  started.pausedAt = this.now();
@@ -1783,7 +2094,34 @@ export class Engine {
1783
2094
  this.emitTransition(node, 'topAnimationstart', spec.name);
1784
2095
  return;
1785
2096
  }
1786
- this.playOrPause(node, current, spec);
2097
+ if (this.playedFrames.get(current) !== frames || inherited !== current.inherited) {
2098
+ this.reframe(node, current, frames, props, inherited);
2099
+ }
2100
+ this.playOrPause(node, node.playing, spec);
2101
+ }
2102
+ /**
2103
+ * The colour `node` inherits, for frames that set `color: currentColor`, which is that colour
2104
+ * wherever the animation has got to, as in CSS. Undefined for frames that do not.
2105
+ */
2106
+ inheritedColour(node, frames) {
2107
+ if (!readsInheritedColour(frames))
2108
+ return undefined;
2109
+ const parent = node.parent && this.styles.resolve(node.parent, this.styleEpoch);
2110
+ return parent?.inherited['color'] ?? 'black';
2111
+ }
2112
+ /**
2113
+ * The name an animation plays now has other frames: a hot swap edited them, or took away the
2114
+ * sheet whose copy won. Or the colour its `color: currentColor` stands for has changed. It
2115
+ * carries on along the new tracks, on its own clock, as a browser does, paused or not.
2116
+ */
2117
+ reframe(node, current, frames, props, inherited) {
2118
+ const tracks = tracksOf(frames, props, inherited);
2119
+ const reframed = { ...current, tracks, inherited };
2120
+ const { values, finished } = sample(reframed, current.pausedAt ?? this.now());
2121
+ const holds = current.spec.fill === 'forwards' || current.spec.fill === 'both';
2122
+ reframed.values = finished && !holds ? {} : values;
2123
+ this.playedFrames.set(reframed, frames);
2124
+ node.playing = reframed;
1787
2125
  }
1788
2126
  /**
1789
2127
  * `animation-play-state` changed on an animation already playing: pausing holds the frame it
@@ -1817,15 +2155,16 @@ export class Engine {
1817
2155
  */
1818
2156
  scrollAnimated(node, spec, frames, props) {
1819
2157
  const current = node.scrolled;
1820
- if (current && sameAnimation(current.spec, spec))
1821
- return Object.assign(props, current.first);
1822
- if (current)
1823
- this.stopScrolled(node);
2158
+ const inherited = this.inheritedColour(node, frames);
2159
+ if (current?.frames === frames && sameAnimation(current.spec, spec)) {
2160
+ return Object.assign(props, this.rescrolled(node, current, frames, props, inherited));
2161
+ }
2162
+ this.stopScrolled(node);
1824
2163
  if (node.playing) {
1825
2164
  node.playing = undefined;
1826
2165
  this.playing.delete(node);
1827
2166
  }
1828
- const tracks = tracksOf(frames, props);
2167
+ const tracks = tracksOf(frames, props, inherited);
1829
2168
  const source = this.scrollSourceOf(node);
1830
2169
  const extent = source ? (this.scrollExtents.get(source)?.[spec.timeline] ?? null) : null;
1831
2170
  const range = rangeOf(spec, extent);
@@ -1838,9 +2177,31 @@ export class Engine {
1838
2177
  // view for the animation to move. Committed with the first frame, so it stays put.
1839
2178
  if (drive)
1840
2179
  first['collapsable'] = false;
1841
- node.scrolled = { spec, tracks, resting, first, source, drive };
2180
+ node.scrolled = { spec, frames, tracks, resting, first, source, drive, inherited };
1842
2181
  return Object.assign(props, first);
1843
2182
  }
2183
+ /**
2184
+ * The first frame of a scroll-driven animation already playing. When the colour its
2185
+ * `color: currentColor` stands for has changed, that frame's colour is taken from tracks built
2186
+ * again. Native drives opacity and transforms only, so a colour holds its first frame, and
2187
+ * nothing native animates is touched.
2188
+ */
2189
+ rescrolled(node, current, frames, props, inherited) {
2190
+ if (inherited === current.inherited)
2191
+ return current.first;
2192
+ // The element's own colour, which a track with no first frame starts at, may be the one it
2193
+ // inherits, so it is read again too.
2194
+ const resting = { ...current.resting, color: props['color'] };
2195
+ const tracks = tracksOf(frames, resting, inherited);
2196
+ const extent = current.source ? this.scrollExtents.get(current.source) : undefined;
2197
+ const range = rangeOf(current.spec, extent?.[current.spec.timeline] ?? null);
2198
+ const first = { ...current.first };
2199
+ const colour = firstFrame(tracks, current.spec, range)['color'];
2200
+ if ('color' in first)
2201
+ first['color'] = colour;
2202
+ node.scrolled = { ...current, tracks, resting, first, inherited };
2203
+ return first;
2204
+ }
1844
2205
  /** Hand a scroll-driven animation's channels to native, fed by `source`'s scroll events. */
1845
2206
  driveScrolled(node, source, spec, tracks, resting, range) {
1846
2207
  if (!this.scrollDriver)
@@ -2114,9 +2475,9 @@ export class Engine {
2114
2475
  if (this.isClean(node) && !styleChanged)
2115
2476
  return node.committed.handle;
2116
2477
  node.styleCommitted = style;
2117
- const childHandles = this.reconcileChildren(node, style);
2118
- const previous = node.committed;
2119
2478
  const viewName = viewNameOf(node);
2479
+ const childHandles = this.reconcileChildren(node, viewName, style);
2480
+ const previous = node.committed;
2120
2481
  this.notePresented(node, viewName);
2121
2482
  if (!previous)
2122
2483
  return this.create(node, viewName, this.mergeProps(node, viewName), childHandles);
@@ -2168,19 +2529,30 @@ export class Engine {
2168
2529
  * handle as it stands, so it is answered here without the call, the resolve and the walk -
2169
2530
  * which, per child, was most of what a one-row update cost.
2170
2531
  */
2171
- reconcileChildren(node, style) {
2532
+ reconcileChildren(node, viewName, style) {
2172
2533
  const context = style?.context;
2173
2534
  if (HOIST_TARGETS.has(node.name))
2174
2535
  return this.reconcileHoistTarget(node, context);
2536
+ // A view that takes its text as a prop has no text children to commit. See `ViewNameOptions`.
2537
+ const textAsProp = TEXT_CONTENT_PROPS[viewName] !== undefined;
2175
2538
  const handles = [];
2176
2539
  for (const child of node.children) {
2177
2540
  if (child.kind === 'anchor' || this.committedElsewhere(child) || this.withheld(child)) {
2178
2541
  continue;
2179
2542
  }
2543
+ if (textAsProp && child.kind === 'text')
2544
+ continue;
2180
2545
  handles.push(this.reconcileUnder(node, child, context));
2181
2546
  }
2182
2547
  return handles;
2183
2548
  }
2549
+ /** Remember an iOS modal host committed as showing, so hiding it waits for `topDismiss`. */
2550
+ notePresented(node, viewName) {
2551
+ if (viewName !== MODAL_HOST || node.props['visible'] === false)
2552
+ return;
2553
+ if (platformOS === 'ios')
2554
+ node.presented = true;
2555
+ }
2184
2556
  /**
2185
2557
  * Whether a child is left out of its parent's commit: a modal host whose `visible` is false.
2186
2558
  *
@@ -2190,18 +2562,19 @@ export class Engine {
2190
2562
  * committed, with `visible` false, until native reports `topDismiss`: Modal.js keeps it that
2191
2563
  * long so the dismissal animates and `(dismiss)` fires. Android drops it at once, as Modal.js
2192
2564
  * does there.
2565
+ *
2566
+ * A withheld host is unmounted, and Fabric drops an unmounted view's event target for good
2567
+ * (`EventEmitter::setEnabled`), so its committed handles are forgotten and it is created afresh
2568
+ * when it is shown again, as Modal.js creates it afresh. Committed again, it ignores every touch.
2193
2569
  */
2194
- /** Remember an iOS modal host committed as showing, so hiding it waits for `topDismiss`. */
2195
- notePresented(node, viewName) {
2196
- if (viewName !== MODAL_HOST || node.props['visible'] === false)
2197
- return;
2198
- if (platformOS === 'ios')
2199
- node.presented = true;
2200
- }
2201
2570
  withheld(child) {
2202
2571
  if (child.props['visible'] !== false || child.presented)
2203
2572
  return false;
2204
- return viewNameOf(child) === MODAL_HOST;
2573
+ if (viewNameOf(child) !== MODAL_HOST)
2574
+ return false;
2575
+ if (child.committed)
2576
+ this.forgetCommitted(child);
2577
+ return true;
2205
2578
  }
2206
2579
  /**
2207
2580
  * `reconcileChildren` for a node other nodes hoist into: its own children, each followed by
@@ -2252,11 +2625,15 @@ export class Engine {
2252
2625
  child.committedUnder = parent;
2253
2626
  return handle;
2254
2627
  }
2255
- /** Let a node and everything under it be created again at the next commit. */
2628
+ /**
2629
+ * Let a node and everything under it be created again at the next commit. The views it keeps
2630
+ * for hoisted names go too: they are children of the native view it is losing.
2631
+ */
2256
2632
  forgetCommitted(node) {
2257
2633
  node.committed = null;
2258
2634
  node.committedUnder = null;
2259
2635
  node.styleCommitted = null;
2636
+ node.kept = undefined;
2260
2637
  for (const child of node.children)
2261
2638
  if (child.committed)
2262
2639
  this.forgetCommitted(child);
@@ -2406,6 +2783,20 @@ export class Engine {
2406
2783
  ` scope defines, so the declaration is dropped, as a browser drops it. Define ${name} on` +
2407
2784
  ` :root or an ancestor, or give the var() a fallback.`);
2408
2785
  }
2786
+ unreadDisplays = new Set();
2787
+ /**
2788
+ * Said once per token and value: the compiler refuses `display: grid` written out, but a token
2789
+ * is only known here, and without this the view lays out as flex with nothing said.
2790
+ */
2791
+ reportUnreadDisplay(name, value) {
2792
+ const report = `${name} ${value}`;
2793
+ if (this.unreadDisplays.has(report))
2794
+ return;
2795
+ this.unreadDisplays.add(report);
2796
+ console.warn(`[angular-native] display: var(${name}) is ${value ?? 'no keyword'}, which is no display` +
2797
+ ` native has, so display is unset and the view lays out as flex. Native has flex, none` +
2798
+ ` and contents, and reads block, inline, inline-block, flow-root and inline-flex as flex.`);
2799
+ }
2409
2800
  checkProps(node) {
2410
2801
  if (!node.claimed || !PRIMITIVE_NAMES.has(node.name))
2411
2802
  return;
@@ -2767,7 +3158,7 @@ export class Engine {
2767
3158
  // Listeners get RN's documented shape, `{nativeEvent}`. Fabric hands us the payload bare;
2768
3159
  // React wraps it in a synthetic event and every RN API is written against `event.nativeEvent`,
2769
3160
  // so handlers ported from RN would silently read undefined otherwise.
2770
- const event = new SyntheticEvent(nativeEvent);
3161
+ const event = new SyntheticEvent(nativeEvent, target);
2771
3162
  try {
2772
3163
  if (topLevelType === 'topDismiss' && target)
2773
3164
  this.dismissed(target);