@ng-native/fabric 0.2.0 → 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/dist/engine.js CHANGED
@@ -7,8 +7,10 @@
7
7
  * because a persistent parent holds references to specific child handles. That bubble is
8
8
  * inherent and is what React's own Fabric renderer does.
9
9
  */
10
- import { StyleResolver, } from "./css.js";
11
- import { animationEvent, bezier, interpolate, sample, step, tracksOf, } from "./transition.js";
10
+ import { boundDeclaration, inlineInherited, setsInherited, StyleResolver, } from "./css.js";
11
+ import { applyAria } from "./aria-props.js";
12
+ import { STYLED_ELEMENTS } from "./element-styles.js";
13
+ import { animationEvent, bezier, interpolate, readsInheritedColour, sample, step, tracksOf, } from "./transition.js";
12
14
  import { isFormsInput } from "./forms-inputs.js";
13
15
  import { transformList } from "./inline-transform.js";
14
16
  import { tokenFromValue } from "./inline-token.js";
@@ -22,14 +24,41 @@ import { FontFaces } from "./font-faces.js";
22
24
  * and this runs for every node that commits.
23
25
  */
24
26
  function animationOf(props) {
25
- const spec = props['$animation'];
27
+ let spec = props['$animation'];
26
28
  if (spec !== undefined)
27
29
  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' };
30
+ for (const [key, field] of Object.entries(ANIMATION_PARTS)) {
31
+ if (!(key in props))
32
+ continue;
33
+ const value = props[key];
34
+ delete props[key];
35
+ if (spec && value !== null)
36
+ spec = { ...spec, ...animationPart(field, value) };
37
+ }
38
+ return spec;
39
+ }
40
+ const ANIMATION_PARTS = {
41
+ $animationDuration: 'duration',
42
+ $animationDelay: 'delay',
43
+ $animationEasing: 'easing',
44
+ $animationIterations: 'iterations',
45
+ $animationDirection: 'direction',
46
+ $animationFill: 'fill',
47
+ $playState: 'paused',
48
+ };
49
+ function animationPart(field, value) {
50
+ switch (field) {
51
+ case 'iterations':
52
+ return { iterations: value === 'infinite' ? null : value };
53
+ case 'direction':
54
+ return {
55
+ direction: value === 'normal' ? undefined : value,
56
+ };
57
+ case 'paused':
58
+ return { paused: value === 'paused' ? true : undefined };
59
+ default:
60
+ return { [field]: value };
61
+ }
33
62
  }
34
63
  /**
35
64
  * Whether two specs ask for the same animation. By what they say rather than which object they
@@ -53,23 +82,24 @@ const SPEC_FIELDS = [
53
82
  'direction',
54
83
  'timeline',
55
84
  ];
85
+ /**
86
+ * The scroll events that mean a finger is dragging the list rather than tapping something in it.
87
+ *
88
+ * `topScrollBeginDrag` is the precise one; `topScroll` is here because a scroll view configured
89
+ * without drag events still reports movement, and a press surviving that is the bug either way.
90
+ */
91
+ const SCROLL_CANCELS_PRESS = new Set(['topScroll', 'topScrollBeginDrag']);
56
92
  /**
57
93
  * RN's `directEventTypes`: delivered to the target only, never up the tree. Everything else
58
94
  * bubbles, which matches RN's `bubblingEventTypes` for the touch and input events that matter.
95
+ * The web host's engine reads this set too, so both hosts deliver an event the same way.
59
96
  *
60
97
  * ponytail: a hand-kept set rather than real ViewConfigs. It is wrong only for a nested
61
98
  * scroll view, where an ancestor listening for the same direct event would not fire (correct)
62
99
  * but a third-party component's custom direct event would bubble (harmless in practice).
63
100
  * Swap it for per-component ViewConfig metadata if that ever matters.
64
101
  */
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([
102
+ export const DIRECT_EVENTS = new Set([
73
103
  'topScroll',
74
104
  'topScrollBeginDrag',
75
105
  'topScrollEndDrag',
@@ -108,9 +138,11 @@ const DIRECT_EVENTS = new Set([
108
138
  */
109
139
  export class SyntheticEvent {
110
140
  nativeEvent;
141
+ target;
111
142
  stopped = false;
112
- constructor(nativeEvent) {
143
+ constructor(nativeEvent, target = null) {
113
144
  this.nativeEvent = nativeEvent;
145
+ this.target = target;
114
146
  }
115
147
  stopPropagation() {
116
148
  this.stopped = true;
@@ -210,6 +242,34 @@ const VIEW_NAMES = {
210
242
  * `@ng-native/expo`, and warning about it would be noise.
211
243
  */
212
244
  const PRIMITIVE_NAMES = new Set(Object.keys(VIEW_NAMES));
245
+ /**
246
+ * HTML's text elements, which a template can write as it would for a browser.
247
+ *
248
+ * Each is text when it holds only text and other text elements, and a view when it holds anything
249
+ * else: a box inside a paragraph has no layout, so `<span>Inbox<view class="dot" /></span>` is a
250
+ * view with its text in a paragraph of its own. Decided as the element is committed, by
251
+ * `viewNameOf`. They have no component to import, so they are not `PRIMITIVE_NAMES`.
252
+ */
253
+ const TEXT_ELEMENTS = new Set([
254
+ ...['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'strong', 'b', 'em', 'i'],
255
+ ...['u', 's', 'small', 'code', 'mark', 'abbr', 'cite', 'time'],
256
+ ]);
257
+ /** HTML's layout elements, each a plain view, as a browser's are boxes with a name. */
258
+ const LAYOUT_ELEMENTS = [
259
+ ...['div', 'section', 'article', 'header', 'footer', 'main', 'nav', 'ul', 'ol', 'li'],
260
+ ];
261
+ for (const name of TEXT_ELEMENTS)
262
+ VIEW_NAMES[name] = 'Paragraph';
263
+ for (const name of LAYOUT_ELEMENTS)
264
+ VIEW_NAMES[name] = 'View';
265
+ /**
266
+ * What an HTML element has for its name alone, given to the node as it is made: a heading's role,
267
+ * which a binding can replace as it can any prop, and whether the engine has styles for it.
268
+ */
269
+ const HTML_ELEMENTS = new Map([
270
+ ...['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map((name) => [name, { role: 'header' }]),
271
+ ...[...STYLED_ELEMENTS].map((name) => [name, { styled: true }]),
272
+ ]);
213
273
  /**
214
274
  * The props each host primitive's native view is sent, by element name, as the components
215
275
  * declare them: every input the element's component has, and the props it writes that no input
@@ -244,6 +304,14 @@ export function claimHost(node) {
244
304
  export function markComponentHost(node) {
245
305
  node.componentHost = true;
246
306
  }
307
+ /**
308
+ * Say that a component's host is the native view its element name is registered as: a typed
309
+ * wrapper for the view, where a component of the app's own with the same selector is a plain view.
310
+ * Called from the component's constructor. See `ViewNameOptions.yieldsToComponents`.
311
+ */
312
+ export function keepNativeView(node) {
313
+ node.nativeView = true;
314
+ }
247
315
  /** `text-input` -> `TextInput`: how the components package names the class for an element. */
248
316
  const className = (element) => element.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase());
249
317
  /** Names Android registers differently. */
@@ -256,6 +324,11 @@ export const ANDROID_VIEW_NAMES = {
256
324
  'safe-area-view': 'View',
257
325
  'input-accessory-view': 'View',
258
326
  };
327
+ /**
328
+ * iOS's names for the elements Android names differently, so reporting iOS after Android puts
329
+ * them back.
330
+ */
331
+ const IOS_VIEW_NAMES = Object.fromEntries(Object.keys(ANDROID_VIEW_NAMES).map((element) => [element, VIEW_NAMES[element]]));
259
332
  /**
260
333
  * The platform the host reported at startup. Defaults to iOS, which is what the view-name table
261
334
  * above assumes too.
@@ -274,11 +347,9 @@ export function nativePlatform() {
274
347
  */
275
348
  export function registerPlatformComponents(platform) {
276
349
  platformOS = platform;
277
- if (platform !== 'android')
278
- return;
279
- for (const [element, viewName] of Object.entries(ANDROID_VIEW_NAMES)) {
350
+ const names = platform === 'android' ? ANDROID_VIEW_NAMES : IOS_VIEW_NAMES;
351
+ for (const [element, viewName] of Object.entries(names))
280
352
  registerViewName(element, viewName);
281
- }
282
353
  }
283
354
  /**
284
355
  * Props RN's JS wrappers apply before the caller's, per native view name.
@@ -318,6 +389,88 @@ const DEFAULT_VIEW = 'View';
318
389
  /** A top-level text, the one view that aligns its lines. A nested text is a `VirtualText`. */
319
390
  const PARAGRAPH = 'Paragraph';
320
391
  const textDirection = (value) => value === 'ltr' || value === 'rtl' ? value : undefined;
392
+ /**
393
+ * What iOS scales a text's `lineHeight` by: the system text size, capped by the text's own
394
+ * `maxFontSizeMultiplier` where it is at least 1, and 1 without font scaling
395
+ * (`RCTEffectiveFontSizeMultiplierFromTextAttributes`).
396
+ */
397
+ function textScale(props, fontScale = 1) {
398
+ if (props['allowFontScaling'] === false)
399
+ return 1;
400
+ const cap = props['maxFontSizeMultiplier'];
401
+ return typeof cap === 'number' && cap >= 1 ? Math.min(fontScale, cap) : fontScale;
402
+ }
403
+ /**
404
+ * Centre the text of a single-line text field that has a line height, as Chrome centres an
405
+ * input's, keeping the height the line height gives it.
406
+ *
407
+ * Where a `height` in points sizes the field, the line height has nothing left to do, and is left
408
+ * out on both platforms: on Android, `EditText` centres a line box taller than the font's own
409
+ * about 1.7pt high of centre in a 44pt field, and centres the font's own exactly. A percentage
410
+ * height sizes the field only where its parent's height is definite, which is known at layout
411
+ * alone, so such a field is left as it was. Elsewhere Android sizes the field by the line height
412
+ * and centres the text in it itself, and keeps it.
413
+ *
414
+ * On iOS, React Native's field sets `lineHeight` as the paragraph's minimum and maximum line
415
+ * height and, unlike a paragraph (`RCTApplyBaselineOffset`), never offsets the baseline, so the
416
+ * glyphs sit at the bottom of a line box taller than the font. One line has nothing to space, so
417
+ * the line height is left out. What it does in Chrome, and on Android, is set the field's height:
418
+ * line height, padding and border. That is kept as a `minHeight`, the larger of it and the field's
419
+ * own, within its `maxHeight`: the whole sum for a border-box field, Yoga's default, and the line
420
+ * height alone for a content-box one. A null `height` is no height. A value that is not a number
421
+ * cannot be added up, and leaves the field as it was.
422
+ */
423
+ function centreSingleLine(viewName, props, fontScale) {
424
+ const lineHeight = props['lineHeight'];
425
+ if (!TEXT_INPUTS.has(viewName) || typeof lineHeight !== 'number')
426
+ return;
427
+ if (props['multiline'] === true)
428
+ return;
429
+ const height = props['height'];
430
+ if (typeof height === 'number') {
431
+ delete props['lineHeight'];
432
+ return;
433
+ }
434
+ // A percentage is a fixed height only where the parent's is definite, known at layout alone.
435
+ if (isSet(height) && height !== 'auto')
436
+ return;
437
+ // Android sizes the field by its line height, and centres the text in it, itself.
438
+ if (viewName !== 'TextInput')
439
+ return;
440
+ const edge = (side, logical) => [
441
+ props[`padding${side}`] ??
442
+ props[`paddingBlock${logical}`] ??
443
+ props['paddingBlock'] ??
444
+ props['paddingVertical'] ??
445
+ props['padding'] ??
446
+ 0,
447
+ props[`border${side}Width`] ?? props['borderWidth'] ?? 0,
448
+ ];
449
+ const line = lineHeight * textScale(props, fontScale);
450
+ const box = [line, ...edge('Top', 'Start'), ...edge('Bottom', 'End')];
451
+ const own = props['minHeight'] ?? 0;
452
+ const max = props['maxHeight'] ?? Infinity;
453
+ if (![...box, own, max].every((part) => typeof part === 'number'))
454
+ return;
455
+ delete props['lineHeight'];
456
+ // A content-box field's minHeight is its content's: Yoga adds the padding and border itself.
457
+ const content = props['boxSizing'] === 'content-box'
458
+ ? line
459
+ : box.reduce((sum, part) => sum + part, 0);
460
+ props['minHeight'] = Math.max(own, Math.min(content, max));
461
+ }
462
+ /**
463
+ * Start a multiline Android text field's text at the top, as iOS and a `<textarea>` do.
464
+ *
465
+ * React Native maps an unset `textAlignVertical` to `Gravity.NO_GRAVITY`, which `ReactEditText`
466
+ * reads as the `EditText`'s own gravity, `center_vertical`. One the app set, through the input or
467
+ * a CSS `vertical-align`, stays.
468
+ */
469
+ function alignMultiline(viewName, props) {
470
+ if (viewName !== 'AndroidTextInput' || props['multiline'] !== true)
471
+ return;
472
+ props['textAlignVertical'] ??= 'top';
473
+ }
321
474
  /**
322
475
  * Resolve a paragraph's `text-align` against its direction, as CSS does.
323
476
  *
@@ -361,13 +514,61 @@ const MODAL_HOST = 'ModalHostView';
361
514
  * and skipping it is not cosmetic: `RNSScreenStackHeaderConfig` without its `position: absolute`
362
515
  * joins the screen's flex layout and pushes every page down by the height of the header.
363
516
  */
364
- export function registerViewName(elementName, viewName, defaultProps) {
517
+ export function registerViewName(elementName, viewName, defaultProps, options) {
365
518
  VIEW_NAMES[elementName] = viewName;
366
- if (!defaultProps)
367
- return;
519
+ if (options?.yieldsToComponents)
520
+ YIELDING.add(elementName);
521
+ else
522
+ YIELDING.delete(elementName);
523
+ // A name the app registers is the app's: no longer decided by what the element holds.
524
+ TEXT_ELEMENTS.delete(elementName);
368
525
  for (const name of typeof viewName === 'string' ? [viewName] : [viewName.ios, viewName.android]) {
369
- DEFAULT_PROPS[name] = defaultProps;
526
+ if (defaultProps)
527
+ DEFAULT_PROPS[name] = defaultProps;
528
+ if (options?.textContent)
529
+ TEXT_CONTENT_PROPS[name] = options.textContent;
530
+ }
531
+ }
532
+ /** The registered names that give way to a component's own host. See `ViewNameOptions`. */
533
+ const YIELDING = new Set();
534
+ /** The prop each view that takes its text content as a prop reads it from. See `ViewNameOptions`. */
535
+ const TEXT_CONTENT_PROPS = {};
536
+ /** Whether `node` takes its text content as a prop rather than as text children. */
537
+ function takesTextAsProp(node) {
538
+ return node.kind === 'element' && TEXT_CONTENT_PROPS[viewNameOf(node)] !== undefined;
539
+ }
540
+ /** Sets a view's text prop from its content, unless the prop is set explicitly. */
541
+ function withTextContent(node, viewName, props) {
542
+ const prop = TEXT_CONTENT_PROPS[viewName];
543
+ if (!prop || node.props[prop] !== undefined)
544
+ return;
545
+ const text = textContent(node);
546
+ if (text)
547
+ props[prop] = text;
548
+ }
549
+ /**
550
+ * The whitespace a paragraph drops at its ends is what CSS collapses: spaces, tabs and line
551
+ * breaks. A no-break space is text, as in a browser, so `&nbsp;` is how a value keeps a space at
552
+ * its end; `String.prototype.trim` would take that too.
553
+ */
554
+ const trimStart = (text) => text.replace(/^[ \t\n\r\f]+/, '');
555
+ const trimEnd = (text) => text.replace(/[ \t\n\r\f]+$/, '');
556
+ const trimEnds = (text) => trimEnd(trimStart(text));
557
+ /**
558
+ * The text written inside a view that takes it as a prop: every run joined, with the whitespace at
559
+ * the two ends dropped as a paragraph drops it, and kept inside. A nested view is drawn after the
560
+ * text, as SwiftUI's `Text` through `@expo/ui` draws a child `Text`, so the space before it stays.
561
+ */
562
+ function textContent(node) {
563
+ let text = '';
564
+ let nested = false;
565
+ for (const child of node.children) {
566
+ if (child.kind === 'text')
567
+ text += child.text;
568
+ else if (child.kind === 'element')
569
+ nested = true;
370
570
  }
571
+ return nested ? trimStart(text) : trimEnd(trimStart(text));
371
572
  }
372
573
  /** An element's registered view name, for the platform the host reported. */
373
574
  function registeredViewName(elementName) {
@@ -381,6 +582,17 @@ function registeredViewName(elementName) {
381
582
  * a paragraph, and a text input on either platform. See `Engine.remeasureText`.
382
583
  */
383
584
  const MEASURED_VIEWS = new Set(['Paragraph', 'TextInput', 'AndroidTextInput']);
585
+ /** Leave a family that is still loading out of `style`, remembering it. See `loadingFamilies`. */
586
+ function holdLoadingFamily(node, style) {
587
+ const family = style['fontFamily'];
588
+ if (typeof family === 'string' && loadingFamilies.has(family)) {
589
+ delete style['fontFamily'];
590
+ node.heldFamily = family;
591
+ }
592
+ else if (node.heldFamily !== undefined) {
593
+ node.heldFamily = undefined;
594
+ }
595
+ }
384
596
  /** What each mounted app does when faces register. See `fontsRegistered`. */
385
597
  const fontListeners = new Set();
386
598
  /** Hear about faces registering with the platform, until the returned function is called. */
@@ -400,6 +612,45 @@ export function fontsRegistered(families) {
400
612
  for (const listener of [...fontListeners])
401
613
  listener(names);
402
614
  }
615
+ /**
616
+ * Families a load has asked the platform for and not yet settled, by how many loads asked. Text
617
+ * naming one is laid out in the fallback without the name until it settles: native caches a
618
+ * text's measurement by its family name, so a fallback measurement under the face's name would
619
+ * outlast the face arriving.
620
+ */
621
+ const loadingFamilies = new Map();
622
+ /** Faces a load is about to ask the platform for. See `loadingFamilies`. */
623
+ export function fontsLoading(families) {
624
+ for (const family of families)
625
+ loadingFamilies.set(family, (loadingFamilies.get(family) ?? 0) + 1);
626
+ }
627
+ /** What each mounted app does when a load settles. See `fontsSettled`. */
628
+ const settleListeners = new Set();
629
+ /** Hear about loads settling, until the returned function is called. */
630
+ export function onFontsSettled(listener) {
631
+ settleListeners.add(listener);
632
+ return () => settleListeners.delete(listener);
633
+ }
634
+ /**
635
+ * Faces a load asked for and has finished with, registered or not, before `fontsRegistered`.
636
+ * Text held back for one that no load still asks for is laid out with the name again: see
637
+ * `Engine.fontsSettled`.
638
+ */
639
+ export function fontsSettled(families) {
640
+ const settled = new Set();
641
+ for (const family of families) {
642
+ const left = (loadingFamilies.get(family) ?? 1) - 1;
643
+ if (left > 0)
644
+ loadingFamilies.set(family, left);
645
+ else {
646
+ loadingFamilies.delete(family);
647
+ settled.add(family);
648
+ }
649
+ }
650
+ if (settled.size)
651
+ for (const listener of [...settleListeners])
652
+ listener(settled);
653
+ }
403
654
  /** Whether a paragraph, or a span in it, was committed asking for one of these families. */
404
655
  function namesFamily(node, families) {
405
656
  const family = node.committed?.props['fontFamily'];
@@ -470,12 +721,13 @@ const STAND_INS = {};
470
721
  */
471
722
  function paragraphText(node) {
472
723
  let root = node.parent;
473
- if (root?.name !== 'text')
474
- return node.text;
724
+ // Text written straight into a view is a paragraph of its own, and all of it.
725
+ if (root === null || !isTextElement(root))
726
+ return node.box ? trimEnds(node.text) : node.text;
475
727
  // The commonest paragraph by far: one run and nothing else, so it is both the first and last.
476
- if (root.children.length === 1 && root.parent?.name !== 'text')
477
- return node.text.trim();
478
- while (root.parent?.name === 'text')
728
+ if (root.children.length === 1 && !isTextElement(root.parent))
729
+ return trimEnds(node.text);
730
+ while (isTextElement(root.parent))
479
731
  root = root.parent;
480
732
  const runs = [];
481
733
  const collect = (from) => {
@@ -484,18 +736,39 @@ function paragraphText(node) {
484
736
  if (child.text)
485
737
  runs.push(child);
486
738
  }
487
- else if (child.name === 'text')
739
+ else if (isTextElement(child))
488
740
  collect(child);
489
741
  }
490
742
  };
491
743
  collect(root);
492
744
  let text = node.text;
493
745
  if (runs[0] === node)
494
- text = text.replace(/^\s+/, '');
746
+ text = trimStart(text);
495
747
  if (runs[runs.length - 1] === node)
496
- text = text.replace(/\s+$/, '');
748
+ text = trimEnd(text);
497
749
  return text;
498
750
  }
751
+ /**
752
+ * Whether an element is text: a `<text>`, or one of HTML's text elements that holds only text and
753
+ * other such elements. Worked out each time, from the tree as it is, so nothing can go stale.
754
+ */
755
+ function isTextElement(node) {
756
+ if (node === null || node.kind !== 'element')
757
+ return false;
758
+ if (node.name === 'text')
759
+ return true;
760
+ // One lookup settles nearly every other element, which is a view.
761
+ if (registeredViewName(node.name) !== PARAGRAPH)
762
+ return false;
763
+ return !TEXT_ELEMENTS.has(node.name) || holdsOnlyText(node);
764
+ }
765
+ function holdsOnlyText(node) {
766
+ for (const child of node.children ?? []) {
767
+ if (child.kind === 'element' && !isTextElement(child))
768
+ return false;
769
+ }
770
+ return true;
771
+ }
499
772
  /**
500
773
  * The native view a node commits as: `Paragraph` for a `<text>`, `VirtualText` for one nested in
501
774
  * another, `RawText` for a run of text, and `View` for anything unregistered, which is what an
@@ -511,9 +784,169 @@ export function viewNameOf(node) {
511
784
  // RN models nested text as a span inside a paragraph: `Paragraph > [RawText, Text > RawText]`.
512
785
  // Committing a nested `<text>` as another Paragraph gives a paragraph inside a paragraph,
513
786
  // which lays out as a separate block instead of flowing inline.
514
- if (node.name === 'text' && node.parent?.name === 'text')
515
- return VIRTUAL_TEXT;
516
- return registeredViewName(node.name) ?? DEFAULT_VIEW;
787
+ // A component of the app's own under a name a view set registered: its host is a plain view.
788
+ if (node.componentHost && !node.nativeView && YIELDING.has(node.name))
789
+ return DEFAULT_VIEW;
790
+ const registered = registeredViewName(node.name);
791
+ // Nearly every element: a view of some kind, by its name alone.
792
+ if (registered !== PARAGRAPH)
793
+ return registered ?? DEFAULT_VIEW;
794
+ // One of HTML's text elements that holds something other than text: a view.
795
+ if (node.name !== 'text' && TEXT_ELEMENTS.has(node.name) && !holdsOnlyText(node)) {
796
+ return DEFAULT_VIEW;
797
+ }
798
+ return isTextElement(node.parent) ? VIRTUAL_TEXT : PARAGRAPH;
799
+ }
800
+ /**
801
+ * The props every native view reads as a boolean: the `bool` fields of React Native's
802
+ * `BaseViewProps`, `AccessibilityProps` and `HostPlatformViewProps`, by the names JavaScript sends.
803
+ *
804
+ * An attribute arrives as text. A component that declares the prop as an input turns it into a
805
+ * boolean itself, but an element nothing claims, such as a component's own host, holds what it
806
+ * was given, and Android refuses a string where it reads a boolean: `focusable="false"` is
807
+ * `java.lang.String cannot be cast to java.lang.Boolean` at the first commit. iOS ignores it.
808
+ *
809
+ * So the text is made a boolean as it is committed, `"false"` false and anything else true, as an
810
+ * attribute reads in HTML. The node keeps the text, which is what `[focusable="false"]` in a
811
+ * stylesheet matches against.
812
+ */
813
+ const BOOLEAN_VIEW_PROPS = new Set([
814
+ 'accessibilityElementsHidden',
815
+ 'accessibilityIgnoresInvertColors',
816
+ 'accessibilityRespondsToUserInteraction',
817
+ 'accessibilityShowsLargeContentViewer',
818
+ 'accessibilityViewIsModal',
819
+ 'accessible',
820
+ 'collapsable',
821
+ 'collapsableChildren',
822
+ 'focusable',
823
+ 'hasTVPreferredFocus',
824
+ 'needsOffscreenAlphaCompositing',
825
+ // The flags native reads to know an event has a listener, which are booleans as well.
826
+ 'onAccessibilityAction',
827
+ 'onAccessibilityEscape',
828
+ 'onAccessibilityMagicTap',
829
+ 'onAccessibilityTap',
830
+ 'onLayout',
831
+ 'removeClippedSubviews',
832
+ 'renderToHardwareTextureAndroid',
833
+ 'screenReaderFocusable',
834
+ 'shouldRasterizeIOS',
835
+ ]);
836
+ /**
837
+ * Whether `node` holds nothing `:empty` sees besides `moved`: so it was empty before `moved` came,
838
+ * or is now that it has gone. An anchor is a comment on the web, which `:empty` does not see.
839
+ */
840
+ function wasOrIsEmptyWithout(node, moved) {
841
+ if (moved.kind === 'anchor')
842
+ return false;
843
+ for (const child of node.children) {
844
+ if (child !== moved && child.kind !== 'anchor')
845
+ return false;
846
+ }
847
+ return true;
848
+ }
849
+ /**
850
+ * A node's own props onto what it commits with. No native prop has a hyphen: `data-*` and `aria-*`
851
+ * attributes stay on the node for selectors to match, and an `aria-*` one is mapped to the prop
852
+ * native reads.
853
+ */
854
+ function writeOwnProps(own, props) {
855
+ let aria = false;
856
+ for (const key of Object.keys(own)) {
857
+ if (key.includes('-'))
858
+ aria ||= key.startsWith('aria-');
859
+ else if (key !== 'style' && key !== INTRINSIC_SIZE && key !== STYLE_OVERRIDE) {
860
+ props[key] = committedProp(key, own[key]);
861
+ }
862
+ }
863
+ if (aria)
864
+ applyAria(own, props);
865
+ }
866
+ /** A prop as it is committed: a boolean view prop held as text is the boolean the text says. */
867
+ function committedProp(key, value) {
868
+ return typeof value === 'string' && BOOLEAN_VIEW_PROPS.has(key) ? value !== 'false' : value;
869
+ }
870
+ /**
871
+ * The `pointer-events` an element ends up with, in the order its props are merged: its inline
872
+ * style's, then its `pointerEvents` prop, then what the sheets resolve. The inline one is read for
873
+ * itself because an element no rule matches resolves to what it inherits, with its own inline
874
+ * value only in what it hands down.
875
+ */
876
+ function pointerEventsOf(node, resolved) {
877
+ const inline = node.inlineInherits ? inlineInherited(node.props['style']) : null;
878
+ const own = inline?.['pointerEvents'];
879
+ // Merged over the prop, so its `inherit` is what the sheets and the parent settled, not the prop.
880
+ if (own === 'inherit')
881
+ return resolved['pointerEvents'];
882
+ return own ?? node.props['pointerEvents'] ?? resolved['pointerEvents'];
883
+ }
884
+ /**
885
+ * A node's `pointer-events` as native takes it, once its props and styles are merged.
886
+ *
887
+ * In CSS `none` is the element alone, and a descendant's `auto` takes touches again, which native
888
+ * calls `box-none`: what a `none` with something inside to open it is committed as. Native's own
889
+ * `none` is the whole subtree. It is what a `none` with nothing inside to open it stays, because
890
+ * Android's text and images take no `pointerEvents` of their own and only an ancestor's `none`
891
+ * keeps touches off them. It is also what the `pointerEvents` prop means, so a `none` that is the
892
+ * prop's is left as it is.
893
+ *
894
+ * An inline `inherit` is what the element has without it, which is `resolved`: what the sheets
895
+ * and its parent settled.
896
+ */
897
+ function nativePointerEvents(node, style, resolved) {
898
+ let value = style['pointerEvents'];
899
+ if (value === undefined)
900
+ return;
901
+ if (value === 'inherit') {
902
+ value = resolved;
903
+ if (value === undefined)
904
+ delete style['pointerEvents'];
905
+ else
906
+ style['pointerEvents'] = value;
907
+ }
908
+ if (value === 'none' && node.touchWithin && node.props['pointerEvents'] !== 'none') {
909
+ style['pointerEvents'] = 'box-none';
910
+ }
911
+ }
912
+ /** Whether `node`, whose `pointer-events` is `value`, takes touches or holds something that does. */
913
+ function takesTouches(node, value, prop) {
914
+ if (value === undefined)
915
+ return false;
916
+ // The prop's `none` is native's: the whole subtree, whatever is inside it.
917
+ if (value === 'none' && prop === 'none')
918
+ return false;
919
+ // CSS's `none` and native's `box-none` take no touches themselves: their children answer.
920
+ if (value === 'none' || value === 'box-none') {
921
+ return node.children.some((child) => child.touchWithin);
922
+ }
923
+ return true;
924
+ }
925
+ /**
926
+ * Records on `node` whether it, or something inside it, takes touches where it would inherit
927
+ * `none`. When that changes a `none` element's own answer, its props are marked to be committed
928
+ * again.
929
+ *
930
+ * Called as a node is reconciled, after its children, so theirs are already current: a child that
931
+ * was skipped as clean has nothing beneath it that changed, and a change beneath marks every
932
+ * ancestor to come back through here.
933
+ */
934
+ function noteTouches(node, style) {
935
+ if (!style)
936
+ return;
937
+ // Nearly every node: no `pointer-events` in any sheet, style or prop, and nothing recorded.
938
+ const resolved = style.style['pointerEvents'];
939
+ const prop = node.props['pointerEvents'];
940
+ if (resolved === undefined && prop === undefined && !node.inlineInherits && !node.touchWithin) {
941
+ return;
942
+ }
943
+ const value = pointerEventsOf(node, style.style);
944
+ const within = takesTouches(node, value, prop);
945
+ if (within === !!node.touchWithin)
946
+ return;
947
+ node.touchWithin = within;
948
+ if (value === 'none')
949
+ node.propsDirty = true;
517
950
  }
518
951
  /** Whether `node` is `ancestor` or somewhere under it. */
519
952
  function isWithin(node, ancestor) {
@@ -616,6 +1049,10 @@ class RetainedNode {
616
1049
  propsDirty = false;
617
1050
  structureDirty = false;
618
1051
  subtreeDirty = false;
1052
+ touchWithin = undefined;
1053
+ box = undefined;
1054
+ anonymous = undefined;
1055
+ styled = undefined;
619
1056
  transitions;
620
1057
  playing;
621
1058
  scrolled;
@@ -749,8 +1186,9 @@ const IS_COLOR_PROP = /color(android)?$/i;
749
1186
  const colorProps = new Map();
750
1187
  function isColorProp(key) {
751
1188
  let answer = colorProps.get(key);
752
- if (answer === undefined)
753
- colorProps.set(key, (answer = IS_COLOR_PROP.test(key)));
1189
+ if (answer === undefined) {
1190
+ colorProps.set(key, (answer = IS_COLOR_PROP.test(key) || BRUSH_PROPS.has(key)));
1191
+ }
754
1192
  return answer;
755
1193
  }
756
1194
  const ASSET_PROPS = new Set(['source', 'defaultSource', 'loadingIndicatorSource']);
@@ -763,7 +1201,30 @@ const ASSET_PROPS = new Set(['source', 'defaultSource', 'loadingIndicatorSource'
763
1201
  * approximated, it is dropped.
764
1202
  */
765
1203
  const NESTED_COLOR_LIST_PROPS = new Set(['boxShadow']);
1204
+ /** The props of an SVG shape that take a brush, not a colour. */
1205
+ const BRUSH_PROPS = new Set(['fill', 'stroke']);
766
1206
  /** Styles arrive as objects, arrays, nested arrays and nulls. Reduce to one object. */
1207
+ /**
1208
+ * A bound transform is still the CSS string: see `inline-transform.ts`. One CSS cannot read
1209
+ * leaves the transform the rules set, as a browser drops it.
1210
+ */
1211
+ function boundTransform(node, style, cascaded) {
1212
+ const text = style['transform'];
1213
+ if (typeof text !== 'string')
1214
+ return style;
1215
+ // The same list for the same string, commit after commit: a transition compares the two ends
1216
+ // it is given, and a list made afresh each time reads as a new place to go.
1217
+ if (node.boundTransform?.text !== text)
1218
+ node.boundTransform = { text, list: transformList(text) };
1219
+ const list = node.boundTransform.list ?? cascaded;
1220
+ if (list === undefined)
1221
+ delete style['transform'];
1222
+ else
1223
+ style['transform'] = list;
1224
+ return style;
1225
+ }
1226
+ /** A transform string as the list native reads, and none for one CSS cannot read. */
1227
+ const transformOf = (value) => transformList(value) ?? [];
767
1228
  function flattenStyle(value, into) {
768
1229
  if (!value)
769
1230
  return into;
@@ -839,7 +1300,7 @@ function composeTransform(node, props) {
839
1300
  }
840
1301
  else {
841
1302
  // A bound style's transform is still the CSS string: see `inline-transform.ts`.
842
- const own = typeof transform === 'string' ? transformList(transform) : transform;
1303
+ const own = typeof transform === 'string' ? transformOf(transform) : transform;
843
1304
  value = [translate, rotate, scale, own].flatMap((part) => (Array.isArray(part) ? part : []));
844
1305
  node.composedTransform = { parts, value };
845
1306
  }
@@ -949,15 +1410,19 @@ export class Engine {
949
1410
  * for them. Only those in the tree: see `releaseDetached`.
950
1411
  */
951
1412
  hoisted = new Set();
952
- /** Whether a child list has lost a member since the last commit. See `releaseDetached`. */
953
- removedSinceCommit = false;
1413
+ /** The nodes taken out of a child list since the last commit. See `releaseDetached`. */
1414
+ removedSinceCommit = new Set();
954
1415
  /**
955
- * `@keyframes` by name, across every sheet seen so far. One registry rather than one per sheet,
1416
+ * `@keyframes` by name, across every sheet registered. One registry rather than one per sheet,
956
1417
  * because that is the scope CSS gives them: a name defined in a global stylesheet is usable
957
1418
  * from a component's. The cost is that two components defining the same name collide, last one
958
1419
  * in wins, exactly as two stylesheets in a document would.
959
1420
  */
960
1421
  keyframes = new Map();
1422
+ /** Every sheet registered, in the order a document would hold them. See `sheetReplaced`. */
1423
+ sheetOrder = [];
1424
+ /** The `@keyframes` each playing animation was started from. See `startPlaying`. */
1425
+ playedFrames = new WeakMap();
961
1426
  /** Element names already reported as unclaimed, so a list of a thousand rows reports once. */
962
1427
  reported = new Set();
963
1428
  processColor;
@@ -973,12 +1438,15 @@ export class Engine {
973
1438
  const conditions = options.conditions ?? { width: 0, height: 0, colorScheme: 'light' };
974
1439
  this.styles = new StyleResolver(options.globalStyles ?? null, conditions);
975
1440
  this.viewportSize = { width: conditions.width, height: conditions.height };
1441
+ this.fontScale = conditions.fontScale;
976
1442
  if (options.tokens)
977
1443
  this.styles.setRootTokens(options.tokens);
978
1444
  this.structuralSheets = options.globalStyles?.structural === true;
979
1445
  this.dev = options.dev ?? globalThis.__DEV__ === true;
980
- if (this.dev)
981
- this.styles.onUndefinedToken = (name, props) => this.reportUndefinedToken(name, props);
1446
+ if (this.dev) {
1447
+ this.styles.onUndefinedToken = (name, props, on) => this.reportUndefinedToken(name, props, on);
1448
+ this.styles.onUnreadDisplay = (name, value) => this.reportUnreadDisplay(name, value);
1449
+ }
982
1450
  this.now = options.now ?? (() => globalThis.performance?.now?.() ?? Date.now());
983
1451
  this.onDirty = options.onDirty;
984
1452
  this.onError = options.onError;
@@ -1050,6 +1518,35 @@ export class Engine {
1050
1518
  * a caller owns the properties it names and nothing else. Each owner writes all of its own at
1051
1519
  * once - four insets together, never one - so nothing is left over from a previous rotation.
1052
1520
  */
1521
+ /**
1522
+ * Match `sheet` against every node from now on, after the global sheet the engine was made
1523
+ * with, and restyle everything; false, and nothing done, when it already is. How a platform
1524
+ * applies a component whose CSS is not scoped, such as Angular's `ViewEncapsulation.None`.
1525
+ * `replacing` is a sheet added before that this one takes the place of, after a hot swap.
1526
+ */
1527
+ addGlobalSheet(sheet, replacing) {
1528
+ if (!this.styles.addGlobalSheet(sheet, replacing))
1529
+ return false;
1530
+ if (replacing)
1531
+ this.sheetReplaced(replacing, sheet);
1532
+ else
1533
+ this.registerSheet(sheet);
1534
+ if (sheet.structural)
1535
+ this.structuralSheets = true;
1536
+ this.markPath(this.root);
1537
+ return true;
1538
+ }
1539
+ /**
1540
+ * Stop matching a sheet `addGlobalSheet` added, and restyle everything; false, and nothing
1541
+ * done, when it is not one. A hot swap that leaves a None component no rules, or makes it scoped.
1542
+ */
1543
+ removeGlobalSheet(sheet) {
1544
+ if (!this.styles.removeGlobalSheet(sheet))
1545
+ return false;
1546
+ this.sheetReplaced(sheet, null);
1547
+ this.markPath(this.root);
1548
+ return true;
1549
+ }
1053
1550
  updateTokens(next) {
1054
1551
  this.styles.setRootTokens({ ...this.styles.rootTokens, ...next });
1055
1552
  this.root.subtreeDirty = true;
@@ -1102,6 +1599,25 @@ export class Engine {
1102
1599
  * text attributes change, which the same cap does. Android sets it only when `fontFamily` is in
1103
1600
  * the props it is sent, and the engine sends only what changed, so it is sent again once.
1104
1601
  */
1602
+ /**
1603
+ * Loads have settled for these families: every node held back for one, a span inside a
1604
+ * paragraph included, is laid out with the name again, registered or not. See `loadingFamilies`.
1605
+ */
1606
+ fontsSettled(families) {
1607
+ let held = false;
1608
+ const visit = (node) => {
1609
+ for (const child of node.children) {
1610
+ if (child.heldFamily !== undefined && families.has(child.heldFamily)) {
1611
+ this.markProps(child, false);
1612
+ held = true;
1613
+ }
1614
+ visit(child);
1615
+ }
1616
+ };
1617
+ visit(this.root);
1618
+ if (held)
1619
+ this.commit();
1620
+ }
1105
1621
  fontsRegistered(families) {
1106
1622
  const visit = (node) => {
1107
1623
  for (const child of node.children) {
@@ -1189,23 +1705,57 @@ export class Engine {
1189
1705
  this.markProps(node, false);
1190
1706
  this.commit();
1191
1707
  }
1708
+ /**
1709
+ * A node's component rules are its creating sheet's, so this matches it against `like`'s, or
1710
+ * against the one it was created with once `like` is null.
1711
+ */
1712
+ adoptScope(node, like) {
1713
+ if (like && !this.ownSheets.has(node))
1714
+ this.ownSheets.set(node, node.sheet);
1715
+ const sheet = like ? like.sheet : this.ownSheets.get(node);
1716
+ if (sheet === undefined || node.sheet === sheet)
1717
+ return;
1718
+ node.sheet = sheet;
1719
+ if (sheet?.structural)
1720
+ this.structuralSheets = true;
1721
+ this.markProps(node);
1722
+ }
1723
+ /** The sheet each node given another's scope was created with. */
1724
+ ownSheets = new WeakMap();
1192
1725
  /** The window's size, from the conditions media queries use. Zero until the platform says. */
1193
1726
  get viewport() {
1194
1727
  return this.viewportSize;
1195
1728
  }
1196
1729
  viewportSize;
1730
+ /** The system text size, from the conditions. See `Conditions.fontScale`. */
1731
+ fontScale;
1732
+ viewportWatchers = new Set();
1733
+ /** Calls `listener` after each change of conditions, which is when `viewport` can change. */
1734
+ watchViewport(listener) {
1735
+ this.viewportWatchers.add(listener);
1736
+ return () => this.viewportWatchers.delete(listener);
1737
+ }
1197
1738
  updateConditions(next) {
1198
1739
  this.viewportSize = { width: next.width, height: next.height };
1740
+ this.fontScale = next.fontScale;
1199
1741
  this.styles.setConditions(next);
1200
1742
  this.root.subtreeDirty = true;
1201
1743
  // Committed here rather than left to the next change-detection pass, because there may not
1202
1744
  // be one: no view is dirty, so a tick does no work and never reaches the renderer at all.
1203
1745
  this.commit();
1746
+ for (const watcher of this.viewportWatchers)
1747
+ watcher();
1204
1748
  }
1205
1749
  // --- mutation API -----------------------------------------------------
1206
1750
  createElement(name, sheet = null) {
1207
1751
  const node = new RetainedNode('element', name, this);
1208
1752
  node.sheet = sheet;
1753
+ const html = HTML_ELEMENTS.get(name);
1754
+ if (html !== undefined) {
1755
+ if (html.role)
1756
+ node.props['accessibilityRole'] = html.role;
1757
+ node.styled = html.styled;
1758
+ }
1209
1759
  if (sheet?.structural)
1210
1760
  this.structuralSheets = true;
1211
1761
  if (HOISTS[name])
@@ -1220,6 +1770,26 @@ export class Engine {
1220
1770
  node.classes?.delete(name);
1221
1771
  this.markProps(node);
1222
1772
  }
1773
+ /**
1774
+ * The sheet of the component `node` hosts, whose `:host` rules it matches. Set again when a hot
1775
+ * swap replaces the component's sheet, which recreates the views inside but not the host, so the
1776
+ * host is styled afresh here.
1777
+ */
1778
+ setHostSheet(node, sheet) {
1779
+ if (node.hostSheet === sheet)
1780
+ return;
1781
+ node.hostSheet = sheet;
1782
+ this.markProps(node);
1783
+ }
1784
+ /**
1785
+ * Style for `node` that any matched rule or inline style overrides, as a browser's own default
1786
+ * for an element is. `mount` gives the root component's host the surface's height this way, as
1787
+ * the web's mount point has, unless the app's `:host` sizes it otherwise.
1788
+ */
1789
+ setDefaultStyle(node, style) {
1790
+ node.defaultStyle = style;
1791
+ this.markProps(node);
1792
+ }
1223
1793
  /** `class="a b"` from a template. Replaces the set rather than adding to it. */
1224
1794
  setClasses(node, value) {
1225
1795
  const names = value.split(/\s+/).filter(Boolean);
@@ -1241,7 +1811,8 @@ export class Engine {
1241
1811
  parent.children.push(child);
1242
1812
  if (child.dormantHoists)
1243
1813
  this.wakeHoists(child);
1244
- this.markStructure(parent);
1814
+ this.markStructure(parent, child);
1815
+ this.markTextContent(parent, child);
1245
1816
  }
1246
1817
  insertBefore(parent, child, ref) {
1247
1818
  this.detach(child);
@@ -1253,7 +1824,8 @@ export class Engine {
1253
1824
  parent.children.splice(at, 0, child);
1254
1825
  if (child.dormantHoists)
1255
1826
  this.wakeHoists(child);
1256
- this.markStructure(parent);
1827
+ this.markStructure(parent, child);
1828
+ this.markTextContent(parent, child);
1257
1829
  }
1258
1830
  removeChild(parent, child) {
1259
1831
  const target = parent ?? child.parent;
@@ -1264,8 +1836,17 @@ export class Engine {
1264
1836
  return;
1265
1837
  target.children.splice(at, 1);
1266
1838
  child.parent = null;
1267
- this.removedSinceCommit = true;
1268
- this.markStructure(target);
1839
+ this.removedSinceCommit.add(child);
1840
+ this.markStructure(target, child);
1841
+ this.markTextContent(target, child);
1842
+ }
1843
+ /**
1844
+ * A child came or went under a view that takes its text as a prop: that prop changed. A span
1845
+ * changes it too, by keeping or dropping the space before it.
1846
+ */
1847
+ markTextContent(parent, child) {
1848
+ if (child.kind !== 'anchor' && takesTextAsProp(parent))
1849
+ this.markProps(parent);
1269
1850
  }
1270
1851
  /** A subtree that went out of the tree is back in: its hoisted nodes commit again. */
1271
1852
  wakeHoists(node) {
@@ -1288,12 +1869,17 @@ export class Engine {
1288
1869
  * whole. So a hoisted node moves to the root of the subtree it left with, which is garbage along
1289
1870
  * with it if the subtree never returns, and is handed back when it does. An animation on a node
1290
1871
  * out of the tree stops rather than running a frame loop for something nobody can see, and
1291
- * starts again if the node returns.
1872
+ * starts again if the node returns. And a node out of the tree when the commit runs forgets its
1873
+ * committed views, as a withheld modal does: the commit unmounts them, Fabric never re-enables an
1874
+ * unmounted view's events (`EventEmitter::setEnabled`), and committed again they would ignore
1875
+ * every touch. It comes back created afresh, as React creates an element it mounts again. One
1876
+ * put back before the commit never left, and keeps its views.
1292
1877
  *
1293
- * Once per commit, and only after a removal: the walks are the depth of a handful of nodes.
1878
+ * Once per commit, and only after a removal: the walks are the depth of a handful of nodes, and
1879
+ * forgetting walks each subtree that left once.
1294
1880
  */
1295
1881
  releaseDetached() {
1296
- this.removedSinceCommit = false;
1882
+ this.forgetDetached();
1297
1883
  for (const node of this.hoisted) {
1298
1884
  const top = this.topOf(node);
1299
1885
  if (top === this.root)
@@ -1319,6 +1905,14 @@ export class Engine {
1319
1905
  if (this.focusedNode && this.topOf(this.focusedNode) !== this.root)
1320
1906
  this.focusedNode = null;
1321
1907
  }
1908
+ /** Forget the committed views of each node removed since the last commit that is still out. */
1909
+ forgetDetached() {
1910
+ for (const node of this.removedSinceCommit) {
1911
+ if (node.committed && this.topOf(node) !== this.root)
1912
+ this.forgetCommitted(node);
1913
+ }
1914
+ this.removedSinceCommit.clear();
1915
+ }
1322
1916
  /** The root of the tree a node is in: the engine's root while it is attached. */
1323
1917
  topOf(node) {
1324
1918
  let top = node;
@@ -1373,7 +1967,17 @@ export class Engine {
1373
1967
  // Inline style is applied after the cascade and inherited by nothing, so it cannot change
1374
1968
  // what any node matches or inherits. Every other prop can: `[disabled]` is a selector. An
1375
1969
  // inline `direction` is the exception, because the paragraphs below it align by it.
1376
- this.markProps(node, key !== 'style' || this.noteInlineDirection(node));
1970
+ this.markProps(node, key !== 'style' || this.inlineReachesStyle(node));
1971
+ }
1972
+ /**
1973
+ * Whether a node's inline style can change what it or anything below it resolves to: it sets,
1974
+ * or until now set, a property its children inherit.
1975
+ */
1976
+ inlineReachesStyle(node) {
1977
+ const inherits = setsInherited(node.props['style']);
1978
+ const inherited = node.inlineInherits === true;
1979
+ node.inlineInherits = inherits;
1980
+ return this.noteInlineDirection(node) || inherits || inherited;
1377
1981
  }
1378
1982
  /** Whether a node's inline style sets `direction`, remembering that one has. */
1379
1983
  noteInlineDirection(node) {
@@ -1395,7 +1999,26 @@ export class Engine {
1395
1999
  * has changed for the props diff to notice.
1396
2000
  */
1397
2001
  styleChanged(node) {
1398
- this.markProps(node, this.noteInlineDirection(node));
2002
+ this.markProps(node, this.inlineReachesStyle(node));
2003
+ }
2004
+ /**
2005
+ * A bound declaration, `[style.color]`, whose value may be a `var()`. One that is, is kept as a
2006
+ * declaration the cascade settles with the node's tokens, as a rule's is, and true is answered:
2007
+ * the text is no value to put in the node's style. Any other value forgets what was kept.
2008
+ */
2009
+ setBoundStyle(node, prop, value) {
2010
+ const text = typeof value === 'string' && /^\s*var\(/i.test(value) ? value : undefined;
2011
+ const bound = text === undefined ? null : boundDeclaration(prop, tokenFromValue(text), node.name);
2012
+ if (!bound && !node.boundByProp?.has(prop))
2013
+ return false;
2014
+ const byProp = (node.boundByProp ??= new Map());
2015
+ if (bound)
2016
+ byProp.set(prop, bound);
2017
+ else
2018
+ byProp.delete(prop);
2019
+ node.boundStyle = byProp.size ? [...byProp.values()] : null;
2020
+ this.markProps(node);
2021
+ return bound !== null;
1399
2022
  }
1400
2023
  /**
1401
2024
  * Set a custom property on a node, `--tint` from `[style.--tint]`, or remove it with `null`.
@@ -1421,6 +2044,8 @@ export class Engine {
1421
2044
  setText(node, value) {
1422
2045
  node.text = value;
1423
2046
  this.markProps(node);
2047
+ if (node.parent)
2048
+ this.markTextContent(node.parent, node);
1424
2049
  }
1425
2050
  setEventListener(node, topLevelType, fn) {
1426
2051
  this.registerEventHandler();
@@ -1460,7 +2085,8 @@ export class Engine {
1460
2085
  siblings[i].styleDirty = true;
1461
2086
  }
1462
2087
  }
1463
- markStructure(node) {
2088
+ /** `node`'s child list changed: `moved` came into it or went out of it. */
2089
+ markStructure(node, moved) {
1464
2090
  node.structureDirty = true;
1465
2091
  // A child list that moved changes what its members match, though nothing about them did:
1466
2092
  // the old last row is no longer the last. Only sheets that ask about position pay for this.
@@ -1468,6 +2094,10 @@ export class Engine {
1468
2094
  node.styleDirty = true;
1469
2095
  for (const child of node.children)
1470
2096
  child.styleDirty = true;
2097
+ // `.box:empty + .spacer`: what a later sibling matches can hang on whether this node is
2098
+ // empty, which only its first child arriving or its last one leaving changes.
2099
+ if (wasOrIsEmptyWithout(node, moved))
2100
+ this.markLaterSiblings(node);
1471
2101
  }
1472
2102
  this.markPath(node.parent);
1473
2103
  }
@@ -1494,7 +2124,7 @@ export class Engine {
1494
2124
  * optimisation.
1495
2125
  */
1496
2126
  commit() {
1497
- if (this.removedSinceCommit)
2127
+ if (this.removedSinceCommit.size)
1498
2128
  this.releaseDetached();
1499
2129
  // The root is never reconciled itself, so it has no `committed` record and `isClean` would
1500
2130
  // always say dirty. Ask the flags directly, or every change-detection pass ends in a
@@ -1511,6 +2141,8 @@ export class Engine {
1511
2141
  this.facesAdded = false;
1512
2142
  const set = this.fabric.createChildSet(this.rootTag);
1513
2143
  for (const child of this.visibleChildren(this.root)) {
2144
+ if (this.withheld(child))
2145
+ continue;
1514
2146
  this.fabric.appendChildToSet(set, this.reconcileUnder(this.root, child));
1515
2147
  }
1516
2148
  this.clearFlags(this.root);
@@ -1529,10 +2161,59 @@ export class Engine {
1529
2161
  }
1530
2162
  this.flushTransitionEvents();
1531
2163
  this.scrollDriver?.afterCommit();
1532
- if (this.facesAdded)
2164
+ // Read first: the commit that settles keyframes starts by clearing it.
2165
+ const facesAdded = this.facesAdded;
2166
+ if (this.awaitingKeyframes.size)
2167
+ this.settleKeyframes();
2168
+ if (facesAdded)
1533
2169
  this.rematchFonts();
1534
2170
  return true;
1535
2171
  }
2172
+ /** The first commit whose new views have not been drawn yet, while `commitUnseen` runs. */
2173
+ unseen = Infinity;
2174
+ /**
2175
+ * A commit for what changed in the same turn as earlier commits, before a frame was drawn.
2176
+ * `since` is how many commits there had been before the first of them (`stats.commits`).
2177
+ *
2178
+ * A view those commits created has not been seen in the style they gave it, so what this one
2179
+ * gives it is where it starts, and no transition runs toward it. That is what a browser
2180
+ * does with a class added to an element in the turn that inserted it, and what `animate.enter`
2181
+ * is built on: Angular adds the enter class after the render pass that created the element, and
2182
+ * takes it off a frame later, which is the one change the transition is for.
2183
+ */
2184
+ commitUnseen(since) {
2185
+ this.unseen = since;
2186
+ try {
2187
+ return this.commit();
2188
+ }
2189
+ finally {
2190
+ this.unseen = Infinity;
2191
+ }
2192
+ }
2193
+ /** Nodes that asked for `@keyframes` this commit had not met, by the name they asked for. */
2194
+ awaitingKeyframes = new Map();
2195
+ /**
2196
+ * A sheet is registered when the first node it styles is committed, so a node committed before
2197
+ * it can name keyframes that arrive later in the same commit. Those nodes play them from another
2198
+ * commit straight away, as a browser applies keyframes however the sheets arrived; a name still
2199
+ * unknown once the commit is over is reported.
2200
+ */
2201
+ settleKeyframes() {
2202
+ const waiting = [...this.awaitingKeyframes];
2203
+ this.awaitingKeyframes.clear();
2204
+ let found = false;
2205
+ for (const [node, name] of waiting) {
2206
+ if (this.keyframes.has(name)) {
2207
+ this.markProps(node, false);
2208
+ found = true;
2209
+ }
2210
+ else if (this.dev) {
2211
+ this.reportMissingKeyframes(name);
2212
+ }
2213
+ }
2214
+ if (found)
2215
+ this.commit();
2216
+ }
1536
2217
  scrollDriver;
1537
2218
  /** Whether native can move a view by a scroll with no JavaScript in between. */
1538
2219
  get drivesScroll() {
@@ -1599,12 +2280,14 @@ export class Engine {
1599
2280
  }
1600
2281
  /** Anchors take part in sibling ordering but never reach Fabric. */
1601
2282
  visibleChildren(node) {
1602
- return node.children.filter((child) => child.kind !== 'anchor');
2283
+ // A view that takes its text as a prop has no text children to commit. See `ViewNameOptions`.
2284
+ const textAsProp = takesTextAsProp(node);
2285
+ return node.children.filter((child) => child.kind !== 'anchor' && !(textAsProp && child.kind === 'text'));
1603
2286
  }
1604
2287
  /**
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.
2288
+ * Everything a node renders with, unprocessed. Precedence, weakest first: native defaults, the
2289
+ * node's `defaultStyle`, matched CSS, explicit props, inline style, a component's
2290
+ * `styleOverride`. Inline wins over CSS for the same reason it does on the web.
1608
2291
  *
1609
2292
  * Raw, because this is what the next commit diffs against. Colours and asset ids are converted
1610
2293
  * on the way out instead (`processed`), and only for the keys that changed: the converters
@@ -1618,29 +2301,28 @@ export class Engine {
1618
2301
  this.checkProps(node);
1619
2302
  this.registerSheet(node.sheet);
1620
2303
  this.registerSheet(node.hostSheet);
1621
- const props = { ...DEFAULT_PROPS[viewName] };
2304
+ const props = { ...DEFAULT_PROPS[viewName], ...node.defaultStyle };
1622
2305
  Object.assign(props, this.styles.resolve(node, this.styleEpoch).style);
1623
- for (const key of Object.keys(node.props)) {
1624
- // No native prop has a hyphen. `data-*` and `aria-*` attributes stay on the node for
1625
- // selectors to match, and the components package maps `aria-*` to what native reads.
1626
- if (key !== 'style' &&
1627
- key !== INTRINSIC_SIZE &&
1628
- key !== STYLE_OVERRIDE &&
1629
- !key.includes('-')) {
1630
- props[key] = node.props[key];
1631
- }
1632
- }
1633
- const style = flattenStyle(node.props['style'], props);
2306
+ const resolved = props['pointerEvents'];
2307
+ writeOwnProps(node.props, props);
2308
+ withTextContent(node, viewName, props);
2309
+ const cascaded = props['transform'];
2310
+ const style = boundTransform(node, flattenStyle(node.props['style'], props), cascaded);
2311
+ nativePointerEvents(node, style, resolved);
1634
2312
  const intrinsic = node.props[INTRINSIC_SIZE];
1635
2313
  if (intrinsic)
1636
2314
  applyIntrinsicSize(style, intrinsic);
1637
2315
  flattenStyle(node.props[STYLE_OVERRIDE], style);
1638
2316
  this.fontFaces.apply(style);
2317
+ holdLoadingFamily(node, style);
1639
2318
  if (viewName === PARAGRAPH)
1640
2319
  alignText(style, this.directionOf(node, style));
1641
2320
  if (this.fontsRefreshed)
1642
2321
  this.capForFonts(node, style);
1643
- return composeTransform(node, this.animated(node, this.transitioned(node, style)));
2322
+ alignMultiline(viewName, style);
2323
+ const merged = composeTransform(node, this.animated(node, this.transitioned(node, style)));
2324
+ centreSingleLine(viewName, merged, this.fontScale);
2325
+ return merged;
1644
2326
  }
1645
2327
  /**
1646
2328
  * The text size cap that lays a paragraph or text input out again once a face it names has
@@ -1698,12 +2380,70 @@ export class Engine {
1698
2380
  if (!sheet || this.knownSheets.has(sheet))
1699
2381
  return;
1700
2382
  this.knownSheets.add(sheet);
2383
+ this.sheetOrder.push(sheet);
1701
2384
  for (const name of Object.keys(sheet.keyframes ?? {})) {
1702
2385
  this.keyframes.set(name, sheet.keyframes[name]);
1703
2386
  }
1704
2387
  if (sheet.fonts && this.fontFaces.add(sheet.fonts))
1705
2388
  this.facesAdded = true;
1706
2389
  }
2390
+ /**
2391
+ * A hot swap edited a component's sheet into `next`, or took it away with null: `sheet`'s
2392
+ * `@keyframes` go, and `next`'s take its place among the others, as an edited stylesheet keeps
2393
+ * its place in a document. A name another sheet also defines falls back to that one. Every
2394
+ * animation re-resolves the name it plays, so one whose keyframes went stops, as in a browser.
2395
+ * A `sheet` already gone, such as a global sheet removed, leaves `next` registered last.
2396
+ */
2397
+ sheetReplaced(sheet, next) {
2398
+ if (sheet === next && this.knownSheets.has(sheet))
2399
+ return;
2400
+ const at = this.sheetOrder.indexOf(sheet);
2401
+ if (at === -1) {
2402
+ this.registerSheet(next);
2403
+ return;
2404
+ }
2405
+ this.knownSheets.delete(sheet);
2406
+ if (next && !this.knownSheets.has(next)) {
2407
+ this.knownSheets.add(next);
2408
+ this.sheetOrder[at] = next;
2409
+ }
2410
+ else {
2411
+ this.sheetOrder.splice(at, 1);
2412
+ }
2413
+ if (sheet.keyframes || next?.keyframes)
2414
+ this.reindexKeyframes();
2415
+ if (sheet.fonts || next?.fonts)
2416
+ this.reindexFonts();
2417
+ }
2418
+ /**
2419
+ * Every sheet's `@font-face` rules again, in order, and every text that names a family matched
2420
+ * again: a face whose rule went is no longer matched, though the platform keeps it registered.
2421
+ */
2422
+ reindexFonts() {
2423
+ this.fontFaces.clear();
2424
+ for (const sheet of this.sheetOrder)
2425
+ if (sheet.fonts)
2426
+ this.fontFaces.add(sheet.fonts);
2427
+ this.markFontText(this.root);
2428
+ }
2429
+ /** Every sheet's `@keyframes` again, in order, and every animation told to look them up. */
2430
+ reindexKeyframes() {
2431
+ this.keyframes.clear();
2432
+ for (const sheet of this.sheetOrder) {
2433
+ for (const name of Object.keys(sheet.keyframes ?? {})) {
2434
+ this.keyframes.set(name, sheet.keyframes[name]);
2435
+ }
2436
+ }
2437
+ this.markAnimated(this.root);
2438
+ }
2439
+ /** Mark every node under `node` with an animation, so it looks its keyframes up again. */
2440
+ markAnimated(node) {
2441
+ for (const child of node.children) {
2442
+ if (child.playing || child.scrolled)
2443
+ this.markProps(child, false);
2444
+ this.markAnimated(child);
2445
+ }
2446
+ }
1707
2447
  /** Every `@font-face` seen, global as on the web: a face declared in one sheet serves all. */
1708
2448
  fontFaces = new FontFaces();
1709
2449
  /** Whether a sheet first used in this commit declared a face. See `rematchFonts`. */
@@ -1714,18 +2454,19 @@ export class Engine {
1714
2454
  * Once per sheet that declares faces, so walking the tree costs nothing that matters.
1715
2455
  */
1716
2456
  rematchFonts() {
1717
- const visit = (node) => {
1718
- for (const child of node.children) {
1719
- if (child.kind !== 'element')
1720
- continue;
1721
- if (typeof child.committed?.props['fontFamily'] === 'string')
1722
- this.markProps(child, false);
1723
- visit(child);
1724
- }
1725
- };
1726
- visit(this.root);
2457
+ this.markFontText(this.root);
1727
2458
  this.commit();
1728
2459
  }
2460
+ /** Mark every element under `node` committed with a family, so it is matched again. */
2461
+ markFontText(node) {
2462
+ for (const child of node.children) {
2463
+ if (child.kind !== 'element')
2464
+ continue;
2465
+ if (typeof child.committed?.props['fontFamily'] === 'string')
2466
+ this.markProps(child, false);
2467
+ this.markFontText(child);
2468
+ }
2469
+ }
1729
2470
  knownSheets = new WeakSet();
1730
2471
  /**
1731
2472
  * Start, advance or stop the `@keyframes` animation a node's style asks for.
@@ -1740,6 +2481,7 @@ export class Engine {
1740
2481
  this.stopScrolled(node);
1741
2482
  if (!spec) {
1742
2483
  if (node.playing) {
2484
+ this.cancelPlaying(node);
1743
2485
  node.playing = undefined;
1744
2486
  this.playing.delete(node);
1745
2487
  }
@@ -1747,8 +2489,13 @@ export class Engine {
1747
2489
  }
1748
2490
  const frames = this.keyframes.get(spec.name);
1749
2491
  if (!frames) {
1750
- if (this.dev)
1751
- this.reportMissingKeyframes(spec.name);
2492
+ // Keyframes a hot swap deleted, from under an animation that was playing them, or ones a
2493
+ // sheet later in this commit has: see `settleKeyframes`.
2494
+ this.cancelPlaying(node);
2495
+ node.playing = undefined;
2496
+ this.playing.delete(node);
2497
+ this.stopScrolled(node);
2498
+ this.awaitingKeyframes.set(node, spec.name);
1752
2499
  return props;
1753
2500
  }
1754
2501
  if (spec.timeline)
@@ -1756,13 +2503,38 @@ export class Engine {
1756
2503
  this.startPlaying(node, spec, frames, props);
1757
2504
  return Object.assign(props, node.playing?.values ?? {});
1758
2505
  }
2506
+ /**
2507
+ * A clock animation the scroll plays instead: the same animation where only the timeline moved,
2508
+ * and one cancelled where the name changed.
2509
+ */
2510
+ handOverToScroll(node, spec) {
2511
+ if (!node.playing)
2512
+ return;
2513
+ if (node.playing.spec.name !== spec.name)
2514
+ this.cancelPlaying(node);
2515
+ node.playing = undefined;
2516
+ this.playing.delete(node);
2517
+ }
2518
+ /**
2519
+ * `animationcancel` for an animation stopped before it ended: the element no longer asks for it,
2520
+ * asks for another by name, or its keyframes went, as a browser fires it.
2521
+ */
2522
+ cancelPlaying(node) {
2523
+ const running = node.playing;
2524
+ if (running && !running.done)
2525
+ this.emitTransition(node, 'topAnimationcancel', running.spec.name);
2526
+ }
1759
2527
  /** Start the clock on an animation, unless the node is already playing this one. */
1760
2528
  startPlaying(node, spec, frames, props) {
1761
2529
  const current = node.playing;
2530
+ const inherited = this.inheritedColour(node, frames);
1762
2531
  if (!current || !sameAnimation(current.spec, spec)) {
2532
+ if (current && current.spec.name !== spec.name)
2533
+ this.cancelPlaying(node);
1763
2534
  const started = {
1764
2535
  spec,
1765
- tracks: tracksOf(frames, props),
2536
+ tracks: tracksOf(frames, props, inherited),
2537
+ inherited,
1766
2538
  start: this.now(),
1767
2539
  values: {},
1768
2540
  done: false,
@@ -1775,6 +2547,7 @@ export class Engine {
1775
2547
  // resting style: `animation-fill-mode: backwards` behaviour, always. It is what an entrance
1776
2548
  // wants, and the alternative is the flash again.
1777
2549
  started.values = sample(started, this.now()).values;
2550
+ this.playedFrames.set(started, frames);
1778
2551
  node.playing = started;
1779
2552
  if (spec.paused)
1780
2553
  started.pausedAt = this.now();
@@ -1783,7 +2556,34 @@ export class Engine {
1783
2556
  this.emitTransition(node, 'topAnimationstart', spec.name);
1784
2557
  return;
1785
2558
  }
1786
- this.playOrPause(node, current, spec);
2559
+ if (this.playedFrames.get(current) !== frames || inherited !== current.inherited) {
2560
+ this.reframe(node, current, frames, props, inherited);
2561
+ }
2562
+ this.playOrPause(node, node.playing, spec);
2563
+ }
2564
+ /**
2565
+ * The colour `node` inherits, for frames that set `color: currentColor`, which is that colour
2566
+ * wherever the animation has got to, as in CSS. Undefined for frames that do not.
2567
+ */
2568
+ inheritedColour(node, frames) {
2569
+ if (!readsInheritedColour(frames))
2570
+ return undefined;
2571
+ const parent = node.parent && this.styles.resolve(node.parent, this.styleEpoch);
2572
+ return parent?.inherited['color'] ?? 'black';
2573
+ }
2574
+ /**
2575
+ * The name an animation plays now has other frames: a hot swap edited them, or took away the
2576
+ * sheet whose copy won. Or the colour its `color: currentColor` stands for has changed. It
2577
+ * carries on along the new tracks, on its own clock, as a browser does, paused or not.
2578
+ */
2579
+ reframe(node, current, frames, props, inherited) {
2580
+ const tracks = tracksOf(frames, props, inherited);
2581
+ const reframed = { ...current, tracks, inherited };
2582
+ const { values, finished } = sample(reframed, current.pausedAt ?? this.now());
2583
+ const holds = current.spec.fill === 'forwards' || current.spec.fill === 'both';
2584
+ reframed.values = finished && !holds ? {} : values;
2585
+ this.playedFrames.set(reframed, frames);
2586
+ node.playing = reframed;
1787
2587
  }
1788
2588
  /**
1789
2589
  * `animation-play-state` changed on an animation already playing: pausing holds the frame it
@@ -1817,15 +2617,13 @@ export class Engine {
1817
2617
  */
1818
2618
  scrollAnimated(node, spec, frames, props) {
1819
2619
  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);
1824
- if (node.playing) {
1825
- node.playing = undefined;
1826
- this.playing.delete(node);
2620
+ const inherited = this.inheritedColour(node, frames);
2621
+ if (current?.frames === frames && sameAnimation(current.spec, spec)) {
2622
+ return Object.assign(props, this.rescrolled(node, current, frames, props, inherited));
1827
2623
  }
1828
- const tracks = tracksOf(frames, props);
2624
+ this.stopScrolled(node);
2625
+ this.handOverToScroll(node, spec);
2626
+ const tracks = tracksOf(frames, props, inherited);
1829
2627
  const source = this.scrollSourceOf(node);
1830
2628
  const extent = source ? (this.scrollExtents.get(source)?.[spec.timeline] ?? null) : null;
1831
2629
  const range = rangeOf(spec, extent);
@@ -1838,9 +2636,31 @@ export class Engine {
1838
2636
  // view for the animation to move. Committed with the first frame, so it stays put.
1839
2637
  if (drive)
1840
2638
  first['collapsable'] = false;
1841
- node.scrolled = { spec, tracks, resting, first, source, drive };
2639
+ node.scrolled = { spec, frames, tracks, resting, first, source, drive, inherited };
1842
2640
  return Object.assign(props, first);
1843
2641
  }
2642
+ /**
2643
+ * The first frame of a scroll-driven animation already playing. When the colour its
2644
+ * `color: currentColor` stands for has changed, that frame's colour is taken from tracks built
2645
+ * again. Native drives opacity and transforms only, so a colour holds its first frame, and
2646
+ * nothing native animates is touched.
2647
+ */
2648
+ rescrolled(node, current, frames, props, inherited) {
2649
+ if (inherited === current.inherited)
2650
+ return current.first;
2651
+ // The element's own colour, which a track with no first frame starts at, may be the one it
2652
+ // inherits, so it is read again too.
2653
+ const resting = { ...current.resting, color: props['color'] };
2654
+ const tracks = tracksOf(frames, resting, inherited);
2655
+ const extent = current.source ? this.scrollExtents.get(current.source) : undefined;
2656
+ const range = rangeOf(current.spec, extent?.[current.spec.timeline] ?? null);
2657
+ const first = { ...current.first };
2658
+ const colour = firstFrame(tracks, current.spec, range)['color'];
2659
+ if ('color' in first)
2660
+ first['color'] = colour;
2661
+ node.scrolled = { ...current, tracks, resting, first, inherited };
2662
+ return first;
2663
+ }
1844
2664
  /** Hand a scroll-driven animation's channels to native, fed by `source`'s scroll events. */
1845
2665
  driveScrolled(node, source, spec, tracks, resting, range) {
1846
2666
  if (!this.scrollDriver)
@@ -1919,6 +2739,9 @@ export class Engine {
1919
2739
  `has loaded, so the block has to exist somewhere by the time the animation starts.`);
1920
2740
  }
1921
2741
  transitioned(node, props) {
2742
+ // Nobody saw what the last commit gave a view it created, so this is where the view starts.
2743
+ if (node.bornIn >= this.unseen)
2744
+ node.transitions = undefined;
1922
2745
  const spec = transitionSpec(props);
1923
2746
  if (!spec && !node.transitions)
1924
2747
  return props;
@@ -2030,7 +2853,7 @@ export class Engine {
2030
2853
  if (value === null || value === undefined)
2031
2854
  out[key] = value;
2032
2855
  else if (isColorProp(key))
2033
- out[key] = this.color(value);
2856
+ out[key] = this.painted(key, value);
2034
2857
  // A list of sources has already been resolved by whoever built it, as RN's resolver
2035
2858
  // also assumes: it passes any object through and only turns a number into one.
2036
2859
  else if (ASSET_PROPS.has(key) && !Array.isArray(value))
@@ -2043,7 +2866,7 @@ export class Engine {
2043
2866
  out[key] = this.processFilters(value);
2044
2867
  // A bound style's transform is still the CSS string: see `inline-transform.ts`.
2045
2868
  else if (key === 'transform' && typeof value === 'string')
2046
- out[key] = transformList(value);
2869
+ out[key] = transformOf(value);
2047
2870
  else
2048
2871
  out[key] = value;
2049
2872
  }
@@ -2114,9 +2937,11 @@ export class Engine {
2114
2937
  if (this.isClean(node) && !styleChanged)
2115
2938
  return node.committed.handle;
2116
2939
  node.styleCommitted = style;
2117
- const childHandles = this.reconcileChildren(node, style);
2118
- const previous = node.committed;
2119
2940
  const viewName = viewNameOf(node);
2941
+ this.forgetRenamed(node, viewName);
2942
+ const childHandles = this.reconcileChildren(node, viewName, style);
2943
+ noteTouches(node, style);
2944
+ const previous = node.committed;
2120
2945
  this.notePresented(node, viewName);
2121
2946
  if (!previous)
2122
2947
  return this.create(node, viewName, this.mergeProps(node, viewName), childHandles);
@@ -2134,7 +2959,7 @@ export class Engine {
2134
2959
  handle = this.clone(previous.handle, propsPayload && this.processed(propsPayload), childrenChanged ? childHandles : null);
2135
2960
  // A clone is the same view, so it keeps the tag it was created with, not the newest tag
2136
2961
  // handed out, which is some other node's.
2137
- node.committed = { handle, tag: previous.tag, props, childHandles };
2962
+ node.committed = { handle, tag: previous.tag, props, childHandles, viewName };
2138
2963
  }
2139
2964
  this.clearFlags(node);
2140
2965
  return handle;
@@ -2168,7 +2993,7 @@ export class Engine {
2168
2993
  * handle as it stands, so it is answered here without the call, the resolve and the walk -
2169
2994
  * which, per child, was most of what a one-row update cost.
2170
2995
  */
2171
- reconcileChildren(node, style) {
2996
+ reconcileChildren(node, viewName, style) {
2172
2997
  const context = style?.context;
2173
2998
  if (HOIST_TARGETS.has(node.name))
2174
2999
  return this.reconcileHoistTarget(node, context);
@@ -2177,20 +3002,57 @@ export class Engine {
2177
3002
  if (child.kind === 'anchor' || this.committedElsewhere(child) || this.withheld(child)) {
2178
3003
  continue;
2179
3004
  }
3005
+ if (child.kind === 'text') {
3006
+ const handle = this.reconcileText(node, child, viewName, context);
3007
+ if (handle)
3008
+ handles.push(handle);
3009
+ continue;
3010
+ }
2180
3011
  handles.push(this.reconcileUnder(node, child, context));
2181
3012
  }
2182
3013
  return handles;
2183
3014
  }
2184
3015
  /**
2185
- * Whether a child is left out of its parent's commit: a modal host whose `visible` is false.
3016
+ * The paragraph a run of text written straight into a view is drawn in, or nothing for a run of
3017
+ * whitespace, which a browser draws nothing for between the items of a flex box either.
2186
3018
  *
2187
- * The native host covers the screen and takes every touch whether or not it presents anything,
2188
- * which is why React Native's Modal.js renders null while hidden. This is the same thing, so a
2189
- * hidden `<modal>` leaves the screen under it usable. On iOS a modal that was showing stays
2190
- * committed, with `visible` false, until native reports `topDismiss`: Modal.js keeps it that
2191
- * long so the dismissal animates and `(dismiss)` fires. Android drops it at once, as Modal.js
2192
- * does there.
3019
+ * It is the text's own, kept on the text node, and no part of the tree: its parent is the view,
3020
+ * so it inherits what the view's text would, and no rule matches it.
3021
+ */
3022
+ /**
3023
+ * A run of text under `parent`, which commits as `viewName`: the text itself inside a text, a
3024
+ * paragraph of its own inside any other view, and nothing where the view takes its text as a
3025
+ * prop (see `ViewNameOptions`) or the run is whitespace between a view's children.
2193
3026
  */
3027
+ reconcileText(parent, text, viewName, context) {
3028
+ if (TEXT_CONTENT_PROPS[viewName] !== undefined)
3029
+ return null;
3030
+ if (viewName === PARAGRAPH || viewName === VIRTUAL_TEXT) {
3031
+ // Under a text element, the paragraph it had in a view is done with. Under that paragraph
3032
+ // itself, which is where a loose run is committed, it is the one to keep.
3033
+ if (text.box && text.box !== parent)
3034
+ text.box = undefined;
3035
+ return this.reconcileUnder(parent, text, context);
3036
+ }
3037
+ const box = this.looseText(parent, text);
3038
+ return box ? this.reconcileUnder(parent, box, context) : null;
3039
+ }
3040
+ looseText(parent, text) {
3041
+ if (!trimEnds(text.text))
3042
+ return null;
3043
+ let box = text.box;
3044
+ if (!box) {
3045
+ box = new RetainedNode('element', 'text', this);
3046
+ box.anonymous = true;
3047
+ box.children = [text];
3048
+ text.box = box;
3049
+ }
3050
+ box.parent = parent;
3051
+ // The text is not under the box in the tree, so a change to it never marked the box.
3052
+ if (!this.isClean(text))
3053
+ box.subtreeDirty = true;
3054
+ return box;
3055
+ }
2194
3056
  /** Remember an iOS modal host committed as showing, so hiding it waits for `topDismiss`. */
2195
3057
  notePresented(node, viewName) {
2196
3058
  if (viewName !== MODAL_HOST || node.props['visible'] === false)
@@ -2198,10 +3060,28 @@ export class Engine {
2198
3060
  if (platformOS === 'ios')
2199
3061
  node.presented = true;
2200
3062
  }
3063
+ /**
3064
+ * Whether a child is left out of its parent's commit: a modal host whose `visible` is false.
3065
+ *
3066
+ * The native host covers the screen and takes every touch whether or not it presents anything,
3067
+ * which is why React Native's Modal.js renders null while hidden. This is the same thing, so a
3068
+ * hidden `<modal>` leaves the screen under it usable. On iOS a modal that was showing stays
3069
+ * committed, with `visible` false, until native reports `topDismiss`: Modal.js keeps it that
3070
+ * long so the dismissal animates and `(dismiss)` fires. Android drops it at once, as Modal.js
3071
+ * does there.
3072
+ *
3073
+ * A withheld host is unmounted, and Fabric drops an unmounted view's event target for good
3074
+ * (`EventEmitter::setEnabled`), so its committed handles are forgotten and it is created afresh
3075
+ * when it is shown again, as Modal.js creates it afresh. Committed again, it ignores every touch.
3076
+ */
2201
3077
  withheld(child) {
2202
3078
  if (child.props['visible'] !== false || child.presented)
2203
3079
  return false;
2204
- return viewNameOf(child) === MODAL_HOST;
3080
+ if (viewNameOf(child) !== MODAL_HOST)
3081
+ return false;
3082
+ if (child.committed)
3083
+ this.forgetCommitted(child);
3084
+ return true;
2205
3085
  }
2206
3086
  /**
2207
3087
  * `reconcileChildren` for a node other nodes hoist into: its own children, each followed by
@@ -2252,14 +3132,29 @@ export class Engine {
2252
3132
  child.committedUnder = parent;
2253
3133
  return handle;
2254
3134
  }
2255
- /** Let a node and everything under it be created again at the next commit. */
3135
+ /**
3136
+ * A node whose native view is no longer the one it was created as is created again: a text
3137
+ * element that came to hold a view, or stopped. A clone keeps the view it was made as.
3138
+ */
3139
+ forgetRenamed(node, viewName) {
3140
+ if (node.committed && node.committed.viewName !== viewName)
3141
+ this.forgetCommitted(node);
3142
+ }
3143
+ /**
3144
+ * Let a node and everything under it be created again at the next commit. The views it keeps
3145
+ * for hoisted names go too: they are children of the native view it is losing.
3146
+ */
2256
3147
  forgetCommitted(node) {
2257
3148
  node.committed = null;
2258
3149
  node.committedUnder = null;
2259
3150
  node.styleCommitted = null;
2260
- for (const child of node.children)
2261
- if (child.committed)
3151
+ node.kept = undefined;
3152
+ if (node.box?.committed)
3153
+ this.forgetCommitted(node.box);
3154
+ for (const child of node.children) {
3155
+ if (child.committed || child.box?.committed)
2262
3156
  this.forgetCommitted(child);
3157
+ }
2263
3158
  }
2264
3159
  /** A hoisted node, which commits into an ancestor further up than the parent it is in. */
2265
3160
  committedElsewhere(child) {
@@ -2301,7 +3196,7 @@ export class Engine {
2301
3196
  };
2302
3197
  const payload = diffProps(kept.committed.props, props);
2303
3198
  const handle = this.clone(kept.committed.handle, payload && this.processed(payload), []);
2304
- kept.committed = { handle, tag: kept.committed.tag, props, childHandles: [] };
3199
+ kept.committed = { ...kept.committed, handle, props, childHandles: [] };
2305
3200
  kept.node = null;
2306
3201
  }
2307
3202
  handles.push(kept.committed.handle);
@@ -2370,13 +3265,14 @@ export class Engine {
2370
3265
  const handle = this.fabric.createNode(tag, viewName, this.rootTag, this.processed(props), node);
2371
3266
  for (const child of childHandles)
2372
3267
  this.fabric.appendChild(handle, child);
2373
- node.committed = { handle, tag, props, childHandles };
3268
+ node.committed = { handle, tag, props, childHandles, viewName };
3269
+ node.bornIn = this.stats.commits;
2374
3270
  this.clearFlags(node);
2375
3271
  return handle;
2376
3272
  }
2377
3273
  /**
2378
3274
  * Angular's own unknown-element check does not run in this pipeline, so a `<text>` in a
2379
- * template that never imported `Text` compiles, renders as a plain view, and looks like a
3275
+ * template that never imported `Text` compiles, draws without the component, and looks like a
2380
3276
  * layout bug. On the first commit of any known element with no component behind it, say so.
2381
3277
  */
2382
3278
  checkClaimed(node) {
@@ -2387,7 +3283,8 @@ export class Engine {
2387
3283
  this.reported.add(node.name);
2388
3284
  console.error(`[angular-native] <${node.name}> is used in a template that does not import ` +
2389
3285
  `${className(node.name)}. Add it to the component's \`imports\` from ` +
2390
- `'@ng-native/components'; without it the element renders as a plain view.`);
3286
+ `'@ng-native/components'; without it the element has none of the component's inputs ` +
3287
+ `or behavior, so a prop written on it stays the text it was written as.`);
2391
3288
  }
2392
3289
  /**
2393
3290
  * The misspelt-prop check. Angular's own "Can't bind to" check needs a DOM to run, so a
@@ -2398,14 +3295,30 @@ export class Engine {
2398
3295
  */
2399
3296
  undefinedTokens = new Set();
2400
3297
  /** Said once per name: a phone has no inspector to show the declaration struck out. */
2401
- reportUndefinedToken(name, props) {
3298
+ reportUndefinedToken(name, props, on) {
2402
3299
  if (this.undefinedTokens.has(name))
2403
3300
  return;
2404
3301
  this.undefinedTokens.add(name);
2405
- console.warn(`[angular-native] var(${name}) in ${props.join(', ')} names a custom property nothing in` +
3302
+ // A bound declaration has no rule to be found by, so it says which element it is on.
3303
+ const where = on === undefined ? props.join(', ') : `${props.join(', ')}, bound on <${on}>,`;
3304
+ console.warn(`[angular-native] var(${name}) in ${where} names a custom property nothing in` +
2406
3305
  ` scope defines, so the declaration is dropped, as a browser drops it. Define ${name} on` +
2407
3306
  ` :root or an ancestor, or give the var() a fallback.`);
2408
3307
  }
3308
+ unreadDisplays = new Set();
3309
+ /**
3310
+ * Said once per token and value: the compiler refuses `display: grid` written out, but a token
3311
+ * is only known here, and without this the view lays out as flex with nothing said.
3312
+ */
3313
+ reportUnreadDisplay(name, value) {
3314
+ const report = `${name} ${value}`;
3315
+ if (this.unreadDisplays.has(report))
3316
+ return;
3317
+ this.unreadDisplays.add(report);
3318
+ console.warn(`[angular-native] display: var(${name}) is ${value ?? 'no keyword'}, which is no display` +
3319
+ ` native has, so display is unset and the view lays out as flex. Native has flex, none` +
3320
+ ` and contents, and reads block, inline, inline-block, flow-root and inline-flex as flex.`);
3321
+ }
2409
3322
  checkProps(node) {
2410
3323
  if (!node.claimed || !PRIMITIVE_NAMES.has(node.name))
2411
3324
  return;
@@ -2617,6 +3530,18 @@ export class Engine {
2617
3530
  resolveAsset(value) {
2618
3531
  return this.resolveAssetSource(value);
2619
3532
  }
3533
+ /**
3534
+ * A colour prop as native takes it. A shape's `fill` or `stroke` the cascade settled from a
3535
+ * token is the solid brush react-native-svg takes, and `none` no paint at all.
3536
+ */
3537
+ painted(key, value) {
3538
+ if (!BRUSH_PROPS.has(key))
3539
+ return this.color(value);
3540
+ // Already a brush, as the icon wrote it: only one the cascade settled is still text.
3541
+ if (typeof value !== 'string')
3542
+ return value;
3543
+ return value === 'none' ? null : { type: 0, payload: this.color(value) };
3544
+ }
2620
3545
  /**
2621
3546
  * The host's `processColor`, and the one place a platform colour becomes one.
2622
3547
  *
@@ -2767,7 +3692,7 @@ export class Engine {
2767
3692
  // Listeners get RN's documented shape, `{nativeEvent}`. Fabric hands us the payload bare;
2768
3693
  // React wraps it in a synthetic event and every RN API is written against `event.nativeEvent`,
2769
3694
  // so handlers ported from RN would silently read undefined otherwise.
2770
- const event = new SyntheticEvent(nativeEvent);
3695
+ const event = new SyntheticEvent(nativeEvent, target);
2771
3696
  try {
2772
3697
  if (topLevelType === 'topDismiss' && target)
2773
3698
  this.dismissed(target);