@ng-native/fabric 0.8.0 → 0.10.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, faded, fills, inlineInherited, setsInherited, StyleResolver, usesActive, elementsAtEnd, placeReadElsewhere, siblingReach, } from "./css.js";
10
+ import { boundDeclaration, faded, fills, inlineInherited, setsInherited, StyleResolver, usesActive, elementsAtEnd, hasAbove, 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 { aimsElsewhere, animationEvent, bezier, interpolate, readsInheritedColour, sample, step, steppedKeys, tracksOf, } from "./transition.js";
@@ -28,7 +28,7 @@ function animationOf(props) {
28
28
  let spec = props['$animation'];
29
29
  if (spec !== undefined)
30
30
  delete props['$animation'];
31
- for (const [key, field] of Object.entries(ANIMATION_PARTS)) {
31
+ for (const [key, field] of ANIMATION_ENTRIES) {
32
32
  if (!(key in props))
33
33
  continue;
34
34
  const value = props[key];
@@ -47,6 +47,8 @@ const ANIMATION_PARTS = {
47
47
  $animationFill: 'fill',
48
48
  $playState: 'paused',
49
49
  };
50
+ /** Listed once: `animationOf` reads them for every node that commits. */
51
+ const ANIMATION_ENTRIES = Object.entries(ANIMATION_PARTS);
50
52
  function animationPart(field, value) {
51
53
  switch (field) {
52
54
  case 'iterations':
@@ -537,8 +539,8 @@ function lineBaseline(props, line, [, top, , bottom], fontScale) {
537
539
  * out on both platforms: on Android, `EditText` centres a line box taller than the font's own
538
540
  * about 1.7pt high of centre in a 44pt field, and centres the font's own exactly. A percentage
539
541
  * height sizes the field only where its parent's height is definite, which is known at layout
540
- * alone, so such a field is left as it was. Elsewhere Android sizes the field by the line height
541
- * and centres the text in it itself, and keeps it.
542
+ * alone, so on Android such a field is left as it was. Elsewhere Android sizes the field by the
543
+ * line height and centres the text in it itself, and keeps it.
542
544
  *
543
545
  * On iOS, React Native's field sets `lineHeight` as the paragraph's minimum and maximum line
544
546
  * height and, unlike a paragraph (`RCTApplyBaselineOffset`), never offsets the baseline, so the
@@ -561,10 +563,9 @@ function centreSingleLine(viewName, props, fontScale, onBaseline = false) {
561
563
  heightBaseline(props, height, onBaseline, fontScale);
562
564
  return;
563
565
  }
564
- // A percentage is a fixed height only where the parent's is definite, known at layout alone.
565
- if (isSet(height) && height !== 'auto')
566
- return;
567
- // Android sizes the field by its line height, and centres the text in it, itself.
566
+ // Android sizes the field by its line height, and centres the text in it, itself. A
567
+ // percentage is a fixed height only where the parent's is definite, known at layout alone:
568
+ // there too the line height is left out on iOS, and kept as the least the field is tall.
568
569
  if (viewName !== 'TextInput')
569
570
  return;
570
571
  const edge = (side, logical) => blockEdge(props, side, logical);
@@ -847,10 +848,11 @@ const TOUCHED_ALPHA = 0.011;
847
848
  function stillTouched(props) {
848
849
  // One told to take no touch is not to be sent one, seen or not.
849
850
  if (props['pointerEvents'] === 'none')
850
- return;
851
+ return props;
851
852
  const opacity = props['opacity'];
852
853
  if (typeof opacity === 'number' && opacity < TOUCHED_ALPHA)
853
854
  props['opacity'] = TOUCHED_ALPHA;
855
+ return props;
854
856
  }
855
857
  /**
856
858
  * Leave `aspect-ratio` out for a box with both a width and a height. CSS takes the ratio only
@@ -865,6 +867,29 @@ function ratioForAutoSize(props) {
865
867
  delete props['aspectRatio'];
866
868
  }
867
869
  const OUTLINE_KEYS = ['outlineWidth', 'outlineStyle', 'outlineColor', 'outlineOffset'];
870
+ /** Whether a side of a border has a width and nothing that gives it a colour. */
871
+ function colourless(props, side, flipped) {
872
+ const width = props[side.width] ?? props['borderWidth'];
873
+ if (typeof width !== 'number' || width <= 0)
874
+ return false;
875
+ const given = [side.color, flipped ? side.flipped : side.logical, 'borderColor'];
876
+ return given.every((key) => props[key] == null);
877
+ }
878
+ /**
879
+ * Each side a border has: its width, its colour, and the logical colour React Native takes for
880
+ * it too, read left to right and, `flipped`, right to left.
881
+ */
882
+ const BORDER_SIDES = [
883
+ ['Top', 'BlockStart', 'BlockStart'],
884
+ ['Right', 'End', 'Start'],
885
+ ['Bottom', 'BlockEnd', 'BlockEnd'],
886
+ ['Left', 'Start', 'End'],
887
+ ].map(([side, logical, flipped]) => ({
888
+ width: `border${side}Width`,
889
+ color: `border${side}Color`,
890
+ logical: `border${logical}Color`,
891
+ flipped: `border${flipped}Color`,
892
+ }));
868
893
  /**
869
894
  * Send nothing for an outline of no width, which is what `outline: none` is: it draws nothing,
870
895
  * and a view with no outline is the same view. Sent, it is an update that is not one, and on
@@ -916,6 +941,64 @@ function alignMultiline(viewName, props) {
916
941
  return;
917
942
  props['textAlignVertical'] ??= 'top';
918
943
  }
944
+ /** The props of a run of text that change how tall its line is drawn. */
945
+ const SIZES_A_RUN = new Set(['style', 'allowFontScaling', 'maxFontSizeMultiplier']);
946
+ /**
947
+ * Keep a paragraph as tall as one of its lines, as a browser's line box is whatever holds it.
948
+ * Yoga measures an item no taller than the room inside its container, and the text view cuts
949
+ * its letters off there: a 24 point line in a row with 16 points between its paddings. The
950
+ * line is the paragraph's own or a taller one of a run in it, `run`, which is scaled already. With it goes what is
951
+ * around the text and inside the height Yoga gives a view, its padding and border above and
952
+ * below, but for a content box. Only where a line height is a length, and nothing gives the
953
+ * paragraph a height of its own.
954
+ *
955
+ * The line is as the system text size draws it: native scales a line height with the text, so
956
+ * a line held to its points at a smaller size is shorter than its box and sits at the top of
957
+ * it, over what a row centres it beside.
958
+ */
959
+ function lineTall(props, run, fontScale) {
960
+ const own = props['lineHeight'];
961
+ const line = Math.max(typeof own === 'number' ? own * textScale(props, fontScale) : 0, run);
962
+ if (line <= 0)
963
+ return;
964
+ const unset = (value) => value == null || value === 'auto';
965
+ if (!unset(props['height']) || !unset(props['minHeight']))
966
+ return;
967
+ const around = props['boxSizing'] === 'content-box' ? 0 : aroundALine(props);
968
+ // To a thousandth of a point: a product of two fractions is rarely the number it reads as.
969
+ props['minHeight'] = Math.round((line + around) * 1000) / 1000;
970
+ }
971
+ /** The points of padding and border above and below a view's content, where each is a length. */
972
+ function aroundALine(props) {
973
+ const edge = (...keys) => {
974
+ const found = keys.map((key) => props[key]).find((value) => value != null);
975
+ return typeof found === 'number' ? found : 0;
976
+ };
977
+ return (edge('paddingTop', 'paddingVertical', 'padding') +
978
+ edge('paddingBottom', 'paddingVertical', 'padding') +
979
+ edge('borderTopWidth', 'borderWidth') +
980
+ edge('borderBottomWidth', 'borderWidth'));
981
+ }
982
+ /**
983
+ * Where a line may break: at a space, after a hyphen, and anywhere in a script written without
984
+ * spaces. A soft hyphen and a zero-width space are places to break that draw nothing.
985
+ */
986
+ const BREAKS = /[\s\-\u00ad\u200b\u2010\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/;
987
+ /** Whether a paragraph's text, all its runs together, is one word: something, with no break. */
988
+ function unbroken(node) {
989
+ let text = '';
990
+ const collect = (from) => {
991
+ for (const child of from.children) {
992
+ if (child.kind === 'text')
993
+ text += child.text;
994
+ else if (isTextElement(child))
995
+ collect(child);
996
+ }
997
+ };
998
+ collect(node);
999
+ const words = trimEnds(text);
1000
+ return words !== '' && !BREAKS.test(words);
1001
+ }
919
1002
  /**
920
1003
  * Resolve a paragraph's `text-align` against its direction, as CSS does.
921
1004
  *
@@ -1411,6 +1494,23 @@ const MOVES_BOXES = new Set([
1411
1494
  ...['aspectRatio', 'fontSize', 'lineHeight', 'fontFamily', 'fontWeight', 'letterSpacing'],
1412
1495
  ...['numberOfLines', 'start', 'end'],
1413
1496
  ]);
1497
+ /** Whether native is moving a view to a prop's value, by a transition or an animation it laid out. */
1498
+ function laidByNative(node, key) {
1499
+ const eased = node.transitions?.get(key);
1500
+ if (eased?.laid && !eased.done)
1501
+ return true;
1502
+ return node.playing?.laid === true && node.playing.tracks.has(key);
1503
+ }
1504
+ /** Whether an animation plays once through, forwards, with no wait before it. */
1505
+ const playedOnce = (spec) => spec.delay === 0 && spec.duration > 0 && spec.iterations === 1 && !spec.direction;
1506
+ /** Whether a track is two frames on one curve, the first of them where the view is drawn. */
1507
+ function fromWhereDrawn(track, drawn) {
1508
+ const [first, last] = track;
1509
+ if (track.length !== 2 || first.easing || last.value == null)
1510
+ return false;
1511
+ // One that ends where it starts moves nothing: there is nothing for native to lay out.
1512
+ return Object.is(first.value, drawn) && !Object.is(first.value, last.value);
1513
+ }
1414
1514
  /** The curves a layout animation has, as the timing function each is. */
1415
1515
  const LAYOUT_CURVES = [
1416
1516
  ['linear', [0, 0, 1, 1]],
@@ -1555,6 +1655,7 @@ function noteTouches(node, style) {
1555
1655
  if (value === 'none')
1556
1656
  node.propsDirty = true;
1557
1657
  }
1658
+ const hasClasses = (node, names) => names.every((name) => node.classes?.has(name));
1558
1659
  /** Whether `node` is `ancestor` or somewhere under it. */
1559
1660
  function isWithin(node, ancestor) {
1560
1661
  for (let at = node; at; at = at.parent)
@@ -1645,6 +1746,7 @@ class RetainedNode {
1645
1746
  customProperties = null;
1646
1747
  styleCache = null;
1647
1748
  styleDirty = true;
1749
+ ownDirty = false;
1648
1750
  styleCommitted = null;
1649
1751
  fitContainer = undefined;
1650
1752
  heightBasis = undefined;
@@ -1657,6 +1759,8 @@ class RetainedNode {
1657
1759
  committedUnder = null;
1658
1760
  kept = undefined;
1659
1761
  remeasure = undefined;
1762
+ oneWord = undefined;
1763
+ raised = undefined;
1660
1764
  propsDirty = false;
1661
1765
  structureDirty = false;
1662
1766
  subtreeDirty = false;
@@ -1861,6 +1965,8 @@ const TRANSITION_TIMING = {
1861
1965
  $transitionDelay: 'delay',
1862
1966
  $transitionEasing: 'easing',
1863
1967
  };
1968
+ /** Listed once, as `ANIMATION_ENTRIES` is. */
1969
+ const TRANSITION_ENTRIES = Object.entries(TRANSITION_TIMING);
1864
1970
  /**
1865
1971
  * The transition spec, with any timing set by a rule of its own laid over it: `.duration-700`
1866
1972
  * beside `.transition`. A rule that writes the timing itself compiles it to `null`, which is how
@@ -1874,7 +1980,7 @@ function transitionSpec(props) {
1874
1980
  let spec = props['$transition'];
1875
1981
  if (spec !== undefined)
1876
1982
  delete props['$transition'];
1877
- for (const [key, part] of Object.entries(TRANSITION_TIMING)) {
1983
+ for (const [key, part] of TRANSITION_ENTRIES) {
1878
1984
  if (!(key in props))
1879
1985
  continue;
1880
1986
  const value = props[key];
@@ -1993,6 +2099,14 @@ const now = () => globalThis.performance?.now?.() ?? Date.now();
1993
2099
  function sameHandles(a, b) {
1994
2100
  return a.length === b.length && a.every((handle, i) => handle === b[i]);
1995
2101
  }
2102
+ /** Whether two sets of the same classes hold them in the same order. */
2103
+ function inOrder(a, b) {
2104
+ const other = b.values();
2105
+ for (const name of a)
2106
+ if (other.next().value !== name)
2107
+ return false;
2108
+ return true;
2109
+ }
1996
2110
  /**
1997
2111
  * Whether a node could be the element a compound is written for, by its classes alone: its own,
1998
2112
  * and those of one alternative of each `:is()` on it, which is where Tailwind names a group or
@@ -2322,12 +2436,16 @@ export class Engine {
2322
2436
  */
2323
2437
  fontsSettled(families) {
2324
2438
  let held = false;
2439
+ const release = (node) => {
2440
+ if (node?.heldFamily === undefined || !families.has(node.heldFamily))
2441
+ return;
2442
+ this.markProps(node, false);
2443
+ held = true;
2444
+ };
2325
2445
  const visit = (node) => {
2326
2446
  for (const child of node.children) {
2327
- if (child.heldFamily !== undefined && families.has(child.heldFamily)) {
2328
- this.markProps(child, false);
2329
- held = true;
2330
- }
2447
+ // The paragraph of a run of text written straight into a view is kept on the text.
2448
+ release(child.kind === 'text' ? child.box : child);
2331
2449
  visit(child);
2332
2450
  }
2333
2451
  };
@@ -2336,22 +2454,28 @@ export class Engine {
2336
2454
  this.commit();
2337
2455
  }
2338
2456
  fontsRegistered(families) {
2457
+ /** Lay `node` out again where it is text naming one of the families, saying whether it was. */
2458
+ const refresh = (node) => {
2459
+ // As it is drawn: a text element placing its text is a view, with the paragraph inside it.
2460
+ const viewName = committedViewName(node);
2461
+ const input = TEXT_INPUTS.has(viewName);
2462
+ const named = (viewName === PARAGRAPH || input) && namesFamily(node, families);
2463
+ if (!node.committed || !named)
2464
+ return false;
2465
+ this.fontRefreshes.set(node, (this.fontRefreshes.get(node) ?? 0) + 1);
2466
+ if (input)
2467
+ this.fontResends.add(node);
2468
+ this.fontsRefreshed = true;
2469
+ this.markProps(node, false);
2470
+ return true;
2471
+ };
2339
2472
  const visit = (node) => {
2340
2473
  for (const child of node.children) {
2341
- if (child.kind !== 'element')
2342
- continue;
2343
- const viewName = viewNameOf(child);
2344
- const input = TEXT_INPUTS.has(viewName);
2345
- if (child.committed && (viewName === PARAGRAPH || input) && namesFamily(child, families)) {
2346
- this.fontRefreshes.set(child, (this.fontRefreshes.get(child) ?? 0) + 1);
2347
- if (input)
2348
- this.fontResends.add(child);
2349
- this.fontsRefreshed = true;
2350
- this.markProps(child, false);
2351
- }
2352
- else {
2474
+ // The paragraph of a run of text written straight into a view is kept on the text.
2475
+ if (child.kind === 'text' && child.box)
2476
+ refresh(child.box);
2477
+ if (child.kind === 'element' && !refresh(child))
2353
2478
  visit(child);
2354
- }
2355
2479
  }
2356
2480
  };
2357
2481
  visit(this.root);
@@ -2495,34 +2619,46 @@ export class Engine {
2495
2619
  * Mark a node whose classes changed, by what the rules that name those classes can restyle.
2496
2620
  *
2497
2621
  * A class no selector names changes no style: a library's own marker, of an overlay that is
2498
- * animating or a control that was touched. One named only as what a rule's element is
2499
- * inside, `.animating .close`, restyles the elements under the node that such rules are for.
2500
- * Either way a whole subtree is not styled again for a class that has no say in it. Any other
2501
- * class restyles the node and everything under it, as any other change to it does.
2622
+ * animating or a control that was touched. One a rule is for the element of, `.on`, has that
2623
+ * element matched again, and one named as what a rule's element is inside, `.animating .close`
2624
+ * or `.close:is(.animating *)`, the elements under the node that such rules are for. What is
2625
+ * under any of those is styled again from the rules it matched, for what it inherits: a `dark`
2626
+ * class on the root finds no rule again for an element no `dark` rule is for. Any other class
2627
+ * has the node and everything under it matched again, as any other change to it does.
2502
2628
  *
2503
2629
  * A sheet that names a class later restyles what it is for as it arrives, and a node not yet
2504
2630
  * committed is styled when it is, with the class as it is then.
2505
2631
  */
2506
2632
  markClasses(node, changed) {
2633
+ // An element still being built, in no tree and never styled: nothing is styled by it yet,
2634
+ // and it is styled when it is committed, with the classes it has then. Every element of a
2635
+ // screen is given its classes this way, so what they reach is not asked for each.
2636
+ if (node.parent === null && node.styleCache === null)
2637
+ return this.markProps(node, false);
2507
2638
  const reach = this.styles.reachOf(changed);
2508
2639
  if (reach === true)
2509
2640
  return this.markProps(node);
2510
2641
  this.markProps(node, false);
2511
- if (reach)
2642
+ if (!reach)
2643
+ return;
2644
+ if (reach.own)
2645
+ node.ownDirty = true;
2646
+ if (this.styles.reachesUnder(reach))
2512
2647
  this.markUnder(node, reach);
2513
2648
  }
2514
- /** Have styled again each element under a node that one of `subjects`' rules could be for. */
2649
+ /**
2650
+ * Have matched again each element under a node that one of `subjects`' rules could be for. One
2651
+ * under another is marked too: what is under an element marked this way keeps its rules.
2652
+ */
2515
2653
  markUnder(node, subjects) {
2516
2654
  for (const child of node.children) {
2517
2655
  if (child.kind !== 'element')
2518
2656
  continue;
2519
2657
  if (this.styles.isFor(subjects, child)) {
2520
- // And with it all under it, which is styled from it.
2521
- child.styleDirty = true;
2658
+ child.ownDirty = true;
2522
2659
  this.markPath(child);
2523
2660
  }
2524
- else
2525
- this.markUnder(child, subjects);
2661
+ this.markUnder(child, subjects);
2526
2662
  }
2527
2663
  }
2528
2664
  /**
@@ -2546,9 +2682,13 @@ export class Engine {
2546
2682
  */
2547
2683
  setDefaultStyle(node, style) {
2548
2684
  node.defaultStyle = style;
2549
- this.markProps(node);
2685
+ // Under what the cascade comes to, and read by no selector: nothing is matched again.
2686
+ this.markProps(node, false);
2550
2687
  }
2551
- /** `class="a b"` from a template. Replaces the set rather than adding to it. */
2688
+ /**
2689
+ * `class="a b"` from a template. Replaces the set rather than adding to it. The same classes in
2690
+ * another order are a change too, to a selector that reads the class attribute, `[class^="a"]`.
2691
+ */
2552
2692
  setClasses(node, value) {
2553
2693
  const names = value.split(/\s+/).filter(Boolean);
2554
2694
  const before = node.classes;
@@ -2558,7 +2698,8 @@ export class Engine {
2558
2698
  for (const name of before ?? [])
2559
2699
  if (!node.classes?.has(name))
2560
2700
  changed.push(name);
2561
- this.markClasses(node, changed);
2701
+ const reordered = !changed.length && !!before && !!node.classes && !inOrder(before, node.classes);
2702
+ this.markClasses(node, reordered ? node.classes : changed);
2562
2703
  }
2563
2704
  /** Angular's `@if`/`@for`/`ViewContainerRef` markers. Ordered, never committed. */
2564
2705
  createAnchor() {
@@ -2570,6 +2711,7 @@ export class Engine {
2570
2711
  return node;
2571
2712
  }
2572
2713
  appendChild(parent, child) {
2714
+ this.markAdopted(child, parent);
2573
2715
  this.detach(child);
2574
2716
  child.parent = parent;
2575
2717
  parent.children.push(child);
@@ -2579,6 +2721,7 @@ export class Engine {
2579
2721
  this.markTextContent(parent, child);
2580
2722
  }
2581
2723
  insertBefore(parent, child, ref) {
2724
+ this.markAdopted(child, parent);
2582
2725
  this.detach(child);
2583
2726
  child.parent = parent;
2584
2727
  const at = ref ? parent.children.indexOf(ref) : -1;
@@ -2591,6 +2734,16 @@ export class Engine {
2591
2734
  this.markStructure(parent, child, at < 0 ? parent.children.length - 1 : at);
2592
2735
  this.markTextContent(parent, child);
2593
2736
  }
2737
+ /**
2738
+ * A node coming under a parent other than the one it is under, or under any once it has been
2739
+ * taken out: what is over it may be other than it was styled under. The resolver sees that for
2740
+ * itself by the parent's cache, except between two boxes that share one, where it is told here.
2741
+ * Moved within its own parent, a row of a list reordered, it is styled as it was.
2742
+ */
2743
+ markAdopted(child, parent) {
2744
+ if (child.parent !== parent && this.styles.styledUnderShared(child))
2745
+ child.styleDirty = true;
2746
+ }
2594
2747
  removeChild(parent, child) {
2595
2748
  const target = parent ?? child.parent;
2596
2749
  if (!target)
@@ -2609,8 +2762,27 @@ export class Engine {
2609
2762
  * changes it too, by keeping or dropping the space before it.
2610
2763
  */
2611
2764
  markTextContent(parent, child) {
2612
- if (child.kind !== 'anchor' && takesTextAsProp(parent))
2765
+ if (child.kind === 'anchor')
2766
+ return;
2767
+ if (takesTextAsProp(parent))
2613
2768
  this.markProps(parent);
2769
+ else if (isTextElement(parent))
2770
+ this.markWords(parent, child.kind === 'element');
2771
+ }
2772
+ /**
2773
+ * Text came, went or changed in a paragraph: where it has become one word, or stopped being
2774
+ * one, the paragraph is merged again, since that is one line or as many as it takes. Asked
2775
+ * of the paragraph's outermost element, which is the view; any other change leaves it be.
2776
+ */
2777
+ markWords(within, run = false) {
2778
+ let root = within;
2779
+ while (isTextElement(root.parent))
2780
+ root = root.parent;
2781
+ // A run that came or went can be the tallest line of the paragraph: see `lineTall`.
2782
+ // ponytail: one restyled to another line height is not; mark its paragraph if seen cut.
2783
+ const words = root.oneWord !== undefined && unbroken(root) !== root.oneWord;
2784
+ if (run || words)
2785
+ this.markProps(root, false);
2614
2786
  }
2615
2787
  /** A subtree that went out of the tree is back in: its hoisted nodes commit again. */
2616
2788
  wakeHoists(node) {
@@ -2704,6 +2876,11 @@ export class Engine {
2704
2876
  this.stopNative(node, node.playing);
2705
2877
  node.playing = undefined;
2706
2878
  this.stopScrolled(node);
2879
+ // And what follows it, where it is the scroll view: each starts again if it is put back.
2880
+ for (const follower of this.scrollTimelines.get(node) ?? []) {
2881
+ this.stopScrolled(follower);
2882
+ this.markProps(follower, false);
2883
+ }
2707
2884
  this.running.delete(node);
2708
2885
  this.playing.delete(node);
2709
2886
  this.hoisted.delete(node);
@@ -2748,6 +2925,19 @@ export class Engine {
2748
2925
  // field's text on every key, match nothing they did not.
2749
2926
  const read = key === 'style' ? this.inlineReachesStyle(node) : this.styles.reads(key);
2750
2927
  this.markProps(node, read || heirs);
2928
+ if (SIZES_A_RUN.has(key))
2929
+ this.markParagraph(node);
2930
+ }
2931
+ /**
2932
+ * Have the paragraph a run of text is in merged again: it is as tall as the tallest line of
2933
+ * its runs, by a line height or a text scaling set on one of them. See `lineTall`.
2934
+ */
2935
+ markParagraph(run) {
2936
+ let paragraph = run;
2937
+ while (paragraph.parent && isTextElement(paragraph.parent))
2938
+ paragraph = paragraph.parent;
2939
+ if (paragraph !== run)
2940
+ this.markProps(paragraph, false);
2751
2941
  }
2752
2942
  /**
2753
2943
  * Paint a node's background on one of its children instead of on the node: the node keeps its
@@ -2803,6 +2993,7 @@ export class Engine {
2803
2993
  */
2804
2994
  styleChanged(node) {
2805
2995
  this.markProps(node, this.inlineReachesStyle(node));
2996
+ this.markParagraph(node);
2806
2997
  }
2807
2998
  /**
2808
2999
  * A bound declaration, `[style.color]`, whose value may be a `var()`. One that is, is kept as a
@@ -2842,7 +3033,10 @@ export class Engine {
2842
3033
  else {
2843
3034
  node.customProperties = { ...current, [name]: token };
2844
3035
  }
2845
- this.markProps(node);
3036
+ // In scope for the node's own rules and for what is under it, and read by no selector: the
3037
+ // node is styled again, and those under it from the rules they matched.
3038
+ this.markProps(node, false);
3039
+ node.ownDirty = true;
2846
3040
  }
2847
3041
  setText(node, value) {
2848
3042
  const filled = (node.text === '') !== (value === '');
@@ -2895,7 +3089,11 @@ export class Engine {
2895
3089
  }
2896
3090
  /** Start matching ancestors again on a change, once a sheet that uses `:has()` is in play. */
2897
3091
  watchHas(sheet) {
2898
- if (!sheet?.has || this.hasSheets)
3092
+ if (!sheet?.has)
3093
+ return;
3094
+ if (!this.hasReaders.has(sheet))
3095
+ this.hasReaders.set(sheet, hasAbove(sheet));
3096
+ if (this.hasSheets)
2899
3097
  return;
2900
3098
  this.hasSheets = true;
2901
3099
  this.styles.tracksHas = true;
@@ -2912,15 +3110,53 @@ export class Engine {
2912
3110
  * which would restyle everything under them. Only sheets that use `:has()` pay.
2913
3111
  */
2914
3112
  markBeneath(from) {
2915
- for (let node = from; node; node = node.parent)
3113
+ for (let node = from; node; node = node.parent) {
2916
3114
  node.hasDirty = true;
3115
+ if (this.hasReaders.size)
3116
+ this.changedBeneath.add(node);
3117
+ }
2917
3118
  }
3119
+ /**
3120
+ * The boxes rules ask `:has()` of above the node they style, `.card:has(.x) .title`, by the
3121
+ * sheet that asks: a sheet replaced or taken away asks no more.
3122
+ */
3123
+ hasReaders = new Map();
3124
+ /** The nodes something changed beneath since the last commit, where any sheet has such a rule. */
3125
+ changedBeneath = new Set();
3126
+ /**
3127
+ * Have matched again what is under each box that changed beneath and that such a rule could
3128
+ * be asking: the nodes the rule is for, and no other. Once for a commit, however many changes
3129
+ * there were beneath the box: a table gains its rows one at a time.
3130
+ */
3131
+ markUnderHas() {
3132
+ if (!this.changedBeneath.size)
3133
+ return;
3134
+ for (const node of this.changedBeneath) {
3135
+ for (const readers of this.hasReaders.values()) {
3136
+ for (const { classes, subjects } of readers) {
3137
+ if (classes.every((name) => node.classes?.has(name)))
3138
+ this.markUnder(node, subjects);
3139
+ }
3140
+ }
3141
+ }
3142
+ this.changedBeneath.clear();
3143
+ }
3144
+ /**
3145
+ * What `node` matches changed, which the elements after it can read: as many of them as a rule
3146
+ * steps along from one, every one for a `~`, and none where no sheet steps at all.
3147
+ */
2918
3148
  markLaterSiblings(node) {
2919
- const siblings = node.parent?.children;
3149
+ let left = this.structuralAfter;
3150
+ const siblings = left > 0 ? node.parent?.children : undefined;
2920
3151
  if (!siblings)
2921
3152
  return;
2922
- for (let i = siblings.indexOf(node) + 1; i < siblings.length; i++) {
2923
- siblings[i].styleDirty = true;
3153
+ // Each matched again, and left as it was with all under it where it matches what it did:
3154
+ // see `markPlace`.
3155
+ for (let i = siblings.indexOf(node) + 1; i < siblings.length && left > 0; i++) {
3156
+ const sibling = siblings[i];
3157
+ this.markPlace(sibling);
3158
+ if (sibling.kind === 'element')
3159
+ left--;
2924
3160
  }
2925
3161
  }
2926
3162
  /** `node`'s child list changed: `moved` came into it at `at`, or went out of it from there. */
@@ -2995,9 +3231,16 @@ export class Engine {
2995
3231
  * which every such compound it could be asks for: a compound with none could be any element.
2996
3232
  */
2997
3233
  placeReadFromUnder(node) {
2998
- for (const compound of this.placeElsewhere) {
2999
- if (compound.classes.every((name) => node.classes?.has(name)))
3234
+ for (const { compound, above, child } of this.placeElsewhere) {
3235
+ if (!hasClasses(node, compound.classes))
3236
+ continue;
3237
+ // And by what the rule has it in, which a compound with no class is told from.
3238
+ if (!above?.length)
3000
3239
  return true;
3240
+ for (let up = node.parent; up; up = child ? null : up.parent) {
3241
+ if (hasClasses(up, above))
3242
+ return true;
3243
+ }
3001
3244
  }
3002
3245
  return false;
3003
3246
  }
@@ -3048,6 +3291,7 @@ export class Engine {
3048
3291
  // completeRoot that changes nothing: during a fling that is one wasted native commit per
3049
3292
  // frame, since any listener firing marks the view for refresh.
3050
3293
  this.settleCreated();
3294
+ this.markUnderHas();
3051
3295
  if (!this.root.structureDirty && !this.root.subtreeDirty)
3052
3296
  return false;
3053
3297
  const started = now();
@@ -3297,8 +3541,11 @@ export class Engine {
3297
3541
  flattenStyle(node.props[STYLE_OVERRIDE], style);
3298
3542
  this.fontFaces.apply(style);
3299
3543
  holdLoadingFamily(node, style);
3300
- if (viewName === PARAGRAPH)
3544
+ if (viewName === PARAGRAPH) {
3301
3545
  alignText(style, this.directionOf(node, style));
3546
+ this.wholeWords(node, style);
3547
+ lineTall(style, this.tallestRun(node, style, 0), this.fontScale);
3548
+ }
3302
3549
  if (this.fontsRefreshed)
3303
3550
  this.capForFonts(node, style);
3304
3551
  alignMultiline(viewName, style);
@@ -3314,6 +3561,7 @@ export class Engine {
3314
3561
  // After the basis, which is a size given where it is committed as one.
3315
3562
  ratioForAutoSize(merged);
3316
3563
  noOutline(merged);
3564
+ this.borderInText(node, merged);
3317
3565
  // Last, on what is committed: an override or an animation can hide a box, or place it.
3318
3566
  hiddenOutOfFlow(merged);
3319
3567
  delete merged['touchAction'];
@@ -3321,8 +3569,103 @@ export class Engine {
3321
3569
  stillTouched(merged);
3322
3570
  this.heldFor(node, merged);
3323
3571
  this.movePaint(node, merged);
3572
+ if (node.raised !== undefined)
3573
+ merged['zIndex'] ??= node.raised;
3324
3574
  return merged;
3325
3575
  }
3576
+ /**
3577
+ * The z-index a view is drawn at for the boxes in it: the highest of theirs. A browser draws a
3578
+ * box with a z-index over what follows the boxes it is in, and a native view is ordered among
3579
+ * the views beside it and no further, so each view the box is in is raised as far. Read from
3580
+ * what its children committed, which for a raised child is what raised it. A child with a
3581
+ * z-index of its own answers with that and nothing from inside it, as the stacking context it
3582
+ * is. Only a plain view is raised: a scroll view clips what is in it, and a screen is ordered
3583
+ * by its stack.
3584
+ *
3585
+ * ponytail: a z-index is the one thing read as making a stacking context. Opacity and a
3586
+ * transform make one in a browser too; read them here if a box is ever raised past one.
3587
+ */
3588
+ raisedBy(node, viewName) {
3589
+ if (viewName !== DEFAULT_VIEW)
3590
+ return undefined;
3591
+ let over = 0;
3592
+ for (const child of node.children) {
3593
+ const z = child.committed?.props['zIndex'];
3594
+ if (typeof z !== 'number' || z <= over)
3595
+ continue;
3596
+ if (!this.committedElsewhere(child) && !this.withheld(child))
3597
+ over = z;
3598
+ }
3599
+ return over || undefined;
3600
+ }
3601
+ /** Read how far a view is raised, once its children are committed: its props are, if it moved. */
3602
+ raise(node, viewName) {
3603
+ const raised = this.raisedBy(node, viewName);
3604
+ if (raised === node.raised)
3605
+ return;
3606
+ node.raised = raised;
3607
+ node.propsDirty = true;
3608
+ }
3609
+ /**
3610
+ * Draw a border that has a width and no colour in the colour of the text, as a browser does:
3611
+ * `border-color` starts as `currentColor`, and native starts it as black. Each side with a
3612
+ * width, where nothing gave that side a colour. Left to native where there is no text colour
3613
+ * to take, which is black in a browser too.
3614
+ */
3615
+ borderInText(node, props) {
3616
+ let text;
3617
+ // The side a logical colour is for is the other one where text is read right to left.
3618
+ const flipped = (props['borderStartColor'] != null || props['borderEndColor'] != null) &&
3619
+ this.styles.readsRightToLeft(props);
3620
+ for (const side of BORDER_SIDES) {
3621
+ if (!colourless(props, side, flipped))
3622
+ continue;
3623
+ text ??= props['color'] ?? this.styles.resolve(node, this.styleEpoch).inherited['color'];
3624
+ if (text == null)
3625
+ return;
3626
+ props[side.color] = text;
3627
+ }
3628
+ }
3629
+ /**
3630
+ * The tallest line, as the system text size draws it, of the runs of text in a paragraph:
3631
+ * none is 0. A run scales as what it is in does, `within`, but for what it says of that
3632
+ * itself: one that does not scale, or stops at a size, beside text that does.
3633
+ */
3634
+ tallestRun(node, within, tallest) {
3635
+ for (const child of node.children) {
3636
+ if (!isTextElement(child))
3637
+ continue;
3638
+ const scaling = {
3639
+ allowFontScaling: child.props['allowFontScaling'] ?? within['allowFontScaling'],
3640
+ maxFontSizeMultiplier: child.props['maxFontSizeMultiplier'] ?? within['maxFontSizeMultiplier'],
3641
+ };
3642
+ const line = inlineOf(child)['lineHeight'] ??
3643
+ this.styles.resolve(child, this.styleEpoch).style['lineHeight'];
3644
+ const drawn = typeof line === 'number' ? line * textScale(scaling, this.fontScale) : 0;
3645
+ tallest = this.tallestRun(child, scaling, Math.max(tallest, drawn));
3646
+ }
3647
+ return tallest;
3648
+ }
3649
+ /**
3650
+ * Keep a paragraph whose text has nowhere to wrap on one line, as a browser does: a word is
3651
+ * not broken inside, and what does not fit is past the edge. Native breaks a word wider than
3652
+ * its line between any two letters. Cut at the edge, where no rule says how it ends.
3653
+ */
3654
+ wholeWords(node, props) {
3655
+ // Remembered, for its text changing to be told from its text changing what this answers.
3656
+ node.oneWord = props['numberOfLines'] == null ? unbroken(node) : undefined;
3657
+ if (!node.oneWord)
3658
+ return;
3659
+ props['numberOfLines'] = 1;
3660
+ const ended = [
3661
+ this.styles.resolve(node, this.styleEpoch).style,
3662
+ node.props,
3663
+ inlineOf(node),
3664
+ flattenStyle(node.props[STYLE_OVERRIDE], {}),
3665
+ ];
3666
+ if (ended.every((from) => from['ellipsizeMode'] == null))
3667
+ props['ellipsizeMode'] = 'clip';
3668
+ }
3326
3669
  /**
3327
3670
  * Leave a node's background out of what it commits when a child paints it, and give that child
3328
3671
  * its parent's: the cascade's, and over it the parent's inline style.
@@ -3439,6 +3782,8 @@ export class Engine {
3439
3782
  sheetReplaced(sheet, next) {
3440
3783
  if (sheet === next && this.knownSheets.has(sheet))
3441
3784
  return;
3785
+ this.hasReaders.delete(sheet);
3786
+ this.watchHas(next);
3442
3787
  this.unwatchActive(sheet);
3443
3788
  this.watchActive(next);
3444
3789
  const at = this.sheetOrder.indexOf(sheet);
@@ -3538,7 +3883,8 @@ export class Engine {
3538
3883
  }
3539
3884
  return props;
3540
3885
  }
3541
- const frames = this.keyframes.get(spec.name);
3886
+ const written = this.keyframesOf(node, spec.name);
3887
+ const frames = written && this.framesFor(node, written);
3542
3888
  if (!frames) {
3543
3889
  // Keyframes a hot swap deleted, from under an animation that was playing them, or ones a
3544
3890
  // sheet later in this commit has: see `settleKeyframes`.
@@ -3554,6 +3900,49 @@ export class Engine {
3554
3900
  this.startPlaying(node, spec, frames, props);
3555
3901
  return Object.assign(props, playedOver(node));
3556
3902
  }
3903
+ /**
3904
+ * The keyframes a node's animation names: those of its own component's sheet where it has
3905
+ * some by that name, as Angular scopes a component's keyframes in a browser, and otherwise
3906
+ * whichever sheet has them, since any other is global.
3907
+ *
3908
+ * ponytail: a host is asked of the sheet it is written in before its own component's, so
3909
+ * a `:host` animation is played by its parent's frames where both have the name.
3910
+ */
3911
+ keyframesOf(node, name) {
3912
+ const own = node.sheet?.keyframes?.[name] ?? node.hostSheet?.keyframes?.[name];
3913
+ return own ?? this.keyframes.get(name);
3914
+ }
3915
+ /** Each node's frames as they settle on it, kept for as long as they settle the same. */
3916
+ settledFrames = new WeakMap();
3917
+ /**
3918
+ * An animation's frames for the node playing it: those with a value that waits for the node,
3919
+ * a `var()` it sets, settled against it. The same frames are answered while they settle the
3920
+ * same, and others once a variable changes, which is what an animation under way is played
3921
+ * on from: see `reframe`.
3922
+ */
3923
+ framesFor(node, frames) {
3924
+ if (!frames.some((frame) => frame.deferred))
3925
+ return frames;
3926
+ const kept = this.settledFrames.get(node);
3927
+ // Styled as it was, it settles as it did: a commit a frame asks, and most change nothing.
3928
+ const styled = node.styleCache;
3929
+ if (kept?.of === frames && kept.styled === styled)
3930
+ return kept.frames;
3931
+ const settled = frames.map((frame) => frame.deferred && this.styles.settleFor(node, frame.deferred, this.styleEpoch));
3932
+ const key = JSON.stringify(settled);
3933
+ if (kept?.of === frames && kept.key === key) {
3934
+ kept.styled = styled;
3935
+ return kept.frames;
3936
+ }
3937
+ const made = frames.map((frame, at) => {
3938
+ const values = Object.entries(settled[at] ?? {}).filter(([, value]) => value !== undefined);
3939
+ return values.length
3940
+ ? { ...frame, declarations: { ...frame.declarations, ...Object.fromEntries(values) } }
3941
+ : frame;
3942
+ });
3943
+ this.settledFrames.set(node, { of: frames, styled, key, frames: made });
3944
+ return made;
3945
+ }
3557
3946
  /**
3558
3947
  * A clock animation the scroll plays instead: the same animation where only the timeline moved,
3559
3948
  * and one cancelled where the name changed.
@@ -3749,8 +4138,8 @@ export class Engine {
3749
4138
  node.playing = started;
3750
4139
  if (spec.paused)
3751
4140
  started.pausedAt = this.now();
3752
- else if (!this.playNatively(node, started, props))
3753
- this.playing.add(node);
4141
+ else
4142
+ this.playSomewhere(node, started, props);
3754
4143
  this.emitTransition(node, 'topAnimationstart', spec.name);
3755
4144
  return;
3756
4145
  }
@@ -3764,6 +4153,13 @@ export class Engine {
3764
4153
  }
3765
4154
  this.playOrPause(node, node.playing, spec);
3766
4155
  }
4156
+ /** Play an animation that has just started: by native's layout, by native's driver, or here. */
4157
+ playSomewhere(node, started, props) {
4158
+ if (this.layPlayed(node, started))
4159
+ this.laidPlayed.push({ node, running: started });
4160
+ else if (!this.playNatively(node, started, props))
4161
+ this.playing.add(node);
4162
+ }
3767
4163
  /**
3768
4164
  * The colour `node` inherits, for frames that set `color: currentColor`, which is that colour
3769
4165
  * wherever the animation has got to, as in CSS. Undefined for frames that do not.
@@ -3781,7 +4177,8 @@ export class Engine {
3781
4177
  */
3782
4178
  reframe(node, current, frames, props, inherited) {
3783
4179
  const tracks = tracksOf(frames, props, inherited);
3784
- const reframed = { ...current, tracks, inherited };
4180
+ // One native was laying out goes on from JavaScript, by the clock it started on.
4181
+ const reframed = { ...current, tracks, inherited, laid: undefined };
3785
4182
  const { values, finished } = sample(reframed, current.pausedAt ?? this.now());
3786
4183
  const holds = current.spec.fill === 'forwards' || current.spec.fill === 'both';
3787
4184
  reframed.values = finished && !holds ? {} : values;
@@ -3814,7 +4211,7 @@ export class Engine {
3814
4211
  /** Every node playing a scroll-driven animation, by the scroll view that plays it. */
3815
4212
  scrollTimelines = new Map();
3816
4213
  /** How far each such scroll view scrolls, along each axis, once one of its events has said. */
3817
- scrollExtents = new Map();
4214
+ scrollExtents = new WeakMap();
3818
4215
  /**
3819
4216
  * An animation played by the nearest scroll view's offset: `animation-timeline: scroll()`.
3820
4217
  *
@@ -3900,8 +4297,13 @@ export class Engine {
3900
4297
  return;
3901
4298
  node.scrolled = undefined;
3902
4299
  scrolled.drive?.stop();
3903
- if (scrolled.source)
3904
- this.scrollTimelines.get(scrolled.source)?.delete(node);
4300
+ const playing = scrolled.source && this.scrollTimelines.get(scrolled.source);
4301
+ if (!playing)
4302
+ return;
4303
+ playing.delete(node);
4304
+ // The last to follow this scroll view: a key left here keeps it, and all it is linked to.
4305
+ if (!playing.size)
4306
+ this.scrollTimelines.delete(scrolled.source);
3905
4307
  }
3906
4308
  /**
3907
4309
  * A scroll view has said how far it scrolls. Every animation on it with a range that depends on
@@ -3975,7 +4377,8 @@ export class Engine {
3975
4377
  }
3976
4378
  /** Nobody saw what the last commit gave a view it created, so this is where the view starts. */
3977
4379
  unseenTransitions(node) {
3978
- if (node.bornIn < this.unseen)
4380
+ // One with no commit it was born in has been seen, whatever was committed since.
4381
+ if (node.bornIn === undefined || node.bornIn < this.unseen)
3979
4382
  return;
3980
4383
  this.stopEased(node);
3981
4384
  node.transitions = undefined;
@@ -4037,36 +4440,101 @@ export class Engine {
4037
4440
  if (eased.laid && !eased.done)
4038
4441
  props[key] = eased.to;
4039
4442
  }
4443
+ /** The animations this commit lays out at their last frame, for native to move the views to. */
4444
+ laidPlayed = [];
4040
4445
  /**
4041
- * Before a commit is handed over: ask native to move what it lays out, or take the
4042
- * transitions back to ease them from JavaScript where the commit moves anything else, and
4043
- * where they would not all take as long. Answers whether the tree has to be reconciled again.
4446
+ * Whether an animation that has just started is one native can play by laying the view out
4447
+ * at its last frame: two frames of sizes or places, played once from where the view is drawn
4448
+ * now, which is where native moves it from. It is then committed at that frame; see `layOut`.
4449
+ */
4450
+ layPlayed(node, running) {
4451
+ const drawn = node.committed?.props;
4452
+ const { spec, tracks } = running;
4453
+ if (!this.fabric.configureNextLayoutAnimation || !drawn || !tracks.size)
4454
+ return false;
4455
+ if (!playedOnce(spec))
4456
+ return false;
4457
+ for (const [key, track] of tracks) {
4458
+ if (!LAID_OUT.has(key) || !fromWhereDrawn(track, drawn[key]))
4459
+ return false;
4460
+ }
4461
+ running.laid = true;
4462
+ running.values = Object.fromEntries([...tracks].map(([key, track]) => [key, track[1].value]));
4463
+ return true;
4464
+ }
4465
+ /** Go on from JavaScript with an animation native was to lay out, by the clock it started on. */
4466
+ playLaidHere(node, running) {
4467
+ running.laid = undefined;
4468
+ running.values = sample(running, this.now()).values;
4469
+ this.playing.add(node);
4470
+ }
4471
+ /** An animation native laid out has had its time: it is at its last frame, and says so. */
4472
+ endLaidPlayed(node, running) {
4473
+ if (!running.laid || running.done || node.playing !== running)
4474
+ return;
4475
+ Object.assign(running, { done: true, laid: undefined });
4476
+ if (!this.keeps(node, running)) {
4477
+ running.values = {};
4478
+ this.markProps(node, false);
4479
+ }
4480
+ this.emitTransition(node, 'topAnimationend', running.spec.name);
4481
+ this.flushTransitionEvents();
4482
+ if (this.pending)
4483
+ this.commit();
4484
+ }
4485
+ /**
4486
+ * Before a commit is handed over: ask native to move what it lays out, and take back to
4487
+ * ease from JavaScript whatever it cannot: all of it where the commit moves anything else,
4488
+ * and otherwise what would not take as long as the longest. Answers whether the tree has to
4489
+ * be reconciled again.
4044
4490
  */
4045
4491
  layOut() {
4046
4492
  const laying = this.laying;
4493
+ const played = this.laidPlayed.filter(({ node, running }) => node.playing === running);
4047
4494
  const others = this.movesOthers;
4048
4495
  this.laying = [];
4496
+ this.laidPlayed = [];
4049
4497
  this.movesOthers = false;
4050
- if (!laying.length)
4498
+ if (!laying.length && !played.length)
4051
4499
  return false;
4052
- const [first] = laying;
4053
- const alike = laying.every(({ eased }) => eased.duration === first.eased.duration);
4054
- if (others || !alike) {
4055
- for (const { node, eased } of laying) {
4056
- eased.laid = undefined;
4057
- this.running.add(node);
4058
- }
4059
- for (const { node } of laying)
4060
- this.markProps(node, false);
4061
- return true;
4500
+ // Native takes one length of time for a commit: the longest asked of it, and whatever
4501
+ // would take another is eased from JavaScript. None of it, where anything else moves.
4502
+ const longest = Math.max(...laying.map(({ eased }) => eased.duration), ...played.map(({ running }) => running.spec.duration));
4503
+ const kept = (duration) => !others && duration === longest;
4504
+ const back = [];
4505
+ for (const { node, eased } of laying) {
4506
+ if (kept(eased.duration))
4507
+ continue;
4508
+ eased.laid = undefined;
4509
+ this.running.add(node);
4510
+ back.push(node);
4511
+ }
4512
+ for (const { node, running } of played) {
4513
+ if (kept(running.spec.duration))
4514
+ continue;
4515
+ this.playLaidHere(node, running);
4516
+ back.push(node);
4062
4517
  }
4063
- const { duration, easing } = first.eased;
4518
+ for (const node of back)
4519
+ this.markProps(node, false);
4520
+ if (!others)
4521
+ this.askLayout(laying, played, longest);
4522
+ return back.length > 0;
4523
+ }
4524
+ /** Ask native to move what the commit lays out over `duration`, and end each when it is up. */
4525
+ askLayout(laying, played, duration) {
4526
+ const easing = laying.find(({ eased }) => eased.laid)?.eased.easing ??
4527
+ played.find(({ running }) => running.laid).running.spec.easing;
4064
4528
  const nothing = () => undefined;
4065
4529
  this.fabric.configureNextLayoutAnimation({ duration, update: { type: layoutCurve(easing) } }, nothing, nothing);
4066
4530
  for (const { node, key, eased } of laying) {
4067
- setTimeout(() => this.endLaid(node, key, eased), duration);
4531
+ if (eased.laid)
4532
+ setTimeout(() => this.endLaid(node, key, eased), duration);
4533
+ }
4534
+ for (const { node, running } of played) {
4535
+ if (running.laid)
4536
+ setTimeout(() => this.endLaidPlayed(node, running), duration);
4068
4537
  }
4069
- return false;
4070
4538
  }
4071
4539
  /**
4072
4540
  * Note a commit that resizes or moves a box in no transition native lays out: a prop of one
@@ -4078,8 +4546,7 @@ export class Engine {
4078
4546
  if (resized)
4079
4547
  this.movesOthers = true;
4080
4548
  for (const key in payload) {
4081
- const eased = node.transitions?.get(key);
4082
- if (eased?.laid && !eased.done)
4549
+ if (laidByNative(node, key))
4083
4550
  continue;
4084
4551
  // Text as long as it was is taken to be as wide, as a grid takes it: see `shapeOf`.
4085
4552
  const longer = key === 'text' && String(payload[key]).length !== String(words).length;
@@ -4131,13 +4598,16 @@ export class Engine {
4131
4598
  }
4132
4599
  playEased(node, state, keys, props) {
4133
4600
  const first = state.get(keys[0]);
4601
+ // Where the view is committed: one that takes a touch stops short of no opacity.
4602
+ const touched = this.responders.has(node);
4603
+ const seen = (key, value) => touched && key === 'opacity' ? stillTouched({ ...props, opacity: value })['opacity'] : value;
4134
4604
  const tracks = new Map(keys.map((key) => {
4135
4605
  const { current, to, easing } = state.get(key);
4136
4606
  return [
4137
4607
  key,
4138
4608
  [
4139
- { offset: 0, value: current, easing },
4140
- { offset: 1, value: to },
4609
+ { offset: 0, value: seen(key, current), easing },
4610
+ { offset: 1, value: seen(key, to) },
4141
4611
  ],
4142
4612
  ];
4143
4613
  }));
@@ -4491,6 +4961,7 @@ export class Engine {
4491
4961
  this.noteBaselineFields(node);
4492
4962
  this.freshBaselines(node);
4493
4963
  const childHandles = this.reconcileChildren(node, viewName, style);
4964
+ this.raise(node, viewName);
4494
4965
  noteTouches(node, style);
4495
4966
  const previous = node.committed;
4496
4967
  this.notePresented(node, viewName);
@@ -4601,8 +5072,12 @@ export class Engine {
4601
5072
  }
4602
5073
  box.parent = parent;
4603
5074
  // The text is not under the box in the tree, so a change to it never marked the box.
4604
- if (!this.isClean(text))
5075
+ if (!this.isClean(text)) {
4605
5076
  box.subtreeDirty = true;
5077
+ // Nor had it merged again where the text became one word, or stopped being one.
5078
+ if (box.oneWord !== undefined && unbroken(box) !== box.oneWord)
5079
+ box.propsDirty = true;
5080
+ }
4606
5081
  return box;
4607
5082
  }
4608
5083
  /** Remember an iOS modal host committed as showing, so hiding it waits for `topDismiss`. */
@@ -4986,6 +5461,11 @@ export class Engine {
4986
5461
  setResponder(node, handlers) {
4987
5462
  this.registerEventHandler();
4988
5463
  this.responders.set(node, handlers);
5464
+ // A fade native is playing was given to it as a view that takes no touch fades: to
5465
+ // nothing. From here it is eased to where the view is committed.
5466
+ const fade = node.transitions?.get('opacity')?.native;
5467
+ if (fade)
5468
+ this.backToScriptEased(node, fade, this.now());
4989
5469
  this.keepNative(node, true);
4990
5470
  // What it is committed with can hang on whether it takes a touch: see `stillTouched`.
4991
5471
  this.markProps(node);
@@ -5213,12 +5693,14 @@ export class Engine {
5213
5693
  this.currentResponder = null;
5214
5694
  this.tellNative(node, false);
5215
5695
  const handlers = this.responders.get(node);
5696
+ // Before the handler is told: what it does is for the render pass after it to commit
5697
+ // whole, and not to be drawn part made with the view no longer pressed.
5698
+ if (changed)
5699
+ this.commit();
5216
5700
  if (terminated)
5217
5701
  handlers?.onResponderTerminate?.(event);
5218
5702
  else
5219
5703
  handlers?.onResponderRelease?.(event);
5220
- if (changed)
5221
- this.commit();
5222
5704
  }
5223
5705
  tellNative(node, isResponder) {
5224
5706
  const handle = node.committed?.handle;