@ng-native/fabric 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/aria-props.d.ts +16 -0
  2. package/dist/aria-props.js +78 -0
  3. package/dist/aria-props.js.map +1 -0
  4. package/dist/color-mix.js +1 -16
  5. package/dist/color-mix.js.map +1 -1
  6. package/dist/css.d.ts +169 -2
  7. package/dist/css.js +676 -34
  8. package/dist/css.js.map +1 -1
  9. package/dist/element-styles.d.ts +15 -0
  10. package/dist/element-styles.js +103 -0
  11. package/dist/element-styles.js.map +1 -0
  12. package/dist/engine.d.ts +252 -7
  13. package/dist/engine.js +1081 -80
  14. package/dist/engine.js.map +1 -1
  15. package/dist/font-faces.d.ts +5 -2
  16. package/dist/font-faces.js +13 -2
  17. package/dist/font-faces.js.map +1 -1
  18. package/dist/index.d.ts +3 -1
  19. package/dist/index.js +3 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/inline-derived-token.js +11 -10
  22. package/dist/inline-derived-token.js.map +1 -1
  23. package/dist/inline-token.d.ts +7 -0
  24. package/dist/inline-token.js +53 -8
  25. package/dist/inline-token.js.map +1 -1
  26. package/dist/inline-transform.d.ts +5 -1
  27. package/dist/inline-transform.js +34 -6
  28. package/dist/inline-transform.js.map +1 -1
  29. package/dist/native-drive.d.ts +21 -0
  30. package/dist/native-drive.js +60 -0
  31. package/dist/native-drive.js.map +1 -1
  32. package/dist/premultiplied-stops.d.ts +7 -0
  33. package/dist/premultiplied-stops.js +131 -0
  34. package/dist/premultiplied-stops.js.map +1 -0
  35. package/dist/scroll-animation.d.ts +11 -0
  36. package/dist/scroll-animation.js +29 -0
  37. package/dist/scroll-animation.js.map +1 -1
  38. package/dist/transition.d.ts +15 -0
  39. package/dist/transition.js +42 -1
  40. package/dist/transition.js.map +1 -1
  41. package/package.json +1 -1
package/dist/engine.js CHANGED
@@ -7,14 +7,17 @@
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";
10
+ import { boundDeclaration, inlineInherited, setsInherited, StyleResolver, usesActive, } from "./css.js";
11
+ import { applyAria } from "./aria-props.js";
12
+ import { STYLED_ELEMENTS } from "./element-styles.js";
11
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";
15
17
  import { NativeScrollDriver, scrollFeed, } from "./native-drive.js";
16
- import { firstFrame, rangeOf, scrollChannels } from "./scroll-animation.js";
18
+ import { clockChannels, firstFrame, rangeOf, scrollChannels } from "./scroll-animation.js";
17
19
  import { FontFaces } from "./font-faces.js";
20
+ import { premultipliedStops } from "./premultiplied-stops.js";
18
21
  /**
19
22
  * The animation a node's style asks for, with a play state from a rule of its own applied. Both
20
23
  * are instructions for this engine and nothing native has heard of them, so they come out of the
@@ -240,6 +243,34 @@ const VIEW_NAMES = {
240
243
  * `@ng-native/expo`, and warning about it would be noise.
241
244
  */
242
245
  const PRIMITIVE_NAMES = new Set(Object.keys(VIEW_NAMES));
246
+ /**
247
+ * HTML's text elements, which a template can write as it would for a browser.
248
+ *
249
+ * Each is text when it holds only text and other text elements, and a view when it holds anything
250
+ * else: a box inside a paragraph has no layout, so `<span>Inbox<view class="dot" /></span>` is a
251
+ * view with its text in a paragraph of its own. Decided as the element is committed, by
252
+ * `viewNameOf`. They have no component to import, so they are not `PRIMITIVE_NAMES`.
253
+ */
254
+ const TEXT_ELEMENTS = new Set([
255
+ ...['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'strong', 'b', 'em', 'i'],
256
+ ...['u', 's', 'small', 'code', 'mark', 'abbr', 'cite', 'time'],
257
+ ]);
258
+ /** HTML's layout elements, each a plain view, as a browser's are boxes with a name. */
259
+ const LAYOUT_ELEMENTS = [
260
+ ...['div', 'section', 'article', 'header', 'footer', 'main', 'nav', 'ul', 'ol', 'li'],
261
+ ];
262
+ for (const name of TEXT_ELEMENTS)
263
+ VIEW_NAMES[name] = 'Paragraph';
264
+ for (const name of LAYOUT_ELEMENTS)
265
+ VIEW_NAMES[name] = 'View';
266
+ /**
267
+ * What an HTML element has for its name alone, given to the node as it is made: a heading's role,
268
+ * which a binding can replace as it can any prop, and whether the engine has styles for it.
269
+ */
270
+ const HTML_ELEMENTS = new Map([
271
+ ...['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map((name) => [name, { role: 'header' }]),
272
+ ...[...STYLED_ELEMENTS].map((name) => [name, { styled: true }]),
273
+ ]);
243
274
  /**
244
275
  * The props each host primitive's native view is sent, by element name, as the components
245
276
  * declare them: every input the element's component has, and the props it writes that no input
@@ -274,6 +305,14 @@ export function claimHost(node) {
274
305
  export function markComponentHost(node) {
275
306
  node.componentHost = true;
276
307
  }
308
+ /**
309
+ * Say that a component's host is the native view its element name is registered as: a typed
310
+ * wrapper for the view, where a component of the app's own with the same selector is a plain view.
311
+ * Called from the component's constructor. See `ViewNameOptions.yieldsToComponents`.
312
+ */
313
+ export function keepNativeView(node) {
314
+ node.nativeView = true;
315
+ }
277
316
  /** `text-input` -> `TextInput`: how the components package names the class for an element. */
278
317
  const className = (element) => element.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase());
279
318
  /** Names Android registers differently. */
@@ -421,6 +460,124 @@ function centreSingleLine(viewName, props, fontScale) {
421
460
  : box.reduce((sum, part) => sum + part, 0);
422
461
  props['minHeight'] = Math.max(own, Math.min(content, max));
423
462
  }
463
+ /**
464
+ * What a container's own style says for `key`, in the order `mergeProps` applies them: a
465
+ * component's override, inline, a prop, its stylesheet, its default.
466
+ */
467
+ function ownLayout(node, key) {
468
+ return (flattenStyle(node.props[STYLE_OVERRIDE], {})[key] ??
469
+ flattenStyle(node.props['style'], {})[key] ??
470
+ node.props[key] ??
471
+ node.styleCache?.style[key] ??
472
+ node.defaultStyle?.[key]);
473
+ }
474
+ /**
475
+ * A container's main axis and how it aligns across it, as `row` or `column` and a keyword. A
476
+ * `display: contents` node is no container: its children are laid out in the one above it.
477
+ */
478
+ function containerOf(node) {
479
+ if (ownLayout(node, 'display') === 'contents')
480
+ return 'contents';
481
+ const direction = String(ownLayout(node, 'flexDirection') ?? 'column');
482
+ return `${direction.startsWith('row') ? 'row' : 'column'} ${ownLayout(node, 'alignItems') ?? 'stretch'}`;
483
+ }
484
+ /**
485
+ * The node a box is laid out in: its parent, or the one above every `display: contents` ancestor.
486
+ * Each of those is told a `fit-content` child passed through, so that when it stops being
487
+ * `contents`, and is the container, the child is worked out again.
488
+ */
489
+ function layoutParent(node) {
490
+ let parent = node.parent;
491
+ while (parent && containerOf(parent) === 'contents') {
492
+ parent.fitContainer = 'contents';
493
+ parent = parent.parent;
494
+ }
495
+ return parent;
496
+ }
497
+ const STRETCHES = new Set([undefined, null, 'auto', 'stretch']);
498
+ /**
499
+ * `width: fit-content` and `height: fit-content`, which Yoga has no size for.
500
+ *
501
+ * Along its container's main axis a box is as big as its content already, so the size is
502
+ * dropped. Across it a box stretches, where a browser keeps it at its content's size and at the
503
+ * start: `align-self: flex-start` does both. An alignment the container or the box asked for is
504
+ * kept, since it stops the stretch already.
505
+ */
506
+ function fitContent(node, props) {
507
+ const width = props['width'] === 'fit-content';
508
+ const height = props['height'] === 'fit-content';
509
+ if (!width && !height)
510
+ return;
511
+ if (width)
512
+ delete props['width'];
513
+ if (height)
514
+ delete props['height'];
515
+ const parent = layoutParent(node);
516
+ if (!parent)
517
+ return;
518
+ const container = containerOf(parent);
519
+ parent.fitContainer = container;
520
+ const [direction, align] = container.split(' ');
521
+ const across = direction === 'row' ? height : width;
522
+ if (across && align === 'stretch' && STRETCHES.has(props['alignSelf'])) {
523
+ props['alignSelf'] = 'flex-start';
524
+ }
525
+ }
526
+ const isPercent = (value) => typeof value === 'string' && value.endsWith('%');
527
+ /**
528
+ * Whether a box has a height a percentage can be taken of, as CSS defines one: a height of its
529
+ * own, or one the layout around it gives it. A box as tall as what it holds has none.
530
+ *
531
+ * Asked of each box on the way up, and remembered on it, so a change to any of them works out
532
+ * again the boxes beneath it. Where the answer is not known for certain it is yes, which leaves
533
+ * the percentage to Yoga as before: a view native sizes itself, or one placed out of the flow.
534
+ */
535
+ function definiteHeight(node) {
536
+ return (node.heightBasis = hasDefiniteHeight(node));
537
+ }
538
+ function hasDefiniteHeight(node) {
539
+ // The root is the screen, and only a plain view is sized by its content alone.
540
+ if (!node.parent || node.kind !== 'element' || viewNameOf(node) !== 'View')
541
+ return true;
542
+ const parent = layoutParent(node);
543
+ if (!parent)
544
+ return true;
545
+ const height = ownLayout(node, 'height');
546
+ if (isPercent(height))
547
+ return definiteHeight(parent);
548
+ return sizesItself(node, height) || heightFromLayout(node, parent);
549
+ }
550
+ const NO_HEIGHT = new Set([undefined, null, 'auto', 'fit-content']);
551
+ /** Whether a box has a height from its own style: written, or placed, or kept in proportion. */
552
+ function sizesItself(node, height) {
553
+ if (!NO_HEIGHT.has(height))
554
+ return true;
555
+ return ownLayout(node, 'position') === 'absolute' || ownLayout(node, 'aspectRatio') != null;
556
+ }
557
+ /** Whether the container a box is in gives it a height: a row's, or a share of a column's. */
558
+ function heightFromLayout(node, parent) {
559
+ const [direction, align] = containerOf(parent).split(' ');
560
+ if (direction === 'row') {
561
+ // Stretched across a row, it is as tall as the row.
562
+ const self = ownLayout(node, 'alignSelf');
563
+ return STRETCHES.has(self) && align === 'stretch';
564
+ }
565
+ // In a column it has the height it grows to, where the column has one to share out.
566
+ const grows = Number(ownLayout(node, 'flexGrow') ?? ownLayout(node, 'flex') ?? 0) > 0;
567
+ return grows && definiteHeight(parent);
568
+ }
569
+ /**
570
+ * `height: 50%` under a parent with no height to take it of, which CSS reads as `auto`. Yoga
571
+ * takes it of the space on offer, the screen's or everything a scroll view holds, so no height
572
+ * is sent.
573
+ */
574
+ function percentHeight(node, props) {
575
+ if (!isPercent(props['height']))
576
+ return;
577
+ const parent = layoutParent(node);
578
+ if (parent && !definiteHeight(parent))
579
+ delete props['height'];
580
+ }
424
581
  /**
425
582
  * Start a multiline Android text field's text at the top, as iOS and a `<textarea>` do.
426
583
  *
@@ -478,6 +635,12 @@ const MODAL_HOST = 'ModalHostView';
478
635
  */
479
636
  export function registerViewName(elementName, viewName, defaultProps, options) {
480
637
  VIEW_NAMES[elementName] = viewName;
638
+ if (options?.yieldsToComponents)
639
+ YIELDING.add(elementName);
640
+ else
641
+ YIELDING.delete(elementName);
642
+ // A name the app registers is the app's: no longer decided by what the element holds.
643
+ TEXT_ELEMENTS.delete(elementName);
481
644
  for (const name of typeof viewName === 'string' ? [viewName] : [viewName.ios, viewName.android]) {
482
645
  if (defaultProps)
483
646
  DEFAULT_PROPS[name] = defaultProps;
@@ -485,6 +648,8 @@ export function registerViewName(elementName, viewName, defaultProps, options) {
485
648
  TEXT_CONTENT_PROPS[name] = options.textContent;
486
649
  }
487
650
  }
651
+ /** The registered names that give way to a component's own host. See `ViewNameOptions`. */
652
+ const YIELDING = new Set();
488
653
  /** The prop each view that takes its text content as a prop reads it from. See `ViewNameOptions`. */
489
654
  const TEXT_CONTENT_PROPS = {};
490
655
  /** Whether `node` takes its text content as a prop rather than as text children. */
@@ -500,6 +665,14 @@ function withTextContent(node, viewName, props) {
500
665
  if (text)
501
666
  props[prop] = text;
502
667
  }
668
+ /**
669
+ * The whitespace a paragraph drops at its ends is what CSS collapses: spaces, tabs and line
670
+ * breaks. A no-break space is text, as in a browser, so `&nbsp;` is how a value keeps a space at
671
+ * its end; `String.prototype.trim` would take that too.
672
+ */
673
+ const trimStart = (text) => text.replace(/^[ \t\n\r\f]+/, '');
674
+ const trimEnd = (text) => text.replace(/[ \t\n\r\f]+$/, '');
675
+ const trimEnds = (text) => trimEnd(trimStart(text));
503
676
  /**
504
677
  * The text written inside a view that takes it as a prop: every run joined, with the whitespace at
505
678
  * the two ends dropped as a paragraph drops it, and kept inside. A nested view is drawn after the
@@ -514,7 +687,7 @@ function textContent(node) {
514
687
  else if (child.kind === 'element')
515
688
  nested = true;
516
689
  }
517
- return nested ? text.trimStart() : text.trim();
690
+ return nested ? trimStart(text) : trimEnd(trimStart(text));
518
691
  }
519
692
  /** An element's registered view name, for the platform the host reported. */
520
693
  function registeredViewName(elementName) {
@@ -528,6 +701,17 @@ function registeredViewName(elementName) {
528
701
  * a paragraph, and a text input on either platform. See `Engine.remeasureText`.
529
702
  */
530
703
  const MEASURED_VIEWS = new Set(['Paragraph', 'TextInput', 'AndroidTextInput']);
704
+ /** Leave a family that is still loading out of `style`, remembering it. See `loadingFamilies`. */
705
+ function holdLoadingFamily(node, style) {
706
+ const family = style['fontFamily'];
707
+ if (typeof family === 'string' && loadingFamilies.has(family)) {
708
+ delete style['fontFamily'];
709
+ node.heldFamily = family;
710
+ }
711
+ else if (node.heldFamily !== undefined) {
712
+ node.heldFamily = undefined;
713
+ }
714
+ }
531
715
  /** What each mounted app does when faces register. See `fontsRegistered`. */
532
716
  const fontListeners = new Set();
533
717
  /** Hear about faces registering with the platform, until the returned function is called. */
@@ -547,6 +731,45 @@ export function fontsRegistered(families) {
547
731
  for (const listener of [...fontListeners])
548
732
  listener(names);
549
733
  }
734
+ /**
735
+ * Families a load has asked the platform for and not yet settled, by how many loads asked. Text
736
+ * naming one is laid out in the fallback without the name until it settles: native caches a
737
+ * text's measurement by its family name, so a fallback measurement under the face's name would
738
+ * outlast the face arriving.
739
+ */
740
+ const loadingFamilies = new Map();
741
+ /** Faces a load is about to ask the platform for. See `loadingFamilies`. */
742
+ export function fontsLoading(families) {
743
+ for (const family of families)
744
+ loadingFamilies.set(family, (loadingFamilies.get(family) ?? 0) + 1);
745
+ }
746
+ /** What each mounted app does when a load settles. See `fontsSettled`. */
747
+ const settleListeners = new Set();
748
+ /** Hear about loads settling, until the returned function is called. */
749
+ export function onFontsSettled(listener) {
750
+ settleListeners.add(listener);
751
+ return () => settleListeners.delete(listener);
752
+ }
753
+ /**
754
+ * Faces a load asked for and has finished with, registered or not, before `fontsRegistered`.
755
+ * Text held back for one that no load still asks for is laid out with the name again: see
756
+ * `Engine.fontsSettled`.
757
+ */
758
+ export function fontsSettled(families) {
759
+ const settled = new Set();
760
+ for (const family of families) {
761
+ const left = (loadingFamilies.get(family) ?? 1) - 1;
762
+ if (left > 0)
763
+ loadingFamilies.set(family, left);
764
+ else {
765
+ loadingFamilies.delete(family);
766
+ settled.add(family);
767
+ }
768
+ }
769
+ if (settled.size)
770
+ for (const listener of [...settleListeners])
771
+ listener(settled);
772
+ }
550
773
  /** Whether a paragraph, or a span in it, was committed asking for one of these families. */
551
774
  function namesFamily(node, families) {
552
775
  const family = node.committed?.props['fontFamily'];
@@ -617,12 +840,13 @@ const STAND_INS = {};
617
840
  */
618
841
  function paragraphText(node) {
619
842
  let root = node.parent;
620
- if (root?.name !== 'text')
621
- return node.text;
843
+ // Text written straight into a view is a paragraph of its own, and all of it.
844
+ if (root === null || !isTextElement(root))
845
+ return node.box ? trimEnds(node.text) : node.text;
622
846
  // The commonest paragraph by far: one run and nothing else, so it is both the first and last.
623
- if (root.children.length === 1 && root.parent?.name !== 'text')
624
- return node.text.trim();
625
- while (root.parent?.name === 'text')
847
+ if (root.children.length === 1 && !isTextElement(root.parent))
848
+ return trimEnds(node.text);
849
+ while (isTextElement(root.parent))
626
850
  root = root.parent;
627
851
  const runs = [];
628
852
  const collect = (from) => {
@@ -631,18 +855,63 @@ function paragraphText(node) {
631
855
  if (child.text)
632
856
  runs.push(child);
633
857
  }
634
- else if (child.name === 'text')
858
+ else if (isTextElement(child))
635
859
  collect(child);
636
860
  }
637
861
  };
638
862
  collect(root);
639
863
  let text = node.text;
640
864
  if (runs[0] === node)
641
- text = text.replace(/^\s+/, '');
865
+ text = trimStart(text);
642
866
  if (runs[runs.length - 1] === node)
643
- text = text.replace(/\s+$/, '');
867
+ text = trimEnd(text);
644
868
  return text;
645
869
  }
870
+ /**
871
+ * Whether an element is text: a `<text>`, or one of HTML's text elements that holds only text and
872
+ * other such elements. Worked out each time, from the tree as it is, so nothing can go stale.
873
+ */
874
+ function isTextElement(node) {
875
+ if (node === null || node.kind !== 'element')
876
+ return false;
877
+ if (node.name === 'text')
878
+ return true;
879
+ // One lookup settles nearly every other element, which is a view.
880
+ if (registeredViewName(node.name) !== PARAGRAPH)
881
+ return false;
882
+ return !TEXT_ELEMENTS.has(node.name) || holdsOnlyText(node);
883
+ }
884
+ function holdsOnlyText(node) {
885
+ for (const child of node.children ?? []) {
886
+ if (child.kind === 'element' && !isTextElement(child))
887
+ return false;
888
+ }
889
+ return true;
890
+ }
891
+ const ALIGNS = new Set(['center', 'flex-end', 'space-around', 'space-evenly']);
892
+ /**
893
+ * Whether one of HTML's text elements is a flex container that aligns the one run of text it
894
+ * holds, as an avatar's initials are centred: `<span class="flex items-center justify-center">`.
895
+ *
896
+ * A browser makes the run a flex item and places it. A paragraph's text is its content, which no
897
+ * alignment moves, so such an element commits as a view, and its text as the paragraph any view's
898
+ * loose text is. Read from the node's own style on each reconcile, which a change to it brings
899
+ * about, so nothing is kept to go stale. A `<text>` is a paragraph whatever its style, and an
900
+ * element holding more than the one run is left as it was.
901
+ */
902
+ function aligningText(node) {
903
+ if (node.kind !== 'element' || node.name === 'text' || !TEXT_ELEMENTS.has(node.name)) {
904
+ return false;
905
+ }
906
+ if (node.children.length !== 1 || node.children[0].kind !== 'text')
907
+ return false;
908
+ if (isTextElement(node.parent) || ownLayout(node, 'display') !== 'flex')
909
+ return false;
910
+ return (ALIGNS.has(ownLayout(node, 'alignItems')) ||
911
+ ALIGNS.has(ownLayout(node, 'justifyContent')));
912
+ }
913
+ /** The view a node is committed as: `viewNameOf`, but for a text element that aligns its text. */
914
+ const committedViewName = (node) => aligningText(node) ? DEFAULT_VIEW : viewNameOf(node);
646
915
  /**
647
916
  * The native view a node commits as: `Paragraph` for a `<text>`, `VirtualText` for one nested in
648
917
  * another, `RawText` for a run of text, and `View` for anything unregistered, which is what an
@@ -658,9 +927,196 @@ export function viewNameOf(node) {
658
927
  // RN models nested text as a span inside a paragraph: `Paragraph > [RawText, Text > RawText]`.
659
928
  // Committing a nested `<text>` as another Paragraph gives a paragraph inside a paragraph,
660
929
  // which lays out as a separate block instead of flowing inline.
661
- if (node.name === 'text' && node.parent?.name === 'text')
662
- return VIRTUAL_TEXT;
663
- return registeredViewName(node.name) ?? DEFAULT_VIEW;
930
+ // A component of the app's own under a name a view set registered: its host is a plain view.
931
+ if (node.componentHost && !node.nativeView && YIELDING.has(node.name))
932
+ return DEFAULT_VIEW;
933
+ const registered = registeredViewName(node.name);
934
+ // Nearly every element: a view of some kind, by its name alone.
935
+ if (registered !== PARAGRAPH)
936
+ return registered ?? DEFAULT_VIEW;
937
+ // One of HTML's text elements that holds something other than text: a view.
938
+ if (node.name !== 'text' && TEXT_ELEMENTS.has(node.name) && !holdsOnlyText(node)) {
939
+ return DEFAULT_VIEW;
940
+ }
941
+ // The paragraph the engine makes for a view's loose text is under a view, whatever the name
942
+ // of the element that view is.
943
+ return !node.anonymous && isTextElement(node.parent) ? VIRTUAL_TEXT : PARAGRAPH;
944
+ }
945
+ /**
946
+ * The props every native view reads as a boolean: the `bool` fields of React Native's
947
+ * `BaseViewProps`, `AccessibilityProps` and `HostPlatformViewProps`, by the names JavaScript sends.
948
+ *
949
+ * An attribute arrives as text. A component that declares the prop as an input turns it into a
950
+ * boolean itself, but an element nothing claims, such as a component's own host, holds what it
951
+ * was given, and Android refuses a string where it reads a boolean: `focusable="false"` is
952
+ * `java.lang.String cannot be cast to java.lang.Boolean` at the first commit. iOS ignores it.
953
+ *
954
+ * So the text is made a boolean as it is committed, `"false"` false and anything else true, as an
955
+ * attribute reads in HTML. The node keeps the text, which is what `[focusable="false"]` in a
956
+ * stylesheet matches against.
957
+ */
958
+ const BOOLEAN_VIEW_PROPS = new Set([
959
+ 'accessibilityElementsHidden',
960
+ 'accessibilityIgnoresInvertColors',
961
+ 'accessibilityRespondsToUserInteraction',
962
+ 'accessibilityShowsLargeContentViewer',
963
+ 'accessibilityViewIsModal',
964
+ 'accessible',
965
+ 'collapsable',
966
+ 'collapsableChildren',
967
+ 'focusable',
968
+ 'hasTVPreferredFocus',
969
+ 'needsOffscreenAlphaCompositing',
970
+ // The flags native reads to know an event has a listener, which are booleans as well.
971
+ 'onAccessibilityAction',
972
+ 'onAccessibilityEscape',
973
+ 'onAccessibilityMagicTap',
974
+ 'onAccessibilityTap',
975
+ 'onLayout',
976
+ 'removeClippedSubviews',
977
+ 'renderToHardwareTextureAndroid',
978
+ 'screenReaderFocusable',
979
+ 'shouldRasterizeIOS',
980
+ ]);
981
+ /**
982
+ * Whether `node` holds nothing `:empty` sees besides `moved`: so it was empty before `moved` came,
983
+ * or is now that it has gone. An anchor is a comment on the web, which `:empty` does not see.
984
+ */
985
+ function wasOrIsEmptyWithout(node, moved) {
986
+ if (moved.kind === 'anchor')
987
+ return false;
988
+ for (const child of node.children) {
989
+ if (child !== moved && child.kind !== 'anchor')
990
+ return false;
991
+ }
992
+ return true;
993
+ }
994
+ /**
995
+ * Have the child that paints a node's background merged again with the node: nothing of the
996
+ * child's own moved, and what it paints may have.
997
+ */
998
+ function repaint(node) {
999
+ if (node.paintsOn?.parent === node)
1000
+ node.paintsOn.propsDirty = true;
1001
+ }
1002
+ /** A sixtieth of a second: how far apart the frames native is given for an animation are. */
1003
+ const NATIVE_FRAME = 1000 / 60;
1004
+ /** The style keys that are a view's background. */
1005
+ const PAINT_KEYS = [
1006
+ 'backgroundColor',
1007
+ 'experimental_backgroundImage',
1008
+ 'experimental_backgroundSize',
1009
+ 'experimental_backgroundPosition',
1010
+ 'experimental_backgroundRepeat',
1011
+ ];
1012
+ /**
1013
+ * A node's own props onto what it commits with. No native prop has a hyphen: `data-*` and `aria-*`
1014
+ * attributes stay on the node for selectors to match, and an `aria-*` one is mapped to the prop
1015
+ * native reads. The node's `attributeOnly` names stay on it the same way.
1016
+ */
1017
+ function writeOwnProps(own, props, attributeOnly) {
1018
+ let aria = false;
1019
+ for (const key of Object.keys(own)) {
1020
+ if (attributeOnly?.has(key))
1021
+ continue;
1022
+ if (key.includes('-'))
1023
+ aria ||= key.startsWith('aria-');
1024
+ else if (key !== 'style' && key !== INTRINSIC_SIZE && key !== STYLE_OVERRIDE) {
1025
+ props[key] = committedProp(key, own[key]);
1026
+ }
1027
+ }
1028
+ if (!aria)
1029
+ return;
1030
+ // An attribute-only `aria-*` is not mapped to the prop native reads either.
1031
+ const mapped = attributeOnly?.size
1032
+ ? Object.fromEntries(Object.entries(own).filter(([key]) => !attributeOnly.has(key)))
1033
+ : own;
1034
+ applyAria(mapped, props);
1035
+ }
1036
+ /** A prop as it is committed: a boolean view prop held as text is the boolean the text says. */
1037
+ function committedProp(key, value) {
1038
+ return typeof value === 'string' && BOOLEAN_VIEW_PROPS.has(key) ? value !== 'false' : value;
1039
+ }
1040
+ /**
1041
+ * The `pointer-events` an element ends up with, in the order its props are merged: its inline
1042
+ * style's, then its `pointerEvents` prop, then what the sheets resolve. The inline one is read for
1043
+ * itself because an element no rule matches resolves to what it inherits, with its own inline
1044
+ * value only in what it hands down.
1045
+ */
1046
+ function pointerEventsOf(node, resolved) {
1047
+ const inline = node.inlineInherits ? inlineInherited(node.props['style']) : null;
1048
+ const own = inline?.['pointerEvents'];
1049
+ // Merged over the prop, so its `inherit` is what the sheets and the parent settled, not the prop.
1050
+ if (own === 'inherit')
1051
+ return resolved['pointerEvents'];
1052
+ return own ?? node.props['pointerEvents'] ?? resolved['pointerEvents'];
1053
+ }
1054
+ /**
1055
+ * A node's `pointer-events` as native takes it, once its props and styles are merged.
1056
+ *
1057
+ * In CSS `none` is the element alone, and a descendant's `auto` takes touches again, which native
1058
+ * calls `box-none`: what a `none` with something inside to open it is committed as. Native's own
1059
+ * `none` is the whole subtree. It is what a `none` with nothing inside to open it stays, because
1060
+ * Android's text and images take no `pointerEvents` of their own and only an ancestor's `none`
1061
+ * keeps touches off them. It is also what the `pointerEvents` prop means, so a `none` that is the
1062
+ * prop's is left as it is.
1063
+ *
1064
+ * An inline `inherit` is what the element has without it, which is `resolved`: what the sheets
1065
+ * and its parent settled.
1066
+ */
1067
+ function nativePointerEvents(node, style, resolved) {
1068
+ let value = style['pointerEvents'];
1069
+ if (value === undefined)
1070
+ return;
1071
+ if (value === 'inherit') {
1072
+ value = resolved;
1073
+ if (value === undefined)
1074
+ delete style['pointerEvents'];
1075
+ else
1076
+ style['pointerEvents'] = value;
1077
+ }
1078
+ if (value === 'none' && node.touchWithin && node.props['pointerEvents'] !== 'none') {
1079
+ style['pointerEvents'] = 'box-none';
1080
+ }
1081
+ }
1082
+ /** Whether `node`, whose `pointer-events` is `value`, takes touches or holds something that does. */
1083
+ function takesTouches(node, value, prop) {
1084
+ if (value === undefined)
1085
+ return false;
1086
+ // The prop's `none` is native's: the whole subtree, whatever is inside it.
1087
+ if (value === 'none' && prop === 'none')
1088
+ return false;
1089
+ // CSS's `none` and native's `box-none` take no touches themselves: their children answer.
1090
+ if (value === 'none' || value === 'box-none') {
1091
+ return node.children.some((child) => child.touchWithin);
1092
+ }
1093
+ return true;
1094
+ }
1095
+ /**
1096
+ * Records on `node` whether it, or something inside it, takes touches where it would inherit
1097
+ * `none`. When that changes a `none` element's own answer, its props are marked to be committed
1098
+ * again.
1099
+ *
1100
+ * Called as a node is reconciled, after its children, so theirs are already current: a child that
1101
+ * was skipped as clean has nothing beneath it that changed, and a change beneath marks every
1102
+ * ancestor to come back through here.
1103
+ */
1104
+ function noteTouches(node, style) {
1105
+ if (!style)
1106
+ return;
1107
+ // Nearly every node: no `pointer-events` in any sheet, style or prop, and nothing recorded.
1108
+ const resolved = style.style['pointerEvents'];
1109
+ const prop = node.props['pointerEvents'];
1110
+ if (resolved === undefined && prop === undefined && !node.inlineInherits && !node.touchWithin) {
1111
+ return;
1112
+ }
1113
+ const value = pointerEventsOf(node, style.style);
1114
+ const within = takesTouches(node, value, prop);
1115
+ if (within === !!node.touchWithin)
1116
+ return;
1117
+ node.touchWithin = within;
1118
+ if (value === 'none')
1119
+ node.propsDirty = true;
664
1120
  }
665
1121
  /** Whether `node` is `ancestor` or somewhere under it. */
666
1122
  function isWithin(node, ancestor) {
@@ -753,6 +1209,10 @@ class RetainedNode {
753
1209
  styleCache = null;
754
1210
  styleDirty = true;
755
1211
  styleCommitted = null;
1212
+ fitContainer = undefined;
1213
+ heightBasis = undefined;
1214
+ hasDirty = false;
1215
+ stateDirty = false;
756
1216
  ownStyle = undefined;
757
1217
  claimed = undefined;
758
1218
  dormantHoists = null;
@@ -763,6 +1223,10 @@ class RetainedNode {
763
1223
  propsDirty = false;
764
1224
  structureDirty = false;
765
1225
  subtreeDirty = false;
1226
+ touchWithin = undefined;
1227
+ box = undefined;
1228
+ anonymous = undefined;
1229
+ styled = undefined;
766
1230
  transitions;
767
1231
  playing;
768
1232
  scrolled;
@@ -892,12 +1356,20 @@ class RetainedNode {
892
1356
  * colours - `shouldRasterizeIOS` is a boolean.
893
1357
  */
894
1358
  const IS_COLOR_PROP = /color(android)?$/i;
1359
+ /**
1360
+ * Colour props React Native does not name with a `color` suffix: `<switch>`'s pair ends in the
1361
+ * state each one paints - `trackColorForTrue` and `trackColorForFalse`. They are the same type
1362
+ * problem the `Android` suffix causes: Android's `ColorPropConverter` takes a number or a
1363
+ * platform-colour map, refuses a string, and throws rather than approximating one.
1364
+ */
1365
+ const STATE_COLOR_PROPS = new Set(['trackColorForTrue', 'trackColorForFalse']);
895
1366
  /** Answers per key name, once: the regex ran for every prop of every node in every commit. */
896
1367
  const colorProps = new Map();
897
1368
  function isColorProp(key) {
898
1369
  let answer = colorProps.get(key);
899
- if (answer === undefined)
900
- colorProps.set(key, (answer = IS_COLOR_PROP.test(key)));
1370
+ if (answer === undefined) {
1371
+ colorProps.set(key, (answer = IS_COLOR_PROP.test(key) || STATE_COLOR_PROPS.has(key) || BRUSH_PROPS.has(key)));
1372
+ }
901
1373
  return answer;
902
1374
  }
903
1375
  const ASSET_PROPS = new Set(['source', 'defaultSource', 'loadingIndicatorSource']);
@@ -910,7 +1382,30 @@ const ASSET_PROPS = new Set(['source', 'defaultSource', 'loadingIndicatorSource'
910
1382
  * approximated, it is dropped.
911
1383
  */
912
1384
  const NESTED_COLOR_LIST_PROPS = new Set(['boxShadow']);
1385
+ /** The props of an SVG shape that take a brush, not a colour. */
1386
+ const BRUSH_PROPS = new Set(['fill', 'stroke']);
913
1387
  /** Styles arrive as objects, arrays, nested arrays and nulls. Reduce to one object. */
1388
+ /**
1389
+ * A bound transform is still the CSS string: see `inline-transform.ts`. One CSS cannot read
1390
+ * leaves the transform the rules set, as a browser drops it.
1391
+ */
1392
+ function boundTransform(node, style, cascaded) {
1393
+ const text = style['transform'];
1394
+ if (typeof text !== 'string')
1395
+ return style;
1396
+ // The same list for the same string, commit after commit: a transition compares the two ends
1397
+ // it is given, and a list made afresh each time reads as a new place to go.
1398
+ if (node.boundTransform?.text !== text)
1399
+ node.boundTransform = { text, list: transformList(text) };
1400
+ const list = node.boundTransform.list ?? cascaded;
1401
+ if (list === undefined)
1402
+ delete style['transform'];
1403
+ else
1404
+ style['transform'] = list;
1405
+ return style;
1406
+ }
1407
+ /** A transform string as the list native reads, and none for one CSS cannot read. */
1408
+ const transformOf = (value) => transformList(value) ?? [];
914
1409
  function flattenStyle(value, into) {
915
1410
  if (!value)
916
1411
  return into;
@@ -986,7 +1481,7 @@ function composeTransform(node, props) {
986
1481
  }
987
1482
  else {
988
1483
  // A bound style's transform is still the CSS string: see `inline-transform.ts`.
989
- const own = typeof transform === 'string' ? transformList(transform) : transform;
1484
+ const own = typeof transform === 'string' ? transformOf(transform) : transform;
990
1485
  value = [translate, rotate, scale, own].flatMap((part) => (Array.isArray(part) ? part : []));
991
1486
  node.composedTransform = { parts, value };
992
1487
  }
@@ -1117,6 +1612,8 @@ export class Engine {
1117
1612
  * does, a child list can move without anything else needing to be looked at again.
1118
1613
  */
1119
1614
  structuralSheets = false;
1615
+ /** Whether any sheet uses `:has()`. See `markBeneath`. */
1616
+ hasSheets = false;
1120
1617
  resolveAssetSource;
1121
1618
  constructor(fabric, rootTag, options = {}) {
1122
1619
  this.fabric = fabric;
@@ -1128,9 +1625,11 @@ export class Engine {
1128
1625
  if (options.tokens)
1129
1626
  this.styles.setRootTokens(options.tokens);
1130
1627
  this.structuralSheets = options.globalStyles?.structural === true;
1628
+ this.watchHas(options.globalStyles);
1629
+ this.watchActive(options.globalStyles);
1131
1630
  this.dev = options.dev ?? globalThis.__DEV__ === true;
1132
1631
  if (this.dev) {
1133
- this.styles.onUndefinedToken = (name, props) => this.reportUndefinedToken(name, props);
1632
+ this.styles.onUndefinedToken = (name, props, on) => this.reportUndefinedToken(name, props, on);
1134
1633
  this.styles.onUnreadDisplay = (name, value) => this.reportUnreadDisplay(name, value);
1135
1634
  }
1136
1635
  this.now = options.now ?? (() => globalThis.performance?.now?.() ?? Date.now());
@@ -1192,11 +1691,70 @@ export class Engine {
1192
1691
  if (node.active === active)
1193
1692
  continue;
1194
1693
  node.active = active;
1195
- this.markProps(node);
1694
+ this.markActive(node);
1196
1695
  changed = true;
1197
1696
  }
1198
1697
  return changed;
1199
1698
  }
1699
+ /** Whether any sheet styles a pressed element, and each compound that asks about one from another. */
1700
+ activeUse = {
1701
+ own: false,
1702
+ elsewhere: [],
1703
+ };
1704
+ /** What each sheet in play asks about a pressed element: `activeUse` is all of them together. */
1705
+ activeBySheet = new Map();
1706
+ /** Note what a sheet in play asks about a pressed element. See `markActive`. */
1707
+ watchActive(sheet) {
1708
+ // Asked for every element a component's sheet is given to: noted once a sheet.
1709
+ if (!sheet || this.activeBySheet.has(sheet))
1710
+ return;
1711
+ const use = usesActive(sheet);
1712
+ this.activeBySheet.set(sheet, use);
1713
+ if (!use.own && !use.elsewhere.length)
1714
+ return;
1715
+ this.countActive();
1716
+ if (this.styles.tracksHas)
1717
+ return;
1718
+ // What a pressed element is compared with is the rules it matched, which no cache kept until
1719
+ // a sheet asked. Nothing is resolved yet when the sheet is the one the engine was made with.
1720
+ this.styles.tracksHas = true;
1721
+ if (!this.root)
1722
+ return;
1723
+ this.root.styleDirty = true;
1724
+ this.markPath(this.root);
1725
+ }
1726
+ /** A sheet has left play, by a hot swap or by being removed: what it asked goes with it. */
1727
+ unwatchActive(sheet) {
1728
+ if (this.activeBySheet.delete(sheet))
1729
+ this.countActive();
1730
+ }
1731
+ countActive() {
1732
+ const all = [...this.activeBySheet.values()];
1733
+ this.activeUse = {
1734
+ own: all.some((use) => use.own),
1735
+ elsewhere: all.flatMap((use) => use.elsewhere),
1736
+ };
1737
+ }
1738
+ /**
1739
+ * A press began or ended on `node` or under it, which is all that changed about it. A press
1740
+ * reaches every ancestor up to the root, and marking each as restyled would style the whole
1741
+ * screen again, twice a press. So each is only matched again, and keeps its style and all
1742
+ * that is under it unless it now matches other rules. An element a rule asks about from
1743
+ * inside or beside it, `.card:active .title`, changes more than itself, and is restyled with
1744
+ * everything under it as any other change is. With no rule for a pressed element at all there
1745
+ * is nothing to match.
1746
+ */
1747
+ markActive(node) {
1748
+ const { own, elsewhere } = this.activeUse;
1749
+ if (!own && !elsewhere.length)
1750
+ return;
1751
+ // By its classes alone: a compound with none could be any element.
1752
+ const asked = elsewhere.some((compound) => compound.classes.every((name) => node.classes?.has(name)));
1753
+ if (asked)
1754
+ return this.markProps(node);
1755
+ node.stateDirty = true;
1756
+ this.markPath(node.parent ?? node);
1757
+ }
1200
1758
  /**
1201
1759
  * Custom properties the device knows and the stylesheet cannot, once they change.
1202
1760
  *
@@ -1219,6 +1777,8 @@ export class Engine {
1219
1777
  this.registerSheet(sheet);
1220
1778
  if (sheet.structural)
1221
1779
  this.structuralSheets = true;
1780
+ this.watchHas(sheet);
1781
+ this.watchActive(sheet);
1222
1782
  this.markPath(this.root);
1223
1783
  return true;
1224
1784
  }
@@ -1249,14 +1809,19 @@ export class Engine {
1249
1809
  * which is what makes Fabric dirty a measured node and measure it at the size now in effect.
1250
1810
  */
1251
1811
  remeasureText() {
1812
+ const again = (measured) => {
1813
+ measured.remeasure = true;
1814
+ this.markProps(measured, false);
1815
+ };
1252
1816
  const visit = (node) => {
1253
1817
  for (const child of node.children) {
1818
+ // The paragraph of a run of text written straight into a view is kept on the text.
1819
+ if (child.kind === 'text' && child.box?.committed)
1820
+ again(child.box);
1254
1821
  if (child.kind !== 'element')
1255
1822
  continue;
1256
- if (child.committed && MEASURED_VIEWS.has(viewNameOf(child))) {
1257
- child.remeasure = true;
1258
- this.markProps(child, false);
1259
- }
1823
+ if (child.committed && MEASURED_VIEWS.has(committedViewName(child)))
1824
+ again(child);
1260
1825
  visit(child);
1261
1826
  }
1262
1827
  };
@@ -1285,6 +1850,25 @@ export class Engine {
1285
1850
  * text attributes change, which the same cap does. Android sets it only when `fontFamily` is in
1286
1851
  * the props it is sent, and the engine sends only what changed, so it is sent again once.
1287
1852
  */
1853
+ /**
1854
+ * Loads have settled for these families: every node held back for one, a span inside a
1855
+ * paragraph included, is laid out with the name again, registered or not. See `loadingFamilies`.
1856
+ */
1857
+ fontsSettled(families) {
1858
+ let held = false;
1859
+ const visit = (node) => {
1860
+ for (const child of node.children) {
1861
+ if (child.heldFamily !== undefined && families.has(child.heldFamily)) {
1862
+ this.markProps(child, false);
1863
+ held = true;
1864
+ }
1865
+ visit(child);
1866
+ }
1867
+ };
1868
+ visit(this.root);
1869
+ if (held)
1870
+ this.commit();
1871
+ }
1288
1872
  fontsRegistered(families) {
1289
1873
  const visit = (node) => {
1290
1874
  for (const child of node.children) {
@@ -1385,6 +1969,8 @@ export class Engine {
1385
1969
  node.sheet = sheet;
1386
1970
  if (sheet?.structural)
1387
1971
  this.structuralSheets = true;
1972
+ this.watchHas(sheet);
1973
+ this.watchActive(sheet);
1388
1974
  this.markProps(node);
1389
1975
  }
1390
1976
  /** The sheet each node given another's scope was created with. */
@@ -1396,6 +1982,12 @@ export class Engine {
1396
1982
  viewportSize;
1397
1983
  /** The system text size, from the conditions. See `Conditions.fontScale`. */
1398
1984
  fontScale;
1985
+ viewportWatchers = new Set();
1986
+ /** Calls `listener` after each change of conditions, which is when `viewport` can change. */
1987
+ watchViewport(listener) {
1988
+ this.viewportWatchers.add(listener);
1989
+ return () => this.viewportWatchers.delete(listener);
1990
+ }
1399
1991
  updateConditions(next) {
1400
1992
  this.viewportSize = { width: next.width, height: next.height };
1401
1993
  this.fontScale = next.fontScale;
@@ -1404,13 +1996,23 @@ export class Engine {
1404
1996
  // Committed here rather than left to the next change-detection pass, because there may not
1405
1997
  // be one: no view is dirty, so a tick does no work and never reaches the renderer at all.
1406
1998
  this.commit();
1999
+ for (const watcher of this.viewportWatchers)
2000
+ watcher();
1407
2001
  }
1408
2002
  // --- mutation API -----------------------------------------------------
1409
2003
  createElement(name, sheet = null) {
1410
2004
  const node = new RetainedNode('element', name, this);
1411
2005
  node.sheet = sheet;
2006
+ const html = HTML_ELEMENTS.get(name);
2007
+ if (html !== undefined) {
2008
+ if (html.role)
2009
+ node.props['accessibilityRole'] = html.role;
2010
+ node.styled = html.styled;
2011
+ }
1412
2012
  if (sheet?.structural)
1413
2013
  this.structuralSheets = true;
2014
+ this.watchHas(sheet);
2015
+ this.watchActive(sheet);
1414
2016
  if (HOISTS[name])
1415
2017
  this.hoisted.add(node);
1416
2018
  return node;
@@ -1432,6 +2034,7 @@ export class Engine {
1432
2034
  if (node.hostSheet === sheet)
1433
2035
  return;
1434
2036
  node.hostSheet = sheet;
2037
+ this.watchActive(sheet);
1435
2038
  this.markProps(node);
1436
2039
  }
1437
2040
  /**
@@ -1464,7 +2067,7 @@ export class Engine {
1464
2067
  parent.children.push(child);
1465
2068
  if (child.dormantHoists)
1466
2069
  this.wakeHoists(child);
1467
- this.markStructure(parent);
2070
+ this.markStructure(parent, child);
1468
2071
  this.markTextContent(parent, child);
1469
2072
  }
1470
2073
  insertBefore(parent, child, ref) {
@@ -1477,7 +2080,7 @@ export class Engine {
1477
2080
  parent.children.splice(at, 0, child);
1478
2081
  if (child.dormantHoists)
1479
2082
  this.wakeHoists(child);
1480
- this.markStructure(parent);
2083
+ this.markStructure(parent, child);
1481
2084
  this.markTextContent(parent, child);
1482
2085
  }
1483
2086
  removeChild(parent, child) {
@@ -1490,7 +2093,7 @@ export class Engine {
1490
2093
  target.children.splice(at, 1);
1491
2094
  child.parent = null;
1492
2095
  this.removedSinceCommit.add(child);
1493
- this.markStructure(target);
2096
+ this.markStructure(target, child);
1494
2097
  this.markTextContent(target, child);
1495
2098
  }
1496
2099
  /**
@@ -1547,6 +2150,7 @@ export class Engine {
1547
2150
  node.playing = undefined;
1548
2151
  this.markProps(node, false);
1549
2152
  }
2153
+ this.releasePlayedNatively();
1550
2154
  for (const nodes of this.scrollTimelines.values()) {
1551
2155
  for (const node of nodes) {
1552
2156
  if (this.topOf(node) === this.root)
@@ -1581,8 +2185,14 @@ export class Engine {
1581
2185
  destroyNode(node) {
1582
2186
  node.listeners?.clear();
1583
2187
  node.listeners = null;
1584
- node.committed = null;
2188
+ // With what it holds: a node Angular destroys can be put back, as content projected into
2189
+ // an `@if` is, and its children's views would then be handed to the view it is made again
2190
+ // as. Native gives a view one parent for its life, and aborts on a second.
2191
+ if (node.committed)
2192
+ this.forgetCommitted(node);
1585
2193
  node.transitions = undefined;
2194
+ if (node.playing)
2195
+ this.stopNative(node, node.playing);
1586
2196
  node.playing = undefined;
1587
2197
  this.stopScrolled(node);
1588
2198
  this.running.delete(node);
@@ -1603,6 +2213,9 @@ export class Engine {
1603
2213
  return siblings[siblings.indexOf(node) + 1] ?? null;
1604
2214
  }
1605
2215
  setProp(node, key, value) {
2216
+ // A prop written again is a binding's, and is committed as any other is.
2217
+ if (node.attributeOnly?.delete(key))
2218
+ this.markProps(node, false);
1606
2219
  if (value === undefined || value === null) {
1607
2220
  // Nothing to remove, so nothing changed. Worth its own branch because it is the common
1608
2221
  // case, not a rare one: a host primitive carries a host binding for every prop React Native
@@ -1620,7 +2233,40 @@ export class Engine {
1620
2233
  // Inline style is applied after the cascade and inherited by nothing, so it cannot change
1621
2234
  // what any node matches or inherits. Every other prop can: `[disabled]` is a selector. An
1622
2235
  // inline `direction` is the exception, because the paragraphs below it align by it.
1623
- this.markProps(node, key !== 'style' || this.noteInlineDirection(node));
2236
+ this.markProps(node, key !== 'style' || this.inlineReachesStyle(node));
2237
+ }
2238
+ /**
2239
+ * Paint a node's background on one of its children instead of on the node: the node keeps its
2240
+ * `background-color` and `background-image` in the cascade, where a stylesheet wrote them, and
2241
+ * the child is the view that draws them. What a view that masks its children and not itself
2242
+ * needs, as `<gradient-text>` is.
2243
+ */
2244
+ paintOn(node, child) {
2245
+ node.paintsOn = child;
2246
+ this.markProps(node, false);
2247
+ this.markProps(child, false);
2248
+ }
2249
+ /**
2250
+ * Keep a prop the node already has as an attribute: on the node for a selector such as
2251
+ * `:host([variant='primary'])` to match, and left out of what is committed to the native view.
2252
+ * It is committed again once the prop is next written. What the platform adapter does with a
2253
+ * static attribute that is an input of the host's component.
2254
+ */
2255
+ keepAsAttribute(node, key) {
2256
+ if (!(key in node.props))
2257
+ return;
2258
+ (node.attributeOnly ??= new Set()).add(key);
2259
+ this.markProps(node, false);
2260
+ }
2261
+ /**
2262
+ * Whether a node's inline style can change what it or anything below it resolves to: it sets,
2263
+ * or until now set, a property its children inherit.
2264
+ */
2265
+ inlineReachesStyle(node) {
2266
+ const inherits = setsInherited(node.props['style']);
2267
+ const inherited = node.inlineInherits === true;
2268
+ node.inlineInherits = inherits;
2269
+ return this.noteInlineDirection(node) || inherits || inherited;
1624
2270
  }
1625
2271
  /** Whether a node's inline style sets `direction`, remembering that one has. */
1626
2272
  noteInlineDirection(node) {
@@ -1642,7 +2288,26 @@ export class Engine {
1642
2288
  * has changed for the props diff to notice.
1643
2289
  */
1644
2290
  styleChanged(node) {
1645
- this.markProps(node, this.noteInlineDirection(node));
2291
+ this.markProps(node, this.inlineReachesStyle(node));
2292
+ }
2293
+ /**
2294
+ * A bound declaration, `[style.color]`, whose value may be a `var()`. One that is, is kept as a
2295
+ * declaration the cascade settles with the node's tokens, as a rule's is, and true is answered:
2296
+ * the text is no value to put in the node's style. Any other value forgets what was kept.
2297
+ */
2298
+ setBoundStyle(node, prop, value) {
2299
+ const text = typeof value === 'string' && /^\s*var\(/i.test(value) ? value : undefined;
2300
+ const bound = text === undefined ? null : boundDeclaration(prop, tokenFromValue(text), node.name);
2301
+ if (!bound && !node.boundByProp?.has(prop))
2302
+ return false;
2303
+ const byProp = (node.boundByProp ??= new Map());
2304
+ if (bound)
2305
+ byProp.set(prop, bound);
2306
+ else
2307
+ byProp.delete(prop);
2308
+ node.boundStyle = byProp.size ? [...byProp.values()] : null;
2309
+ this.markProps(node);
2310
+ return bound !== null;
1646
2311
  }
1647
2312
  /**
1648
2313
  * Set a custom property on a node, `--tint` from `[style.--tint]`, or remove it with `null`.
@@ -1696,11 +2361,35 @@ export class Engine {
1696
2361
  // state, though nothing about the sibling moved. Only sheets that ask about siblings pay.
1697
2362
  if (this.structuralSheets)
1698
2363
  this.markLaterSiblings(node);
2364
+ if (this.hasSheets)
2365
+ this.markBeneath(node.parent);
1699
2366
  }
1700
2367
  // The root has no parent to carry the mark, so it carries its own: `.dark` on the root alone
1701
2368
  // restyles everything beneath it.
1702
2369
  this.markPath(node.parent ?? node);
1703
2370
  }
2371
+ /** Start matching ancestors again on a change, once a sheet that uses `:has()` is in play. */
2372
+ watchHas(sheet) {
2373
+ if (!sheet?.has || this.hasSheets)
2374
+ return;
2375
+ this.hasSheets = true;
2376
+ this.styles.tracksHas = true;
2377
+ // What is already resolved kept no record of the rules it matched: resolve it again. Nothing
2378
+ // is, when the sheet is the one the engine was made with.
2379
+ if (!this.root)
2380
+ return;
2381
+ this.root.styleDirty = true;
2382
+ this.markPath(this.root);
2383
+ }
2384
+ /**
2385
+ * Something at or beneath `from` changed: every node from there up may match a `:has()` rule
2386
+ * it did not, or stop matching one. They are matched again rather than marked for restyling,
2387
+ * which would restyle everything under them. Only sheets that use `:has()` pay.
2388
+ */
2389
+ markBeneath(from) {
2390
+ for (let node = from; node; node = node.parent)
2391
+ node.hasDirty = true;
2392
+ }
1704
2393
  markLaterSiblings(node) {
1705
2394
  const siblings = node.parent?.children;
1706
2395
  if (!siblings)
@@ -1709,7 +2398,8 @@ export class Engine {
1709
2398
  siblings[i].styleDirty = true;
1710
2399
  }
1711
2400
  }
1712
- markStructure(node) {
2401
+ /** `node`'s child list changed: `moved` came into it or went out of it. */
2402
+ markStructure(node, moved) {
1713
2403
  node.structureDirty = true;
1714
2404
  // A child list that moved changes what its members match, though nothing about them did:
1715
2405
  // the old last row is no longer the last. Only sheets that ask about position pay for this.
@@ -1717,7 +2407,13 @@ export class Engine {
1717
2407
  node.styleDirty = true;
1718
2408
  for (const child of node.children)
1719
2409
  child.styleDirty = true;
2410
+ // `.box:empty + .spacer`: what a later sibling matches can hang on whether this node is
2411
+ // empty, which only its first child arriving or its last one leaving changes.
2412
+ if (wasOrIsEmptyWithout(node, moved))
2413
+ this.markLaterSiblings(node);
1720
2414
  }
2415
+ if (this.hasSheets)
2416
+ this.markBeneath(node);
1721
2417
  this.markPath(node.parent);
1722
2418
  }
1723
2419
  /** Walk up until we hit a node already known to have a dirty subtree. */
@@ -1780,10 +2476,59 @@ export class Engine {
1780
2476
  }
1781
2477
  this.flushTransitionEvents();
1782
2478
  this.scrollDriver?.afterCommit();
1783
- if (this.facesAdded)
2479
+ // Read first: the commit that settles keyframes starts by clearing it.
2480
+ const facesAdded = this.facesAdded;
2481
+ if (this.awaitingKeyframes.size)
2482
+ this.settleKeyframes();
2483
+ if (facesAdded)
1784
2484
  this.rematchFonts();
1785
2485
  return true;
1786
2486
  }
2487
+ /** The first commit whose new views have not been drawn yet, while `commitUnseen` runs. */
2488
+ unseen = Infinity;
2489
+ /**
2490
+ * A commit for what changed in the same turn as earlier commits, before a frame was drawn.
2491
+ * `since` is how many commits there had been before the first of them (`stats.commits`).
2492
+ *
2493
+ * A view those commits created has not been seen in the style they gave it, so what this one
2494
+ * gives it is where it starts, and no transition runs toward it. That is what a browser
2495
+ * does with a class added to an element in the turn that inserted it, and what `animate.enter`
2496
+ * is built on: Angular adds the enter class after the render pass that created the element, and
2497
+ * takes it off a frame later, which is the one change the transition is for.
2498
+ */
2499
+ commitUnseen(since) {
2500
+ this.unseen = since;
2501
+ try {
2502
+ return this.commit();
2503
+ }
2504
+ finally {
2505
+ this.unseen = Infinity;
2506
+ }
2507
+ }
2508
+ /** Nodes that asked for `@keyframes` this commit had not met, by the name they asked for. */
2509
+ awaitingKeyframes = new Map();
2510
+ /**
2511
+ * A sheet is registered when the first node it styles is committed, so a node committed before
2512
+ * it can name keyframes that arrive later in the same commit. Those nodes play them from another
2513
+ * commit straight away, as a browser applies keyframes however the sheets arrived; a name still
2514
+ * unknown once the commit is over is reported.
2515
+ */
2516
+ settleKeyframes() {
2517
+ const waiting = [...this.awaitingKeyframes];
2518
+ this.awaitingKeyframes.clear();
2519
+ let found = false;
2520
+ for (const [node, name] of waiting) {
2521
+ if (this.keyframes.has(name)) {
2522
+ this.markProps(node, false);
2523
+ found = true;
2524
+ }
2525
+ else if (this.dev) {
2526
+ this.reportMissingKeyframes(name);
2527
+ }
2528
+ }
2529
+ if (found)
2530
+ this.commit();
2531
+ }
1787
2532
  scrollDriver;
1788
2533
  /** Whether native can move a view by a scroll with no JavaScript in between. */
1789
2534
  get drivesScroll() {
@@ -1873,23 +2618,24 @@ export class Engine {
1873
2618
  this.registerSheet(node.hostSheet);
1874
2619
  const props = { ...DEFAULT_PROPS[viewName], ...node.defaultStyle };
1875
2620
  Object.assign(props, this.styles.resolve(node, this.styleEpoch).style);
1876
- for (const key of Object.keys(node.props)) {
1877
- // No native prop has a hyphen. `data-*` and `aria-*` attributes stay on the node for
1878
- // selectors to match, and the components package maps `aria-*` to what native reads.
1879
- if (key !== 'style' &&
1880
- key !== INTRINSIC_SIZE &&
1881
- key !== STYLE_OVERRIDE &&
1882
- !key.includes('-')) {
1883
- props[key] = node.props[key];
1884
- }
1885
- }
2621
+ const resolved = props['pointerEvents'];
2622
+ writeOwnProps(node.props, props, node.attributeOnly);
1886
2623
  withTextContent(node, viewName, props);
1887
- const style = flattenStyle(node.props['style'], props);
2624
+ const cascaded = props['transform'];
2625
+ const style = boundTransform(node, flattenStyle(node.props['style'], props), cascaded);
2626
+ if (node.props['style']) {
2627
+ this.styles.overOtherForms(flattenStyle(node.props['style'], {}), style);
2628
+ }
2629
+ nativePointerEvents(node, style, resolved);
2630
+ // Before an image's own size: `fit-content` is no size, so the image's is what it gets.
2631
+ fitContent(node, style);
2632
+ percentHeight(node, style);
1888
2633
  const intrinsic = node.props[INTRINSIC_SIZE];
1889
2634
  if (intrinsic)
1890
2635
  applyIntrinsicSize(style, intrinsic);
1891
2636
  flattenStyle(node.props[STYLE_OVERRIDE], style);
1892
2637
  this.fontFaces.apply(style);
2638
+ holdLoadingFamily(node, style);
1893
2639
  if (viewName === PARAGRAPH)
1894
2640
  alignText(style, this.directionOf(node, style));
1895
2641
  if (this.fontsRefreshed)
@@ -1897,8 +2643,53 @@ export class Engine {
1897
2643
  alignMultiline(viewName, style);
1898
2644
  const merged = composeTransform(node, this.animated(node, this.transitioned(node, style)));
1899
2645
  centreSingleLine(viewName, merged, this.fontScale);
2646
+ this.movePaint(node, merged);
1900
2647
  return merged;
1901
2648
  }
2649
+ /**
2650
+ * Leave a node's background out of what it commits when a child paints it, and give that child
2651
+ * its parent's: the cascade's, and over it the parent's inline style.
2652
+ */
2653
+ movePaint(node, merged) {
2654
+ if (node.paintsOn)
2655
+ for (const key of PAINT_KEYS)
2656
+ delete merged[key];
2657
+ const from = node.parent?.paintsOn === node ? node.parent : null;
2658
+ if (!from)
2659
+ return;
2660
+ const paint = flattenStyle(from.props['style'], {
2661
+ ...this.styles.resolve(from, this.styleEpoch).style,
2662
+ });
2663
+ for (const key of PAINT_KEYS)
2664
+ if (paint[key] !== undefined)
2665
+ merged[key] = paint[key];
2666
+ }
2667
+ /**
2668
+ * A container whose direction or alignment changed since a child with a `fit-content` size
2669
+ * read it, or whose height stopped or started being one a percentage is taken of since a child
2670
+ * with a percentage height asked: those children are merged again. A change to a container
2671
+ * marks nothing beneath it, since none of these is inherited.
2672
+ */
2673
+ refitChildren(node) {
2674
+ // Both are asked, and both remembered, before either has the children merged again.
2675
+ const basis = node.heightBasis;
2676
+ const based = basis !== undefined && definiteHeight(node) !== basis;
2677
+ const fitted = node.fitContainer !== undefined && containerOf(node) !== node.fitContainer;
2678
+ if (fitted)
2679
+ node.fitContainer = containerOf(node);
2680
+ if (based || fitted)
2681
+ this.refit(node);
2682
+ }
2683
+ /** Have a container's children merged again, through any `display: contents` among them. */
2684
+ refit(container) {
2685
+ for (const child of container.children) {
2686
+ if (child.kind !== 'element')
2687
+ continue;
2688
+ child.propsDirty = true;
2689
+ if (containerOf(child) === 'contents')
2690
+ this.refit(child);
2691
+ }
2692
+ }
1902
2693
  /**
1903
2694
  * The text size cap that lays a paragraph or text input out again once a face it names has
1904
2695
  * registered: a step per face above the app's own cap where it set one (below 1 is none, on
@@ -1972,6 +2763,8 @@ export class Engine {
1972
2763
  sheetReplaced(sheet, next) {
1973
2764
  if (sheet === next && this.knownSheets.has(sheet))
1974
2765
  return;
2766
+ this.unwatchActive(sheet);
2767
+ this.watchActive(next);
1975
2768
  const at = this.sheetOrder.indexOf(sheet);
1976
2769
  if (at === -1) {
1977
2770
  this.registerSheet(next);
@@ -1981,14 +2774,25 @@ export class Engine {
1981
2774
  if (next && !this.knownSheets.has(next)) {
1982
2775
  this.knownSheets.add(next);
1983
2776
  this.sheetOrder[at] = next;
1984
- if (next.fonts && this.fontFaces.add(next.fonts))
1985
- this.facesAdded = true;
1986
2777
  }
1987
2778
  else {
1988
2779
  this.sheetOrder.splice(at, 1);
1989
2780
  }
1990
2781
  if (sheet.keyframes || next?.keyframes)
1991
2782
  this.reindexKeyframes();
2783
+ if (sheet.fonts || next?.fonts)
2784
+ this.reindexFonts();
2785
+ }
2786
+ /**
2787
+ * Every sheet's `@font-face` rules again, in order, and every text that names a family matched
2788
+ * again: a face whose rule went is no longer matched, though the platform keeps it registered.
2789
+ */
2790
+ reindexFonts() {
2791
+ this.fontFaces.clear();
2792
+ for (const sheet of this.sheetOrder)
2793
+ if (sheet.fonts)
2794
+ this.fontFaces.add(sheet.fonts);
2795
+ this.markFontText(this.root);
1992
2796
  }
1993
2797
  /** Every sheet's `@keyframes` again, in order, and every animation told to look them up. */
1994
2798
  reindexKeyframes() {
@@ -2018,18 +2822,25 @@ export class Engine {
2018
2822
  * Once per sheet that declares faces, so walking the tree costs nothing that matters.
2019
2823
  */
2020
2824
  rematchFonts() {
2021
- const visit = (node) => {
2022
- for (const child of node.children) {
2023
- if (child.kind !== 'element')
2024
- continue;
2025
- if (typeof child.committed?.props['fontFamily'] === 'string')
2026
- this.markProps(child, false);
2027
- visit(child);
2028
- }
2029
- };
2030
- visit(this.root);
2825
+ this.markFontText(this.root);
2031
2826
  this.commit();
2032
2827
  }
2828
+ /** Mark every element under `node` committed with a family, so it is matched again. */
2829
+ markFontText(node) {
2830
+ const named = (text) => {
2831
+ if (typeof text?.committed?.props['fontFamily'] === 'string')
2832
+ this.markProps(text, false);
2833
+ };
2834
+ for (const child of node.children) {
2835
+ // The paragraph of a run of text written straight into a view is kept on the text.
2836
+ if (child.kind === 'text')
2837
+ named(child.box);
2838
+ if (child.kind !== 'element')
2839
+ continue;
2840
+ named(child);
2841
+ this.markFontText(child);
2842
+ }
2843
+ }
2033
2844
  knownSheets = new WeakSet();
2034
2845
  /**
2035
2846
  * Start, advance or stop the `@keyframes` animation a node's style asks for.
@@ -2044,6 +2855,7 @@ export class Engine {
2044
2855
  this.stopScrolled(node);
2045
2856
  if (!spec) {
2046
2857
  if (node.playing) {
2858
+ this.cancelPlaying(node);
2047
2859
  node.playing = undefined;
2048
2860
  this.playing.delete(node);
2049
2861
  }
@@ -2051,12 +2863,13 @@ export class Engine {
2051
2863
  }
2052
2864
  const frames = this.keyframes.get(spec.name);
2053
2865
  if (!frames) {
2054
- // Keyframes a hot swap deleted, from under an animation that was playing them.
2866
+ // Keyframes a hot swap deleted, from under an animation that was playing them, or ones a
2867
+ // sheet later in this commit has: see `settleKeyframes`.
2868
+ this.cancelPlaying(node);
2055
2869
  node.playing = undefined;
2056
2870
  this.playing.delete(node);
2057
2871
  this.stopScrolled(node);
2058
- if (this.dev)
2059
- this.reportMissingKeyframes(spec.name);
2872
+ this.awaitingKeyframes.set(node, spec.name);
2060
2873
  return props;
2061
2874
  }
2062
2875
  if (spec.timeline)
@@ -2064,11 +2877,116 @@ export class Engine {
2064
2877
  this.startPlaying(node, spec, frames, props);
2065
2878
  return Object.assign(props, node.playing?.values ?? {});
2066
2879
  }
2880
+ /**
2881
+ * A clock animation the scroll plays instead: the same animation where only the timeline moved,
2882
+ * and one cancelled where the name changed.
2883
+ */
2884
+ handOverToScroll(node, spec) {
2885
+ if (!node.playing)
2886
+ return;
2887
+ this.stopNative(node, node.playing);
2888
+ if (node.playing.spec.name !== spec.name)
2889
+ this.cancelPlaying(node);
2890
+ node.playing = undefined;
2891
+ this.playing.delete(node);
2892
+ }
2893
+ /**
2894
+ * `animationcancel` for an animation stopped before it ended: the element no longer asks for it,
2895
+ * asks for another by name, or its keyframes went, as a browser fires it.
2896
+ */
2897
+ cancelPlaying(node) {
2898
+ const running = node.playing;
2899
+ if (running)
2900
+ this.stopNative(node, running);
2901
+ if (running && !running.done)
2902
+ this.emitTransition(node, 'topAnimationcancel', running.spec.name);
2903
+ }
2904
+ /** Stop on native each animation whose view has left the tree. */
2905
+ releasePlayedNatively() {
2906
+ for (const node of [...this.playedNatively]) {
2907
+ if (this.topOf(node) === this.root)
2908
+ continue;
2909
+ this.stopNative(node, node.playing);
2910
+ node.playing = undefined;
2911
+ this.markProps(node, false);
2912
+ }
2913
+ }
2914
+ /** Every node whose animation native is playing, with no frame of it in JavaScript. */
2915
+ playedNatively = new Set();
2916
+ /**
2917
+ * Hand an animation that has just started to native, where native can play it: one that moves
2918
+ * only opacity and transforms, starts at once, and repeats a whole number of times. Native then
2919
+ * runs it frame by frame, and JavaScript hears of it again when it ends. Anything else is
2920
+ * played from here, a commit a frame. See `NativeScrollDriver.play`.
2921
+ */
2922
+ playNatively(node, running, props) {
2923
+ const { spec } = running;
2924
+ if (!this.scrollDriver || spec.delay !== 0 || spec.duration <= 0)
2925
+ return false;
2926
+ const { channels, held, span } = clockChannels(running.tracks, spec, props);
2927
+ if (held.length || !(channels.opacity || channels.transform.length))
2928
+ return false;
2929
+ // There and back is one native animation: an odd number of ways has no whole number of them.
2930
+ const count = spec.iterations === null ? -1 : spec.iterations / span;
2931
+ if (count !== -1 && !(Number.isInteger(count) && count > 0))
2932
+ return false;
2933
+ const length = Math.max(2, Math.round((spec.duration * span) / NATIVE_FRAME) + 1);
2934
+ const frames = Array.from({ length }, (_, i) => i / (length - 1));
2935
+ const native = this.scrollDriver.play(node, channels, { frames, toValue: span, iterations: count }, (view) => this.tagOf(view), () => this.endedNatively(node, running));
2936
+ if (!native)
2937
+ return false;
2938
+ running.native = native;
2939
+ // As Animated does: Fabric flattens a view that only lays out, and then there is no native
2940
+ // view for the animation to move.
2941
+ running.values = { ...running.values, collapsable: false };
2942
+ this.playedNatively.add(node);
2943
+ return true;
2944
+ }
2945
+ /**
2946
+ * Native reached the end of an animation it was playing. The frame it ends at, or the resting
2947
+ * style where it does not fill, is committed before the view is let go, so nothing shows of
2948
+ * the frame it started at, which is what the view had been committed with.
2949
+ */
2950
+ endedNatively(node, running) {
2951
+ if (node.playing !== running || !running.native)
2952
+ return;
2953
+ const { native, spec } = running;
2954
+ running.native = undefined;
2955
+ this.playedNatively.delete(node);
2956
+ const end = running.start + spec.duration * (spec.iterations ?? 1);
2957
+ this.advancePlayer(node, Math.max(this.now(), end));
2958
+ this.commit();
2959
+ native.stop();
2960
+ }
2961
+ /** Take an animation back from native, which stops it there. The caller plays it on, or not. */
2962
+ stopNative(node, running) {
2963
+ if (!running.native)
2964
+ return;
2965
+ running.native.stop();
2966
+ running.native = undefined;
2967
+ this.playedNatively.delete(node);
2968
+ }
2969
+ /**
2970
+ * Play on from JavaScript an animation native was playing, from where its clock has got to:
2971
+ * what a pause, other frames or a view made again need, none of which native can be told of.
2972
+ */
2973
+ backToScript(node, running) {
2974
+ if (!running.native)
2975
+ return;
2976
+ this.stopNative(node, running);
2977
+ running.values = sample(running, this.now()).values;
2978
+ if (!running.done && running.pausedAt === undefined)
2979
+ this.playing.add(node);
2980
+ }
2067
2981
  /** Start the clock on an animation, unless the node is already playing this one. */
2068
2982
  startPlaying(node, spec, frames, props) {
2069
2983
  const current = node.playing;
2070
2984
  const inherited = this.inheritedColour(node, frames);
2071
2985
  if (!current || !sameAnimation(current.spec, spec)) {
2986
+ if (current && current.spec.name !== spec.name)
2987
+ this.cancelPlaying(node);
2988
+ else if (current)
2989
+ this.stopNative(node, current);
2072
2990
  const started = {
2073
2991
  spec,
2074
2992
  tracks: tracksOf(frames, props, inherited),
@@ -2089,12 +3007,16 @@ export class Engine {
2089
3007
  node.playing = started;
2090
3008
  if (spec.paused)
2091
3009
  started.pausedAt = this.now();
2092
- else
3010
+ else if (!this.playNatively(node, started, props))
2093
3011
  this.playing.add(node);
2094
3012
  this.emitTransition(node, 'topAnimationstart', spec.name);
2095
3013
  return;
2096
3014
  }
3015
+ // A view made again is another view: the one native was moving is gone.
3016
+ if (node.committed === null)
3017
+ this.backToScript(node, current);
2097
3018
  if (this.playedFrames.get(current) !== frames || inherited !== current.inherited) {
3019
+ this.backToScript(node, current);
2098
3020
  this.reframe(node, current, frames, props, inherited);
2099
3021
  }
2100
3022
  this.playOrPause(node, node.playing, spec);
@@ -2132,6 +3054,7 @@ export class Engine {
2132
3054
  current.spec = spec;
2133
3055
  const now = this.now();
2134
3056
  if (spec.paused && current.pausedAt === undefined && !current.done) {
3057
+ this.backToScript(node, current);
2135
3058
  current.pausedAt = now;
2136
3059
  this.playing.delete(node);
2137
3060
  }
@@ -2160,10 +3083,7 @@ export class Engine {
2160
3083
  return Object.assign(props, this.rescrolled(node, current, frames, props, inherited));
2161
3084
  }
2162
3085
  this.stopScrolled(node);
2163
- if (node.playing) {
2164
- node.playing = undefined;
2165
- this.playing.delete(node);
2166
- }
3086
+ this.handOverToScroll(node, spec);
2167
3087
  const tracks = tracksOf(frames, props, inherited);
2168
3088
  const source = this.scrollSourceOf(node);
2169
3089
  const extent = source ? (this.scrollExtents.get(source)?.[spec.timeline] ?? null) : null;
@@ -2280,6 +3200,9 @@ export class Engine {
2280
3200
  `has loaded, so the block has to exist somewhere by the time the animation starts.`);
2281
3201
  }
2282
3202
  transitioned(node, props) {
3203
+ // Nobody saw what the last commit gave a view it created, so this is where the view starts.
3204
+ if (node.bornIn >= this.unseen)
3205
+ node.transitions = undefined;
2283
3206
  const spec = transitionSpec(props);
2284
3207
  if (!spec && !node.transitions)
2285
3208
  return props;
@@ -2391,7 +3314,7 @@ export class Engine {
2391
3314
  if (value === null || value === undefined)
2392
3315
  out[key] = value;
2393
3316
  else if (isColorProp(key))
2394
- out[key] = this.color(value);
3317
+ out[key] = this.painted(key, value);
2395
3318
  // A list of sources has already been resolved by whoever built it, as RN's resolver
2396
3319
  // also assumes: it passes any object through and only turns a number into one.
2397
3320
  else if (ASSET_PROPS.has(key) && !Array.isArray(value))
@@ -2404,7 +3327,7 @@ export class Engine {
2404
3327
  out[key] = this.processFilters(value);
2405
3328
  // A bound style's transform is still the CSS string: see `inline-transform.ts`.
2406
3329
  else if (key === 'transform' && typeof value === 'string')
2407
- out[key] = transformList(value);
3330
+ out[key] = transformOf(value);
2408
3331
  else
2409
3332
  out[key] = value;
2410
3333
  }
@@ -2442,7 +3365,8 @@ export class Engine {
2442
3365
  *
2443
3366
  * A stop's colour is a colour like any other, but it is two levels down and nothing else looks
2444
3367
  * there: without this the device is handed the string `rgb(255, 0, 0)` where it wants a number,
2445
- * and paints nothing.
3368
+ * and paints nothing. The stops are first rewritten so native's interpolation paints what
3369
+ * CSS's premultiplied one does (see `premultipliedStops`).
2446
3370
  */
2447
3371
  processGradients(value) {
2448
3372
  if (!Array.isArray(value))
@@ -2453,7 +3377,7 @@ export class Engine {
2453
3377
  return entry;
2454
3378
  return {
2455
3379
  ...gradient,
2456
- colorStops: gradient.colorStops.map((stop) => ({
3380
+ colorStops: premultipliedStops(gradient.colorStops).map((stop) => ({
2457
3381
  ...stop,
2458
3382
  color: this.color(stop.color),
2459
3383
  })),
@@ -2475,8 +3399,12 @@ export class Engine {
2475
3399
  if (this.isClean(node) && !styleChanged)
2476
3400
  return node.committed.handle;
2477
3401
  node.styleCommitted = style;
2478
- const viewName = viewNameOf(node);
3402
+ repaint(node);
3403
+ this.refitChildren(node);
3404
+ const viewName = committedViewName(node);
3405
+ this.forgetRenamed(node, viewName);
2479
3406
  const childHandles = this.reconcileChildren(node, viewName, style);
3407
+ noteTouches(node, style);
2480
3408
  const previous = node.committed;
2481
3409
  this.notePresented(node, viewName);
2482
3410
  if (!previous)
@@ -2495,7 +3423,7 @@ export class Engine {
2495
3423
  handle = this.clone(previous.handle, propsPayload && this.processed(propsPayload), childrenChanged ? childHandles : null);
2496
3424
  // A clone is the same view, so it keeps the tag it was created with, not the newest tag
2497
3425
  // handed out, which is some other node's.
2498
- node.committed = { handle, tag: previous.tag, props, childHandles };
3426
+ node.committed = { handle, tag: previous.tag, props, childHandles, viewName };
2499
3427
  }
2500
3428
  this.clearFlags(node);
2501
3429
  return handle;
@@ -2533,19 +3461,62 @@ export class Engine {
2533
3461
  const context = style?.context;
2534
3462
  if (HOIST_TARGETS.has(node.name))
2535
3463
  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;
2538
3464
  const handles = [];
2539
3465
  for (const child of node.children) {
2540
3466
  if (child.kind === 'anchor' || this.committedElsewhere(child) || this.withheld(child)) {
2541
3467
  continue;
2542
3468
  }
2543
- if (textAsProp && child.kind === 'text')
3469
+ if (child.kind === 'text') {
3470
+ const handle = this.reconcileText(node, child, viewName, context);
3471
+ if (handle)
3472
+ handles.push(handle);
2544
3473
  continue;
3474
+ }
2545
3475
  handles.push(this.reconcileUnder(node, child, context));
2546
3476
  }
2547
3477
  return handles;
2548
3478
  }
3479
+ /**
3480
+ * The paragraph a run of text written straight into a view is drawn in, or nothing for a run of
3481
+ * whitespace, which a browser draws nothing for between the items of a flex box either.
3482
+ *
3483
+ * It is the text's own, kept on the text node, and no part of the tree: its parent is the view,
3484
+ * so it inherits what the view's text would, and no rule matches it.
3485
+ */
3486
+ /**
3487
+ * A run of text under `parent`, which commits as `viewName`: the text itself inside a text, a
3488
+ * paragraph of its own inside any other view, and nothing where the view takes its text as a
3489
+ * prop (see `ViewNameOptions`) or the run is whitespace between a view's children.
3490
+ */
3491
+ reconcileText(parent, text, viewName, context) {
3492
+ if (TEXT_CONTENT_PROPS[viewName] !== undefined)
3493
+ return null;
3494
+ if (viewName === PARAGRAPH || viewName === VIRTUAL_TEXT) {
3495
+ // Under a text element, the paragraph it had in a view is done with. Under that paragraph
3496
+ // itself, which is where a loose run is committed, it is the one to keep.
3497
+ if (text.box && text.box !== parent)
3498
+ text.box = undefined;
3499
+ return this.reconcileUnder(parent, text, context);
3500
+ }
3501
+ const box = this.looseText(parent, text);
3502
+ return box ? this.reconcileUnder(parent, box, context) : null;
3503
+ }
3504
+ looseText(parent, text) {
3505
+ if (!trimEnds(text.text))
3506
+ return null;
3507
+ let box = text.box;
3508
+ if (!box) {
3509
+ box = new RetainedNode('element', 'text', this);
3510
+ box.anonymous = true;
3511
+ box.children = [text];
3512
+ text.box = box;
3513
+ }
3514
+ box.parent = parent;
3515
+ // The text is not under the box in the tree, so a change to it never marked the box.
3516
+ if (!this.isClean(text))
3517
+ box.subtreeDirty = true;
3518
+ return box;
3519
+ }
2549
3520
  /** Remember an iOS modal host committed as showing, so hiding it waits for `topDismiss`. */
2550
3521
  notePresented(node, viewName) {
2551
3522
  if (viewName !== MODAL_HOST || node.props['visible'] === false)
@@ -2625,6 +3596,14 @@ export class Engine {
2625
3596
  child.committedUnder = parent;
2626
3597
  return handle;
2627
3598
  }
3599
+ /**
3600
+ * A node whose native view is no longer the one it was created as is created again: a text
3601
+ * element that came to hold a view, or stopped. A clone keeps the view it was made as.
3602
+ */
3603
+ forgetRenamed(node, viewName) {
3604
+ if (node.committed && node.committed.viewName !== viewName)
3605
+ this.forgetCommitted(node);
3606
+ }
2628
3607
  /**
2629
3608
  * Let a node and everything under it be created again at the next commit. The views it keeps
2630
3609
  * for hoisted names go too: they are children of the native view it is losing.
@@ -2634,9 +3613,12 @@ export class Engine {
2634
3613
  node.committedUnder = null;
2635
3614
  node.styleCommitted = null;
2636
3615
  node.kept = undefined;
2637
- for (const child of node.children)
2638
- if (child.committed)
3616
+ if (node.box?.committed)
3617
+ this.forgetCommitted(node.box);
3618
+ for (const child of node.children) {
3619
+ if (child.committed || child.box?.committed)
2639
3620
  this.forgetCommitted(child);
3621
+ }
2640
3622
  }
2641
3623
  /** A hoisted node, which commits into an ancestor further up than the parent it is in. */
2642
3624
  committedElsewhere(child) {
@@ -2678,7 +3660,7 @@ export class Engine {
2678
3660
  };
2679
3661
  const payload = diffProps(kept.committed.props, props);
2680
3662
  const handle = this.clone(kept.committed.handle, payload && this.processed(payload), []);
2681
- kept.committed = { handle, tag: kept.committed.tag, props, childHandles: [] };
3663
+ kept.committed = { ...kept.committed, handle, props, childHandles: [] };
2682
3664
  kept.node = null;
2683
3665
  }
2684
3666
  handles.push(kept.committed.handle);
@@ -2747,24 +3729,29 @@ export class Engine {
2747
3729
  const handle = this.fabric.createNode(tag, viewName, this.rootTag, this.processed(props), node);
2748
3730
  for (const child of childHandles)
2749
3731
  this.fabric.appendChild(handle, child);
2750
- node.committed = { handle, tag, props, childHandles };
3732
+ node.committed = { handle, tag, props, childHandles, viewName };
3733
+ node.bornIn = this.stats.commits;
2751
3734
  this.clearFlags(node);
2752
3735
  return handle;
2753
3736
  }
2754
3737
  /**
2755
3738
  * Angular's own unknown-element check does not run in this pipeline, so a `<text>` in a
2756
- * template that never imported `Text` compiles, renders as a plain view, and looks like a
3739
+ * template that never imported `Text` compiles, draws without the component, and looks like a
2757
3740
  * layout bug. On the first commit of any known element with no component behind it, say so.
2758
3741
  */
2759
3742
  checkClaimed(node) {
2760
- if (node.kind !== 'element' || node.claimed || !PRIMITIVE_NAMES.has(node.name))
3743
+ // The paragraph the engine wraps loose text in is its own: no template wrote it.
3744
+ if (node.kind !== 'element' || node.claimed || node.anonymous)
3745
+ return;
3746
+ if (!PRIMITIVE_NAMES.has(node.name))
2761
3747
  return;
2762
3748
  if (this.reported.has(node.name))
2763
3749
  return;
2764
3750
  this.reported.add(node.name);
2765
3751
  console.error(`[angular-native] <${node.name}> is used in a template that does not import ` +
2766
3752
  `${className(node.name)}. Add it to the component's \`imports\` from ` +
2767
- `'@ng-native/components'; without it the element renders as a plain view.`);
3753
+ `'@ng-native/components'; without it the element has none of the component's inputs ` +
3754
+ `or behavior, so a prop written on it stays the text it was written as.`);
2768
3755
  }
2769
3756
  /**
2770
3757
  * The misspelt-prop check. Angular's own "Can't bind to" check needs a DOM to run, so a
@@ -2775,11 +3762,13 @@ export class Engine {
2775
3762
  */
2776
3763
  undefinedTokens = new Set();
2777
3764
  /** Said once per name: a phone has no inspector to show the declaration struck out. */
2778
- reportUndefinedToken(name, props) {
3765
+ reportUndefinedToken(name, props, on) {
2779
3766
  if (this.undefinedTokens.has(name))
2780
3767
  return;
2781
3768
  this.undefinedTokens.add(name);
2782
- console.warn(`[angular-native] var(${name}) in ${props.join(', ')} names a custom property nothing in` +
3769
+ // A bound declaration has no rule to be found by, so it says which element it is on.
3770
+ const where = on === undefined ? props.join(', ') : `${props.join(', ')}, bound on <${on}>,`;
3771
+ console.warn(`[angular-native] var(${name}) in ${where} names a custom property nothing in` +
2783
3772
  ` scope defines, so the declaration is dropped, as a browser drops it. Define ${name} on` +
2784
3773
  ` :root or an ancestor, or give the var() a fallback.`);
2785
3774
  }
@@ -3008,6 +3997,18 @@ export class Engine {
3008
3997
  resolveAsset(value) {
3009
3998
  return this.resolveAssetSource(value);
3010
3999
  }
4000
+ /**
4001
+ * A colour prop as native takes it. A shape's `fill` or `stroke` the cascade settled from a
4002
+ * token is the solid brush react-native-svg takes, and `none` no paint at all.
4003
+ */
4004
+ painted(key, value) {
4005
+ if (!BRUSH_PROPS.has(key))
4006
+ return this.color(value);
4007
+ // Already a brush, as the icon wrote it: only one the cascade settled is still text.
4008
+ if (typeof value !== 'string')
4009
+ return value;
4010
+ return value === 'none' ? null : { type: 0, payload: this.color(value) };
4011
+ }
3011
4012
  /**
3012
4013
  * The host's `processColor`, and the one place a platform colour becomes one.
3013
4014
  *