@ng-native/fabric 0.5.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, 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
13
  import { animationEvent, bezier, interpolate, readsInheritedColour, sample, step, tracksOf, } from "./transition.js";
@@ -15,7 +15,7 @@ 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
  /**
@@ -401,6 +401,38 @@ function textScale(props, fontScale = 1) {
401
401
  const cap = props['maxFontSizeMultiplier'];
402
402
  return typeof cap === 'number' && cap >= 1 ? Math.min(fontScale, cap) : fontScale;
403
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
+ }
404
436
  /**
405
437
  * Centre the text of a single-line text field that has a line height, as Chrome centres an
406
438
  * input's, keeping the height the line height gives it.
@@ -438,15 +470,7 @@ function centreSingleLine(viewName, props, fontScale) {
438
470
  // Android sizes the field by its line height, and centres the text in it, itself.
439
471
  if (viewName !== 'TextInput')
440
472
  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
- ];
473
+ const edge = (side, logical) => blockEdge(props, side, logical);
450
474
  const line = lineHeight * textScale(props, fontScale);
451
475
  const box = [line, ...edge('Top', 'Start'), ...edge('Bottom', 'End')];
452
476
  const own = props['minHeight'] ?? 0;
@@ -460,13 +484,29 @@ function centreSingleLine(viewName, props, fontScale) {
460
484
  : box.reduce((sum, part) => sum + part, 0);
461
485
  props['minHeight'] = Math.max(own, Math.min(content, max));
462
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
+ }
463
503
  /**
464
504
  * What a container's own style says for `key`, in the order `mergeProps` applies them: a
465
505
  * component's override, inline, a prop, its stylesheet, its default.
466
506
  */
467
507
  function ownLayout(node, key) {
468
508
  return (flattenStyle(node.props[STYLE_OVERRIDE], {})[key] ??
469
- flattenStyle(node.props['style'], {})[key] ??
509
+ inlineOf(node)[key] ??
470
510
  node.props[key] ??
471
511
  node.styleCache?.style[key] ??
472
512
  node.defaultStyle?.[key]);
@@ -524,6 +564,42 @@ function fitContent(node, props) {
524
564
  }
525
565
  }
526
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
+ }
527
603
  /**
528
604
  * Whether a box has a height a percentage can be taken of, as CSS defines one: a height of its
529
605
  * own, or one the layout around it gives it. A box as tall as what it holds has none.
@@ -558,14 +634,90 @@ function sizesItself(node, height) {
558
634
  function heightFromLayout(node, parent) {
559
635
  const [direction, align] = containerOf(parent).split(' ');
560
636
  if (direction === 'row') {
561
- // Stretched across a row, it is as tall as the 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.
562
640
  const self = ownLayout(node, 'alignSelf');
563
- return STRETCHES.has(self) && align === 'stretch';
641
+ return STRETCHES.has(self) && align === 'stretch' && definiteHeight(parent);
564
642
  }
565
643
  // In a column it has the height it grows to, where the column has one to share out.
566
644
  const grows = Number(ownLayout(node, 'flexGrow') ?? ownLayout(node, 'flex') ?? 0) > 0;
567
645
  return grows && definiteHeight(parent);
568
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
+ }
569
721
  /**
570
722
  * `height: 50%` under a parent with no height to take it of, which CSS reads as `auto`. Yoga
571
723
  * takes it of the space on offer, the screen's or everything a scroll view holds, so no height
@@ -574,6 +726,9 @@ function heightFromLayout(node, parent) {
574
726
  function percentHeight(node, props) {
575
727
  if (!isPercent(props['height']))
576
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;
577
732
  const parent = layoutParent(node);
578
733
  if (parent && !definiteHeight(parent))
579
734
  delete props['height'];
@@ -623,6 +778,43 @@ const TEXT_ALIGN = {
623
778
  };
624
779
  /** The native modal host, which is left out of a commit while `visible` is false. */
625
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;
626
818
  /**
627
819
  * Register a codegen'd third-party Fabric component, e.g.
628
820
  * `registerViewName('rns-screen', 'RNSScreen')`, which is how the router reaches
@@ -881,12 +1073,14 @@ function isTextElement(node) {
881
1073
  return false;
882
1074
  return !TEXT_ELEMENTS.has(node.name) || holdsOnlyText(node);
883
1075
  }
1076
+ /** Whether a text element holds text and nothing else. One that holds nothing holds no text. */
884
1077
  function holdsOnlyText(node) {
885
- for (const child of node.children ?? []) {
1078
+ const children = node.children ?? [];
1079
+ for (const child of children) {
886
1080
  if (child.kind === 'element' && !isTextElement(child))
887
1081
  return false;
888
1082
  }
889
- return true;
1083
+ return children.length > 0;
890
1084
  }
891
1085
  const ALIGNS = new Set(['center', 'flex-end', 'space-around', 'space-evenly']);
892
1086
  /**
@@ -896,20 +1090,26 @@ const ALIGNS = new Set(['center', 'flex-end', 'space-around', 'space-evenly']);
896
1090
  * A browser makes the run a flex item and places it. A paragraph's text is its content, which no
897
1091
  * alignment moves, so such an element commits as a view, and its text as the paragraph any view's
898
1092
  * 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
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
900
1095
  * element holding more than the one run is left as it was.
901
1096
  */
902
1097
  function aligningText(node) {
903
1098
  if (node.kind !== 'element' || node.name === 'text' || !TEXT_ELEMENTS.has(node.name)) {
904
1099
  return false;
905
1100
  }
906
- if (node.children.length !== 1 || node.children[0].kind !== 'text')
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))
907
1105
  return false;
908
- if (isTextElement(node.parent) || ownLayout(node, 'display') !== 'flex')
1106
+ if (ownLayout(node, 'display') !== 'flex')
909
1107
  return false;
910
1108
  return (ALIGNS.has(ownLayout(node, 'alignItems')) ||
911
1109
  ALIGNS.has(ownLayout(node, 'justifyContent')));
912
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));
913
1113
  /** The view a node is committed as: `viewNameOf`, but for a text element that aligns its text. */
914
1114
  const committedViewName = (node) => aligningText(node) ? DEFAULT_VIEW : viewNameOf(node);
915
1115
  /**
@@ -940,8 +1140,15 @@ export function viewNameOf(node) {
940
1140
  }
941
1141
  // The paragraph the engine makes for a view's loose text is under a view, whatever the name
942
1142
  // of the element that view is.
943
- return !node.anonymous && isTextElement(node.parent) ? VIRTUAL_TEXT : PARAGRAPH;
1143
+ return nestedText(node) ? VIRTUAL_TEXT : PARAGRAPH;
944
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);
945
1152
  /**
946
1153
  * The props every native view reads as a boolean: the `bool` fields of React Native's
947
1154
  * `BaseViewProps`, `AccessibilityProps` and `HostPlatformViewProps`, by the names JavaScript sends.
@@ -980,16 +1187,12 @@ const BOOLEAN_VIEW_PROPS = new Set([
980
1187
  ]);
981
1188
  /**
982
1189
  * 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.
1190
+ * or is now that it has gone. What `:empty` does not see is `fills` in css.ts to say.
984
1191
  */
985
1192
  function wasOrIsEmptyWithout(node, moved) {
986
- if (moved.kind === 'anchor')
1193
+ if (!fills(moved))
987
1194
  return false;
988
- for (const child of node.children) {
989
- if (child !== moved && child.kind !== 'anchor')
990
- return false;
991
- }
992
- return true;
1195
+ return !node.children.some((child) => child !== moved && fills(child));
993
1196
  }
994
1197
  /**
995
1198
  * Have the child that paints a node's background merged again with the node: nothing of the
@@ -1612,6 +1815,14 @@ export class Engine {
1612
1815
  * does, a child list can move without anything else needing to be looked at again.
1613
1816
  */
1614
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();
1615
1826
  /** Whether any sheet uses `:has()`. See `markBeneath`. */
1616
1827
  hasSheets = false;
1617
1828
  resolveAssetSource;
@@ -1624,7 +1835,7 @@ export class Engine {
1624
1835
  this.fontScale = conditions.fontScale;
1625
1836
  if (options.tokens)
1626
1837
  this.styles.setRootTokens(options.tokens);
1627
- this.structuralSheets = options.globalStyles?.structural === true;
1838
+ this.watchStructure(options.globalStyles);
1628
1839
  this.watchHas(options.globalStyles);
1629
1840
  this.watchActive(options.globalStyles);
1630
1841
  this.dev = options.dev ?? globalThis.__DEV__ === true;
@@ -1775,13 +1986,26 @@ export class Engine {
1775
1986
  this.sheetReplaced(replacing, sheet);
1776
1987
  else
1777
1988
  this.registerSheet(sheet);
1778
- if (sheet.structural)
1779
- this.structuralSheets = true;
1989
+ this.watchStructure(sheet);
1780
1990
  this.watchHas(sheet);
1781
1991
  this.watchActive(sheet);
1782
1992
  this.markPath(this.root);
1993
+ this.markWrittenFor(sheet, this.root);
1783
1994
  return true;
1784
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
+ }
1785
2009
  /**
1786
2010
  * Stop matching a sheet `addGlobalSheet` added, and restyle everything; false, and nothing
1787
2011
  * done, when it is not one. A hot swap that leaves a None component no rules, or makes it scoped.
@@ -1967,8 +2191,8 @@ export class Engine {
1967
2191
  if (sheet === undefined || node.sheet === sheet)
1968
2192
  return;
1969
2193
  node.sheet = sheet;
1970
- if (sheet?.structural)
1971
- this.structuralSheets = true;
2194
+ this.styles.noteSheet(sheet);
2195
+ this.watchStructure(sheet);
1972
2196
  this.watchHas(sheet);
1973
2197
  this.watchActive(sheet);
1974
2198
  this.markProps(node);
@@ -2003,14 +2227,14 @@ export class Engine {
2003
2227
  createElement(name, sheet = null) {
2004
2228
  const node = new RetainedNode('element', name, this);
2005
2229
  node.sheet = sheet;
2230
+ this.styles.noteSheet(sheet);
2006
2231
  const html = HTML_ELEMENTS.get(name);
2007
2232
  if (html !== undefined) {
2008
2233
  if (html.role)
2009
2234
  node.props['accessibilityRole'] = html.role;
2010
2235
  node.styled = html.styled;
2011
2236
  }
2012
- if (sheet?.structural)
2013
- this.structuralSheets = true;
2237
+ this.watchStructure(sheet);
2014
2238
  this.watchHas(sheet);
2015
2239
  this.watchActive(sheet);
2016
2240
  if (HOISTS[name])
@@ -2019,11 +2243,45 @@ export class Engine {
2019
2243
  }
2020
2244
  addClass(node, name) {
2021
2245
  (node.classes ??= new Set()).add(name);
2022
- this.markProps(node);
2246
+ this.markClasses(node, [name]);
2023
2247
  }
2024
2248
  removeClass(node, name) {
2025
2249
  node.classes?.delete(name);
2026
- 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
+ }
2027
2285
  }
2028
2286
  /**
2029
2287
  * The sheet of the component `node` hosts, whose `:host` rules it matches. Set again when a hot
@@ -2034,6 +2292,8 @@ export class Engine {
2034
2292
  if (node.hostSheet === sheet)
2035
2293
  return;
2036
2294
  node.hostSheet = sheet;
2295
+ this.styles.noteSheet(sheet);
2296
+ this.watchStructure(sheet);
2037
2297
  this.watchActive(sheet);
2038
2298
  this.markProps(node);
2039
2299
  }
@@ -2049,8 +2309,14 @@ export class Engine {
2049
2309
  /** `class="a b"` from a template. Replaces the set rather than adding to it. */
2050
2310
  setClasses(node, value) {
2051
2311
  const names = value.split(/\s+/).filter(Boolean);
2312
+ const before = node.classes;
2052
2313
  node.classes = names.length ? new Set(names) : null;
2053
- 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);
2054
2320
  }
2055
2321
  /** Angular's `@if`/`@for`/`ViewContainerRef` markers. Ordered, never committed. */
2056
2322
  createAnchor() {
@@ -2067,7 +2333,7 @@ export class Engine {
2067
2333
  parent.children.push(child);
2068
2334
  if (child.dormantHoists)
2069
2335
  this.wakeHoists(child);
2070
- this.markStructure(parent, child);
2336
+ this.markStructure(parent, child, parent.children.length - 1);
2071
2337
  this.markTextContent(parent, child);
2072
2338
  }
2073
2339
  insertBefore(parent, child, ref) {
@@ -2080,7 +2346,7 @@ export class Engine {
2080
2346
  parent.children.splice(at, 0, child);
2081
2347
  if (child.dormantHoists)
2082
2348
  this.wakeHoists(child);
2083
- this.markStructure(parent, child);
2349
+ this.markStructure(parent, child, at < 0 ? parent.children.length - 1 : at);
2084
2350
  this.markTextContent(parent, child);
2085
2351
  }
2086
2352
  removeChild(parent, child) {
@@ -2093,7 +2359,7 @@ export class Engine {
2093
2359
  target.children.splice(at, 1);
2094
2360
  child.parent = null;
2095
2361
  this.removedSinceCommit.add(child);
2096
- this.markStructure(target, child);
2362
+ this.markStructure(target, child, at);
2097
2363
  this.markTextContent(target, child);
2098
2364
  }
2099
2365
  /**
@@ -2233,7 +2499,9 @@ export class Engine {
2233
2499
  // Inline style is applied after the cascade and inherited by nothing, so it cannot change
2234
2500
  // what any node matches or inherits. Every other prop can: `[disabled]` is a selector. An
2235
2501
  // inline `direction` is the exception, because the paragraphs below it align by it.
2236
- 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);
2237
2505
  }
2238
2506
  /**
2239
2507
  * Paint a node's background on one of its children instead of on the node: the node keeps its
@@ -2331,10 +2599,21 @@ export class Engine {
2331
2599
  this.markProps(node);
2332
2600
  }
2333
2601
  setText(node, value) {
2602
+ const filled = (node.text === '') !== (value === '');
2334
2603
  node.text = value;
2335
2604
  this.markProps(node);
2336
- if (node.parent)
2337
- 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);
2338
2617
  }
2339
2618
  setEventListener(node, topLevelType, fn) {
2340
2619
  this.registerEventHandler();
@@ -2398,24 +2677,101 @@ export class Engine {
2398
2677
  siblings[i].styleDirty = true;
2399
2678
  }
2400
2679
  }
2401
- /** `node`'s child list changed: `moved` came into it or went out of it. */
2402
- 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) {
2403
2682
  node.structureDirty = true;
2404
2683
  // A child list that moved changes what its members match, though nothing about them did:
2405
2684
  // the old last row is no longer the last. Only sheets that ask about position pay for this.
2406
2685
  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))
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;
2413
2694
  this.markLaterSiblings(node);
2695
+ }
2414
2696
  }
2415
2697
  if (this.hasSheets)
2416
2698
  this.markBeneath(node);
2417
2699
  this.markPath(node.parent);
2418
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
+ }
2419
2775
  /** Walk up until we hit a node already known to have a dirty subtree. */
2420
2776
  markPath(node) {
2421
2777
  const wasClean = !this.root.structureDirty && !this.root.subtreeDirty;
@@ -2461,6 +2817,7 @@ export class Engine {
2461
2817
  this.fabric.appendChildToSet(set, this.reconcileUnder(this.root, child));
2462
2818
  }
2463
2819
  this.clearFlags(this.root);
2820
+ this.announceCommit();
2464
2821
  this.fabric.completeRoot(this.rootTag, set);
2465
2822
  const elapsed = now() - started;
2466
2823
  this.stats.commits++;
@@ -2475,6 +2832,7 @@ export class Engine {
2475
2832
  this.stats.slowCommits++;
2476
2833
  }
2477
2834
  this.flushTransitionEvents();
2835
+ this.playSized();
2478
2836
  this.scrollDriver?.afterCommit();
2479
2837
  // Read first: the commit that settles keyframes starts by clearing it.
2480
2838
  const facesAdded = this.facesAdded;
@@ -2484,6 +2842,25 @@ export class Engine {
2484
2842
  this.rematchFonts();
2485
2843
  return true;
2486
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
+ }
2487
2864
  /** The first commit whose new views have not been drawn yet, while `commitUnseen` runs. */
2488
2865
  unseen = Infinity;
2489
2866
  /**
@@ -2588,11 +2965,36 @@ export class Engine {
2588
2965
  if (this.transitionEvents.length === 0)
2589
2966
  return;
2590
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.
2591
2969
  for (const listener of [...(node.listeners?.get(type) ?? [])]) {
2592
- listener(animationEvent(type, node, property));
2970
+ try {
2971
+ listener(animationEvent(type, node, property));
2972
+ }
2973
+ catch (error) {
2974
+ this.reportEventError(error, type);
2975
+ }
2593
2976
  }
2977
+ if (type === 'topAnimationend')
2978
+ this.letGo(node);
2594
2979
  }
2595
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
+ }
2596
2998
  /** Anchors take part in sibling ordering but never reach Fabric. */
2597
2999
  visibleChildren(node) {
2598
3000
  // A view that takes its text as a prop has no text children to commit. See `ViewNameOptions`.
@@ -2602,13 +3004,21 @@ export class Engine {
2602
3004
  /**
2603
3005
  * Everything a node renders with, unprocessed. Precedence, weakest first: native defaults, the
2604
3006
  * 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.
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`.
2606
3009
  *
2607
3010
  * Raw, because this is what the next commit diffs against. Colours and asset ids are converted
2608
3011
  * on the way out instead (`processed`), and only for the keys that changed: the converters
2609
3012
  * return fresh objects, so diffing their output would re-send every image and shadow on every
2610
3013
  * unrelated change.
2611
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
+ }
2612
3022
  mergeProps(node, viewName) {
2613
3023
  if (node.kind === 'text')
2614
3024
  return { text: paragraphText(node) };
@@ -2622,14 +3032,15 @@ export class Engine {
2622
3032
  writeOwnProps(node.props, props, node.attributeOnly);
2623
3033
  withTextContent(node, viewName, props);
2624
3034
  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
- }
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);
2629
3039
  nativePointerEvents(node, style, resolved);
2630
3040
  // Before an image's own size: `fit-content` is no size, so the image's is what it gets.
2631
3041
  fitContent(node, style);
2632
3042
  percentHeight(node, style);
3043
+ placeholderFaded(style);
2633
3044
  const intrinsic = node.props[INTRINSIC_SIZE];
2634
3045
  if (intrinsic)
2635
3046
  applyIntrinsicSize(style, intrinsic);
@@ -2641,8 +3052,20 @@ export class Engine {
2641
3052
  if (this.fontsRefreshed)
2642
3053
  this.capForFonts(node, style);
2643
3054
  alignMultiline(viewName, style);
3055
+ rowsTall(style, this.fontScale);
2644
3056
  const merged = composeTransform(node, this.animated(node, this.transitioned(node, style)));
2645
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);
2646
3069
  this.movePaint(node, merged);
2647
3070
  return merged;
2648
3071
  }
@@ -2657,9 +3080,7 @@ export class Engine {
2657
3080
  const from = node.parent?.paintsOn === node ? node.parent : null;
2658
3081
  if (!from)
2659
3082
  return;
2660
- const paint = flattenStyle(from.props['style'], {
2661
- ...this.styles.resolve(from, this.styleEpoch).style,
2662
- });
3083
+ const paint = { ...this.styles.resolve(from, this.styleEpoch).style, ...inlineOf(from) };
2663
3084
  for (const key of PAINT_KEYS)
2664
3085
  if (paint[key] !== undefined)
2665
3086
  merged[key] = paint[key];
@@ -2718,7 +3139,7 @@ export class Engine {
2718
3139
  if (!this.inlineDirection)
2719
3140
  return own;
2720
3141
  for (let at = node; at; at = at.parent) {
2721
- const inline = textDirection(flattenStyle(at.props['style'], {})['direction']);
3142
+ const inline = textDirection(inlineOf(at)['direction']);
2722
3143
  if (inline)
2723
3144
  return inline;
2724
3145
  const cascaded = textDirection(at.styleCache?.style['direction']);
@@ -2746,6 +3167,7 @@ export class Engine {
2746
3167
  if (!sheet || this.knownSheets.has(sheet))
2747
3168
  return;
2748
3169
  this.knownSheets.add(sheet);
3170
+ this.styles.noteSheet(sheet);
2749
3171
  this.sheetOrder.push(sheet);
2750
3172
  for (const name of Object.keys(sheet.keyframes ?? {})) {
2751
3173
  this.keyframes.set(name, sheet.keyframes[name]);
@@ -2773,6 +3195,7 @@ export class Engine {
2773
3195
  this.knownSheets.delete(sheet);
2774
3196
  if (next && !this.knownSheets.has(next)) {
2775
3197
  this.knownSheets.add(next);
3198
+ this.styles.noteSheet(next);
2776
3199
  this.sheetOrder[at] = next;
2777
3200
  }
2778
3201
  else {
@@ -2919,11 +3342,69 @@ export class Engine {
2919
3342
  * runs it frame by frame, and JavaScript hears of it again when it ends. Anything else is
2920
3343
  * played from here, a commit a frame. See `NativeScrollDriver.play`.
2921
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
+ }
2922
3402
  playNatively(node, running, props) {
2923
3403
  const { spec } = running;
2924
- if (!this.scrollDriver || spec.delay !== 0 || spec.duration <= 0)
3404
+ const sized = this.scrollDriver ? this.sized(node, running, props) : null;
3405
+ if (!this.scrollDriver || !sized)
2925
3406
  return false;
2926
- const { channels, held, span } = clockChannels(running.tracks, spec, props);
3407
+ const { channels, held, span } = clockChannels(sized.tracks, spec, sized.resting);
2927
3408
  if (held.length || !(channels.opacity || channels.transform.length))
2928
3409
  return false;
2929
3410
  // There and back is one native animation: an odd number of ways has no whole number of them.
@@ -3044,6 +3525,10 @@ export class Engine {
3044
3525
  reframed.values = finished && !holds ? {} : values;
3045
3526
  this.playedFrames.set(reframed, frames);
3046
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);
3047
3532
  }
3048
3533
  /**
3049
3534
  * `animation-play-state` changed on an animation already playing: pausing holds the frame it
@@ -3223,6 +3708,21 @@ export class Engine {
3223
3708
  node.transitions = undefined;
3224
3709
  return props;
3225
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
+ }
3226
3726
  /** One `@keyframes` player, one frame on. */
3227
3727
  advancePlayer(node, now) {
3228
3728
  const running = node.playing;
@@ -3234,12 +3734,19 @@ export class Engine {
3234
3734
  this.playing.delete(node);
3235
3735
  return;
3236
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;
3237
3740
  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);
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);
3243
3750
  if (!finished)
3244
3751
  return;
3245
3752
  running.done = true;
@@ -3539,6 +4046,8 @@ export class Engine {
3539
4046
  * when it is shown again, as Modal.js creates it afresh. Committed again, it ignores every touch.
3540
4047
  */
3541
4048
  withheld(child) {
4049
+ if (this.undisplayed(child))
4050
+ return true;
3542
4051
  if (child.props['visible'] !== false || child.presented)
3543
4052
  return false;
3544
4053
  if (viewNameOf(child) !== MODAL_HOST)
@@ -3547,6 +4056,42 @@ export class Engine {
3547
4056
  this.forgetCommitted(child);
3548
4057
  return true;
3549
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
+ }
3550
4095
  /**
3551
4096
  * `reconcileChildren` for a node other nodes hoist into: its own children, each followed by
3552
4097
  * the hoisted nodes written inside it, then those written in it directly, then the views it
@@ -3569,9 +4114,10 @@ export class Engine {
3569
4114
  else
3570
4115
  handles.push(this.reconcileUnder(node, child, context));
3571
4116
  const moved = hoisted?.get(child);
3572
- if (moved)
3573
- for (const written of moved)
4117
+ for (const written of moved ?? []) {
4118
+ if (!this.undisplayedUnder(node, written))
3574
4119
  handles.push(this.land(node, written, landed));
4120
+ }
3575
4121
  }
3576
4122
  for (const child of direct)
3577
4123
  handles.push(this.land(node, child, landed));
@@ -3860,9 +4406,12 @@ export class Engine {
3860
4406
  this.registerEventHandler();
3861
4407
  this.responders.set(node, handlers);
3862
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);
3863
4411
  return () => {
3864
4412
  this.responders.delete(node);
3865
4413
  this.keepNative(node, false);
4414
+ this.markProps(node);
3866
4415
  if (this.currentResponder !== node)
3867
4416
  return;
3868
4417
  // Torn down under the finger: no release is coming, so the chain it marked is cleared
@@ -4094,7 +4643,9 @@ export class Engine {
4094
4643
  const handle = node.committed?.handle;
4095
4644
  if (!handle || !this.fabric.setIsJSResponder)
4096
4645
  return;
4097
- 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;
4098
4649
  this.fabric.setIsJSResponder(handle, isResponder, block);
4099
4650
  }
4100
4651
  /** Runs before listener propagation, as React's responder plugin does. */
@@ -4156,6 +4707,10 @@ export class Engine {
4156
4707
  dispatchEvent(instanceHandle, topLevelType, nativeEvent) {
4157
4708
  const target = instanceHandle;
4158
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;
4159
4714
  // Listeners get RN's documented shape, `{nativeEvent}`. Fabric hands us the payload bare;
4160
4715
  // React wraps it in a synthetic event and every RN API is written against `event.nativeEvent`,
4161
4716
  // so handlers ported from RN would silently read undefined otherwise.
@@ -4166,6 +4721,7 @@ export class Engine {
4166
4721
  if (topLevelType === 'topScroll' && target && this.scrollTimelines.has(target)) {
4167
4722
  this.measuredScroll(target, nativeEvent);
4168
4723
  }
4724
+ this.holdSwipe(target, topLevelType, nativeEvent);
4169
4725
  this.trackFocus(target, topLevelType);
4170
4726
  if (target)
4171
4727
  this.cancelPressForScroll(target, topLevelType, event);
@@ -4177,6 +4733,116 @@ export class Engine {
4177
4733
  }
4178
4734
  this.propagate(target, topLevelType, event);
4179
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
+ }
4180
4846
  /** A presented modal host has finished leaving, so a hidden one can come out of the tree. */
4181
4847
  dismissed(target) {
4182
4848
  if (!target.presented)