@ng-native/fabric 0.4.0 → 0.6.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,7 +7,7 @@
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 { boundDeclaration, inlineInherited, setsInherited, StyleResolver, } from "./css.js";
10
+ import { boundDeclaration, faded, fills, inlineInherited, setsInherited, StyleResolver, usesActive, elementsAtEnd, placeReadElsewhere, siblingReach, } from "./css.js";
11
11
  import { applyAria } from "./aria-props.js";
12
12
  import { STYLED_ELEMENTS } from "./element-styles.js";
13
13
  import { animationEvent, bezier, interpolate, readsInheritedColour, sample, step, tracksOf, } from "./transition.js";
@@ -15,8 +15,9 @@ import { isFormsInput } from "./forms-inputs.js";
15
15
  import { transformList } from "./inline-transform.js";
16
16
  import { tokenFromValue } from "./inline-token.js";
17
17
  import { NativeScrollDriver, scrollFeed, } from "./native-drive.js";
18
- import { firstFrame, rangeOf, scrollChannels } from "./scroll-animation.js";
18
+ import { clockChannels, firstFrame, movesByShare, rangeOf, scrollChannels, sharesAsPoints, } from "./scroll-animation.js";
19
19
  import { FontFaces } from "./font-faces.js";
20
+ import { premultipliedStops } from "./premultiplied-stops.js";
20
21
  /**
21
22
  * The animation a node's style asks for, with a play state from a rule of its own applied. Both
22
23
  * are instructions for this engine and nothing native has heard of them, so they come out of the
@@ -400,6 +401,38 @@ function textScale(props, fontScale = 1) {
400
401
  const cap = props['maxFontSizeMultiplier'];
401
402
  return typeof cap === 'number' && cap >= 1 ? Math.min(fontScale, cap) : fontScale;
402
403
  }
404
+ /** A field's padding and border on its top or bottom edge, as they are written. */
405
+ const blockEdge = (props, side, logical) => [
406
+ props[`padding${side}`] ??
407
+ props[`paddingBlock${logical}`] ??
408
+ props['paddingBlock'] ??
409
+ props['paddingVertical'] ??
410
+ props['padding'] ??
411
+ 0,
412
+ props[`border${side}Width`] ?? props['borderWidth'] ?? 0,
413
+ ];
414
+ /**
415
+ * A multiline field's `rows` as its height, where it was given none: that many lines of its
416
+ * line height at the system text size, inside its padding and border, as a browser sizes a `<textarea>`. A native field
417
+ * is as tall as its text, which is one line before anything is typed.
418
+ */
419
+ function rowsTall(props, fontScale) {
420
+ const rows = Number(props['rows']);
421
+ const line = props['lineHeight'];
422
+ if (props['multiline'] !== true || !(rows > 0) || typeof line !== 'number')
423
+ return;
424
+ if (isSet(props['height']) && props['height'] !== 'auto')
425
+ return;
426
+ // A content box's height is its lines alone: Yoga adds the padding and border itself.
427
+ const edges = props['boxSizing'] === 'content-box'
428
+ ? []
429
+ : [...blockEdge(props, 'Top', 'Start'), ...blockEdge(props, 'Bottom', 'End')];
430
+ if (!edges.every((part) => typeof part === 'number'))
431
+ return;
432
+ // At the system text size, which the field's lines are drawn at.
433
+ const lines = rows * line * textScale(props, fontScale);
434
+ props['height'] = edges.reduce((sum, part) => sum + part, lines);
435
+ }
403
436
  /**
404
437
  * Centre the text of a single-line text field that has a line height, as Chrome centres an
405
438
  * input's, keeping the height the line height gives it.
@@ -437,15 +470,7 @@ function centreSingleLine(viewName, props, fontScale) {
437
470
  // Android sizes the field by its line height, and centres the text in it, itself.
438
471
  if (viewName !== 'TextInput')
439
472
  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
- ];
473
+ const edge = (side, logical) => blockEdge(props, side, logical);
449
474
  const line = lineHeight * textScale(props, fontScale);
450
475
  const box = [line, ...edge('Top', 'Start'), ...edge('Bottom', 'End')];
451
476
  const own = props['minHeight'] ?? 0;
@@ -459,6 +484,255 @@ function centreSingleLine(viewName, props, fontScale) {
459
484
  : box.reduce((sum, part) => sum + part, 0);
460
485
  props['minHeight'] = Math.max(own, Math.min(content, max));
461
486
  }
487
+ const NO_STYLE = {};
488
+ /**
489
+ * An element's inline style, less each property a rule it matches declared `!important`: an
490
+ * inline style is the last of the plain declarations, under every important one.
491
+ *
492
+ * ponytail: by the name a property is committed under, so an inline `padding` is not taken out
493
+ * by an important `padding-top`. Expand the inline shorthand here if one is ever written so.
494
+ */
495
+ function inlineOf(node) {
496
+ if (!node.props['style'])
497
+ return NO_STYLE;
498
+ const inline = flattenStyle(node.props['style'], {});
499
+ for (const key of node.styleCache?.important ?? [])
500
+ delete inline[key];
501
+ return inline;
502
+ }
503
+ /**
504
+ * What a container's own style says for `key`, in the order `mergeProps` applies them: a
505
+ * component's override, inline, a prop, its stylesheet, its default.
506
+ */
507
+ function ownLayout(node, key) {
508
+ return (flattenStyle(node.props[STYLE_OVERRIDE], {})[key] ??
509
+ inlineOf(node)[key] ??
510
+ node.props[key] ??
511
+ node.styleCache?.style[key] ??
512
+ node.defaultStyle?.[key]);
513
+ }
514
+ /**
515
+ * A container's main axis and how it aligns across it, as `row` or `column` and a keyword. A
516
+ * `display: contents` node is no container: its children are laid out in the one above it.
517
+ */
518
+ function containerOf(node) {
519
+ if (ownLayout(node, 'display') === 'contents')
520
+ return 'contents';
521
+ const direction = String(ownLayout(node, 'flexDirection') ?? 'column');
522
+ return `${direction.startsWith('row') ? 'row' : 'column'} ${ownLayout(node, 'alignItems') ?? 'stretch'}`;
523
+ }
524
+ /**
525
+ * The node a box is laid out in: its parent, or the one above every `display: contents` ancestor.
526
+ * Each of those is told a `fit-content` child passed through, so that when it stops being
527
+ * `contents`, and is the container, the child is worked out again.
528
+ */
529
+ function layoutParent(node) {
530
+ let parent = node.parent;
531
+ while (parent && containerOf(parent) === 'contents') {
532
+ parent.fitContainer = 'contents';
533
+ parent = parent.parent;
534
+ }
535
+ return parent;
536
+ }
537
+ const STRETCHES = new Set([undefined, null, 'auto', 'stretch']);
538
+ /**
539
+ * `width: fit-content` and `height: fit-content`, which Yoga has no size for.
540
+ *
541
+ * Along its container's main axis a box is as big as its content already, so the size is
542
+ * dropped. Across it a box stretches, where a browser keeps it at its content's size and at the
543
+ * start: `align-self: flex-start` does both. An alignment the container or the box asked for is
544
+ * kept, since it stops the stretch already.
545
+ */
546
+ function fitContent(node, props) {
547
+ const width = props['width'] === 'fit-content';
548
+ const height = props['height'] === 'fit-content';
549
+ if (!width && !height)
550
+ return;
551
+ if (width)
552
+ delete props['width'];
553
+ if (height)
554
+ delete props['height'];
555
+ const parent = layoutParent(node);
556
+ if (!parent)
557
+ return;
558
+ const container = containerOf(parent);
559
+ parent.fitContainer = container;
560
+ const [direction, align] = container.split(' ');
561
+ const across = direction === 'row' ? height : width;
562
+ if (across && align === 'stretch' && STRETCHES.has(props['alignSelf'])) {
563
+ props['alignSelf'] = 'flex-start';
564
+ }
565
+ }
566
+ const isPercent = (value) => typeof value === 'string' && value.endsWith('%');
567
+ /** Whether a `flex-basis` is a length or a percentage of something: not zero, and not `auto`. */
568
+ const isSizedBasis = (basis) => isPercent(basis)
569
+ ? Number.parseFloat(basis) !== 0
570
+ : typeof basis === 'number' && basis !== 0;
571
+ /**
572
+ * A `flex-basis` that is a length or a percentage, as the size it is along its container's main
573
+ * axis: a width in a row and a height in a column, over one written for that axis.
574
+ *
575
+ * Yoga works a `flexBasis` out once for a view and keeps the answer, and React Native never has
576
+ * it forgotten when the view's props change (`computedFlexBasis`, cleared only by
577
+ * `markDirtyAndPropagate`). A basis that changes after the first layout is not read again, and
578
+ * a percentage does not follow the box it is a share of. A size is worked out on every pass, and
579
+ * with no basis Yoga takes the size along the main axis as the basis: the same sum.
580
+ *
581
+ * A basis of zero is left as it is, which is every `flex: 1`: under a container with no size on
582
+ * its main axis Yoga passes over a basis and sizes the box by its content, where a size of zero
583
+ * would be zero. A box out of the flow is no flex item, and keeps its own size.
584
+ */
585
+ function basisAsSize(node, props) {
586
+ const basis = props['flexBasis'];
587
+ if (!isSizedBasis(basis) || props['position'] === 'absolute')
588
+ return;
589
+ const parent = layoutParent(node);
590
+ if (!parent)
591
+ return;
592
+ const container = containerOf(parent);
593
+ // Remembered on the container, so the box is merged again when the direction changes.
594
+ parent.fitContainer = container;
595
+ delete props['flexBasis'];
596
+ if (container.startsWith('row'))
597
+ props['width'] = basis;
598
+ else {
599
+ props['height'] = basis;
600
+ percentHeight(node, props);
601
+ }
602
+ }
603
+ /**
604
+ * Whether a box has a height a percentage can be taken of, as CSS defines one: a height of its
605
+ * own, or one the layout around it gives it. A box as tall as what it holds has none.
606
+ *
607
+ * Asked of each box on the way up, and remembered on it, so a change to any of them works out
608
+ * again the boxes beneath it. Where the answer is not known for certain it is yes, which leaves
609
+ * the percentage to Yoga as before: a view native sizes itself, or one placed out of the flow.
610
+ */
611
+ function definiteHeight(node) {
612
+ return (node.heightBasis = hasDefiniteHeight(node));
613
+ }
614
+ function hasDefiniteHeight(node) {
615
+ // The root is the screen, and only a plain view is sized by its content alone.
616
+ if (!node.parent || node.kind !== 'element' || viewNameOf(node) !== 'View')
617
+ return true;
618
+ const parent = layoutParent(node);
619
+ if (!parent)
620
+ return true;
621
+ const height = ownLayout(node, 'height');
622
+ if (isPercent(height))
623
+ return definiteHeight(parent);
624
+ return sizesItself(node, height) || heightFromLayout(node, parent);
625
+ }
626
+ const NO_HEIGHT = new Set([undefined, null, 'auto', 'fit-content']);
627
+ /** Whether a box has a height from its own style: written, or placed, or kept in proportion. */
628
+ function sizesItself(node, height) {
629
+ if (!NO_HEIGHT.has(height))
630
+ return true;
631
+ return ownLayout(node, 'position') === 'absolute' || ownLayout(node, 'aspectRatio') != null;
632
+ }
633
+ /** Whether the container a box is in gives it a height: a row's, or a share of a column's. */
634
+ function heightFromLayout(node, parent) {
635
+ const [direction, align] = containerOf(parent).split(' ');
636
+ if (direction === 'row') {
637
+ // Stretched across a row, it is as tall as the row, where the row has a height to be. One
638
+ // as tall as its content gives none: Yoga takes the percentage of the space on offer there
639
+ // too, the screen's, where a browser takes it of the row once its content has sized it.
640
+ const self = ownLayout(node, 'alignSelf');
641
+ return STRETCHES.has(self) && align === 'stretch' && definiteHeight(parent);
642
+ }
643
+ // In a column it has the height it grows to, where the column has one to share out.
644
+ const grows = Number(ownLayout(node, 'flexGrow') ?? ownLayout(node, 'flex') ?? 0) > 0;
645
+ return grows && definiteHeight(parent);
646
+ }
647
+ /**
648
+ * A placeholder's opacity, which native has none for, as its colour faded by that share: none of
649
+ * it at 0, which is how a stylesheet hides a placeholder under a label, and all of it at 1.
650
+ *
651
+ * ponytail: with no colour of its own the placeholder is native's default, and a platform colour
652
+ * is native's to resolve: neither is known here, so each is hidden at 0 and left as it is above
653
+ * that. Name the platform's default, or read the colour back, if one is faded part of the way.
654
+ */
655
+ function placeholderFaded(props) {
656
+ const opacity = props['placeholderOpacity'];
657
+ if (opacity === undefined)
658
+ return;
659
+ delete props['placeholderOpacity'];
660
+ if (typeof opacity !== 'number' || opacity >= 1)
661
+ return;
662
+ const colour = props['placeholderTextColor'];
663
+ if (typeof colour === 'string')
664
+ props['placeholderTextColor'] = faded(colour, Math.max(opacity, 0));
665
+ else if (opacity <= 0)
666
+ props['placeholderTextColor'] = 'rgba(0, 0, 0, 0)';
667
+ }
668
+ /** The least alpha iOS sends a touch to a view at: under a hundredth it is passed over. */
669
+ const TOUCHED_ALPHA = 0.011;
670
+ /**
671
+ * A view that takes a touch and has no opacity, committed with the least there is to be sent
672
+ * one. A browser sends a press to an element at `opacity: 0`, which is how a see-through
673
+ * backdrop hears a press outside a menu; iOS passes over a view it would not draw. A hundredth
674
+ * of what the view draws is not seen.
675
+ */
676
+ function stillTouched(props) {
677
+ // One told to take no touch is not to be sent one, seen or not.
678
+ if (props['pointerEvents'] === 'none')
679
+ return;
680
+ const opacity = props['opacity'];
681
+ if (typeof opacity === 'number' && opacity < TOUCHED_ALPHA)
682
+ props['opacity'] = TOUCHED_ALPHA;
683
+ }
684
+ /**
685
+ * Leave `aspect-ratio` out for a box with both a width and a height. CSS takes the ratio only
686
+ * for a size that is `auto`; Yoga takes it over one of two that are given, the height in a
687
+ * column and the width in a row.
688
+ */
689
+ function ratioForAutoSize(props) {
690
+ if (!isSet(props['aspectRatio']))
691
+ return;
692
+ const given = (size) => isSet(size) && size !== 'auto';
693
+ if (given(props['width']) && given(props['height']))
694
+ delete props['aspectRatio'];
695
+ }
696
+ const OUTLINE_KEYS = ['outlineWidth', 'outlineStyle', 'outlineColor', 'outlineOffset'];
697
+ /**
698
+ * Send nothing for an outline of no width, which is what `outline: none` is: it draws nothing,
699
+ * and a view with no outline is the same view. Sent, it is an update that is not one, and on
700
+ * Android a text field given an outline prop after it is first drawn has its background set
701
+ * again, with which the padding Android gives a text field comes back over the padding it was
702
+ * laid out with: a field as tall as its line clips its text away. A stylesheet takes a
703
+ * browser's focus ring off a field with `outline: none` for the focused field alone.
704
+ */
705
+ function noOutline(props) {
706
+ if (props['outlineWidth'] !== 0)
707
+ return;
708
+ for (const key of OUTLINE_KEYS)
709
+ delete props[key];
710
+ }
711
+ /**
712
+ * A box with `display: none`, out of the flow as well. It takes no room either way, but Yoga
713
+ * reads an item's baseline from its first child that is in the flow, and a hidden one has no
714
+ * layout to read: under a row with `align-items: baseline` the baseline is not a number, and
715
+ * the row comes to no height with nothing in it placed. Out of the flow it is passed over.
716
+ */
717
+ function hiddenOutOfFlow(props) {
718
+ if (props['display'] === 'none')
719
+ props['position'] = 'absolute';
720
+ }
721
+ /**
722
+ * `height: 50%` under a parent with no height to take it of, which CSS reads as `auto`. Yoga
723
+ * takes it of the space on offer, the screen's or everything a scroll view holds, so no height
724
+ * is sent.
725
+ */
726
+ function percentHeight(node, props) {
727
+ if (!isPercent(props['height']))
728
+ return;
729
+ // Out of the flow, it is of the box that holds it, which is laid out first and has a height.
730
+ if (props['position'] === 'absolute')
731
+ return;
732
+ const parent = layoutParent(node);
733
+ if (parent && !definiteHeight(parent))
734
+ delete props['height'];
735
+ }
462
736
  /**
463
737
  * Start a multiline Android text field's text at the top, as iOS and a `<textarea>` do.
464
738
  *
@@ -504,6 +778,43 @@ const TEXT_ALIGN = {
504
778
  };
505
779
  /** The native modal host, which is left out of a commit while `visible` is false. */
506
780
  const MODAL_HOST = 'ModalHostView';
781
+ /**
782
+ * A screen's `gestureResponseDistance` that no touch is within: how far from each edge a swipe
783
+ * back may start, with -1 for no limit, and none at all from the leading edge. It is what
784
+ * `react-native-screens` reads as the swipe is about to begin; whether the swipe is enabled it
785
+ * read when the finger came down, before anything here had heard of the touch.
786
+ */
787
+ const NO_SWIPE = Object.freeze({ start: -1, end: 0, top: -1, bottom: -1 });
788
+ /** A screen of a native stack, which is what the swipe back is a gesture of. */
789
+ const STACK_SCREEN = 'RNSScreen';
790
+ /** What an element's `touch-action` is: bound on it, or from the rules it matches. */
791
+ function touchActionOf(node) {
792
+ return inlineOf(node)['touchAction'] ?? node.styleCache?.style['touchAction'];
793
+ }
794
+ /** Where a touch is on the screen: its own point, or its first finger's. */
795
+ function pointOf(nativeEvent) {
796
+ const event = (nativeEvent ?? {});
797
+ const touch = event.pageX === undefined ? event.touches?.[0] : event;
798
+ return { x: touch?.pageX ?? 0, y: touch?.pageY ?? 0 };
799
+ }
800
+ /**
801
+ * What a touch on a node leaves to the browser: the `touch-action` of it, or of the nearest
802
+ * element over it that says. Nothing where none does, which leaves everything.
803
+ */
804
+ function touchActions(node) {
805
+ for (let up = node; up; up = up.parent) {
806
+ const said = up.kind === 'element' ? touchActionOf(up) : undefined;
807
+ if (typeof said === 'string')
808
+ return said.split(' ');
809
+ }
810
+ return undefined;
811
+ }
812
+ const PANS_ACROSS = ['auto', 'manipulation', 'pan-x', 'pan-left', 'pan-right'];
813
+ const PANS_ALONG = ['auto', 'manipulation', 'pan-y', 'pan-up', 'pan-down'];
814
+ /** Whether an element keeps a drag in one direction: its `touch-action` leaves no pan that way. */
815
+ const keeps = (actions, pans) => actions !== undefined && !actions.some((action) => pans.includes(action));
816
+ /** How far a finger moves before a browser has settled which way a drag is going. */
817
+ const DRAG_SLOP = 4;
507
818
  /**
508
819
  * Register a codegen'd third-party Fabric component, e.g.
509
820
  * `registerViewName('rns-screen', 'RNSScreen')`, which is how the router reaches
@@ -762,13 +1073,45 @@ function isTextElement(node) {
762
1073
  return false;
763
1074
  return !TEXT_ELEMENTS.has(node.name) || holdsOnlyText(node);
764
1075
  }
1076
+ /** Whether a text element holds text and nothing else. One that holds nothing holds no text. */
765
1077
  function holdsOnlyText(node) {
766
- for (const child of node.children ?? []) {
1078
+ const children = node.children ?? [];
1079
+ for (const child of children) {
767
1080
  if (child.kind === 'element' && !isTextElement(child))
768
1081
  return false;
769
1082
  }
770
- return true;
1083
+ return children.length > 0;
771
1084
  }
1085
+ const ALIGNS = new Set(['center', 'flex-end', 'space-around', 'space-evenly']);
1086
+ /**
1087
+ * Whether one of HTML's text elements is a flex container that aligns the one run of text it
1088
+ * holds, as an avatar's initials are centred: `<span class="flex items-center justify-center">`.
1089
+ *
1090
+ * A browser makes the run a flex item and places it. A paragraph's text is its content, which no
1091
+ * alignment moves, so such an element commits as a view, and its text as the paragraph any view's
1092
+ * loose text is. Read from the node's own style on each reconcile, which a change to it brings
1093
+ * about, so nothing is kept to go stale. The run can be a text element of its own, a label in a
1094
+ * span, which is then the item placed. A `<text>` is a paragraph whatever its style, and an
1095
+ * element holding more than the one run is left as it was.
1096
+ */
1097
+ function aligningText(node) {
1098
+ if (node.kind !== 'element' || node.name === 'text' || !TEXT_ELEMENTS.has(node.name)) {
1099
+ return false;
1100
+ }
1101
+ if (node.children.length !== 1 || !oneRun(node.children[0]))
1102
+ return false;
1103
+ // Under a text element it is a span of that paragraph, unless that one is a view placing it.
1104
+ if (isTextElement(node.parent) && !aligningText(node.parent))
1105
+ return false;
1106
+ if (ownLayout(node, 'display') !== 'flex')
1107
+ return false;
1108
+ return (ALIGNS.has(ownLayout(node, 'alignItems')) ||
1109
+ ALIGNS.has(ownLayout(node, 'justifyContent')));
1110
+ }
1111
+ /** One run of text to place: text itself, or a text element around some, a label in a span. */
1112
+ const oneRun = (child) => child.kind === 'text' || (child.kind === 'element' && TEXT_ELEMENTS.has(child.name));
1113
+ /** The view a node is committed as: `viewNameOf`, but for a text element that aligns its text. */
1114
+ const committedViewName = (node) => aligningText(node) ? DEFAULT_VIEW : viewNameOf(node);
772
1115
  /**
773
1116
  * The native view a node commits as: `Paragraph` for a `<text>`, `VirtualText` for one nested in
774
1117
  * another, `RawText` for a run of text, and `View` for anything unregistered, which is what an
@@ -795,8 +1138,17 @@ export function viewNameOf(node) {
795
1138
  if (node.name !== 'text' && TEXT_ELEMENTS.has(node.name) && !holdsOnlyText(node)) {
796
1139
  return DEFAULT_VIEW;
797
1140
  }
798
- return isTextElement(node.parent) ? VIRTUAL_TEXT : PARAGRAPH;
1141
+ // The paragraph the engine makes for a view's loose text is under a view, whatever the name
1142
+ // of the element that view is.
1143
+ return nestedText(node) ? VIRTUAL_TEXT : PARAGRAPH;
799
1144
  }
1145
+ /**
1146
+ * Whether a text element is a span of the paragraph above it: under another text element, but
1147
+ * not one that is a view for placing this one (see `aligningText`), nor the engine's own box.
1148
+ */
1149
+ const nestedText = (node) => !node.anonymous && isTextElement(node.parent) && !placesText(node.parent);
1150
+ /** Whether a node's parent is a text element committed as a view, to place the run it holds. */
1151
+ const placesText = (parent) => parent != null && 'props' in parent && aligningText(parent);
800
1152
  /**
801
1153
  * The props every native view reads as a boolean: the `bool` fields of React Native's
802
1154
  * `BaseViewProps`, `AccessibilityProps` and `HostPlatformViewProps`, by the names JavaScript sends.
@@ -835,33 +1187,54 @@ const BOOLEAN_VIEW_PROPS = new Set([
835
1187
  ]);
836
1188
  /**
837
1189
  * 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.
1190
+ * or is now that it has gone. What `:empty` does not see is `fills` in css.ts to say.
839
1191
  */
840
1192
  function wasOrIsEmptyWithout(node, moved) {
841
- if (moved.kind === 'anchor')
1193
+ if (!fills(moved))
842
1194
  return false;
843
- for (const child of node.children) {
844
- if (child !== moved && child.kind !== 'anchor')
845
- return false;
846
- }
847
- return true;
1195
+ return !node.children.some((child) => child !== moved && fills(child));
1196
+ }
1197
+ /**
1198
+ * Have the child that paints a node's background merged again with the node: nothing of the
1199
+ * child's own moved, and what it paints may have.
1200
+ */
1201
+ function repaint(node) {
1202
+ if (node.paintsOn?.parent === node)
1203
+ node.paintsOn.propsDirty = true;
848
1204
  }
1205
+ /** A sixtieth of a second: how far apart the frames native is given for an animation are. */
1206
+ const NATIVE_FRAME = 1000 / 60;
1207
+ /** The style keys that are a view's background. */
1208
+ const PAINT_KEYS = [
1209
+ 'backgroundColor',
1210
+ 'experimental_backgroundImage',
1211
+ 'experimental_backgroundSize',
1212
+ 'experimental_backgroundPosition',
1213
+ 'experimental_backgroundRepeat',
1214
+ ];
849
1215
  /**
850
1216
  * A node's own props onto what it commits with. No native prop has a hyphen: `data-*` and `aria-*`
851
1217
  * attributes stay on the node for selectors to match, and an `aria-*` one is mapped to the prop
852
- * native reads.
1218
+ * native reads. The node's `attributeOnly` names stay on it the same way.
853
1219
  */
854
- function writeOwnProps(own, props) {
1220
+ function writeOwnProps(own, props, attributeOnly) {
855
1221
  let aria = false;
856
1222
  for (const key of Object.keys(own)) {
1223
+ if (attributeOnly?.has(key))
1224
+ continue;
857
1225
  if (key.includes('-'))
858
1226
  aria ||= key.startsWith('aria-');
859
1227
  else if (key !== 'style' && key !== INTRINSIC_SIZE && key !== STYLE_OVERRIDE) {
860
1228
  props[key] = committedProp(key, own[key]);
861
1229
  }
862
1230
  }
863
- if (aria)
864
- applyAria(own, props);
1231
+ if (!aria)
1232
+ return;
1233
+ // An attribute-only `aria-*` is not mapped to the prop native reads either.
1234
+ const mapped = attributeOnly?.size
1235
+ ? Object.fromEntries(Object.entries(own).filter(([key]) => !attributeOnly.has(key)))
1236
+ : own;
1237
+ applyAria(mapped, props);
865
1238
  }
866
1239
  /** A prop as it is committed: a boolean view prop held as text is the boolean the text says. */
867
1240
  function committedProp(key, value) {
@@ -1039,6 +1412,10 @@ class RetainedNode {
1039
1412
  styleCache = null;
1040
1413
  styleDirty = true;
1041
1414
  styleCommitted = null;
1415
+ fitContainer = undefined;
1416
+ heightBasis = undefined;
1417
+ hasDirty = false;
1418
+ stateDirty = false;
1042
1419
  ownStyle = undefined;
1043
1420
  claimed = undefined;
1044
1421
  dormantHoists = null;
@@ -1182,12 +1559,19 @@ class RetainedNode {
1182
1559
  * colours - `shouldRasterizeIOS` is a boolean.
1183
1560
  */
1184
1561
  const IS_COLOR_PROP = /color(android)?$/i;
1562
+ /**
1563
+ * Colour props React Native does not name with a `color` suffix: `<switch>`'s pair ends in the
1564
+ * state each one paints - `trackColorForTrue` and `trackColorForFalse`. They are the same type
1565
+ * problem the `Android` suffix causes: Android's `ColorPropConverter` takes a number or a
1566
+ * platform-colour map, refuses a string, and throws rather than approximating one.
1567
+ */
1568
+ const STATE_COLOR_PROPS = new Set(['trackColorForTrue', 'trackColorForFalse']);
1185
1569
  /** Answers per key name, once: the regex ran for every prop of every node in every commit. */
1186
1570
  const colorProps = new Map();
1187
1571
  function isColorProp(key) {
1188
1572
  let answer = colorProps.get(key);
1189
1573
  if (answer === undefined) {
1190
- colorProps.set(key, (answer = IS_COLOR_PROP.test(key) || BRUSH_PROPS.has(key)));
1574
+ colorProps.set(key, (answer = IS_COLOR_PROP.test(key) || STATE_COLOR_PROPS.has(key) || BRUSH_PROPS.has(key)));
1191
1575
  }
1192
1576
  return answer;
1193
1577
  }
@@ -1431,6 +1815,16 @@ export class Engine {
1431
1815
  * does, a child list can move without anything else needing to be looked at again.
1432
1816
  */
1433
1817
  structuralSheets = false;
1818
+ /** How many children after a change in a list one of them reaches. See `siblingReach`. */
1819
+ structuralAfter = 0;
1820
+ /** Whether one of them reaches the children before it. */
1821
+ structuralBefore = false;
1822
+ /** The compounds of theirs that read an element's place from under it. See `markPlace`. */
1823
+ placeElsewhere = new Set();
1824
+ /** The sheets `watchStructure` has read, which it is asked about once an element. */
1825
+ watchedStructure = new WeakSet();
1826
+ /** Whether any sheet uses `:has()`. See `markBeneath`. */
1827
+ hasSheets = false;
1434
1828
  resolveAssetSource;
1435
1829
  constructor(fabric, rootTag, options = {}) {
1436
1830
  this.fabric = fabric;
@@ -1441,7 +1835,9 @@ export class Engine {
1441
1835
  this.fontScale = conditions.fontScale;
1442
1836
  if (options.tokens)
1443
1837
  this.styles.setRootTokens(options.tokens);
1444
- this.structuralSheets = options.globalStyles?.structural === true;
1838
+ this.watchStructure(options.globalStyles);
1839
+ this.watchHas(options.globalStyles);
1840
+ this.watchActive(options.globalStyles);
1445
1841
  this.dev = options.dev ?? globalThis.__DEV__ === true;
1446
1842
  if (this.dev) {
1447
1843
  this.styles.onUndefinedToken = (name, props, on) => this.reportUndefinedToken(name, props, on);
@@ -1506,11 +1902,70 @@ export class Engine {
1506
1902
  if (node.active === active)
1507
1903
  continue;
1508
1904
  node.active = active;
1509
- this.markProps(node);
1905
+ this.markActive(node);
1510
1906
  changed = true;
1511
1907
  }
1512
1908
  return changed;
1513
1909
  }
1910
+ /** Whether any sheet styles a pressed element, and each compound that asks about one from another. */
1911
+ activeUse = {
1912
+ own: false,
1913
+ elsewhere: [],
1914
+ };
1915
+ /** What each sheet in play asks about a pressed element: `activeUse` is all of them together. */
1916
+ activeBySheet = new Map();
1917
+ /** Note what a sheet in play asks about a pressed element. See `markActive`. */
1918
+ watchActive(sheet) {
1919
+ // Asked for every element a component's sheet is given to: noted once a sheet.
1920
+ if (!sheet || this.activeBySheet.has(sheet))
1921
+ return;
1922
+ const use = usesActive(sheet);
1923
+ this.activeBySheet.set(sheet, use);
1924
+ if (!use.own && !use.elsewhere.length)
1925
+ return;
1926
+ this.countActive();
1927
+ if (this.styles.tracksHas)
1928
+ return;
1929
+ // What a pressed element is compared with is the rules it matched, which no cache kept until
1930
+ // a sheet asked. Nothing is resolved yet when the sheet is the one the engine was made with.
1931
+ this.styles.tracksHas = true;
1932
+ if (!this.root)
1933
+ return;
1934
+ this.root.styleDirty = true;
1935
+ this.markPath(this.root);
1936
+ }
1937
+ /** A sheet has left play, by a hot swap or by being removed: what it asked goes with it. */
1938
+ unwatchActive(sheet) {
1939
+ if (this.activeBySheet.delete(sheet))
1940
+ this.countActive();
1941
+ }
1942
+ countActive() {
1943
+ const all = [...this.activeBySheet.values()];
1944
+ this.activeUse = {
1945
+ own: all.some((use) => use.own),
1946
+ elsewhere: all.flatMap((use) => use.elsewhere),
1947
+ };
1948
+ }
1949
+ /**
1950
+ * A press began or ended on `node` or under it, which is all that changed about it. A press
1951
+ * reaches every ancestor up to the root, and marking each as restyled would style the whole
1952
+ * screen again, twice a press. So each is only matched again, and keeps its style and all
1953
+ * that is under it unless it now matches other rules. An element a rule asks about from
1954
+ * inside or beside it, `.card:active .title`, changes more than itself, and is restyled with
1955
+ * everything under it as any other change is. With no rule for a pressed element at all there
1956
+ * is nothing to match.
1957
+ */
1958
+ markActive(node) {
1959
+ const { own, elsewhere } = this.activeUse;
1960
+ if (!own && !elsewhere.length)
1961
+ return;
1962
+ // By its classes alone: a compound with none could be any element.
1963
+ const asked = elsewhere.some((compound) => compound.classes.every((name) => node.classes?.has(name)));
1964
+ if (asked)
1965
+ return this.markProps(node);
1966
+ node.stateDirty = true;
1967
+ this.markPath(node.parent ?? node);
1968
+ }
1514
1969
  /**
1515
1970
  * Custom properties the device knows and the stylesheet cannot, once they change.
1516
1971
  *
@@ -1531,11 +1986,26 @@ export class Engine {
1531
1986
  this.sheetReplaced(replacing, sheet);
1532
1987
  else
1533
1988
  this.registerSheet(sheet);
1534
- if (sheet.structural)
1535
- this.structuralSheets = true;
1989
+ this.watchStructure(sheet);
1990
+ this.watchHas(sheet);
1991
+ this.watchActive(sheet);
1536
1992
  this.markPath(this.root);
1993
+ this.markWrittenFor(sheet, this.root);
1537
1994
  return true;
1538
1995
  }
1996
+ /**
1997
+ * Have styled again each node a sheet just added could match. The rest keep the style they
1998
+ * have, which the resolver lets stand for a node none of the sheet's rules is written for,
1999
+ * so a commit steps over them: the walk here is what finds the ones it must not step over.
2000
+ */
2001
+ markWrittenFor(sheet, node) {
2002
+ if (node.kind === 'element' && this.styles.couldMatch(sheet, node)) {
2003
+ node.styleDirty = true;
2004
+ this.markPath(node);
2005
+ }
2006
+ for (const child of node.children)
2007
+ this.markWrittenFor(sheet, child);
2008
+ }
1539
2009
  /**
1540
2010
  * Stop matching a sheet `addGlobalSheet` added, and restyle everything; false, and nothing
1541
2011
  * done, when it is not one. A hot swap that leaves a None component no rules, or makes it scoped.
@@ -1563,14 +2033,19 @@ export class Engine {
1563
2033
  * which is what makes Fabric dirty a measured node and measure it at the size now in effect.
1564
2034
  */
1565
2035
  remeasureText() {
2036
+ const again = (measured) => {
2037
+ measured.remeasure = true;
2038
+ this.markProps(measured, false);
2039
+ };
1566
2040
  const visit = (node) => {
1567
2041
  for (const child of node.children) {
2042
+ // The paragraph of a run of text written straight into a view is kept on the text.
2043
+ if (child.kind === 'text' && child.box?.committed)
2044
+ again(child.box);
1568
2045
  if (child.kind !== 'element')
1569
2046
  continue;
1570
- if (child.committed && MEASURED_VIEWS.has(viewNameOf(child))) {
1571
- child.remeasure = true;
1572
- this.markProps(child, false);
1573
- }
2047
+ if (child.committed && MEASURED_VIEWS.has(committedViewName(child)))
2048
+ again(child);
1574
2049
  visit(child);
1575
2050
  }
1576
2051
  };
@@ -1716,8 +2191,10 @@ export class Engine {
1716
2191
  if (sheet === undefined || node.sheet === sheet)
1717
2192
  return;
1718
2193
  node.sheet = sheet;
1719
- if (sheet?.structural)
1720
- this.structuralSheets = true;
2194
+ this.styles.noteSheet(sheet);
2195
+ this.watchStructure(sheet);
2196
+ this.watchHas(sheet);
2197
+ this.watchActive(sheet);
1721
2198
  this.markProps(node);
1722
2199
  }
1723
2200
  /** The sheet each node given another's scope was created with. */
@@ -1750,25 +2227,61 @@ export class Engine {
1750
2227
  createElement(name, sheet = null) {
1751
2228
  const node = new RetainedNode('element', name, this);
1752
2229
  node.sheet = sheet;
2230
+ this.styles.noteSheet(sheet);
1753
2231
  const html = HTML_ELEMENTS.get(name);
1754
2232
  if (html !== undefined) {
1755
2233
  if (html.role)
1756
2234
  node.props['accessibilityRole'] = html.role;
1757
2235
  node.styled = html.styled;
1758
2236
  }
1759
- if (sheet?.structural)
1760
- this.structuralSheets = true;
2237
+ this.watchStructure(sheet);
2238
+ this.watchHas(sheet);
2239
+ this.watchActive(sheet);
1761
2240
  if (HOISTS[name])
1762
2241
  this.hoisted.add(node);
1763
2242
  return node;
1764
2243
  }
1765
2244
  addClass(node, name) {
1766
2245
  (node.classes ??= new Set()).add(name);
1767
- this.markProps(node);
2246
+ this.markClasses(node, [name]);
1768
2247
  }
1769
2248
  removeClass(node, name) {
1770
2249
  node.classes?.delete(name);
1771
- this.markProps(node);
2250
+ this.markClasses(node, [name]);
2251
+ }
2252
+ /**
2253
+ * Mark a node whose classes changed, by what the rules that name those classes can restyle.
2254
+ *
2255
+ * A class no selector names changes no style: a library's own marker, of an overlay that is
2256
+ * animating or a control that was touched. One named only as what a rule's element is
2257
+ * inside, `.animating .close`, restyles the elements under the node that such rules are for.
2258
+ * Either way a whole subtree is not styled again for a class that has no say in it. Any other
2259
+ * class restyles the node and everything under it, as any other change to it does.
2260
+ *
2261
+ * A sheet that names a class later restyles what it is for as it arrives, and a node not yet
2262
+ * committed is styled when it is, with the class as it is then.
2263
+ */
2264
+ markClasses(node, changed) {
2265
+ const reach = this.styles.reachOf(changed);
2266
+ if (reach === true)
2267
+ return this.markProps(node);
2268
+ this.markProps(node, false);
2269
+ if (reach)
2270
+ this.markUnder(node, reach);
2271
+ }
2272
+ /** Have styled again each element under a node that one of `subjects`' rules could be for. */
2273
+ markUnder(node, subjects) {
2274
+ for (const child of node.children) {
2275
+ if (child.kind !== 'element')
2276
+ continue;
2277
+ if (this.styles.isFor(subjects, child)) {
2278
+ // And with it all under it, which is styled from it.
2279
+ child.styleDirty = true;
2280
+ this.markPath(child);
2281
+ }
2282
+ else
2283
+ this.markUnder(child, subjects);
2284
+ }
1772
2285
  }
1773
2286
  /**
1774
2287
  * The sheet of the component `node` hosts, whose `:host` rules it matches. Set again when a hot
@@ -1779,6 +2292,9 @@ export class Engine {
1779
2292
  if (node.hostSheet === sheet)
1780
2293
  return;
1781
2294
  node.hostSheet = sheet;
2295
+ this.styles.noteSheet(sheet);
2296
+ this.watchStructure(sheet);
2297
+ this.watchActive(sheet);
1782
2298
  this.markProps(node);
1783
2299
  }
1784
2300
  /**
@@ -1793,8 +2309,14 @@ export class Engine {
1793
2309
  /** `class="a b"` from a template. Replaces the set rather than adding to it. */
1794
2310
  setClasses(node, value) {
1795
2311
  const names = value.split(/\s+/).filter(Boolean);
2312
+ const before = node.classes;
1796
2313
  node.classes = names.length ? new Set(names) : null;
1797
- this.markProps(node);
2314
+ // Each class that came or went: one in both changes nothing.
2315
+ const changed = [...names.filter((name) => !before?.has(name))];
2316
+ for (const name of before ?? [])
2317
+ if (!node.classes?.has(name))
2318
+ changed.push(name);
2319
+ this.markClasses(node, changed);
1798
2320
  }
1799
2321
  /** Angular's `@if`/`@for`/`ViewContainerRef` markers. Ordered, never committed. */
1800
2322
  createAnchor() {
@@ -1811,7 +2333,7 @@ export class Engine {
1811
2333
  parent.children.push(child);
1812
2334
  if (child.dormantHoists)
1813
2335
  this.wakeHoists(child);
1814
- this.markStructure(parent, child);
2336
+ this.markStructure(parent, child, parent.children.length - 1);
1815
2337
  this.markTextContent(parent, child);
1816
2338
  }
1817
2339
  insertBefore(parent, child, ref) {
@@ -1824,7 +2346,7 @@ export class Engine {
1824
2346
  parent.children.splice(at, 0, child);
1825
2347
  if (child.dormantHoists)
1826
2348
  this.wakeHoists(child);
1827
- this.markStructure(parent, child);
2349
+ this.markStructure(parent, child, at < 0 ? parent.children.length - 1 : at);
1828
2350
  this.markTextContent(parent, child);
1829
2351
  }
1830
2352
  removeChild(parent, child) {
@@ -1837,7 +2359,7 @@ export class Engine {
1837
2359
  target.children.splice(at, 1);
1838
2360
  child.parent = null;
1839
2361
  this.removedSinceCommit.add(child);
1840
- this.markStructure(target, child);
2362
+ this.markStructure(target, child, at);
1841
2363
  this.markTextContent(target, child);
1842
2364
  }
1843
2365
  /**
@@ -1894,6 +2416,7 @@ export class Engine {
1894
2416
  node.playing = undefined;
1895
2417
  this.markProps(node, false);
1896
2418
  }
2419
+ this.releasePlayedNatively();
1897
2420
  for (const nodes of this.scrollTimelines.values()) {
1898
2421
  for (const node of nodes) {
1899
2422
  if (this.topOf(node) === this.root)
@@ -1928,8 +2451,14 @@ export class Engine {
1928
2451
  destroyNode(node) {
1929
2452
  node.listeners?.clear();
1930
2453
  node.listeners = null;
1931
- node.committed = null;
2454
+ // With what it holds: a node Angular destroys can be put back, as content projected into
2455
+ // an `@if` is, and its children's views would then be handed to the view it is made again
2456
+ // as. Native gives a view one parent for its life, and aborts on a second.
2457
+ if (node.committed)
2458
+ this.forgetCommitted(node);
1932
2459
  node.transitions = undefined;
2460
+ if (node.playing)
2461
+ this.stopNative(node, node.playing);
1933
2462
  node.playing = undefined;
1934
2463
  this.stopScrolled(node);
1935
2464
  this.running.delete(node);
@@ -1950,6 +2479,9 @@ export class Engine {
1950
2479
  return siblings[siblings.indexOf(node) + 1] ?? null;
1951
2480
  }
1952
2481
  setProp(node, key, value) {
2482
+ // A prop written again is a binding's, and is committed as any other is.
2483
+ if (node.attributeOnly?.delete(key))
2484
+ this.markProps(node, false);
1953
2485
  if (value === undefined || value === null) {
1954
2486
  // Nothing to remove, so nothing changed. Worth its own branch because it is the common
1955
2487
  // case, not a rare one: a host primitive carries a host binding for every prop React Native
@@ -1967,7 +2499,32 @@ export class Engine {
1967
2499
  // Inline style is applied after the cascade and inherited by nothing, so it cannot change
1968
2500
  // what any node matches or inherits. Every other prop can: `[disabled]` is a selector. An
1969
2501
  // inline `direction` is the exception, because the paragraphs below it align by it.
1970
- this.markProps(node, key !== 'style' || this.inlineReachesStyle(node));
2502
+ // And a child that takes one of this node's own values, an `inherit`, follows it.
2503
+ const heirs = node.styleCache?.heirs === true;
2504
+ this.markProps(node, key !== 'style' || this.inlineReachesStyle(node) || heirs);
2505
+ }
2506
+ /**
2507
+ * Paint a node's background on one of its children instead of on the node: the node keeps its
2508
+ * `background-color` and `background-image` in the cascade, where a stylesheet wrote them, and
2509
+ * the child is the view that draws them. What a view that masks its children and not itself
2510
+ * needs, as `<gradient-text>` is.
2511
+ */
2512
+ paintOn(node, child) {
2513
+ node.paintsOn = child;
2514
+ this.markProps(node, false);
2515
+ this.markProps(child, false);
2516
+ }
2517
+ /**
2518
+ * Keep a prop the node already has as an attribute: on the node for a selector such as
2519
+ * `:host([variant='primary'])` to match, and left out of what is committed to the native view.
2520
+ * It is committed again once the prop is next written. What the platform adapter does with a
2521
+ * static attribute that is an input of the host's component.
2522
+ */
2523
+ keepAsAttribute(node, key) {
2524
+ if (!(key in node.props))
2525
+ return;
2526
+ (node.attributeOnly ??= new Set()).add(key);
2527
+ this.markProps(node, false);
1971
2528
  }
1972
2529
  /**
1973
2530
  * Whether a node's inline style can change what it or anything below it resolves to: it sets,
@@ -2042,10 +2599,21 @@ export class Engine {
2042
2599
  this.markProps(node);
2043
2600
  }
2044
2601
  setText(node, value) {
2602
+ const filled = (node.text === '') !== (value === '');
2045
2603
  node.text = value;
2046
2604
  this.markProps(node);
2047
- if (node.parent)
2048
- this.markTextContent(node.parent, node);
2605
+ if (!node.parent)
2606
+ return;
2607
+ this.markTextContent(node.parent, node);
2608
+ // Text of no length is nothing to `:empty`: its first character and its last change what
2609
+ // the element it is in matches, and what comes after that element.
2610
+ if (filled && this.structuralSheets)
2611
+ this.markFilled(node.parent);
2612
+ }
2613
+ markFilled(node) {
2614
+ node.styleDirty = true;
2615
+ this.markLaterSiblings(node);
2616
+ this.markPath(node);
2049
2617
  }
2050
2618
  setEventListener(node, topLevelType, fn) {
2051
2619
  this.registerEventHandler();
@@ -2072,11 +2640,35 @@ export class Engine {
2072
2640
  // state, though nothing about the sibling moved. Only sheets that ask about siblings pay.
2073
2641
  if (this.structuralSheets)
2074
2642
  this.markLaterSiblings(node);
2643
+ if (this.hasSheets)
2644
+ this.markBeneath(node.parent);
2075
2645
  }
2076
2646
  // The root has no parent to carry the mark, so it carries its own: `.dark` on the root alone
2077
2647
  // restyles everything beneath it.
2078
2648
  this.markPath(node.parent ?? node);
2079
2649
  }
2650
+ /** Start matching ancestors again on a change, once a sheet that uses `:has()` is in play. */
2651
+ watchHas(sheet) {
2652
+ if (!sheet?.has || this.hasSheets)
2653
+ return;
2654
+ this.hasSheets = true;
2655
+ this.styles.tracksHas = true;
2656
+ // What is already resolved kept no record of the rules it matched: resolve it again. Nothing
2657
+ // is, when the sheet is the one the engine was made with.
2658
+ if (!this.root)
2659
+ return;
2660
+ this.root.styleDirty = true;
2661
+ this.markPath(this.root);
2662
+ }
2663
+ /**
2664
+ * Something at or beneath `from` changed: every node from there up may match a `:has()` rule
2665
+ * it did not, or stop matching one. They are matched again rather than marked for restyling,
2666
+ * which would restyle everything under them. Only sheets that use `:has()` pay.
2667
+ */
2668
+ markBeneath(from) {
2669
+ for (let node = from; node; node = node.parent)
2670
+ node.hasDirty = true;
2671
+ }
2080
2672
  markLaterSiblings(node) {
2081
2673
  const siblings = node.parent?.children;
2082
2674
  if (!siblings)
@@ -2085,22 +2677,101 @@ export class Engine {
2085
2677
  siblings[i].styleDirty = true;
2086
2678
  }
2087
2679
  }
2088
- /** `node`'s child list changed: `moved` came into it or went out of it. */
2089
- markStructure(node, moved) {
2680
+ /** `node`'s child list changed: `moved` came into it at `at`, or went out of it from there. */
2681
+ markStructure(node, moved, at) {
2090
2682
  node.structureDirty = true;
2091
2683
  // A child list that moved changes what its members match, though nothing about them did:
2092
2684
  // the old last row is no longer the last. Only sheets that ask about position pay for this.
2093
2685
  if (this.structuralSheets) {
2094
- node.styleDirty = true;
2095
- for (const child of node.children)
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))
2686
+ moved.styleDirty = true;
2687
+ this.markRepositioned(node.children, at, moved);
2688
+ // What the node itself matches hangs on its child list only through `:empty`, which only
2689
+ // its first child arriving or its last one leaving changes: and then what a later sibling
2690
+ // matches can too, `.box:empty + .spacer`. Styled again for any other change, everything
2691
+ // under it would be resolved again with it.
2692
+ if (wasOrIsEmptyWithout(node, moved)) {
2693
+ node.styleDirty = true;
2100
2694
  this.markLaterSiblings(node);
2695
+ }
2101
2696
  }
2697
+ if (this.hasSheets)
2698
+ this.markBeneath(node);
2102
2699
  this.markPath(node.parent);
2103
2700
  }
2701
+ /**
2702
+ * Mark the children whose place in a child list that changed at `at` a sheet could read
2703
+ * differently. The two at each end always, which are the old first and last and the new. Those
2704
+ * from `at` on where some sheet counts from the start or asks about any one before, since a
2705
+ * child's place from the start hangs only on those before it; and those before `at` where one
2706
+ * counts from the end. Styling one again resolves everything under it again, so a long list
2707
+ * gaining a row at its end is not the whole list.
2708
+ *
2709
+ * Where the sheets ask no further than the element just before (`+`), a rule steps a known
2710
+ * number of elements along from one whose own match changed, and those are all there is to
2711
+ * mark: that many after the change, and that many after the two at the start, either of which
2712
+ * may have stopped or started being first. Nothing comes after the two at the end.
2713
+ */
2714
+ markRepositioned(children, at, moved) {
2715
+ const steps = this.structuralAfter;
2716
+ const pastFirst = steps === Infinity ? 0 : steps;
2717
+ for (const child of elementsAtEnd(children, 2 + pastFirst, false))
2718
+ this.markPlace(child);
2719
+ for (const child of elementsAtEnd(children, 2, true))
2720
+ this.markPlace(child);
2721
+ if (this.structuralBefore)
2722
+ for (let i = 0; i < at; i++)
2723
+ this.markPlace(children[i]);
2724
+ // The elements after the change, not counting the one that came, which is marked already.
2725
+ let left = steps;
2726
+ for (let i = at; i < children.length && left > 0; i++) {
2727
+ const child = children[i];
2728
+ this.markPlace(child);
2729
+ if (child.kind === 'element' && child !== moved)
2730
+ left--;
2731
+ }
2732
+ }
2733
+ /**
2734
+ * A child's place in its list changed, which is all that changed about it. It is matched
2735
+ * again, and keeps its style and all that is under it unless it now matches other rules: its
2736
+ * place is read by the rules that style it and by no other. Not where a rule reads the place
2737
+ * of an element like it from under it, `.row:nth-child(odd) .label`: that changes more than
2738
+ * the element itself, which is then styled again with everything under it, as is a child
2739
+ * that is no element.
2740
+ */
2741
+ markPlace(child) {
2742
+ if (child.kind !== 'element' || this.placeReadFromUnder(child))
2743
+ child.styleDirty = true;
2744
+ else
2745
+ child.stateDirty = true;
2746
+ }
2747
+ /**
2748
+ * Whether a rule could read this element's place from another element. By its classes alone,
2749
+ * which every such compound it could be asks for: a compound with none could be any element.
2750
+ */
2751
+ placeReadFromUnder(node) {
2752
+ for (const compound of this.placeElsewhere) {
2753
+ if (compound.classes.every((name) => node.classes?.has(name)))
2754
+ return true;
2755
+ }
2756
+ return false;
2757
+ }
2758
+ /** Note what a sheet coming into play asks about a child list. None is unset: see `structural`. */
2759
+ watchStructure(sheet) {
2760
+ if (!sheet?.structural || this.watchedStructure.has(sheet))
2761
+ return;
2762
+ this.watchedStructure.add(sheet);
2763
+ this.structuralSheets = true;
2764
+ const reach = siblingReach(sheet);
2765
+ this.structuralAfter = Math.max(this.structuralAfter, reach.after);
2766
+ if (reach.before)
2767
+ this.structuralBefore = true;
2768
+ for (const compound of placeReadElsewhere(sheet))
2769
+ this.placeElsewhere.add(compound);
2770
+ // What a child whose place changed is compared with is the rules it matched, which a cache
2771
+ // keeps from here on. One made before has none, and is styled again the first time, as it
2772
+ // was before any sheet asked.
2773
+ this.styles.tracksHas = true;
2774
+ }
2104
2775
  /** Walk up until we hit a node already known to have a dirty subtree. */
2105
2776
  markPath(node) {
2106
2777
  const wasClean = !this.root.structureDirty && !this.root.subtreeDirty;
@@ -2146,6 +2817,7 @@ export class Engine {
2146
2817
  this.fabric.appendChildToSet(set, this.reconcileUnder(this.root, child));
2147
2818
  }
2148
2819
  this.clearFlags(this.root);
2820
+ this.announceCommit();
2149
2821
  this.fabric.completeRoot(this.rootTag, set);
2150
2822
  const elapsed = now() - started;
2151
2823
  this.stats.commits++;
@@ -2160,6 +2832,7 @@ export class Engine {
2160
2832
  this.stats.slowCommits++;
2161
2833
  }
2162
2834
  this.flushTransitionEvents();
2835
+ this.playSized();
2163
2836
  this.scrollDriver?.afterCommit();
2164
2837
  // Read first: the commit that settles keyframes starts by clearing it.
2165
2838
  const facesAdded = this.facesAdded;
@@ -2169,6 +2842,25 @@ export class Engine {
2169
2842
  this.rematchFonts();
2170
2843
  return true;
2171
2844
  }
2845
+ beforeCommit = null;
2846
+ announceCommit() {
2847
+ const run = this.beforeCommit;
2848
+ this.beforeCommit = null;
2849
+ run?.();
2850
+ }
2851
+ /**
2852
+ * Run `run` once, as the next commit hands its tree to the host and before the host has it: the
2853
+ * moment for something the host applies to its next commit, which is only this one from here.
2854
+ * A pass that changes nothing commits nothing and runs nothing, so a request waits until it is
2855
+ * taken back with the function returned. A later request replaces an earlier one.
2856
+ */
2857
+ beforeNextCommit(run) {
2858
+ this.beforeCommit = run;
2859
+ return () => {
2860
+ if (this.beforeCommit === run)
2861
+ this.beforeCommit = null;
2862
+ };
2863
+ }
2172
2864
  /** The first commit whose new views have not been drawn yet, while `commitUnseen` runs. */
2173
2865
  unseen = Infinity;
2174
2866
  /**
@@ -2273,11 +2965,36 @@ export class Engine {
2273
2965
  if (this.transitionEvents.length === 0)
2274
2966
  return;
2275
2967
  for (const { node, type, property } of this.transitionEvents.splice(0)) {
2968
+ // One listener that throws stops neither the rest nor the node being let go.
2276
2969
  for (const listener of [...(node.listeners?.get(type) ?? [])]) {
2277
- listener(animationEvent(type, node, property));
2970
+ try {
2971
+ listener(animationEvent(type, node, property));
2972
+ }
2973
+ catch (error) {
2974
+ this.reportEventError(error, type);
2975
+ }
2278
2976
  }
2977
+ if (type === 'topAnimationend')
2978
+ this.letGo(node);
2279
2979
  }
2280
2980
  }
2981
+ /**
2982
+ * The nodes whose animation has ended with no fill and is still held at its last frame, until
2983
+ * its `animationend` has been heard. A browser runs that listener before it paints again, and
2984
+ * an exit animation's listener takes the element away: it is never seen back at rest. Here the
2985
+ * listener runs after the commit, so the commit that ends the animation paints the last frame,
2986
+ * and the element goes back to rest once the listener has run and left it there.
2987
+ */
2988
+ lettingGo = new Set();
2989
+ letGo(node) {
2990
+ if (!this.lettingGo.delete(node))
2991
+ return;
2992
+ const running = node.playing;
2993
+ if (!running?.done)
2994
+ return;
2995
+ running.values = {};
2996
+ this.markProps(node, false);
2997
+ }
2281
2998
  /** Anchors take part in sibling ordering but never reach Fabric. */
2282
2999
  visibleChildren(node) {
2283
3000
  // A view that takes its text as a prop has no text children to commit. See `ViewNameOptions`.
@@ -2287,13 +3004,21 @@ export class Engine {
2287
3004
  /**
2288
3005
  * Everything a node renders with, unprocessed. Precedence, weakest first: native defaults, the
2289
3006
  * 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.
3007
+ * `styleOverride`. Inline wins over CSS as it does on the web, but for a declaration marked
3008
+ * `!important`, which stands over it: see `inlineOf`.
2291
3009
  *
2292
3010
  * Raw, because this is what the next commit diffs against. Colours and asset ids are converted
2293
3011
  * on the way out instead (`processed`), and only for the keys that changed: the converters
2294
3012
  * return fresh objects, so diffing their output would re-send every image and shadow on every
2295
3013
  * unrelated change.
2296
3014
  */
3015
+ /** What a touch on an element that keeps a drag holds off, while it lasts: see `holdSwipe`. */
3016
+ heldFor(node, merged) {
3017
+ if (node === this.swipeHeld)
3018
+ merged['gestureResponseDistance'] = NO_SWIPE;
3019
+ if (node === this.scrollHeld)
3020
+ merged['scrollEnabled'] = false;
3021
+ }
2297
3022
  mergeProps(node, viewName) {
2298
3023
  if (node.kind === 'text')
2299
3024
  return { text: paragraphText(node) };
@@ -2304,11 +3029,18 @@ export class Engine {
2304
3029
  const props = { ...DEFAULT_PROPS[viewName], ...node.defaultStyle };
2305
3030
  Object.assign(props, this.styles.resolve(node, this.styleEpoch).style);
2306
3031
  const resolved = props['pointerEvents'];
2307
- writeOwnProps(node.props, props);
3032
+ writeOwnProps(node.props, props, node.attributeOnly);
2308
3033
  withTextContent(node, viewName, props);
2309
3034
  const cascaded = props['transform'];
2310
- const style = boundTransform(node, flattenStyle(node.props['style'], props), cascaded);
3035
+ const inline = inlineOf(node);
3036
+ const style = boundTransform(node, Object.assign(props, inline), cascaded);
3037
+ if (inline !== NO_STYLE)
3038
+ this.styles.overOtherForms(inline, style);
2311
3039
  nativePointerEvents(node, style, resolved);
3040
+ // Before an image's own size: `fit-content` is no size, so the image's is what it gets.
3041
+ fitContent(node, style);
3042
+ percentHeight(node, style);
3043
+ placeholderFaded(style);
2312
3044
  const intrinsic = node.props[INTRINSIC_SIZE];
2313
3045
  if (intrinsic)
2314
3046
  applyIntrinsicSize(style, intrinsic);
@@ -2320,10 +3052,65 @@ export class Engine {
2320
3052
  if (this.fontsRefreshed)
2321
3053
  this.capForFonts(node, style);
2322
3054
  alignMultiline(viewName, style);
3055
+ rowsTall(style, this.fontScale);
2323
3056
  const merged = composeTransform(node, this.animated(node, this.transitioned(node, style)));
2324
3057
  centreSingleLine(viewName, merged, this.fontScale);
3058
+ // After a transition, which eases the basis as the basis it was written as.
3059
+ basisAsSize(node, merged);
3060
+ // After the basis, which is a size given where it is committed as one.
3061
+ ratioForAutoSize(merged);
3062
+ noOutline(merged);
3063
+ // Last, on what is committed: an override or an animation can hide a box, or place it.
3064
+ hiddenOutOfFlow(merged);
3065
+ delete merged['touchAction'];
3066
+ if (this.responders.has(node))
3067
+ stillTouched(merged);
3068
+ this.heldFor(node, merged);
3069
+ this.movePaint(node, merged);
2325
3070
  return merged;
2326
3071
  }
3072
+ /**
3073
+ * Leave a node's background out of what it commits when a child paints it, and give that child
3074
+ * its parent's: the cascade's, and over it the parent's inline style.
3075
+ */
3076
+ movePaint(node, merged) {
3077
+ if (node.paintsOn)
3078
+ for (const key of PAINT_KEYS)
3079
+ delete merged[key];
3080
+ const from = node.parent?.paintsOn === node ? node.parent : null;
3081
+ if (!from)
3082
+ return;
3083
+ const paint = { ...this.styles.resolve(from, this.styleEpoch).style, ...inlineOf(from) };
3084
+ for (const key of PAINT_KEYS)
3085
+ if (paint[key] !== undefined)
3086
+ merged[key] = paint[key];
3087
+ }
3088
+ /**
3089
+ * A container whose direction or alignment changed since a child with a `fit-content` size
3090
+ * read it, or whose height stopped or started being one a percentage is taken of since a child
3091
+ * with a percentage height asked: those children are merged again. A change to a container
3092
+ * marks nothing beneath it, since none of these is inherited.
3093
+ */
3094
+ refitChildren(node) {
3095
+ // Both are asked, and both remembered, before either has the children merged again.
3096
+ const basis = node.heightBasis;
3097
+ const based = basis !== undefined && definiteHeight(node) !== basis;
3098
+ const fitted = node.fitContainer !== undefined && containerOf(node) !== node.fitContainer;
3099
+ if (fitted)
3100
+ node.fitContainer = containerOf(node);
3101
+ if (based || fitted)
3102
+ this.refit(node);
3103
+ }
3104
+ /** Have a container's children merged again, through any `display: contents` among them. */
3105
+ refit(container) {
3106
+ for (const child of container.children) {
3107
+ if (child.kind !== 'element')
3108
+ continue;
3109
+ child.propsDirty = true;
3110
+ if (containerOf(child) === 'contents')
3111
+ this.refit(child);
3112
+ }
3113
+ }
2327
3114
  /**
2328
3115
  * The text size cap that lays a paragraph or text input out again once a face it names has
2329
3116
  * registered: a step per face above the app's own cap where it set one (below 1 is none, on
@@ -2352,7 +3139,7 @@ export class Engine {
2352
3139
  if (!this.inlineDirection)
2353
3140
  return own;
2354
3141
  for (let at = node; at; at = at.parent) {
2355
- const inline = textDirection(flattenStyle(at.props['style'], {})['direction']);
3142
+ const inline = textDirection(inlineOf(at)['direction']);
2356
3143
  if (inline)
2357
3144
  return inline;
2358
3145
  const cascaded = textDirection(at.styleCache?.style['direction']);
@@ -2380,6 +3167,7 @@ export class Engine {
2380
3167
  if (!sheet || this.knownSheets.has(sheet))
2381
3168
  return;
2382
3169
  this.knownSheets.add(sheet);
3170
+ this.styles.noteSheet(sheet);
2383
3171
  this.sheetOrder.push(sheet);
2384
3172
  for (const name of Object.keys(sheet.keyframes ?? {})) {
2385
3173
  this.keyframes.set(name, sheet.keyframes[name]);
@@ -2397,6 +3185,8 @@ export class Engine {
2397
3185
  sheetReplaced(sheet, next) {
2398
3186
  if (sheet === next && this.knownSheets.has(sheet))
2399
3187
  return;
3188
+ this.unwatchActive(sheet);
3189
+ this.watchActive(next);
2400
3190
  const at = this.sheetOrder.indexOf(sheet);
2401
3191
  if (at === -1) {
2402
3192
  this.registerSheet(next);
@@ -2405,6 +3195,7 @@ export class Engine {
2405
3195
  this.knownSheets.delete(sheet);
2406
3196
  if (next && !this.knownSheets.has(next)) {
2407
3197
  this.knownSheets.add(next);
3198
+ this.styles.noteSheet(next);
2408
3199
  this.sheetOrder[at] = next;
2409
3200
  }
2410
3201
  else {
@@ -2459,11 +3250,17 @@ export class Engine {
2459
3250
  }
2460
3251
  /** Mark every element under `node` committed with a family, so it is matched again. */
2461
3252
  markFontText(node) {
3253
+ const named = (text) => {
3254
+ if (typeof text?.committed?.props['fontFamily'] === 'string')
3255
+ this.markProps(text, false);
3256
+ };
2462
3257
  for (const child of node.children) {
3258
+ // The paragraph of a run of text written straight into a view is kept on the text.
3259
+ if (child.kind === 'text')
3260
+ named(child.box);
2463
3261
  if (child.kind !== 'element')
2464
3262
  continue;
2465
- if (typeof child.committed?.props['fontFamily'] === 'string')
2466
- this.markProps(child, false);
3263
+ named(child);
2467
3264
  this.markFontText(child);
2468
3265
  }
2469
3266
  }
@@ -2510,6 +3307,7 @@ export class Engine {
2510
3307
  handOverToScroll(node, spec) {
2511
3308
  if (!node.playing)
2512
3309
  return;
3310
+ this.stopNative(node, node.playing);
2513
3311
  if (node.playing.spec.name !== spec.name)
2514
3312
  this.cancelPlaying(node);
2515
3313
  node.playing = undefined;
@@ -2521,9 +3319,146 @@ export class Engine {
2521
3319
  */
2522
3320
  cancelPlaying(node) {
2523
3321
  const running = node.playing;
3322
+ if (running)
3323
+ this.stopNative(node, running);
2524
3324
  if (running && !running.done)
2525
3325
  this.emitTransition(node, 'topAnimationcancel', running.spec.name);
2526
3326
  }
3327
+ /** Stop on native each animation whose view has left the tree. */
3328
+ releasePlayedNatively() {
3329
+ for (const node of [...this.playedNatively]) {
3330
+ if (this.topOf(node) === this.root)
3331
+ continue;
3332
+ this.stopNative(node, node.playing);
3333
+ node.playing = undefined;
3334
+ this.markProps(node, false);
3335
+ }
3336
+ }
3337
+ /** Every node whose animation native is playing, with no frame of it in JavaScript. */
3338
+ playedNatively = new Set();
3339
+ /**
3340
+ * Hand an animation that has just started to native, where native can play it: one that moves
3341
+ * only opacity and transforms, starts at once, and repeats a whole number of times. Native then
3342
+ * runs it frame by frame, and JavaScript hears of it again when it ends. Anything else is
3343
+ * played from here, a commit a frame. See `NativeScrollDriver.play`.
3344
+ */
3345
+ /**
3346
+ * The nodes whose animation moves them by a share of their own size and had no size yet when
3347
+ * it started, each with the style it rests at: asked again once the commit has laid them out.
3348
+ */
3349
+ unsized = new Map();
3350
+ /**
3351
+ * An animation's frames as native can interpolate them: a move by a share of the box,
3352
+ * `translateX(200%)`, as the points that is of the size the view is laid out at. Nothing where
3353
+ * the view has no size yet, which is every view until its first commit: it is played from
3354
+ * JavaScript, and handed over once that commit has laid it out. See `playSized`.
3355
+ *
3356
+ * ponytail: the size it had when it started. A box that changes size goes on moving by the
3357
+ * points of the old one until the animation starts again; restart it from the box's layout
3358
+ * event if a bar that resizes mid-animation comes up.
3359
+ */
3360
+ sized(node, running, props) {
3361
+ // One that waits, or takes no time, is played from here whatever it moves by.
3362
+ if (running.spec.delay !== 0 || running.spec.duration <= 0)
3363
+ return null;
3364
+ if (!movesByShare(running.tracks, props))
3365
+ return { tracks: running.tracks, resting: props };
3366
+ let size;
3367
+ this.measure(node, (frame) => (size = frame));
3368
+ if (size)
3369
+ return sharesAsPoints(running.tracks, props, size);
3370
+ this.unsized.set(node, props);
3371
+ return null;
3372
+ }
3373
+ /**
3374
+ * Hand to native each animation that was waiting for its view to have a size. The commit just
3375
+ * made painted its first frame, so its clock starts here: no frame of it has been seen move.
3376
+ */
3377
+ playSized() {
3378
+ if (!this.unsized.size)
3379
+ return;
3380
+ const waiting = [...this.unsized];
3381
+ this.unsized.clear();
3382
+ for (const [node, props] of waiting) {
3383
+ const running = node.playing;
3384
+ if (!running || running.done || running.native || !this.playing.has(node))
3385
+ continue;
3386
+ // Native plays it from its first frame, so its clock starts here: but only where native
3387
+ // takes it. Left in JavaScript, it goes on from the clock it started on.
3388
+ const started = running.start;
3389
+ running.start = this.now();
3390
+ if (this.playNatively(node, running, props)) {
3391
+ this.playing.delete(node);
3392
+ // `collapsable`, which keeps a view for native to move, is in what it holds now.
3393
+ this.markProps(node, false);
3394
+ }
3395
+ else {
3396
+ running.start = started;
3397
+ }
3398
+ }
3399
+ // One with no size after its commit is not laid out at all, and is played from JavaScript.
3400
+ this.unsized.clear();
3401
+ }
3402
+ playNatively(node, running, props) {
3403
+ const { spec } = running;
3404
+ const sized = this.scrollDriver ? this.sized(node, running, props) : null;
3405
+ if (!this.scrollDriver || !sized)
3406
+ return false;
3407
+ const { channels, held, span } = clockChannels(sized.tracks, spec, sized.resting);
3408
+ if (held.length || !(channels.opacity || channels.transform.length))
3409
+ return false;
3410
+ // There and back is one native animation: an odd number of ways has no whole number of them.
3411
+ const count = spec.iterations === null ? -1 : spec.iterations / span;
3412
+ if (count !== -1 && !(Number.isInteger(count) && count > 0))
3413
+ return false;
3414
+ const length = Math.max(2, Math.round((spec.duration * span) / NATIVE_FRAME) + 1);
3415
+ const frames = Array.from({ length }, (_, i) => i / (length - 1));
3416
+ const native = this.scrollDriver.play(node, channels, { frames, toValue: span, iterations: count }, (view) => this.tagOf(view), () => this.endedNatively(node, running));
3417
+ if (!native)
3418
+ return false;
3419
+ running.native = native;
3420
+ // As Animated does: Fabric flattens a view that only lays out, and then there is no native
3421
+ // view for the animation to move.
3422
+ running.values = { ...running.values, collapsable: false };
3423
+ this.playedNatively.add(node);
3424
+ return true;
3425
+ }
3426
+ /**
3427
+ * Native reached the end of an animation it was playing. The frame it ends at, or the resting
3428
+ * style where it does not fill, is committed before the view is let go, so nothing shows of
3429
+ * the frame it started at, which is what the view had been committed with.
3430
+ */
3431
+ endedNatively(node, running) {
3432
+ if (node.playing !== running || !running.native)
3433
+ return;
3434
+ const { native, spec } = running;
3435
+ running.native = undefined;
3436
+ this.playedNatively.delete(node);
3437
+ const end = running.start + spec.duration * (spec.iterations ?? 1);
3438
+ this.advancePlayer(node, Math.max(this.now(), end));
3439
+ this.commit();
3440
+ native.stop();
3441
+ }
3442
+ /** Take an animation back from native, which stops it there. The caller plays it on, or not. */
3443
+ stopNative(node, running) {
3444
+ if (!running.native)
3445
+ return;
3446
+ running.native.stop();
3447
+ running.native = undefined;
3448
+ this.playedNatively.delete(node);
3449
+ }
3450
+ /**
3451
+ * Play on from JavaScript an animation native was playing, from where its clock has got to:
3452
+ * what a pause, other frames or a view made again need, none of which native can be told of.
3453
+ */
3454
+ backToScript(node, running) {
3455
+ if (!running.native)
3456
+ return;
3457
+ this.stopNative(node, running);
3458
+ running.values = sample(running, this.now()).values;
3459
+ if (!running.done && running.pausedAt === undefined)
3460
+ this.playing.add(node);
3461
+ }
2527
3462
  /** Start the clock on an animation, unless the node is already playing this one. */
2528
3463
  startPlaying(node, spec, frames, props) {
2529
3464
  const current = node.playing;
@@ -2531,6 +3466,8 @@ export class Engine {
2531
3466
  if (!current || !sameAnimation(current.spec, spec)) {
2532
3467
  if (current && current.spec.name !== spec.name)
2533
3468
  this.cancelPlaying(node);
3469
+ else if (current)
3470
+ this.stopNative(node, current);
2534
3471
  const started = {
2535
3472
  spec,
2536
3473
  tracks: tracksOf(frames, props, inherited),
@@ -2551,12 +3488,16 @@ export class Engine {
2551
3488
  node.playing = started;
2552
3489
  if (spec.paused)
2553
3490
  started.pausedAt = this.now();
2554
- else
3491
+ else if (!this.playNatively(node, started, props))
2555
3492
  this.playing.add(node);
2556
3493
  this.emitTransition(node, 'topAnimationstart', spec.name);
2557
3494
  return;
2558
3495
  }
3496
+ // A view made again is another view: the one native was moving is gone.
3497
+ if (node.committed === null)
3498
+ this.backToScript(node, current);
2559
3499
  if (this.playedFrames.get(current) !== frames || inherited !== current.inherited) {
3500
+ this.backToScript(node, current);
2560
3501
  this.reframe(node, current, frames, props, inherited);
2561
3502
  }
2562
3503
  this.playOrPause(node, node.playing, spec);
@@ -2584,6 +3525,10 @@ export class Engine {
2584
3525
  reframed.values = finished && !holds ? {} : values;
2585
3526
  this.playedFrames.set(reframed, frames);
2586
3527
  node.playing = reframed;
3528
+ // One that had nothing to paint was let go of: with frames that say something it plays on.
3529
+ const stopped = reframed.done || reframed.pausedAt !== undefined || reframed.native;
3530
+ if (!stopped)
3531
+ this.playing.add(node);
2587
3532
  }
2588
3533
  /**
2589
3534
  * `animation-play-state` changed on an animation already playing: pausing holds the frame it
@@ -2594,6 +3539,7 @@ export class Engine {
2594
3539
  current.spec = spec;
2595
3540
  const now = this.now();
2596
3541
  if (spec.paused && current.pausedAt === undefined && !current.done) {
3542
+ this.backToScript(node, current);
2597
3543
  current.pausedAt = now;
2598
3544
  this.playing.delete(node);
2599
3545
  }
@@ -2762,6 +3708,21 @@ export class Engine {
2762
3708
  node.transitions = undefined;
2763
3709
  return props;
2764
3710
  }
3711
+ /**
3712
+ * Whether an animation that has finished goes on holding its last frame. With a forwards fill
3713
+ * it does, for good: without one the element falls back to whatever the cascade gives it, and
3714
+ * 'backwards' fills the start only. Heard, it holds until whoever listens has been told: see
3715
+ * `lettingGo`.
3716
+ */
3717
+ keeps(node, running) {
3718
+ if (running.spec.fill === 'forwards' || running.spec.fill === 'both')
3719
+ return true;
3720
+ // One that listened and has stopped leaves an empty set behind: nobody to hear it.
3721
+ if (!node.listeners?.get('topAnimationend')?.size)
3722
+ return false;
3723
+ this.lettingGo.add(node);
3724
+ return true;
3725
+ }
2765
3726
  /** One `@keyframes` player, one frame on. */
2766
3727
  advancePlayer(node, now) {
2767
3728
  const running = node.playing;
@@ -2773,12 +3734,19 @@ export class Engine {
2773
3734
  this.playing.delete(node);
2774
3735
  return;
2775
3736
  }
3737
+ // Frames that say nothing paint nothing: a keyframe's one declaration refused at build time
3738
+ // leaves these. No frame of it is committed, and one that never ends has nothing left to do.
3739
+ const idle = running.tracks.size === 0;
2776
3740
  const { values, finished } = sample(running, now);
2777
- // Without a forwards fill a finished animation stops holding anything, and the element falls
2778
- // back to whatever the cascade gives it. 'backwards' fills the start only.
2779
- const holds = running.spec.fill === 'forwards' || running.spec.fill === 'both';
2780
- running.values = finished && !holds ? {} : values;
2781
- this.markProps(node, false);
3741
+ // One of no time is finished already, endless or not, and ends as any other does.
3742
+ if (idle && !finished && running.spec.iterations === null) {
3743
+ this.playing.delete(node);
3744
+ return;
3745
+ }
3746
+ running.values = finished && !this.keeps(node, running) ? {} : values;
3747
+ // Its end is still a commit: that is what its `animationend` is sent after.
3748
+ if (!idle || finished)
3749
+ this.markProps(node, false);
2782
3750
  if (!finished)
2783
3751
  return;
2784
3752
  running.done = true;
@@ -2904,7 +3872,8 @@ export class Engine {
2904
3872
  *
2905
3873
  * A stop's colour is a colour like any other, but it is two levels down and nothing else looks
2906
3874
  * there: without this the device is handed the string `rgb(255, 0, 0)` where it wants a number,
2907
- * and paints nothing.
3875
+ * and paints nothing. The stops are first rewritten so native's interpolation paints what
3876
+ * CSS's premultiplied one does (see `premultipliedStops`).
2908
3877
  */
2909
3878
  processGradients(value) {
2910
3879
  if (!Array.isArray(value))
@@ -2915,7 +3884,7 @@ export class Engine {
2915
3884
  return entry;
2916
3885
  return {
2917
3886
  ...gradient,
2918
- colorStops: gradient.colorStops.map((stop) => ({
3887
+ colorStops: premultipliedStops(gradient.colorStops).map((stop) => ({
2919
3888
  ...stop,
2920
3889
  color: this.color(stop.color),
2921
3890
  })),
@@ -2937,7 +3906,9 @@ export class Engine {
2937
3906
  if (this.isClean(node) && !styleChanged)
2938
3907
  return node.committed.handle;
2939
3908
  node.styleCommitted = style;
2940
- const viewName = viewNameOf(node);
3909
+ repaint(node);
3910
+ this.refitChildren(node);
3911
+ const viewName = committedViewName(node);
2941
3912
  this.forgetRenamed(node, viewName);
2942
3913
  const childHandles = this.reconcileChildren(node, viewName, style);
2943
3914
  noteTouches(node, style);
@@ -3075,6 +4046,8 @@ export class Engine {
3075
4046
  * when it is shown again, as Modal.js creates it afresh. Committed again, it ignores every touch.
3076
4047
  */
3077
4048
  withheld(child) {
4049
+ if (this.undisplayed(child))
4050
+ return true;
3078
4051
  if (child.props['visible'] !== false || child.presented)
3079
4052
  return false;
3080
4053
  if (viewNameOf(child) !== MODAL_HOST)
@@ -3083,6 +4056,42 @@ export class Engine {
3083
4056
  this.forgetCommitted(child);
3084
4057
  return true;
3085
4058
  }
4059
+ /**
4060
+ * Whether an element is `display: none`, which has no box and so no view, nor has anything
4061
+ * in it. A view that is committed and not displayed looks the same, but Yoga marks one as
4062
+ * laid out each time it measures what holds it, and React Native clears the mark only where it
4063
+ * reads that parent's layout: a debug build stops at the mark, on a later commit that has the
4064
+ * parent's layout already. Shown again it is made again, as an element put back in the tree
4065
+ * is: what its views held of their own, a scroll offset, is not kept.
4066
+ */
4067
+ undisplayed(child) {
4068
+ if (child.kind !== 'element')
4069
+ return false;
4070
+ this.styles.resolve(child, this.styleEpoch);
4071
+ if (ownLayout(child, 'display') !== 'none')
4072
+ return false;
4073
+ if (child.committed) {
4074
+ this.forgetCommitted(child);
4075
+ // A field in it has no view to send its blur from.
4076
+ if (isWithin(this.focusedNode, child))
4077
+ this.setFocused(null);
4078
+ }
4079
+ return true;
4080
+ }
4081
+ /**
4082
+ * Whether a node hoisted into `target` is not displayed where it was written: it, or a box
4083
+ * between it and the child of `target` it was written in. That child is asked before this is.
4084
+ */
4085
+ undisplayedUnder(target, written) {
4086
+ for (let at = written; at && at.parent !== target; at = at.parent) {
4087
+ if (!this.undisplayed(at))
4088
+ continue;
4089
+ if (written.committed)
4090
+ this.forgetCommitted(written);
4091
+ return true;
4092
+ }
4093
+ return false;
4094
+ }
3086
4095
  /**
3087
4096
  * `reconcileChildren` for a node other nodes hoist into: its own children, each followed by
3088
4097
  * the hoisted nodes written inside it, then those written in it directly, then the views it
@@ -3105,9 +4114,10 @@ export class Engine {
3105
4114
  else
3106
4115
  handles.push(this.reconcileUnder(node, child, context));
3107
4116
  const moved = hoisted?.get(child);
3108
- if (moved)
3109
- for (const written of moved)
4117
+ for (const written of moved ?? []) {
4118
+ if (!this.undisplayedUnder(node, written))
3110
4119
  handles.push(this.land(node, written, landed));
4120
+ }
3111
4121
  }
3112
4122
  for (const child of direct)
3113
4123
  handles.push(this.land(node, child, landed));
@@ -3276,7 +4286,10 @@ export class Engine {
3276
4286
  * layout bug. On the first commit of any known element with no component behind it, say so.
3277
4287
  */
3278
4288
  checkClaimed(node) {
3279
- if (node.kind !== 'element' || node.claimed || !PRIMITIVE_NAMES.has(node.name))
4289
+ // The paragraph the engine wraps loose text in is its own: no template wrote it.
4290
+ if (node.kind !== 'element' || node.claimed || node.anonymous)
4291
+ return;
4292
+ if (!PRIMITIVE_NAMES.has(node.name))
3280
4293
  return;
3281
4294
  if (this.reported.has(node.name))
3282
4295
  return;
@@ -3393,9 +4406,12 @@ export class Engine {
3393
4406
  this.registerEventHandler();
3394
4407
  this.responders.set(node, handlers);
3395
4408
  this.keepNative(node, true);
4409
+ // What it is committed with can hang on whether it takes a touch: see `stillTouched`.
4410
+ this.markProps(node);
3396
4411
  return () => {
3397
4412
  this.responders.delete(node);
3398
4413
  this.keepNative(node, false);
4414
+ this.markProps(node);
3399
4415
  if (this.currentResponder !== node)
3400
4416
  return;
3401
4417
  // Torn down under the finger: no release is coming, so the chain it marked is cleared
@@ -3627,7 +4643,9 @@ export class Engine {
3627
4643
  const handle = node.committed?.handle;
3628
4644
  if (!handle || !this.fabric.setIsJSResponder)
3629
4645
  return;
3630
- const block = this.responders.get(node)?.blockNativeResponder ?? false;
4646
+ const asked = this.responders.get(node)?.blockNativeResponder ?? false;
4647
+ // And for a drag an element's `touch-action` keeps from the page: see `holdSwipe`.
4648
+ const block = asked || this.drag?.held === true;
3631
4649
  this.fabric.setIsJSResponder(handle, isResponder, block);
3632
4650
  }
3633
4651
  /** Runs before listener propagation, as React's responder plugin does. */
@@ -3689,6 +4707,10 @@ export class Engine {
3689
4707
  dispatchEvent(instanceHandle, topLevelType, nativeEvent) {
3690
4708
  const target = instanceHandle;
3691
4709
  this.trace(target, topLevelType);
4710
+ // What has the focus does not take it again: native answering a focus it was told of, where
4711
+ // whoever asked has already said so.
4712
+ if (topLevelType === 'topFocus' && target !== null && this.focusedNode === target)
4713
+ return;
3692
4714
  // Listeners get RN's documented shape, `{nativeEvent}`. Fabric hands us the payload bare;
3693
4715
  // React wraps it in a synthetic event and every RN API is written against `event.nativeEvent`,
3694
4716
  // so handlers ported from RN would silently read undefined otherwise.
@@ -3699,6 +4721,7 @@ export class Engine {
3699
4721
  if (topLevelType === 'topScroll' && target && this.scrollTimelines.has(target)) {
3700
4722
  this.measuredScroll(target, nativeEvent);
3701
4723
  }
4724
+ this.holdSwipe(target, topLevelType, nativeEvent);
3702
4725
  this.trackFocus(target, topLevelType);
3703
4726
  if (target)
3704
4727
  this.cancelPressForScroll(target, topLevelType, event);
@@ -3710,6 +4733,116 @@ export class Engine {
3710
4733
  }
3711
4734
  this.propagate(target, topLevelType, event);
3712
4735
  }
4736
+ /** The screen whose swipe back is held off while a finger is on what keeps a sideways drag. */
4737
+ swipeHeld = null;
4738
+ /**
4739
+ * Hold a screen's swipe back off for a touch that starts on an element whose `touch-action`
4740
+ * keeps a drag to the side for itself, and give it back when the last finger lifts. From iOS
4741
+ * 26 the swipe starts anywhere on a screen once a finger has moved a little to the right, and
4742
+ * takes the touch from whatever was following it: a slider's thumb stops and the screen
4743
+ * leaves. The screen is told before the finger has moved that far.
4744
+ */
4745
+ holdSwipe(target, type, nativeEvent) {
4746
+ if (type === TOUCH_START)
4747
+ this.holdFrom(target, nativeEvent);
4748
+ else if (type === 'topTouchMove')
4749
+ this.settleDrag(nativeEvent);
4750
+ else if (type === 'topTouchEnd' || type === 'topTouchCancel')
4751
+ this.releaseSwipe(nativeEvent);
4752
+ }
4753
+ /**
4754
+ * A touch on an element that keeps a drag to the side: where it began, and whether native's
4755
+ * own gestures are held off for it. Held from the start where the element keeps a drag down
4756
+ * the page too (`none`); from the moment the finger sets off across where it leaves that one
4757
+ * to the page (`pan-y`), and never for that touch where it sets off down.
4758
+ */
4759
+ drag = null;
4760
+ holdFrom(target, nativeEvent) {
4761
+ // A first finger down is a new touch: the last one's end never came where what it was on
4762
+ // went from under the finger.
4763
+ const fingers = nativeEvent?.touches?.length;
4764
+ if (this.drag && fingers === 1)
4765
+ this.releaseSwipe(null);
4766
+ if (this.drag || !target)
4767
+ return;
4768
+ const actions = touchActions(target);
4769
+ if (!keeps(actions, PANS_ACROSS))
4770
+ return;
4771
+ const all = keeps(actions, PANS_ALONG);
4772
+ this.drag = { ...pointOf(nativeEvent), settled: all, held: all, on: target };
4773
+ if (all)
4774
+ this.holdScroll(target);
4775
+ const screen = this.screenOf(target);
4776
+ if (!screen)
4777
+ return;
4778
+ this.swipeHeld = screen;
4779
+ this.tellScreen(screen);
4780
+ }
4781
+ /** The scroll view whose scrolling is off while a drag over it is an element's own. */
4782
+ scrollHeld = null;
4783
+ /**
4784
+ * Stop the scroll view a node is in from scrolling, until the touch ends. Telling native who
4785
+ * holds the touch does this on Android. On iOS a scroll view is held off only by a responder
4786
+ * that is over it, never one inside it, so it is told not to scroll.
4787
+ */
4788
+ holdScroll(node) {
4789
+ for (let up = node.parent; up; up = up.parent) {
4790
+ if (up.kind !== 'element' || !SCROLL_VIEWS.has(viewNameOf(up)))
4791
+ continue;
4792
+ this.scrollHeld = up;
4793
+ this.tellScreen(up);
4794
+ return;
4795
+ }
4796
+ }
4797
+ /** Settle which way a drag set off, and hold native's own off one that set off across. */
4798
+ settleDrag(nativeEvent) {
4799
+ const drag = this.drag;
4800
+ if (!drag || drag.settled)
4801
+ return;
4802
+ const at = pointOf(nativeEvent);
4803
+ const [across, along] = [Math.abs(at.x - drag.x), Math.abs(at.y - drag.y)];
4804
+ if (Math.max(across, along) < DRAG_SLOP)
4805
+ return;
4806
+ drag.settled = true;
4807
+ drag.held = across > along;
4808
+ if (!drag.held)
4809
+ return;
4810
+ if (this.currentResponder)
4811
+ this.tellNative(this.currentResponder, true);
4812
+ this.holdScroll(drag.on);
4813
+ }
4814
+ /** Give the swipe back once the last finger has lifted. */
4815
+ releaseSwipe(nativeEvent) {
4816
+ const left = nativeEvent?.touches?.length ?? 0;
4817
+ if (left > 0)
4818
+ return;
4819
+ this.drag = null;
4820
+ for (const held of [this.scrollHeld, this.swipeHeld]) {
4821
+ if (held === this.scrollHeld)
4822
+ this.scrollHeld = null;
4823
+ else
4824
+ this.swipeHeld = null;
4825
+ if (held)
4826
+ this.tellScreen(held);
4827
+ }
4828
+ }
4829
+ /**
4830
+ * Commit a screen or a scroll view again now, not with whatever next changes: the swipe begins
4831
+ * once the finger has moved ten points or so, which is a frame or two after it came down.
4832
+ */
4833
+ tellScreen(screen) {
4834
+ // Its props alone: nothing a selector reads moved, so nothing under it is styled again.
4835
+ this.markProps(screen, false);
4836
+ this.commit();
4837
+ }
4838
+ /** The screen of a native stack a node is on, or nothing where it is on none. */
4839
+ screenOf(node) {
4840
+ for (let up = node; up; up = up.parent) {
4841
+ if (up.kind === 'element' && viewNameOf(up) === STACK_SCREEN)
4842
+ return up;
4843
+ }
4844
+ return null;
4845
+ }
3713
4846
  /** A presented modal host has finished leaving, so a hidden one can come out of the tree. */
3714
4847
  dismissed(target) {
3715
4848
  if (!target.presented)