@ng-native/fabric 0.5.0 → 0.7.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,15 +7,15 @@
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, usesActive, } 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
- import { animationEvent, bezier, interpolate, readsInheritedColour, sample, step, tracksOf, } from "./transition.js";
13
+ import { animationEvent, bezier, interpolate, readsInheritedColour, sample, step, steppedKeys, tracksOf, } from "./transition.js";
14
14
  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 { clockChannels, 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
20
  import { premultipliedStops } from "./premultiplied-stops.js";
21
21
  /**
@@ -389,6 +389,33 @@ const VIRTUAL_TEXT = 'VirtualText';
389
389
  const DEFAULT_VIEW = 'View';
390
390
  /** A top-level text, the one view that aligns its lines. A nested text is a `VirtualText`. */
391
391
  const PARAGRAPH = 'Paragraph';
392
+ /**
393
+ * The box Yoga takes a view's baseline from where the view has none of its own: its first child
394
+ * that is laid out in its flow. Words written straight into the view are the paragraph the
395
+ * engine makes around them, and a child that is not displayed has no view to ask.
396
+ */
397
+ function firstInFlow(node) {
398
+ for (const child of node.children ?? []) {
399
+ // Not the paragraph itself, which holds the words it was made around.
400
+ if (child.kind === 'text' && child.box)
401
+ return child.box === node ? undefined : child.box;
402
+ if (child.kind !== 'element' || ownLayout(child, 'display') === 'none')
403
+ continue;
404
+ if (ownLayout(child, 'position') !== 'absolute')
405
+ return child;
406
+ }
407
+ return undefined;
408
+ }
409
+ const isNode = (node) => node !== undefined;
410
+ /** Whether a row lays a child out among its others, and so can take its baseline from it. */
411
+ const inFlow = (child) => child.kind === 'element' && ownLayout(child, 'position') !== 'absolute';
412
+ /** The text field a row's box takes its baseline from: itself, or the first in its flow down. */
413
+ function fieldOnBaseline(item) {
414
+ let at = item;
415
+ while (at?.kind === 'element' && !TEXT_INPUTS.has(viewNameOf(at)))
416
+ at = firstInFlow(at);
417
+ return at?.kind === 'element' ? at : undefined;
418
+ }
392
419
  const textDirection = (value) => value === 'ltr' || value === 'rtl' ? value : undefined;
393
420
  /**
394
421
  * What iOS scales a text's `lineHeight` by: the system text size, capped by the text's own
@@ -401,6 +428,89 @@ function textScale(props, fontScale = 1) {
401
428
  const cap = props['maxFontSizeMultiplier'];
402
429
  return typeof cap === 'number' && cap >= 1 ? Math.min(fontScale, cap) : fontScale;
403
430
  }
431
+ /** A field's padding and border on its top or bottom edge, as they are written. */
432
+ const blockEdge = (props, side, logical) => [
433
+ props[`padding${side}`] ??
434
+ props[`paddingBlock${logical}`] ??
435
+ props['paddingBlock'] ??
436
+ props['paddingVertical'] ??
437
+ props['padding'] ??
438
+ 0,
439
+ props[`border${side}Width`] ?? props['borderWidth'] ?? 0,
440
+ ];
441
+ /**
442
+ * A multiline field's `rows` as its height, where it was given none: that many lines of its
443
+ * line height at the system text size, inside its padding and border, as a browser sizes a `<textarea>`. A native field
444
+ * is as tall as its text, which is one line before anything is typed.
445
+ */
446
+ function rowsTall(props, fontScale) {
447
+ const rows = Number(props['rows']);
448
+ const line = props['lineHeight'];
449
+ if (props['multiline'] !== true || !(rows > 0) || typeof line !== 'number')
450
+ return;
451
+ if (isSet(props['height']) && props['height'] !== 'auto')
452
+ return;
453
+ // A content box's height is its lines alone: Yoga adds the padding and border itself.
454
+ const edges = props['boxSizing'] === 'content-box'
455
+ ? []
456
+ : [...blockEdge(props, 'Top', 'Start'), ...blockEdge(props, 'Bottom', 'End')];
457
+ if (!edges.every((part) => typeof part === 'number'))
458
+ return;
459
+ // At the system text size, which the field's lines are drawn at.
460
+ const lines = rows * line * textScale(props, fontScale);
461
+ props['height'] = edges.reduce((sum, part) => sum + part, lines);
462
+ }
463
+ /** A field's own minimum height: none where it says `auto`, which a web element starts with. */
464
+ const ownMinimum = (minHeight) => (minHeight === 'auto' ? 0 : (minHeight ?? 0));
465
+ /** How tall the system's font is, as a share of its size: its rise over the baseline and fall under. */
466
+ const FONT_LINE = 0.95 + 0.24;
467
+ /**
468
+ * How tall a field is by its line: the line, padding and border of a border-box field, Yoga's
469
+ * default, and its content alone for a content-box one, which Yoga adds the padding and border
470
+ * to itself. In a row aligned by baseline some of the line is kept as padding: `lineBaseline`.
471
+ */
472
+ function lineBox(props, box, onBaseline, fontScale) {
473
+ const half = onBaseline ? lineBaseline(props, box[0], box, fontScale) : 0;
474
+ if (props['boxSizing'] === 'content-box')
475
+ return box[0] - 2 * half;
476
+ return box.reduce((sum, part) => sum + part, 0);
477
+ }
478
+ /**
479
+ * `lineBaseline` for a field a height sizes: the room is what the height leaves its text, and
480
+ * the field stays that height. A content-box field's height is its content's, so what is now
481
+ * padding comes out of it.
482
+ */
483
+ function heightBaseline(props, height, onBaseline, fontScale) {
484
+ if (!onBaseline)
485
+ return;
486
+ const edges = [...blockEdge(props, 'Top', 'Start'), ...blockEdge(props, 'Bottom', 'End')];
487
+ if (!edges.every((part) => typeof part === 'number'))
488
+ return;
489
+ const content = props['boxSizing'] === 'content-box';
490
+ const room = content ? height : edges.reduce((left, part) => left - part, height);
491
+ const half = lineBaseline(props, room, [room, ...edges], fontScale);
492
+ if (content)
493
+ props['height'] = height - 2 * half;
494
+ }
495
+ /**
496
+ * Give a field with its line height left out the baseline of a paragraph in a line as tall, for
497
+ * a field in a row aligned by baseline: half the room the line has over the font is kept as
498
+ * padding over and under the text. React Native says a field's baseline from its text and its
499
+ * top padding, so without this it is the font's own, and the row lifts the paragraphs beside the
500
+ * field to it: a form field's prefix and suffix sit higher than what is typed. Answers the half.
501
+ *
502
+ * ponytail: the font is taken to be as tall as the system's, `FONT_LINE` of its size. Read from
503
+ * the font if a field in another face is seen a point off its prefix.
504
+ */
505
+ function lineBaseline(props, line, [, top, , bottom], fontScale) {
506
+ const size = props['fontSize'];
507
+ if (typeof size !== 'number')
508
+ return 0;
509
+ const half = Math.max(0, (line - size * textScale(props, fontScale) * FONT_LINE) / 2);
510
+ props['paddingTop'] = top + half;
511
+ props['paddingBottom'] = bottom + half;
512
+ return half;
513
+ }
404
514
  /**
405
515
  * Centre the text of a single-line text field that has a line height, as Chrome centres an
406
516
  * input's, keeping the height the line height gives it.
@@ -421,7 +531,7 @@ function textScale(props, fontScale = 1) {
421
531
  * height alone for a content-box one. A null `height` is no height. A value that is not a number
422
532
  * cannot be added up, and leaves the field as it was.
423
533
  */
424
- function centreSingleLine(viewName, props, fontScale) {
534
+ function centreSingleLine(viewName, props, fontScale, onBaseline = false) {
425
535
  const lineHeight = props['lineHeight'];
426
536
  if (!TEXT_INPUTS.has(viewName) || typeof lineHeight !== 'number')
427
537
  return;
@@ -430,6 +540,7 @@ function centreSingleLine(viewName, props, fontScale) {
430
540
  const height = props['height'];
431
541
  if (typeof height === 'number') {
432
542
  delete props['lineHeight'];
543
+ heightBaseline(props, height, onBaseline, fontScale);
433
544
  return;
434
545
  }
435
546
  // A percentage is a fixed height only where the parent's is definite, known at layout alone.
@@ -438,35 +549,40 @@ function centreSingleLine(viewName, props, fontScale) {
438
549
  // Android sizes the field by its line height, and centres the text in it, itself.
439
550
  if (viewName !== 'TextInput')
440
551
  return;
441
- const edge = (side, logical) => [
442
- props[`padding${side}`] ??
443
- props[`paddingBlock${logical}`] ??
444
- props['paddingBlock'] ??
445
- props['paddingVertical'] ??
446
- props['padding'] ??
447
- 0,
448
- props[`border${side}Width`] ?? props['borderWidth'] ?? 0,
449
- ];
552
+ const edge = (side, logical) => blockEdge(props, side, logical);
450
553
  const line = lineHeight * textScale(props, fontScale);
451
554
  const box = [line, ...edge('Top', 'Start'), ...edge('Bottom', 'End')];
452
- const own = props['minHeight'] ?? 0;
555
+ const own = ownMinimum(props['minHeight']);
453
556
  const max = props['maxHeight'] ?? Infinity;
454
557
  if (![...box, own, max].every((part) => typeof part === 'number'))
455
558
  return;
456
559
  delete props['lineHeight'];
457
- // A content-box field's minHeight is its content's: Yoga adds the padding and border itself.
458
- const content = props['boxSizing'] === 'content-box'
459
- ? line
460
- : box.reduce((sum, part) => sum + part, 0);
560
+ const content = lineBox(props, box, onBaseline, fontScale);
461
561
  props['minHeight'] = Math.max(own, Math.min(content, max));
462
562
  }
563
+ const NO_STYLE = {};
564
+ /**
565
+ * An element's inline style, less each property a rule it matches declared `!important`: an
566
+ * inline style is the last of the plain declarations, under every important one.
567
+ *
568
+ * ponytail: by the name a property is committed under, so an inline `padding` is not taken out
569
+ * by an important `padding-top`. Expand the inline shorthand here if one is ever written so.
570
+ */
571
+ function inlineOf(node) {
572
+ if (!node.props['style'])
573
+ return NO_STYLE;
574
+ const inline = flattenStyle(node.props['style'], {});
575
+ for (const key of node.styleCache?.important ?? [])
576
+ delete inline[key];
577
+ return inline;
578
+ }
463
579
  /**
464
580
  * What a container's own style says for `key`, in the order `mergeProps` applies them: a
465
581
  * component's override, inline, a prop, its stylesheet, its default.
466
582
  */
467
583
  function ownLayout(node, key) {
468
584
  return (flattenStyle(node.props[STYLE_OVERRIDE], {})[key] ??
469
- flattenStyle(node.props['style'], {})[key] ??
585
+ inlineOf(node)[key] ??
470
586
  node.props[key] ??
471
587
  node.styleCache?.style[key] ??
472
588
  node.defaultStyle?.[key]);
@@ -504,6 +620,10 @@ const STRETCHES = new Set([undefined, null, 'auto', 'stretch']);
504
620
  * kept, since it stops the stretch already.
505
621
  */
506
622
  function fitContent(node, props) {
623
+ const capped = {
624
+ row: capAtContent(props, 'width', 'maxWidth'),
625
+ column: capAtContent(props, 'height', 'maxHeight'),
626
+ };
507
627
  const width = props['width'] === 'fit-content';
508
628
  const height = props['height'] === 'fit-content';
509
629
  if (!width && !height)
@@ -518,12 +638,83 @@ function fitContent(node, props) {
518
638
  const container = containerOf(parent);
519
639
  parent.fitContainer = container;
520
640
  const [direction, align] = container.split(' ');
641
+ if (capped[direction === 'row' ? 'row' : 'column'])
642
+ withoutGrowth(props);
521
643
  const across = direction === 'row' ? height : width;
522
644
  if (across && align === 'stretch' && STRETCHES.has(props['alignSelf'])) {
523
645
  props['alignSelf'] = 'flex-start';
524
646
  }
525
647
  }
648
+ /**
649
+ * A box capped at its content along its container's main axis, where it would grow: a browser
650
+ * clamps a growing item by its maximum, and there is no such cap to hand Yoga. So it does not
651
+ * grow, and starts from its content, which is as far as the cap would let it get. It may still
652
+ * shrink.
653
+ */
654
+ function withoutGrowth(props) {
655
+ if (typeof props['flex'] === 'number' && props['flex'] > 0) {
656
+ delete props['flex'];
657
+ props['flexShrink'] ??= 1;
658
+ }
659
+ delete props['flexGrow'];
660
+ delete props['flexBasis'];
661
+ }
662
+ /**
663
+ * `max-width: max-content`, and the same of a height: no bigger than its content, which for a
664
+ * box that would fill its container is `fit-content`. Yoga has no such cap. Answers whether the
665
+ * box is the size of its content for it: one that would fill, or one that was `fit-content` as
666
+ * written.
667
+ *
668
+ * ponytail: a size in points is left as it is, since nothing here knows how big the content
669
+ * is. Measure it if a box is ever given both.
670
+ */
671
+ function capAtContent(props, size, cap) {
672
+ if (props[cap] !== 'max-content')
673
+ return false;
674
+ delete props[cap];
675
+ const own = props[size];
676
+ const fills = own == null || own === 'auto' || (typeof own === 'string' && own.endsWith('%'));
677
+ if (fills)
678
+ props[size] = 'fit-content';
679
+ return fills || own === 'fit-content';
680
+ }
526
681
  const isPercent = (value) => typeof value === 'string' && value.endsWith('%');
682
+ /** Whether a `flex-basis` is a length or a percentage of something: not zero, and not `auto`. */
683
+ const isSizedBasis = (basis) => isPercent(basis)
684
+ ? Number.parseFloat(basis) !== 0
685
+ : typeof basis === 'number' && basis !== 0;
686
+ /**
687
+ * A `flex-basis` that is a length or a percentage, as the size it is along its container's main
688
+ * axis: a width in a row and a height in a column, over one written for that axis.
689
+ *
690
+ * Yoga works a `flexBasis` out once for a view and keeps the answer, and React Native never has
691
+ * it forgotten when the view's props change (`computedFlexBasis`, cleared only by
692
+ * `markDirtyAndPropagate`). A basis that changes after the first layout is not read again, and
693
+ * a percentage does not follow the box it is a share of. A size is worked out on every pass, and
694
+ * with no basis Yoga takes the size along the main axis as the basis: the same sum.
695
+ *
696
+ * A basis of zero is left as it is, which is every `flex: 1`: under a container with no size on
697
+ * its main axis Yoga passes over a basis and sizes the box by its content, where a size of zero
698
+ * would be zero. A box out of the flow is no flex item, and keeps its own size.
699
+ */
700
+ function basisAsSize(node, props) {
701
+ const basis = props['flexBasis'];
702
+ if (!isSizedBasis(basis) || props['position'] === 'absolute')
703
+ return;
704
+ const parent = layoutParent(node);
705
+ if (!parent)
706
+ return;
707
+ const container = containerOf(parent);
708
+ // Remembered on the container, so the box is merged again when the direction changes.
709
+ parent.fitContainer = container;
710
+ delete props['flexBasis'];
711
+ if (container.startsWith('row'))
712
+ props['width'] = basis;
713
+ else {
714
+ props['height'] = basis;
715
+ percentHeight(node, props);
716
+ }
717
+ }
527
718
  /**
528
719
  * Whether a box has a height a percentage can be taken of, as CSS defines one: a height of its
529
720
  * own, or one the layout around it gives it. A box as tall as what it holds has none.
@@ -558,14 +749,90 @@ function sizesItself(node, height) {
558
749
  function heightFromLayout(node, parent) {
559
750
  const [direction, align] = containerOf(parent).split(' ');
560
751
  if (direction === 'row') {
561
- // Stretched across a row, it is as tall as the row.
752
+ // Stretched across a row, it is as tall as the row, where the row has a height to be. One
753
+ // as tall as its content gives none: Yoga takes the percentage of the space on offer there
754
+ // too, the screen's, where a browser takes it of the row once its content has sized it.
562
755
  const self = ownLayout(node, 'alignSelf');
563
- return STRETCHES.has(self) && align === 'stretch';
756
+ return STRETCHES.has(self) && align === 'stretch' && definiteHeight(parent);
564
757
  }
565
758
  // In a column it has the height it grows to, where the column has one to share out.
566
759
  const grows = Number(ownLayout(node, 'flexGrow') ?? ownLayout(node, 'flex') ?? 0) > 0;
567
760
  return grows && definiteHeight(parent);
568
761
  }
762
+ /**
763
+ * A placeholder's opacity, which native has none for, as its colour faded by that share: none of
764
+ * it at 0, which is how a stylesheet hides a placeholder under a label, and all of it at 1.
765
+ *
766
+ * ponytail: with no colour of its own the placeholder is native's default, and a platform colour
767
+ * is native's to resolve: neither is known here, so each is hidden at 0 and left as it is above
768
+ * that. Name the platform's default, or read the colour back, if one is faded part of the way.
769
+ */
770
+ function placeholderFaded(props) {
771
+ const opacity = props['placeholderOpacity'];
772
+ if (opacity === undefined)
773
+ return;
774
+ delete props['placeholderOpacity'];
775
+ if (typeof opacity !== 'number' || opacity >= 1)
776
+ return;
777
+ const colour = props['placeholderTextColor'];
778
+ if (typeof colour === 'string')
779
+ props['placeholderTextColor'] = faded(colour, Math.max(opacity, 0));
780
+ else if (opacity <= 0)
781
+ props['placeholderTextColor'] = 'rgba(0, 0, 0, 0)';
782
+ }
783
+ /** The least alpha iOS sends a touch to a view at: under a hundredth it is passed over. */
784
+ const TOUCHED_ALPHA = 0.011;
785
+ /**
786
+ * A view that takes a touch and has no opacity, committed with the least there is to be sent
787
+ * one. A browser sends a press to an element at `opacity: 0`, which is how a see-through
788
+ * backdrop hears a press outside a menu; iOS passes over a view it would not draw. A hundredth
789
+ * of what the view draws is not seen.
790
+ */
791
+ function stillTouched(props) {
792
+ // One told to take no touch is not to be sent one, seen or not.
793
+ if (props['pointerEvents'] === 'none')
794
+ return;
795
+ const opacity = props['opacity'];
796
+ if (typeof opacity === 'number' && opacity < TOUCHED_ALPHA)
797
+ props['opacity'] = TOUCHED_ALPHA;
798
+ }
799
+ /**
800
+ * Leave `aspect-ratio` out for a box with both a width and a height. CSS takes the ratio only
801
+ * for a size that is `auto`; Yoga takes it over one of two that are given, the height in a
802
+ * column and the width in a row.
803
+ */
804
+ function ratioForAutoSize(props) {
805
+ if (!isSet(props['aspectRatio']))
806
+ return;
807
+ const given = (size) => isSet(size) && size !== 'auto';
808
+ if (given(props['width']) && given(props['height']))
809
+ delete props['aspectRatio'];
810
+ }
811
+ const OUTLINE_KEYS = ['outlineWidth', 'outlineStyle', 'outlineColor', 'outlineOffset'];
812
+ /**
813
+ * Send nothing for an outline of no width, which is what `outline: none` is: it draws nothing,
814
+ * and a view with no outline is the same view. Sent, it is an update that is not one, and on
815
+ * Android a text field given an outline prop after it is first drawn has its background set
816
+ * again, with which the padding Android gives a text field comes back over the padding it was
817
+ * laid out with: a field as tall as its line clips its text away. A stylesheet takes a
818
+ * browser's focus ring off a field with `outline: none` for the focused field alone.
819
+ */
820
+ function noOutline(props) {
821
+ if (props['outlineWidth'] !== 0)
822
+ return;
823
+ for (const key of OUTLINE_KEYS)
824
+ delete props[key];
825
+ }
826
+ /**
827
+ * A box with `display: none`, out of the flow as well. It takes no room either way, but Yoga
828
+ * reads an item's baseline from its first child that is in the flow, and a hidden one has no
829
+ * layout to read: under a row with `align-items: baseline` the baseline is not a number, and
830
+ * the row comes to no height with nothing in it placed. Out of the flow it is passed over.
831
+ */
832
+ function hiddenOutOfFlow(props) {
833
+ if (props['display'] === 'none')
834
+ props['position'] = 'absolute';
835
+ }
569
836
  /**
570
837
  * `height: 50%` under a parent with no height to take it of, which CSS reads as `auto`. Yoga
571
838
  * takes it of the space on offer, the screen's or everything a scroll view holds, so no height
@@ -574,6 +841,9 @@ function heightFromLayout(node, parent) {
574
841
  function percentHeight(node, props) {
575
842
  if (!isPercent(props['height']))
576
843
  return;
844
+ // Out of the flow, it is of the box that holds it, which is laid out first and has a height.
845
+ if (props['position'] === 'absolute')
846
+ return;
577
847
  const parent = layoutParent(node);
578
848
  if (parent && !definiteHeight(parent))
579
849
  delete props['height'];
@@ -623,6 +893,43 @@ const TEXT_ALIGN = {
623
893
  };
624
894
  /** The native modal host, which is left out of a commit while `visible` is false. */
625
895
  const MODAL_HOST = 'ModalHostView';
896
+ /**
897
+ * A screen's `gestureResponseDistance` that no touch is within: how far from each edge a swipe
898
+ * back may start, with -1 for no limit, and none at all from the leading edge. It is what
899
+ * `react-native-screens` reads as the swipe is about to begin; whether the swipe is enabled it
900
+ * read when the finger came down, before anything here had heard of the touch.
901
+ */
902
+ const NO_SWIPE = Object.freeze({ start: -1, end: 0, top: -1, bottom: -1 });
903
+ /** A screen of a native stack, which is what the swipe back is a gesture of. */
904
+ const STACK_SCREEN = 'RNSScreen';
905
+ /** What an element's `touch-action` is: bound on it, or from the rules it matches. */
906
+ function touchActionOf(node) {
907
+ return inlineOf(node)['touchAction'] ?? node.styleCache?.style['touchAction'];
908
+ }
909
+ /** Where a touch is on the screen: its own point, or its first finger's. */
910
+ function pointOf(nativeEvent) {
911
+ const event = (nativeEvent ?? {});
912
+ const touch = event.pageX === undefined ? event.touches?.[0] : event;
913
+ return { x: touch?.pageX ?? 0, y: touch?.pageY ?? 0 };
914
+ }
915
+ /**
916
+ * What a touch on a node leaves to the browser: the `touch-action` of it, or of the nearest
917
+ * element over it that says. Nothing where none does, which leaves everything.
918
+ */
919
+ function touchActions(node) {
920
+ for (let up = node; up; up = up.parent) {
921
+ const said = up.kind === 'element' ? touchActionOf(up) : undefined;
922
+ if (typeof said === 'string')
923
+ return said.split(' ');
924
+ }
925
+ return undefined;
926
+ }
927
+ const PANS_ACROSS = ['auto', 'manipulation', 'pan-x', 'pan-left', 'pan-right'];
928
+ const PANS_ALONG = ['auto', 'manipulation', 'pan-y', 'pan-up', 'pan-down'];
929
+ /** Whether an element keeps a drag in one direction: its `touch-action` leaves no pan that way. */
930
+ const keeps = (actions, pans) => actions !== undefined && !actions.some((action) => pans.includes(action));
931
+ /** How far a finger moves before a browser has settled which way a drag is going. */
932
+ const DRAG_SLOP = 4;
626
933
  /**
627
934
  * Register a codegen'd third-party Fabric component, e.g.
628
935
  * `registerViewName('rns-screen', 'RNSScreen')`, which is how the router reaches
@@ -881,12 +1188,14 @@ function isTextElement(node) {
881
1188
  return false;
882
1189
  return !TEXT_ELEMENTS.has(node.name) || holdsOnlyText(node);
883
1190
  }
1191
+ /** Whether a text element holds text and nothing else. One that holds nothing holds no text. */
884
1192
  function holdsOnlyText(node) {
885
- for (const child of node.children ?? []) {
1193
+ const children = node.children ?? [];
1194
+ for (const child of children) {
886
1195
  if (child.kind === 'element' && !isTextElement(child))
887
1196
  return false;
888
1197
  }
889
- return true;
1198
+ return children.length > 0;
890
1199
  }
891
1200
  const ALIGNS = new Set(['center', 'flex-end', 'space-around', 'space-evenly']);
892
1201
  /**
@@ -896,20 +1205,26 @@ const ALIGNS = new Set(['center', 'flex-end', 'space-around', 'space-evenly']);
896
1205
  * A browser makes the run a flex item and places it. A paragraph's text is its content, which no
897
1206
  * alignment moves, so such an element commits as a view, and its text as the paragraph any view's
898
1207
  * 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
1208
+ * about, so nothing is kept to go stale. The run can be a text element of its own, a label in a
1209
+ * span, which is then the item placed. A `<text>` is a paragraph whatever its style, and an
900
1210
  * element holding more than the one run is left as it was.
901
1211
  */
902
1212
  function aligningText(node) {
903
1213
  if (node.kind !== 'element' || node.name === 'text' || !TEXT_ELEMENTS.has(node.name)) {
904
1214
  return false;
905
1215
  }
906
- if (node.children.length !== 1 || node.children[0].kind !== 'text')
1216
+ if (node.children.length !== 1 || !oneRun(node.children[0]))
1217
+ return false;
1218
+ // Under a text element it is a span of that paragraph, unless that one is a view placing it.
1219
+ if (isTextElement(node.parent) && !aligningText(node.parent))
907
1220
  return false;
908
- if (isTextElement(node.parent) || ownLayout(node, 'display') !== 'flex')
1221
+ if (ownLayout(node, 'display') !== 'flex')
909
1222
  return false;
910
1223
  return (ALIGNS.has(ownLayout(node, 'alignItems')) ||
911
1224
  ALIGNS.has(ownLayout(node, 'justifyContent')));
912
1225
  }
1226
+ /** One run of text to place: text itself, or a text element around some, a label in a span. */
1227
+ const oneRun = (child) => child.kind === 'text' || (child.kind === 'element' && TEXT_ELEMENTS.has(child.name));
913
1228
  /** The view a node is committed as: `viewNameOf`, but for a text element that aligns its text. */
914
1229
  const committedViewName = (node) => aligningText(node) ? DEFAULT_VIEW : viewNameOf(node);
915
1230
  /**
@@ -940,8 +1255,15 @@ export function viewNameOf(node) {
940
1255
  }
941
1256
  // The paragraph the engine makes for a view's loose text is under a view, whatever the name
942
1257
  // of the element that view is.
943
- return !node.anonymous && isTextElement(node.parent) ? VIRTUAL_TEXT : PARAGRAPH;
1258
+ return nestedText(node) ? VIRTUAL_TEXT : PARAGRAPH;
944
1259
  }
1260
+ /**
1261
+ * Whether a text element is a span of the paragraph above it: under another text element, but
1262
+ * not one that is a view for placing this one (see `aligningText`), nor the engine's own box.
1263
+ */
1264
+ const nestedText = (node) => !node.anonymous && isTextElement(node.parent) && !placesText(node.parent);
1265
+ /** Whether a node's parent is a text element committed as a view, to place the run it holds. */
1266
+ const placesText = (parent) => parent != null && 'props' in parent && aligningText(parent);
945
1267
  /**
946
1268
  * The props every native view reads as a boolean: the `bool` fields of React Native's
947
1269
  * `BaseViewProps`, `AccessibilityProps` and `HostPlatformViewProps`, by the names JavaScript sends.
@@ -980,16 +1302,12 @@ const BOOLEAN_VIEW_PROPS = new Set([
980
1302
  ]);
981
1303
  /**
982
1304
  * 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.
1305
+ * or is now that it has gone. What `:empty` does not see is `fills` in css.ts to say.
984
1306
  */
985
1307
  function wasOrIsEmptyWithout(node, moved) {
986
- if (moved.kind === 'anchor')
1308
+ if (!fills(moved))
987
1309
  return false;
988
- for (const child of node.children) {
989
- if (child !== moved && child.kind !== 'anchor')
990
- return false;
991
- }
992
- return true;
1310
+ return !node.children.some((child) => child !== moved && fills(child));
993
1311
  }
994
1312
  /**
995
1313
  * Have the child that paints a node's background merged again with the node: nothing of the
@@ -1556,6 +1874,16 @@ const now = () => globalThis.performance?.now?.() ?? Date.now();
1556
1874
  function sameHandles(a, b) {
1557
1875
  return a.length === b.length && a.every((handle, i) => handle === b[i]);
1558
1876
  }
1877
+ /**
1878
+ * Whether a node could be the element a compound is written for, by its classes alone: its own,
1879
+ * and those of one alternative of each `:is()` on it, which is where Tailwind names a group or
1880
+ * a peer. A compound with no class in either could be any element.
1881
+ */
1882
+ function couldBe(compound, node) {
1883
+ if (!compound.classes.every((name) => node.classes?.has(name)))
1884
+ return false;
1885
+ return (compound.is ?? []).every((alternatives) => alternatives.some((option) => couldBe(option, node)));
1886
+ }
1559
1887
  const FRAME_BUDGET_MS = 8;
1560
1888
  export class Engine {
1561
1889
  /** Scopes the CSS resolver's memo. Bumped once per commit. */
@@ -1612,6 +1940,14 @@ export class Engine {
1612
1940
  * does, a child list can move without anything else needing to be looked at again.
1613
1941
  */
1614
1942
  structuralSheets = false;
1943
+ /** How many children after a change in a list one of them reaches. See `siblingReach`. */
1944
+ structuralAfter = 0;
1945
+ /** Whether one of them reaches the children before it. */
1946
+ structuralBefore = false;
1947
+ /** The compounds of theirs that read an element's place from under it. See `markPlace`. */
1948
+ placeElsewhere = new Set();
1949
+ /** The sheets `watchStructure` has read, which it is asked about once an element. */
1950
+ watchedStructure = new WeakSet();
1615
1951
  /** Whether any sheet uses `:has()`. See `markBeneath`. */
1616
1952
  hasSheets = false;
1617
1953
  resolveAssetSource;
@@ -1624,7 +1960,7 @@ export class Engine {
1624
1960
  this.fontScale = conditions.fontScale;
1625
1961
  if (options.tokens)
1626
1962
  this.styles.setRootTokens(options.tokens);
1627
- this.structuralSheets = options.globalStyles?.structural === true;
1963
+ this.watchStructure(options.globalStyles);
1628
1964
  this.watchHas(options.globalStyles);
1629
1965
  this.watchActive(options.globalStyles);
1630
1966
  this.dev = options.dev ?? globalThis.__DEV__ === true;
@@ -1748,9 +2084,7 @@ export class Engine {
1748
2084
  const { own, elsewhere } = this.activeUse;
1749
2085
  if (!own && !elsewhere.length)
1750
2086
  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)
2087
+ if (elsewhere.some((compound) => couldBe(compound, node)))
1754
2088
  return this.markProps(node);
1755
2089
  node.stateDirty = true;
1756
2090
  this.markPath(node.parent ?? node);
@@ -1775,13 +2109,26 @@ export class Engine {
1775
2109
  this.sheetReplaced(replacing, sheet);
1776
2110
  else
1777
2111
  this.registerSheet(sheet);
1778
- if (sheet.structural)
1779
- this.structuralSheets = true;
2112
+ this.watchStructure(sheet);
1780
2113
  this.watchHas(sheet);
1781
2114
  this.watchActive(sheet);
1782
2115
  this.markPath(this.root);
2116
+ this.markWrittenFor(sheet, this.root);
1783
2117
  return true;
1784
2118
  }
2119
+ /**
2120
+ * Have styled again each node a sheet just added could match. The rest keep the style they
2121
+ * have, which the resolver lets stand for a node none of the sheet's rules is written for,
2122
+ * so a commit steps over them: the walk here is what finds the ones it must not step over.
2123
+ */
2124
+ markWrittenFor(sheet, node) {
2125
+ if (node.kind === 'element' && this.styles.couldMatch(sheet, node)) {
2126
+ node.styleDirty = true;
2127
+ this.markPath(node);
2128
+ }
2129
+ for (const child of node.children)
2130
+ this.markWrittenFor(sheet, child);
2131
+ }
1785
2132
  /**
1786
2133
  * Stop matching a sheet `addGlobalSheet` added, and restyle everything; false, and nothing
1787
2134
  * done, when it is not one. A hot swap that leaves a None component no rules, or makes it scoped.
@@ -1967,8 +2314,8 @@ export class Engine {
1967
2314
  if (sheet === undefined || node.sheet === sheet)
1968
2315
  return;
1969
2316
  node.sheet = sheet;
1970
- if (sheet?.structural)
1971
- this.structuralSheets = true;
2317
+ this.styles.noteSheet(sheet);
2318
+ this.watchStructure(sheet);
1972
2319
  this.watchHas(sheet);
1973
2320
  this.watchActive(sheet);
1974
2321
  this.markProps(node);
@@ -2003,14 +2350,14 @@ export class Engine {
2003
2350
  createElement(name, sheet = null) {
2004
2351
  const node = new RetainedNode('element', name, this);
2005
2352
  node.sheet = sheet;
2353
+ this.styles.noteSheet(sheet);
2006
2354
  const html = HTML_ELEMENTS.get(name);
2007
2355
  if (html !== undefined) {
2008
2356
  if (html.role)
2009
2357
  node.props['accessibilityRole'] = html.role;
2010
2358
  node.styled = html.styled;
2011
2359
  }
2012
- if (sheet?.structural)
2013
- this.structuralSheets = true;
2360
+ this.watchStructure(sheet);
2014
2361
  this.watchHas(sheet);
2015
2362
  this.watchActive(sheet);
2016
2363
  if (HOISTS[name])
@@ -2019,11 +2366,45 @@ export class Engine {
2019
2366
  }
2020
2367
  addClass(node, name) {
2021
2368
  (node.classes ??= new Set()).add(name);
2022
- this.markProps(node);
2369
+ this.markClasses(node, [name]);
2023
2370
  }
2024
2371
  removeClass(node, name) {
2025
2372
  node.classes?.delete(name);
2026
- this.markProps(node);
2373
+ this.markClasses(node, [name]);
2374
+ }
2375
+ /**
2376
+ * Mark a node whose classes changed, by what the rules that name those classes can restyle.
2377
+ *
2378
+ * A class no selector names changes no style: a library's own marker, of an overlay that is
2379
+ * animating or a control that was touched. One named only as what a rule's element is
2380
+ * inside, `.animating .close`, restyles the elements under the node that such rules are for.
2381
+ * Either way a whole subtree is not styled again for a class that has no say in it. Any other
2382
+ * class restyles the node and everything under it, as any other change to it does.
2383
+ *
2384
+ * A sheet that names a class later restyles what it is for as it arrives, and a node not yet
2385
+ * committed is styled when it is, with the class as it is then.
2386
+ */
2387
+ markClasses(node, changed) {
2388
+ const reach = this.styles.reachOf(changed);
2389
+ if (reach === true)
2390
+ return this.markProps(node);
2391
+ this.markProps(node, false);
2392
+ if (reach)
2393
+ this.markUnder(node, reach);
2394
+ }
2395
+ /** Have styled again each element under a node that one of `subjects`' rules could be for. */
2396
+ markUnder(node, subjects) {
2397
+ for (const child of node.children) {
2398
+ if (child.kind !== 'element')
2399
+ continue;
2400
+ if (this.styles.isFor(subjects, child)) {
2401
+ // And with it all under it, which is styled from it.
2402
+ child.styleDirty = true;
2403
+ this.markPath(child);
2404
+ }
2405
+ else
2406
+ this.markUnder(child, subjects);
2407
+ }
2027
2408
  }
2028
2409
  /**
2029
2410
  * The sheet of the component `node` hosts, whose `:host` rules it matches. Set again when a hot
@@ -2034,6 +2415,8 @@ export class Engine {
2034
2415
  if (node.hostSheet === sheet)
2035
2416
  return;
2036
2417
  node.hostSheet = sheet;
2418
+ this.styles.noteSheet(sheet);
2419
+ this.watchStructure(sheet);
2037
2420
  this.watchActive(sheet);
2038
2421
  this.markProps(node);
2039
2422
  }
@@ -2049,8 +2432,14 @@ export class Engine {
2049
2432
  /** `class="a b"` from a template. Replaces the set rather than adding to it. */
2050
2433
  setClasses(node, value) {
2051
2434
  const names = value.split(/\s+/).filter(Boolean);
2435
+ const before = node.classes;
2052
2436
  node.classes = names.length ? new Set(names) : null;
2053
- this.markProps(node);
2437
+ // Each class that came or went: one in both changes nothing.
2438
+ const changed = [...names.filter((name) => !before?.has(name))];
2439
+ for (const name of before ?? [])
2440
+ if (!node.classes?.has(name))
2441
+ changed.push(name);
2442
+ this.markClasses(node, changed);
2054
2443
  }
2055
2444
  /** Angular's `@if`/`@for`/`ViewContainerRef` markers. Ordered, never committed. */
2056
2445
  createAnchor() {
@@ -2067,7 +2456,7 @@ export class Engine {
2067
2456
  parent.children.push(child);
2068
2457
  if (child.dormantHoists)
2069
2458
  this.wakeHoists(child);
2070
- this.markStructure(parent, child);
2459
+ this.markStructure(parent, child, parent.children.length - 1);
2071
2460
  this.markTextContent(parent, child);
2072
2461
  }
2073
2462
  insertBefore(parent, child, ref) {
@@ -2080,7 +2469,7 @@ export class Engine {
2080
2469
  parent.children.splice(at, 0, child);
2081
2470
  if (child.dormantHoists)
2082
2471
  this.wakeHoists(child);
2083
- this.markStructure(parent, child);
2472
+ this.markStructure(parent, child, at < 0 ? parent.children.length - 1 : at);
2084
2473
  this.markTextContent(parent, child);
2085
2474
  }
2086
2475
  removeChild(parent, child) {
@@ -2093,7 +2482,7 @@ export class Engine {
2093
2482
  target.children.splice(at, 1);
2094
2483
  child.parent = null;
2095
2484
  this.removedSinceCommit.add(child);
2096
- this.markStructure(target, child);
2485
+ this.markStructure(target, child, at);
2097
2486
  this.markTextContent(target, child);
2098
2487
  }
2099
2488
  /**
@@ -2233,7 +2622,9 @@ export class Engine {
2233
2622
  // Inline style is applied after the cascade and inherited by nothing, so it cannot change
2234
2623
  // what any node matches or inherits. Every other prop can: `[disabled]` is a selector. An
2235
2624
  // inline `direction` is the exception, because the paragraphs below it align by it.
2236
- this.markProps(node, key !== 'style' || this.inlineReachesStyle(node));
2625
+ // And a child that takes one of this node's own values, an `inherit`, follows it.
2626
+ const heirs = node.styleCache?.heirs === true;
2627
+ this.markProps(node, key !== 'style' || this.inlineReachesStyle(node) || heirs);
2237
2628
  }
2238
2629
  /**
2239
2630
  * Paint a node's background on one of its children instead of on the node: the node keeps its
@@ -2331,10 +2722,21 @@ export class Engine {
2331
2722
  this.markProps(node);
2332
2723
  }
2333
2724
  setText(node, value) {
2725
+ const filled = (node.text === '') !== (value === '');
2334
2726
  node.text = value;
2335
2727
  this.markProps(node);
2336
- if (node.parent)
2337
- this.markTextContent(node.parent, node);
2728
+ if (!node.parent)
2729
+ return;
2730
+ this.markTextContent(node.parent, node);
2731
+ // Text of no length is nothing to `:empty`: its first character and its last change what
2732
+ // the element it is in matches, and what comes after that element.
2733
+ if (filled && this.structuralSheets)
2734
+ this.markFilled(node.parent);
2735
+ }
2736
+ markFilled(node) {
2737
+ node.styleDirty = true;
2738
+ this.markLaterSiblings(node);
2739
+ this.markPath(node);
2338
2740
  }
2339
2741
  setEventListener(node, topLevelType, fn) {
2340
2742
  this.registerEventHandler();
@@ -2398,24 +2800,101 @@ export class Engine {
2398
2800
  siblings[i].styleDirty = true;
2399
2801
  }
2400
2802
  }
2401
- /** `node`'s child list changed: `moved` came into it or went out of it. */
2402
- markStructure(node, moved) {
2803
+ /** `node`'s child list changed: `moved` came into it at `at`, or went out of it from there. */
2804
+ markStructure(node, moved, at) {
2403
2805
  node.structureDirty = true;
2404
2806
  // A child list that moved changes what its members match, though nothing about them did:
2405
2807
  // the old last row is no longer the last. Only sheets that ask about position pay for this.
2406
2808
  if (this.structuralSheets) {
2407
- node.styleDirty = true;
2408
- for (const child of node.children)
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))
2809
+ moved.styleDirty = true;
2810
+ this.markRepositioned(node.children, at, moved);
2811
+ // What the node itself matches hangs on its child list only through `:empty`, which only
2812
+ // its first child arriving or its last one leaving changes: and then what a later sibling
2813
+ // matches can too, `.box:empty + .spacer`. Styled again for any other change, everything
2814
+ // under it would be resolved again with it.
2815
+ if (wasOrIsEmptyWithout(node, moved)) {
2816
+ node.styleDirty = true;
2413
2817
  this.markLaterSiblings(node);
2818
+ }
2414
2819
  }
2415
2820
  if (this.hasSheets)
2416
2821
  this.markBeneath(node);
2417
2822
  this.markPath(node.parent);
2418
2823
  }
2824
+ /**
2825
+ * Mark the children whose place in a child list that changed at `at` a sheet could read
2826
+ * differently. The two at each end always, which are the old first and last and the new. Those
2827
+ * from `at` on where some sheet counts from the start or asks about any one before, since a
2828
+ * child's place from the start hangs only on those before it; and those before `at` where one
2829
+ * counts from the end. Styling one again resolves everything under it again, so a long list
2830
+ * gaining a row at its end is not the whole list.
2831
+ *
2832
+ * Where the sheets ask no further than the element just before (`+`), a rule steps a known
2833
+ * number of elements along from one whose own match changed, and those are all there is to
2834
+ * mark: that many after the change, and that many after the two at the start, either of which
2835
+ * may have stopped or started being first. Nothing comes after the two at the end.
2836
+ */
2837
+ markRepositioned(children, at, moved) {
2838
+ const steps = this.structuralAfter;
2839
+ const pastFirst = steps === Infinity ? 0 : steps;
2840
+ for (const child of elementsAtEnd(children, 2 + pastFirst, false))
2841
+ this.markPlace(child);
2842
+ for (const child of elementsAtEnd(children, 2, true))
2843
+ this.markPlace(child);
2844
+ if (this.structuralBefore)
2845
+ for (let i = 0; i < at; i++)
2846
+ this.markPlace(children[i]);
2847
+ // The elements after the change, not counting the one that came, which is marked already.
2848
+ let left = steps;
2849
+ for (let i = at; i < children.length && left > 0; i++) {
2850
+ const child = children[i];
2851
+ this.markPlace(child);
2852
+ if (child.kind === 'element' && child !== moved)
2853
+ left--;
2854
+ }
2855
+ }
2856
+ /**
2857
+ * A child's place in its list changed, which is all that changed about it. It is matched
2858
+ * again, and keeps its style and all that is under it unless it now matches other rules: its
2859
+ * place is read by the rules that style it and by no other. Not where a rule reads the place
2860
+ * of an element like it from under it, `.row:nth-child(odd) .label`: that changes more than
2861
+ * the element itself, which is then styled again with everything under it, as is a child
2862
+ * that is no element.
2863
+ */
2864
+ markPlace(child) {
2865
+ if (child.kind !== 'element' || this.placeReadFromUnder(child))
2866
+ child.styleDirty = true;
2867
+ else
2868
+ child.stateDirty = true;
2869
+ }
2870
+ /**
2871
+ * Whether a rule could read this element's place from another element. By its classes alone,
2872
+ * which every such compound it could be asks for: a compound with none could be any element.
2873
+ */
2874
+ placeReadFromUnder(node) {
2875
+ for (const compound of this.placeElsewhere) {
2876
+ if (compound.classes.every((name) => node.classes?.has(name)))
2877
+ return true;
2878
+ }
2879
+ return false;
2880
+ }
2881
+ /** Note what a sheet coming into play asks about a child list. None is unset: see `structural`. */
2882
+ watchStructure(sheet) {
2883
+ if (!sheet?.structural || this.watchedStructure.has(sheet))
2884
+ return;
2885
+ this.watchedStructure.add(sheet);
2886
+ this.structuralSheets = true;
2887
+ const reach = siblingReach(sheet);
2888
+ this.structuralAfter = Math.max(this.structuralAfter, reach.after);
2889
+ if (reach.before)
2890
+ this.structuralBefore = true;
2891
+ for (const compound of placeReadElsewhere(sheet))
2892
+ this.placeElsewhere.add(compound);
2893
+ // What a child whose place changed is compared with is the rules it matched, which a cache
2894
+ // keeps from here on. One made before has none, and is styled again the first time, as it
2895
+ // was before any sheet asked.
2896
+ this.styles.tracksHas = true;
2897
+ }
2419
2898
  /** Walk up until we hit a node already known to have a dirty subtree. */
2420
2899
  markPath(node) {
2421
2900
  const wasClean = !this.root.structureDirty && !this.root.subtreeDirty;
@@ -2461,6 +2940,7 @@ export class Engine {
2461
2940
  this.fabric.appendChildToSet(set, this.reconcileUnder(this.root, child));
2462
2941
  }
2463
2942
  this.clearFlags(this.root);
2943
+ this.announceCommit();
2464
2944
  this.fabric.completeRoot(this.rootTag, set);
2465
2945
  const elapsed = now() - started;
2466
2946
  this.stats.commits++;
@@ -2475,6 +2955,7 @@ export class Engine {
2475
2955
  this.stats.slowCommits++;
2476
2956
  }
2477
2957
  this.flushTransitionEvents();
2958
+ this.playSized();
2478
2959
  this.scrollDriver?.afterCommit();
2479
2960
  // Read first: the commit that settles keyframes starts by clearing it.
2480
2961
  const facesAdded = this.facesAdded;
@@ -2484,6 +2965,25 @@ export class Engine {
2484
2965
  this.rematchFonts();
2485
2966
  return true;
2486
2967
  }
2968
+ beforeCommit = null;
2969
+ announceCommit() {
2970
+ const run = this.beforeCommit;
2971
+ this.beforeCommit = null;
2972
+ run?.();
2973
+ }
2974
+ /**
2975
+ * Run `run` once, as the next commit hands its tree to the host and before the host has it: the
2976
+ * moment for something the host applies to its next commit, which is only this one from here.
2977
+ * A pass that changes nothing commits nothing and runs nothing, so a request waits until it is
2978
+ * taken back with the function returned. A later request replaces an earlier one.
2979
+ */
2980
+ beforeNextCommit(run) {
2981
+ this.beforeCommit = run;
2982
+ return () => {
2983
+ if (this.beforeCommit === run)
2984
+ this.beforeCommit = null;
2985
+ };
2986
+ }
2487
2987
  /** The first commit whose new views have not been drawn yet, while `commitUnseen` runs. */
2488
2988
  unseen = Infinity;
2489
2989
  /**
@@ -2588,11 +3088,36 @@ export class Engine {
2588
3088
  if (this.transitionEvents.length === 0)
2589
3089
  return;
2590
3090
  for (const { node, type, property } of this.transitionEvents.splice(0)) {
3091
+ // One listener that throws stops neither the rest nor the node being let go.
2591
3092
  for (const listener of [...(node.listeners?.get(type) ?? [])]) {
2592
- listener(animationEvent(type, node, property));
3093
+ try {
3094
+ listener(animationEvent(type, node, property));
3095
+ }
3096
+ catch (error) {
3097
+ this.reportEventError(error, type);
3098
+ }
2593
3099
  }
3100
+ if (type === 'topAnimationend')
3101
+ this.letGo(node);
2594
3102
  }
2595
3103
  }
3104
+ /**
3105
+ * The nodes whose animation has ended with no fill and is still held at its last frame, until
3106
+ * its `animationend` has been heard. A browser runs that listener before it paints again, and
3107
+ * an exit animation's listener takes the element away: it is never seen back at rest. Here the
3108
+ * listener runs after the commit, so the commit that ends the animation paints the last frame,
3109
+ * and the element goes back to rest once the listener has run and left it there.
3110
+ */
3111
+ lettingGo = new Set();
3112
+ letGo(node) {
3113
+ if (!this.lettingGo.delete(node))
3114
+ return;
3115
+ const running = node.playing;
3116
+ if (!running?.done)
3117
+ return;
3118
+ running.values = {};
3119
+ this.markProps(node, false);
3120
+ }
2596
3121
  /** Anchors take part in sibling ordering but never reach Fabric. */
2597
3122
  visibleChildren(node) {
2598
3123
  // A view that takes its text as a prop has no text children to commit. See `ViewNameOptions`.
@@ -2602,13 +3127,21 @@ export class Engine {
2602
3127
  /**
2603
3128
  * Everything a node renders with, unprocessed. Precedence, weakest first: native defaults, the
2604
3129
  * node's `defaultStyle`, matched CSS, explicit props, inline style, a component's
2605
- * `styleOverride`. Inline wins over CSS for the same reason it does on the web.
3130
+ * `styleOverride`. Inline wins over CSS as it does on the web, but for a declaration marked
3131
+ * `!important`, which stands over it: see `inlineOf`.
2606
3132
  *
2607
3133
  * Raw, because this is what the next commit diffs against. Colours and asset ids are converted
2608
3134
  * on the way out instead (`processed`), and only for the keys that changed: the converters
2609
3135
  * return fresh objects, so diffing their output would re-send every image and shadow on every
2610
3136
  * unrelated change.
2611
3137
  */
3138
+ /** What a touch on an element that keeps a drag holds off, while it lasts: see `holdSwipe`. */
3139
+ heldFor(node, merged) {
3140
+ if (node === this.swipeHeld)
3141
+ merged['gestureResponseDistance'] = NO_SWIPE;
3142
+ if (node === this.scrollHeld)
3143
+ merged['scrollEnabled'] = false;
3144
+ }
2612
3145
  mergeProps(node, viewName) {
2613
3146
  if (node.kind === 'text')
2614
3147
  return { text: paragraphText(node) };
@@ -2622,14 +3155,15 @@ export class Engine {
2622
3155
  writeOwnProps(node.props, props, node.attributeOnly);
2623
3156
  withTextContent(node, viewName, props);
2624
3157
  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
- }
3158
+ const inline = inlineOf(node);
3159
+ const style = boundTransform(node, Object.assign(props, inline), cascaded);
3160
+ if (inline !== NO_STYLE)
3161
+ this.styles.overOtherForms(inline, style);
2629
3162
  nativePointerEvents(node, style, resolved);
2630
3163
  // Before an image's own size: `fit-content` is no size, so the image's is what it gets.
2631
3164
  fitContent(node, style);
2632
3165
  percentHeight(node, style);
3166
+ placeholderFaded(style);
2633
3167
  const intrinsic = node.props[INTRINSIC_SIZE];
2634
3168
  if (intrinsic)
2635
3169
  applyIntrinsicSize(style, intrinsic);
@@ -2641,8 +3175,24 @@ export class Engine {
2641
3175
  if (this.fontsRefreshed)
2642
3176
  this.capForFonts(node, style);
2643
3177
  alignMultiline(viewName, style);
3178
+ rowsTall(style, this.fontScale);
2644
3179
  const merged = composeTransform(node, this.animated(node, this.transitioned(node, style)));
2645
- centreSingleLine(viewName, merged, this.fontScale);
3180
+ // On iOS: Android's field says the baseline of the line it is sized and centred by.
3181
+ // Still under the row that marked it: a row taken away is not committed again to unmark it.
3182
+ const row = node.onBaseline;
3183
+ const onBaseline = viewName === 'TextInput' && row !== undefined && isWithin(node, row);
3184
+ centreSingleLine(viewName, merged, this.fontScale, onBaseline);
3185
+ // After a transition, which eases the basis as the basis it was written as.
3186
+ basisAsSize(node, merged);
3187
+ // After the basis, which is a size given where it is committed as one.
3188
+ ratioForAutoSize(merged);
3189
+ noOutline(merged);
3190
+ // Last, on what is committed: an override or an animation can hide a box, or place it.
3191
+ hiddenOutOfFlow(merged);
3192
+ delete merged['touchAction'];
3193
+ if (this.responders.has(node))
3194
+ stillTouched(merged);
3195
+ this.heldFor(node, merged);
2646
3196
  this.movePaint(node, merged);
2647
3197
  return merged;
2648
3198
  }
@@ -2657,9 +3207,7 @@ export class Engine {
2657
3207
  const from = node.parent?.paintsOn === node ? node.parent : null;
2658
3208
  if (!from)
2659
3209
  return;
2660
- const paint = flattenStyle(from.props['style'], {
2661
- ...this.styles.resolve(from, this.styleEpoch).style,
2662
- });
3210
+ const paint = { ...this.styles.resolve(from, this.styleEpoch).style, ...inlineOf(from) };
2663
3211
  for (const key of PAINT_KEYS)
2664
3212
  if (paint[key] !== undefined)
2665
3213
  merged[key] = paint[key];
@@ -2718,7 +3266,7 @@ export class Engine {
2718
3266
  if (!this.inlineDirection)
2719
3267
  return own;
2720
3268
  for (let at = node; at; at = at.parent) {
2721
- const inline = textDirection(flattenStyle(at.props['style'], {})['direction']);
3269
+ const inline = textDirection(inlineOf(at)['direction']);
2722
3270
  if (inline)
2723
3271
  return inline;
2724
3272
  const cascaded = textDirection(at.styleCache?.style['direction']);
@@ -2746,6 +3294,7 @@ export class Engine {
2746
3294
  if (!sheet || this.knownSheets.has(sheet))
2747
3295
  return;
2748
3296
  this.knownSheets.add(sheet);
3297
+ this.styles.noteSheet(sheet);
2749
3298
  this.sheetOrder.push(sheet);
2750
3299
  for (const name of Object.keys(sheet.keyframes ?? {})) {
2751
3300
  this.keyframes.set(name, sheet.keyframes[name]);
@@ -2773,6 +3322,7 @@ export class Engine {
2773
3322
  this.knownSheets.delete(sheet);
2774
3323
  if (next && !this.knownSheets.has(next)) {
2775
3324
  this.knownSheets.add(next);
3325
+ this.styles.noteSheet(next);
2776
3326
  this.sheetOrder[at] = next;
2777
3327
  }
2778
3328
  else {
@@ -2919,11 +3469,69 @@ export class Engine {
2919
3469
  * runs it frame by frame, and JavaScript hears of it again when it ends. Anything else is
2920
3470
  * played from here, a commit a frame. See `NativeScrollDriver.play`.
2921
3471
  */
3472
+ /**
3473
+ * The nodes whose animation moves them by a share of their own size and had no size yet when
3474
+ * it started, each with the style it rests at: asked again once the commit has laid them out.
3475
+ */
3476
+ unsized = new Map();
3477
+ /**
3478
+ * An animation's frames as native can interpolate them: a move by a share of the box,
3479
+ * `translateX(200%)`, as the points that is of the size the view is laid out at. Nothing where
3480
+ * the view has no size yet, which is every view until its first commit: it is played from
3481
+ * JavaScript, and handed over once that commit has laid it out. See `playSized`.
3482
+ *
3483
+ * ponytail: the size it had when it started. A box that changes size goes on moving by the
3484
+ * points of the old one until the animation starts again; restart it from the box's layout
3485
+ * event if a bar that resizes mid-animation comes up.
3486
+ */
3487
+ sized(node, running, props) {
3488
+ // One that waits, or takes no time, is played from here whatever it moves by.
3489
+ if (running.spec.delay !== 0 || running.spec.duration <= 0)
3490
+ return null;
3491
+ if (!movesByShare(running.tracks, props))
3492
+ return { tracks: running.tracks, resting: props };
3493
+ let size;
3494
+ this.measure(node, (frame) => (size = frame));
3495
+ if (size)
3496
+ return sharesAsPoints(running.tracks, props, size);
3497
+ this.unsized.set(node, props);
3498
+ return null;
3499
+ }
3500
+ /**
3501
+ * Hand to native each animation that was waiting for its view to have a size. The commit just
3502
+ * made painted its first frame, so its clock starts here: no frame of it has been seen move.
3503
+ */
3504
+ playSized() {
3505
+ if (!this.unsized.size)
3506
+ return;
3507
+ const waiting = [...this.unsized];
3508
+ this.unsized.clear();
3509
+ for (const [node, props] of waiting) {
3510
+ const running = node.playing;
3511
+ if (!running || running.done || running.native || !this.playing.has(node))
3512
+ continue;
3513
+ // Native plays it from its first frame, so its clock starts here: but only where native
3514
+ // takes it. Left in JavaScript, it goes on from the clock it started on.
3515
+ const started = running.start;
3516
+ running.start = this.now();
3517
+ if (this.playNatively(node, running, props)) {
3518
+ this.playing.delete(node);
3519
+ // `collapsable`, which keeps a view for native to move, is in what it holds now.
3520
+ this.markProps(node, false);
3521
+ }
3522
+ else {
3523
+ running.start = started;
3524
+ }
3525
+ }
3526
+ // One with no size after its commit is not laid out at all, and is played from JavaScript.
3527
+ this.unsized.clear();
3528
+ }
2922
3529
  playNatively(node, running, props) {
2923
3530
  const { spec } = running;
2924
- if (!this.scrollDriver || spec.delay !== 0 || spec.duration <= 0)
3531
+ const sized = this.scrollDriver ? this.sized(node, running, props) : null;
3532
+ if (!this.scrollDriver || !sized)
2925
3533
  return false;
2926
- const { channels, held, span } = clockChannels(running.tracks, spec, props);
3534
+ const { channels, held, span } = clockChannels(sized.tracks, spec, sized.resting);
2927
3535
  if (held.length || !(channels.opacity || channels.transform.length))
2928
3536
  return false;
2929
3537
  // There and back is one native animation: an odd number of ways has no whole number of them.
@@ -3044,6 +3652,10 @@ export class Engine {
3044
3652
  reframed.values = finished && !holds ? {} : values;
3045
3653
  this.playedFrames.set(reframed, frames);
3046
3654
  node.playing = reframed;
3655
+ // One that had nothing to paint was let go of: with frames that say something it plays on.
3656
+ const stopped = reframed.done || reframed.pausedAt !== undefined || reframed.native;
3657
+ if (!stopped)
3658
+ this.playing.add(node);
3047
3659
  }
3048
3660
  /**
3049
3661
  * `animation-play-state` changed on an animation already playing: pausing holds the frame it
@@ -3208,12 +3820,7 @@ export class Engine {
3208
3820
  return props;
3209
3821
  const state = (node.transitions ??= new Map());
3210
3822
  const now = this.now();
3211
- // The properties a transition *names*, even where the node has none yet. A property is only
3212
- // transitioned once it has been seen settling, and one that does not exist is never seen - so
3213
- // `transition: transform` with no transform until a class adds one would arrive instantly,
3214
- // which is the commonest way to write one. `all` names nothing, so it keeps the old rule.
3215
- const named = spec ? Object.keys(spec).filter((key) => key !== 'all') : [];
3216
- for (const key of new Set([...Object.keys(props), ...named])) {
3823
+ for (const key of steppedKeys(props, spec, state)) {
3217
3824
  if (step(state, key, props, spec?.[key] ?? spec?.['all'], now)) {
3218
3825
  this.running.add(node);
3219
3826
  this.emitTransition(node, 'topTransitionstart', key);
@@ -3223,6 +3830,21 @@ export class Engine {
3223
3830
  node.transitions = undefined;
3224
3831
  return props;
3225
3832
  }
3833
+ /**
3834
+ * Whether an animation that has finished goes on holding its last frame. With a forwards fill
3835
+ * it does, for good: without one the element falls back to whatever the cascade gives it, and
3836
+ * 'backwards' fills the start only. Heard, it holds until whoever listens has been told: see
3837
+ * `lettingGo`.
3838
+ */
3839
+ keeps(node, running) {
3840
+ if (running.spec.fill === 'forwards' || running.spec.fill === 'both')
3841
+ return true;
3842
+ // One that listened and has stopped leaves an empty set behind: nobody to hear it.
3843
+ if (!node.listeners?.get('topAnimationend')?.size)
3844
+ return false;
3845
+ this.lettingGo.add(node);
3846
+ return true;
3847
+ }
3226
3848
  /** One `@keyframes` player, one frame on. */
3227
3849
  advancePlayer(node, now) {
3228
3850
  const running = node.playing;
@@ -3234,12 +3856,19 @@ export class Engine {
3234
3856
  this.playing.delete(node);
3235
3857
  return;
3236
3858
  }
3859
+ // Frames that say nothing paint nothing: a keyframe's one declaration refused at build time
3860
+ // leaves these. No frame of it is committed, and one that never ends has nothing left to do.
3861
+ const idle = running.tracks.size === 0;
3237
3862
  const { values, finished } = sample(running, now);
3238
- // Without a forwards fill a finished animation stops holding anything, and the element falls
3239
- // back to whatever the cascade gives it. 'backwards' fills the start only.
3240
- const holds = running.spec.fill === 'forwards' || running.spec.fill === 'both';
3241
- running.values = finished && !holds ? {} : values;
3242
- this.markProps(node, false);
3863
+ // One of no time is finished already, endless or not, and ends as any other does.
3864
+ if (idle && !finished && running.spec.iterations === null) {
3865
+ this.playing.delete(node);
3866
+ return;
3867
+ }
3868
+ running.values = finished && !this.keeps(node, running) ? {} : values;
3869
+ // Its end is still a commit: that is what its `animationend` is sent after.
3870
+ if (!idle || finished)
3871
+ this.markProps(node, false);
3243
3872
  if (!finished)
3244
3873
  return;
3245
3874
  running.done = true;
@@ -3360,6 +3989,58 @@ export class Engine {
3360
3989
  };
3361
3990
  });
3362
3991
  }
3992
+ /**
3993
+ * Mark the text field each box of a row aligned by baseline takes its baseline from, before
3994
+ * the field is merged, and unmark it when the row is aligned another way.
3995
+ */
3996
+ noteBaselineFields(row) {
3997
+ const aligned = ownLayout(row, 'alignItems') === 'baseline';
3998
+ const before = row.baselineFields;
3999
+ if (!aligned && !before)
4000
+ return;
4001
+ const now = new Set(aligned ? row.children.filter(inFlow).map(fieldOnBaseline).filter(isNode) : []);
4002
+ // One that is no longer this row's, and that no other row has marked since.
4003
+ for (const field of before ?? []) {
4004
+ if (!now.has(field) && field.onBaseline === row)
4005
+ this.onBaselineOf(field, undefined);
4006
+ }
4007
+ for (const field of now)
4008
+ if (field.onBaseline !== row)
4009
+ this.onBaselineOf(field, row);
4010
+ row.baselineFields = now.size ? now : undefined;
4011
+ }
4012
+ onBaselineOf(field, row) {
4013
+ field.onBaseline = row;
4014
+ this.markProps(field, false);
4015
+ }
4016
+ /**
4017
+ * Have the text a row aligned by baseline takes a baseline from measured again, where that
4018
+ * text is inside one of the row's boxes and the row is committed again. React Native keeps
4019
+ * what a paragraph measured on the one copy of its node that was measured, and Yoga copies a
4020
+ * node it lays out around: a row that is laid out again asks a box for its baseline, the box
4021
+ * asks the paragraph in it, and a copy that was never measured works it out then, on a node
4022
+ * that is by then not to be changed. A debug build stops there. Committed to be measured
4023
+ * again (`remeasure`), the paragraph is measured in this commit and has the answer.
4024
+ *
4025
+ * ponytail: the first box in the flow at each level, which is the one Yoga asks unless
4026
+ * another is aligned by baseline itself. A row a commit lays out again without the engine
4027
+ * cloning it, as its room changing does, is not reached: clone on a layout event if one is
4028
+ * seen to stop there.
4029
+ */
4030
+ freshBaselines(row) {
4031
+ if (!row.committed || ownLayout(row, 'alignItems') !== 'baseline')
4032
+ return;
4033
+ for (const item of row.children) {
4034
+ let at = firstInFlow(item);
4035
+ while (at?.committed && committedViewName(at) !== PARAGRAPH)
4036
+ at = firstInFlow(at);
4037
+ if (!at?.committed)
4038
+ continue;
4039
+ at.remeasure = true;
4040
+ for (let up = at; up && up !== row; up = up.parent)
4041
+ up.subtreeDirty = true;
4042
+ }
4043
+ }
3363
4044
  /**
3364
4045
  * Copy a list of gradients, running every stop's colour through the host's converter.
3365
4046
  *
@@ -3403,6 +4084,8 @@ export class Engine {
3403
4084
  this.refitChildren(node);
3404
4085
  const viewName = committedViewName(node);
3405
4086
  this.forgetRenamed(node, viewName);
4087
+ this.noteBaselineFields(node);
4088
+ this.freshBaselines(node);
3406
4089
  const childHandles = this.reconcileChildren(node, viewName, style);
3407
4090
  noteTouches(node, style);
3408
4091
  const previous = node.committed;
@@ -3539,6 +4222,8 @@ export class Engine {
3539
4222
  * when it is shown again, as Modal.js creates it afresh. Committed again, it ignores every touch.
3540
4223
  */
3541
4224
  withheld(child) {
4225
+ if (this.undisplayed(child))
4226
+ return true;
3542
4227
  if (child.props['visible'] !== false || child.presented)
3543
4228
  return false;
3544
4229
  if (viewNameOf(child) !== MODAL_HOST)
@@ -3547,6 +4232,42 @@ export class Engine {
3547
4232
  this.forgetCommitted(child);
3548
4233
  return true;
3549
4234
  }
4235
+ /**
4236
+ * Whether an element is `display: none`, which has no box and so no view, nor has anything
4237
+ * in it. A view that is committed and not displayed looks the same, but Yoga marks one as
4238
+ * laid out each time it measures what holds it, and React Native clears the mark only where it
4239
+ * reads that parent's layout: a debug build stops at the mark, on a later commit that has the
4240
+ * parent's layout already. Shown again it is made again, as an element put back in the tree
4241
+ * is: what its views held of their own, a scroll offset, is not kept.
4242
+ */
4243
+ undisplayed(child) {
4244
+ if (child.kind !== 'element')
4245
+ return false;
4246
+ this.styles.resolve(child, this.styleEpoch);
4247
+ if (ownLayout(child, 'display') !== 'none')
4248
+ return false;
4249
+ if (child.committed) {
4250
+ this.forgetCommitted(child);
4251
+ // A field in it has no view to send its blur from.
4252
+ if (isWithin(this.focusedNode, child))
4253
+ this.setFocused(null);
4254
+ }
4255
+ return true;
4256
+ }
4257
+ /**
4258
+ * Whether a node hoisted into `target` is not displayed where it was written: it, or a box
4259
+ * between it and the child of `target` it was written in. That child is asked before this is.
4260
+ */
4261
+ undisplayedUnder(target, written) {
4262
+ for (let at = written; at && at.parent !== target; at = at.parent) {
4263
+ if (!this.undisplayed(at))
4264
+ continue;
4265
+ if (written.committed)
4266
+ this.forgetCommitted(written);
4267
+ return true;
4268
+ }
4269
+ return false;
4270
+ }
3550
4271
  /**
3551
4272
  * `reconcileChildren` for a node other nodes hoist into: its own children, each followed by
3552
4273
  * the hoisted nodes written inside it, then those written in it directly, then the views it
@@ -3569,9 +4290,10 @@ export class Engine {
3569
4290
  else
3570
4291
  handles.push(this.reconcileUnder(node, child, context));
3571
4292
  const moved = hoisted?.get(child);
3572
- if (moved)
3573
- for (const written of moved)
4293
+ for (const written of moved ?? []) {
4294
+ if (!this.undisplayedUnder(node, written))
3574
4295
  handles.push(this.land(node, written, landed));
4296
+ }
3575
4297
  }
3576
4298
  for (const child of direct)
3577
4299
  handles.push(this.land(node, child, landed));
@@ -3860,9 +4582,12 @@ export class Engine {
3860
4582
  this.registerEventHandler();
3861
4583
  this.responders.set(node, handlers);
3862
4584
  this.keepNative(node, true);
4585
+ // What it is committed with can hang on whether it takes a touch: see `stillTouched`.
4586
+ this.markProps(node);
3863
4587
  return () => {
3864
4588
  this.responders.delete(node);
3865
4589
  this.keepNative(node, false);
4590
+ this.markProps(node);
3866
4591
  if (this.currentResponder !== node)
3867
4592
  return;
3868
4593
  // Torn down under the finger: no release is coming, so the chain it marked is cleared
@@ -4094,7 +4819,9 @@ export class Engine {
4094
4819
  const handle = node.committed?.handle;
4095
4820
  if (!handle || !this.fabric.setIsJSResponder)
4096
4821
  return;
4097
- const block = this.responders.get(node)?.blockNativeResponder ?? false;
4822
+ const asked = this.responders.get(node)?.blockNativeResponder ?? false;
4823
+ // And for a drag an element's `touch-action` keeps from the page: see `holdSwipe`.
4824
+ const block = asked || this.drag?.held === true;
4098
4825
  this.fabric.setIsJSResponder(handle, isResponder, block);
4099
4826
  }
4100
4827
  /** Runs before listener propagation, as React's responder plugin does. */
@@ -4156,6 +4883,10 @@ export class Engine {
4156
4883
  dispatchEvent(instanceHandle, topLevelType, nativeEvent) {
4157
4884
  const target = instanceHandle;
4158
4885
  this.trace(target, topLevelType);
4886
+ // What has the focus does not take it again: native answering a focus it was told of, where
4887
+ // whoever asked has already said so.
4888
+ if (topLevelType === 'topFocus' && target !== null && this.focusedNode === target)
4889
+ return;
4159
4890
  // Listeners get RN's documented shape, `{nativeEvent}`. Fabric hands us the payload bare;
4160
4891
  // React wraps it in a synthetic event and every RN API is written against `event.nativeEvent`,
4161
4892
  // so handlers ported from RN would silently read undefined otherwise.
@@ -4166,6 +4897,7 @@ export class Engine {
4166
4897
  if (topLevelType === 'topScroll' && target && this.scrollTimelines.has(target)) {
4167
4898
  this.measuredScroll(target, nativeEvent);
4168
4899
  }
4900
+ this.holdSwipe(target, topLevelType, nativeEvent);
4169
4901
  this.trackFocus(target, topLevelType);
4170
4902
  if (target)
4171
4903
  this.cancelPressForScroll(target, topLevelType, event);
@@ -4177,6 +4909,116 @@ export class Engine {
4177
4909
  }
4178
4910
  this.propagate(target, topLevelType, event);
4179
4911
  }
4912
+ /** The screen whose swipe back is held off while a finger is on what keeps a sideways drag. */
4913
+ swipeHeld = null;
4914
+ /**
4915
+ * Hold a screen's swipe back off for a touch that starts on an element whose `touch-action`
4916
+ * keeps a drag to the side for itself, and give it back when the last finger lifts. From iOS
4917
+ * 26 the swipe starts anywhere on a screen once a finger has moved a little to the right, and
4918
+ * takes the touch from whatever was following it: a slider's thumb stops and the screen
4919
+ * leaves. The screen is told before the finger has moved that far.
4920
+ */
4921
+ holdSwipe(target, type, nativeEvent) {
4922
+ if (type === TOUCH_START)
4923
+ this.holdFrom(target, nativeEvent);
4924
+ else if (type === 'topTouchMove')
4925
+ this.settleDrag(nativeEvent);
4926
+ else if (type === 'topTouchEnd' || type === 'topTouchCancel')
4927
+ this.releaseSwipe(nativeEvent);
4928
+ }
4929
+ /**
4930
+ * A touch on an element that keeps a drag to the side: where it began, and whether native's
4931
+ * own gestures are held off for it. Held from the start where the element keeps a drag down
4932
+ * the page too (`none`); from the moment the finger sets off across where it leaves that one
4933
+ * to the page (`pan-y`), and never for that touch where it sets off down.
4934
+ */
4935
+ drag = null;
4936
+ holdFrom(target, nativeEvent) {
4937
+ // A first finger down is a new touch: the last one's end never came where what it was on
4938
+ // went from under the finger.
4939
+ const fingers = nativeEvent?.touches?.length;
4940
+ if (this.drag && fingers === 1)
4941
+ this.releaseSwipe(null);
4942
+ if (this.drag || !target)
4943
+ return;
4944
+ const actions = touchActions(target);
4945
+ if (!keeps(actions, PANS_ACROSS))
4946
+ return;
4947
+ const all = keeps(actions, PANS_ALONG);
4948
+ this.drag = { ...pointOf(nativeEvent), settled: all, held: all, on: target };
4949
+ if (all)
4950
+ this.holdScroll(target);
4951
+ const screen = this.screenOf(target);
4952
+ if (!screen)
4953
+ return;
4954
+ this.swipeHeld = screen;
4955
+ this.tellScreen(screen);
4956
+ }
4957
+ /** The scroll view whose scrolling is off while a drag over it is an element's own. */
4958
+ scrollHeld = null;
4959
+ /**
4960
+ * Stop the scroll view a node is in from scrolling, until the touch ends. Telling native who
4961
+ * holds the touch does this on Android. On iOS a scroll view is held off only by a responder
4962
+ * that is over it, never one inside it, so it is told not to scroll.
4963
+ */
4964
+ holdScroll(node) {
4965
+ for (let up = node.parent; up; up = up.parent) {
4966
+ if (up.kind !== 'element' || !SCROLL_VIEWS.has(viewNameOf(up)))
4967
+ continue;
4968
+ this.scrollHeld = up;
4969
+ this.tellScreen(up);
4970
+ return;
4971
+ }
4972
+ }
4973
+ /** Settle which way a drag set off, and hold native's own off one that set off across. */
4974
+ settleDrag(nativeEvent) {
4975
+ const drag = this.drag;
4976
+ if (!drag || drag.settled)
4977
+ return;
4978
+ const at = pointOf(nativeEvent);
4979
+ const [across, along] = [Math.abs(at.x - drag.x), Math.abs(at.y - drag.y)];
4980
+ if (Math.max(across, along) < DRAG_SLOP)
4981
+ return;
4982
+ drag.settled = true;
4983
+ drag.held = across > along;
4984
+ if (!drag.held)
4985
+ return;
4986
+ if (this.currentResponder)
4987
+ this.tellNative(this.currentResponder, true);
4988
+ this.holdScroll(drag.on);
4989
+ }
4990
+ /** Give the swipe back once the last finger has lifted. */
4991
+ releaseSwipe(nativeEvent) {
4992
+ const left = nativeEvent?.touches?.length ?? 0;
4993
+ if (left > 0)
4994
+ return;
4995
+ this.drag = null;
4996
+ for (const held of [this.scrollHeld, this.swipeHeld]) {
4997
+ if (held === this.scrollHeld)
4998
+ this.scrollHeld = null;
4999
+ else
5000
+ this.swipeHeld = null;
5001
+ if (held)
5002
+ this.tellScreen(held);
5003
+ }
5004
+ }
5005
+ /**
5006
+ * Commit a screen or a scroll view again now, not with whatever next changes: the swipe begins
5007
+ * once the finger has moved ten points or so, which is a frame or two after it came down.
5008
+ */
5009
+ tellScreen(screen) {
5010
+ // Its props alone: nothing a selector reads moved, so nothing under it is styled again.
5011
+ this.markProps(screen, false);
5012
+ this.commit();
5013
+ }
5014
+ /** The screen of a native stack a node is on, or nothing where it is on none. */
5015
+ screenOf(node) {
5016
+ for (let up = node; up; up = up.parent) {
5017
+ if (up.kind === 'element' && viewNameOf(up) === STACK_SCREEN)
5018
+ return up;
5019
+ }
5020
+ return null;
5021
+ }
4180
5022
  /** A presented modal host has finished leaving, so a hidden one can come out of the tree. */
4181
5023
  dismissed(target) {
4182
5024
  if (!target.presented)