@ng-native/fabric 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * because a persistent parent holds references to specific child handles. That bubble is
8
8
  * inherent and is what React's own Fabric renderer does.
9
9
  */
10
- import { boundDeclaration, inlineInherited, setsInherited, StyleResolver, } from "./css.js";
10
+ import { boundDeclaration, inlineInherited, setsInherited, StyleResolver, usesActive, } from "./css.js";
11
11
  import { applyAria } from "./aria-props.js";
12
12
  import { STYLED_ELEMENTS } from "./element-styles.js";
13
13
  import { animationEvent, bezier, interpolate, readsInheritedColour, sample, step, tracksOf, } from "./transition.js";
@@ -15,8 +15,9 @@ import { isFormsInput } from "./forms-inputs.js";
15
15
  import { transformList } from "./inline-transform.js";
16
16
  import { tokenFromValue } from "./inline-token.js";
17
17
  import { NativeScrollDriver, scrollFeed, } from "./native-drive.js";
18
- import { firstFrame, rangeOf, scrollChannels } from "./scroll-animation.js";
18
+ import { clockChannels, firstFrame, rangeOf, scrollChannels } from "./scroll-animation.js";
19
19
  import { FontFaces } from "./font-faces.js";
20
+ import { premultipliedStops } from "./premultiplied-stops.js";
20
21
  /**
21
22
  * The animation a node's style asks for, with a play state from a rule of its own applied. Both
22
23
  * are instructions for this engine and nothing native has heard of them, so they come out of the
@@ -459,6 +460,124 @@ function centreSingleLine(viewName, props, fontScale) {
459
460
  : box.reduce((sum, part) => sum + part, 0);
460
461
  props['minHeight'] = Math.max(own, Math.min(content, max));
461
462
  }
463
+ /**
464
+ * What a container's own style says for `key`, in the order `mergeProps` applies them: a
465
+ * component's override, inline, a prop, its stylesheet, its default.
466
+ */
467
+ function ownLayout(node, key) {
468
+ return (flattenStyle(node.props[STYLE_OVERRIDE], {})[key] ??
469
+ flattenStyle(node.props['style'], {})[key] ??
470
+ node.props[key] ??
471
+ node.styleCache?.style[key] ??
472
+ node.defaultStyle?.[key]);
473
+ }
474
+ /**
475
+ * A container's main axis and how it aligns across it, as `row` or `column` and a keyword. A
476
+ * `display: contents` node is no container: its children are laid out in the one above it.
477
+ */
478
+ function containerOf(node) {
479
+ if (ownLayout(node, 'display') === 'contents')
480
+ return 'contents';
481
+ const direction = String(ownLayout(node, 'flexDirection') ?? 'column');
482
+ return `${direction.startsWith('row') ? 'row' : 'column'} ${ownLayout(node, 'alignItems') ?? 'stretch'}`;
483
+ }
484
+ /**
485
+ * The node a box is laid out in: its parent, or the one above every `display: contents` ancestor.
486
+ * Each of those is told a `fit-content` child passed through, so that when it stops being
487
+ * `contents`, and is the container, the child is worked out again.
488
+ */
489
+ function layoutParent(node) {
490
+ let parent = node.parent;
491
+ while (parent && containerOf(parent) === 'contents') {
492
+ parent.fitContainer = 'contents';
493
+ parent = parent.parent;
494
+ }
495
+ return parent;
496
+ }
497
+ const STRETCHES = new Set([undefined, null, 'auto', 'stretch']);
498
+ /**
499
+ * `width: fit-content` and `height: fit-content`, which Yoga has no size for.
500
+ *
501
+ * Along its container's main axis a box is as big as its content already, so the size is
502
+ * dropped. Across it a box stretches, where a browser keeps it at its content's size and at the
503
+ * start: `align-self: flex-start` does both. An alignment the container or the box asked for is
504
+ * kept, since it stops the stretch already.
505
+ */
506
+ function fitContent(node, props) {
507
+ const width = props['width'] === 'fit-content';
508
+ const height = props['height'] === 'fit-content';
509
+ if (!width && !height)
510
+ return;
511
+ if (width)
512
+ delete props['width'];
513
+ if (height)
514
+ delete props['height'];
515
+ const parent = layoutParent(node);
516
+ if (!parent)
517
+ return;
518
+ const container = containerOf(parent);
519
+ parent.fitContainer = container;
520
+ const [direction, align] = container.split(' ');
521
+ const across = direction === 'row' ? height : width;
522
+ if (across && align === 'stretch' && STRETCHES.has(props['alignSelf'])) {
523
+ props['alignSelf'] = 'flex-start';
524
+ }
525
+ }
526
+ const isPercent = (value) => typeof value === 'string' && value.endsWith('%');
527
+ /**
528
+ * Whether a box has a height a percentage can be taken of, as CSS defines one: a height of its
529
+ * own, or one the layout around it gives it. A box as tall as what it holds has none.
530
+ *
531
+ * Asked of each box on the way up, and remembered on it, so a change to any of them works out
532
+ * again the boxes beneath it. Where the answer is not known for certain it is yes, which leaves
533
+ * the percentage to Yoga as before: a view native sizes itself, or one placed out of the flow.
534
+ */
535
+ function definiteHeight(node) {
536
+ return (node.heightBasis = hasDefiniteHeight(node));
537
+ }
538
+ function hasDefiniteHeight(node) {
539
+ // The root is the screen, and only a plain view is sized by its content alone.
540
+ if (!node.parent || node.kind !== 'element' || viewNameOf(node) !== 'View')
541
+ return true;
542
+ const parent = layoutParent(node);
543
+ if (!parent)
544
+ return true;
545
+ const height = ownLayout(node, 'height');
546
+ if (isPercent(height))
547
+ return definiteHeight(parent);
548
+ return sizesItself(node, height) || heightFromLayout(node, parent);
549
+ }
550
+ const NO_HEIGHT = new Set([undefined, null, 'auto', 'fit-content']);
551
+ /** Whether a box has a height from its own style: written, or placed, or kept in proportion. */
552
+ function sizesItself(node, height) {
553
+ if (!NO_HEIGHT.has(height))
554
+ return true;
555
+ return ownLayout(node, 'position') === 'absolute' || ownLayout(node, 'aspectRatio') != null;
556
+ }
557
+ /** Whether the container a box is in gives it a height: a row's, or a share of a column's. */
558
+ function heightFromLayout(node, parent) {
559
+ const [direction, align] = containerOf(parent).split(' ');
560
+ if (direction === 'row') {
561
+ // Stretched across a row, it is as tall as the row.
562
+ const self = ownLayout(node, 'alignSelf');
563
+ return STRETCHES.has(self) && align === 'stretch';
564
+ }
565
+ // In a column it has the height it grows to, where the column has one to share out.
566
+ const grows = Number(ownLayout(node, 'flexGrow') ?? ownLayout(node, 'flex') ?? 0) > 0;
567
+ return grows && definiteHeight(parent);
568
+ }
569
+ /**
570
+ * `height: 50%` under a parent with no height to take it of, which CSS reads as `auto`. Yoga
571
+ * takes it of the space on offer, the screen's or everything a scroll view holds, so no height
572
+ * is sent.
573
+ */
574
+ function percentHeight(node, props) {
575
+ if (!isPercent(props['height']))
576
+ return;
577
+ const parent = layoutParent(node);
578
+ if (parent && !definiteHeight(parent))
579
+ delete props['height'];
580
+ }
462
581
  /**
463
582
  * Start a multiline Android text field's text at the top, as iOS and a `<textarea>` do.
464
583
  *
@@ -769,6 +888,30 @@ function holdsOnlyText(node) {
769
888
  }
770
889
  return true;
771
890
  }
891
+ const ALIGNS = new Set(['center', 'flex-end', 'space-around', 'space-evenly']);
892
+ /**
893
+ * Whether one of HTML's text elements is a flex container that aligns the one run of text it
894
+ * holds, as an avatar's initials are centred: `<span class="flex items-center justify-center">`.
895
+ *
896
+ * A browser makes the run a flex item and places it. A paragraph's text is its content, which no
897
+ * alignment moves, so such an element commits as a view, and its text as the paragraph any view's
898
+ * loose text is. Read from the node's own style on each reconcile, which a change to it brings
899
+ * about, so nothing is kept to go stale. A `<text>` is a paragraph whatever its style, and an
900
+ * element holding more than the one run is left as it was.
901
+ */
902
+ function aligningText(node) {
903
+ if (node.kind !== 'element' || node.name === 'text' || !TEXT_ELEMENTS.has(node.name)) {
904
+ return false;
905
+ }
906
+ if (node.children.length !== 1 || node.children[0].kind !== 'text')
907
+ return false;
908
+ if (isTextElement(node.parent) || ownLayout(node, 'display') !== 'flex')
909
+ return false;
910
+ return (ALIGNS.has(ownLayout(node, 'alignItems')) ||
911
+ ALIGNS.has(ownLayout(node, 'justifyContent')));
912
+ }
913
+ /** The view a node is committed as: `viewNameOf`, but for a text element that aligns its text. */
914
+ const committedViewName = (node) => aligningText(node) ? DEFAULT_VIEW : viewNameOf(node);
772
915
  /**
773
916
  * The native view a node commits as: `Paragraph` for a `<text>`, `VirtualText` for one nested in
774
917
  * another, `RawText` for a run of text, and `View` for anything unregistered, which is what an
@@ -795,7 +938,9 @@ export function viewNameOf(node) {
795
938
  if (node.name !== 'text' && TEXT_ELEMENTS.has(node.name) && !holdsOnlyText(node)) {
796
939
  return DEFAULT_VIEW;
797
940
  }
798
- return isTextElement(node.parent) ? VIRTUAL_TEXT : PARAGRAPH;
941
+ // The paragraph the engine makes for a view's loose text is under a view, whatever the name
942
+ // of the element that view is.
943
+ return !node.anonymous && isTextElement(node.parent) ? VIRTUAL_TEXT : PARAGRAPH;
799
944
  }
800
945
  /**
801
946
  * The props every native view reads as a boolean: the `bool` fields of React Native's
@@ -846,22 +991,47 @@ function wasOrIsEmptyWithout(node, moved) {
846
991
  }
847
992
  return true;
848
993
  }
994
+ /**
995
+ * Have the child that paints a node's background merged again with the node: nothing of the
996
+ * child's own moved, and what it paints may have.
997
+ */
998
+ function repaint(node) {
999
+ if (node.paintsOn?.parent === node)
1000
+ node.paintsOn.propsDirty = true;
1001
+ }
1002
+ /** A sixtieth of a second: how far apart the frames native is given for an animation are. */
1003
+ const NATIVE_FRAME = 1000 / 60;
1004
+ /** The style keys that are a view's background. */
1005
+ const PAINT_KEYS = [
1006
+ 'backgroundColor',
1007
+ 'experimental_backgroundImage',
1008
+ 'experimental_backgroundSize',
1009
+ 'experimental_backgroundPosition',
1010
+ 'experimental_backgroundRepeat',
1011
+ ];
849
1012
  /**
850
1013
  * A node's own props onto what it commits with. No native prop has a hyphen: `data-*` and `aria-*`
851
1014
  * attributes stay on the node for selectors to match, and an `aria-*` one is mapped to the prop
852
- * native reads.
1015
+ * native reads. The node's `attributeOnly` names stay on it the same way.
853
1016
  */
854
- function writeOwnProps(own, props) {
1017
+ function writeOwnProps(own, props, attributeOnly) {
855
1018
  let aria = false;
856
1019
  for (const key of Object.keys(own)) {
1020
+ if (attributeOnly?.has(key))
1021
+ continue;
857
1022
  if (key.includes('-'))
858
1023
  aria ||= key.startsWith('aria-');
859
1024
  else if (key !== 'style' && key !== INTRINSIC_SIZE && key !== STYLE_OVERRIDE) {
860
1025
  props[key] = committedProp(key, own[key]);
861
1026
  }
862
1027
  }
863
- if (aria)
864
- applyAria(own, props);
1028
+ if (!aria)
1029
+ return;
1030
+ // An attribute-only `aria-*` is not mapped to the prop native reads either.
1031
+ const mapped = attributeOnly?.size
1032
+ ? Object.fromEntries(Object.entries(own).filter(([key]) => !attributeOnly.has(key)))
1033
+ : own;
1034
+ applyAria(mapped, props);
865
1035
  }
866
1036
  /** A prop as it is committed: a boolean view prop held as text is the boolean the text says. */
867
1037
  function committedProp(key, value) {
@@ -1039,6 +1209,10 @@ class RetainedNode {
1039
1209
  styleCache = null;
1040
1210
  styleDirty = true;
1041
1211
  styleCommitted = null;
1212
+ fitContainer = undefined;
1213
+ heightBasis = undefined;
1214
+ hasDirty = false;
1215
+ stateDirty = false;
1042
1216
  ownStyle = undefined;
1043
1217
  claimed = undefined;
1044
1218
  dormantHoists = null;
@@ -1182,12 +1356,19 @@ class RetainedNode {
1182
1356
  * colours - `shouldRasterizeIOS` is a boolean.
1183
1357
  */
1184
1358
  const IS_COLOR_PROP = /color(android)?$/i;
1359
+ /**
1360
+ * Colour props React Native does not name with a `color` suffix: `<switch>`'s pair ends in the
1361
+ * state each one paints - `trackColorForTrue` and `trackColorForFalse`. They are the same type
1362
+ * problem the `Android` suffix causes: Android's `ColorPropConverter` takes a number or a
1363
+ * platform-colour map, refuses a string, and throws rather than approximating one.
1364
+ */
1365
+ const STATE_COLOR_PROPS = new Set(['trackColorForTrue', 'trackColorForFalse']);
1185
1366
  /** Answers per key name, once: the regex ran for every prop of every node in every commit. */
1186
1367
  const colorProps = new Map();
1187
1368
  function isColorProp(key) {
1188
1369
  let answer = colorProps.get(key);
1189
1370
  if (answer === undefined) {
1190
- colorProps.set(key, (answer = IS_COLOR_PROP.test(key) || BRUSH_PROPS.has(key)));
1371
+ colorProps.set(key, (answer = IS_COLOR_PROP.test(key) || STATE_COLOR_PROPS.has(key) || BRUSH_PROPS.has(key)));
1191
1372
  }
1192
1373
  return answer;
1193
1374
  }
@@ -1431,6 +1612,8 @@ export class Engine {
1431
1612
  * does, a child list can move without anything else needing to be looked at again.
1432
1613
  */
1433
1614
  structuralSheets = false;
1615
+ /** Whether any sheet uses `:has()`. See `markBeneath`. */
1616
+ hasSheets = false;
1434
1617
  resolveAssetSource;
1435
1618
  constructor(fabric, rootTag, options = {}) {
1436
1619
  this.fabric = fabric;
@@ -1442,6 +1625,8 @@ export class Engine {
1442
1625
  if (options.tokens)
1443
1626
  this.styles.setRootTokens(options.tokens);
1444
1627
  this.structuralSheets = options.globalStyles?.structural === true;
1628
+ this.watchHas(options.globalStyles);
1629
+ this.watchActive(options.globalStyles);
1445
1630
  this.dev = options.dev ?? globalThis.__DEV__ === true;
1446
1631
  if (this.dev) {
1447
1632
  this.styles.onUndefinedToken = (name, props, on) => this.reportUndefinedToken(name, props, on);
@@ -1506,11 +1691,70 @@ export class Engine {
1506
1691
  if (node.active === active)
1507
1692
  continue;
1508
1693
  node.active = active;
1509
- this.markProps(node);
1694
+ this.markActive(node);
1510
1695
  changed = true;
1511
1696
  }
1512
1697
  return changed;
1513
1698
  }
1699
+ /** Whether any sheet styles a pressed element, and each compound that asks about one from another. */
1700
+ activeUse = {
1701
+ own: false,
1702
+ elsewhere: [],
1703
+ };
1704
+ /** What each sheet in play asks about a pressed element: `activeUse` is all of them together. */
1705
+ activeBySheet = new Map();
1706
+ /** Note what a sheet in play asks about a pressed element. See `markActive`. */
1707
+ watchActive(sheet) {
1708
+ // Asked for every element a component's sheet is given to: noted once a sheet.
1709
+ if (!sheet || this.activeBySheet.has(sheet))
1710
+ return;
1711
+ const use = usesActive(sheet);
1712
+ this.activeBySheet.set(sheet, use);
1713
+ if (!use.own && !use.elsewhere.length)
1714
+ return;
1715
+ this.countActive();
1716
+ if (this.styles.tracksHas)
1717
+ return;
1718
+ // What a pressed element is compared with is the rules it matched, which no cache kept until
1719
+ // a sheet asked. Nothing is resolved yet when the sheet is the one the engine was made with.
1720
+ this.styles.tracksHas = true;
1721
+ if (!this.root)
1722
+ return;
1723
+ this.root.styleDirty = true;
1724
+ this.markPath(this.root);
1725
+ }
1726
+ /** A sheet has left play, by a hot swap or by being removed: what it asked goes with it. */
1727
+ unwatchActive(sheet) {
1728
+ if (this.activeBySheet.delete(sheet))
1729
+ this.countActive();
1730
+ }
1731
+ countActive() {
1732
+ const all = [...this.activeBySheet.values()];
1733
+ this.activeUse = {
1734
+ own: all.some((use) => use.own),
1735
+ elsewhere: all.flatMap((use) => use.elsewhere),
1736
+ };
1737
+ }
1738
+ /**
1739
+ * A press began or ended on `node` or under it, which is all that changed about it. A press
1740
+ * reaches every ancestor up to the root, and marking each as restyled would style the whole
1741
+ * screen again, twice a press. So each is only matched again, and keeps its style and all
1742
+ * that is under it unless it now matches other rules. An element a rule asks about from
1743
+ * inside or beside it, `.card:active .title`, changes more than itself, and is restyled with
1744
+ * everything under it as any other change is. With no rule for a pressed element at all there
1745
+ * is nothing to match.
1746
+ */
1747
+ markActive(node) {
1748
+ const { own, elsewhere } = this.activeUse;
1749
+ if (!own && !elsewhere.length)
1750
+ return;
1751
+ // By its classes alone: a compound with none could be any element.
1752
+ const asked = elsewhere.some((compound) => compound.classes.every((name) => node.classes?.has(name)));
1753
+ if (asked)
1754
+ return this.markProps(node);
1755
+ node.stateDirty = true;
1756
+ this.markPath(node.parent ?? node);
1757
+ }
1514
1758
  /**
1515
1759
  * Custom properties the device knows and the stylesheet cannot, once they change.
1516
1760
  *
@@ -1533,6 +1777,8 @@ export class Engine {
1533
1777
  this.registerSheet(sheet);
1534
1778
  if (sheet.structural)
1535
1779
  this.structuralSheets = true;
1780
+ this.watchHas(sheet);
1781
+ this.watchActive(sheet);
1536
1782
  this.markPath(this.root);
1537
1783
  return true;
1538
1784
  }
@@ -1563,14 +1809,19 @@ export class Engine {
1563
1809
  * which is what makes Fabric dirty a measured node and measure it at the size now in effect.
1564
1810
  */
1565
1811
  remeasureText() {
1812
+ const again = (measured) => {
1813
+ measured.remeasure = true;
1814
+ this.markProps(measured, false);
1815
+ };
1566
1816
  const visit = (node) => {
1567
1817
  for (const child of node.children) {
1818
+ // The paragraph of a run of text written straight into a view is kept on the text.
1819
+ if (child.kind === 'text' && child.box?.committed)
1820
+ again(child.box);
1568
1821
  if (child.kind !== 'element')
1569
1822
  continue;
1570
- if (child.committed && MEASURED_VIEWS.has(viewNameOf(child))) {
1571
- child.remeasure = true;
1572
- this.markProps(child, false);
1573
- }
1823
+ if (child.committed && MEASURED_VIEWS.has(committedViewName(child)))
1824
+ again(child);
1574
1825
  visit(child);
1575
1826
  }
1576
1827
  };
@@ -1718,6 +1969,8 @@ export class Engine {
1718
1969
  node.sheet = sheet;
1719
1970
  if (sheet?.structural)
1720
1971
  this.structuralSheets = true;
1972
+ this.watchHas(sheet);
1973
+ this.watchActive(sheet);
1721
1974
  this.markProps(node);
1722
1975
  }
1723
1976
  /** The sheet each node given another's scope was created with. */
@@ -1758,6 +2011,8 @@ export class Engine {
1758
2011
  }
1759
2012
  if (sheet?.structural)
1760
2013
  this.structuralSheets = true;
2014
+ this.watchHas(sheet);
2015
+ this.watchActive(sheet);
1761
2016
  if (HOISTS[name])
1762
2017
  this.hoisted.add(node);
1763
2018
  return node;
@@ -1779,6 +2034,7 @@ export class Engine {
1779
2034
  if (node.hostSheet === sheet)
1780
2035
  return;
1781
2036
  node.hostSheet = sheet;
2037
+ this.watchActive(sheet);
1782
2038
  this.markProps(node);
1783
2039
  }
1784
2040
  /**
@@ -1894,6 +2150,7 @@ export class Engine {
1894
2150
  node.playing = undefined;
1895
2151
  this.markProps(node, false);
1896
2152
  }
2153
+ this.releasePlayedNatively();
1897
2154
  for (const nodes of this.scrollTimelines.values()) {
1898
2155
  for (const node of nodes) {
1899
2156
  if (this.topOf(node) === this.root)
@@ -1928,8 +2185,14 @@ export class Engine {
1928
2185
  destroyNode(node) {
1929
2186
  node.listeners?.clear();
1930
2187
  node.listeners = null;
1931
- node.committed = null;
2188
+ // With what it holds: a node Angular destroys can be put back, as content projected into
2189
+ // an `@if` is, and its children's views would then be handed to the view it is made again
2190
+ // as. Native gives a view one parent for its life, and aborts on a second.
2191
+ if (node.committed)
2192
+ this.forgetCommitted(node);
1932
2193
  node.transitions = undefined;
2194
+ if (node.playing)
2195
+ this.stopNative(node, node.playing);
1933
2196
  node.playing = undefined;
1934
2197
  this.stopScrolled(node);
1935
2198
  this.running.delete(node);
@@ -1950,6 +2213,9 @@ export class Engine {
1950
2213
  return siblings[siblings.indexOf(node) + 1] ?? null;
1951
2214
  }
1952
2215
  setProp(node, key, value) {
2216
+ // A prop written again is a binding's, and is committed as any other is.
2217
+ if (node.attributeOnly?.delete(key))
2218
+ this.markProps(node, false);
1953
2219
  if (value === undefined || value === null) {
1954
2220
  // Nothing to remove, so nothing changed. Worth its own branch because it is the common
1955
2221
  // case, not a rare one: a host primitive carries a host binding for every prop React Native
@@ -1969,6 +2235,29 @@ export class Engine {
1969
2235
  // inline `direction` is the exception, because the paragraphs below it align by it.
1970
2236
  this.markProps(node, key !== 'style' || this.inlineReachesStyle(node));
1971
2237
  }
2238
+ /**
2239
+ * Paint a node's background on one of its children instead of on the node: the node keeps its
2240
+ * `background-color` and `background-image` in the cascade, where a stylesheet wrote them, and
2241
+ * the child is the view that draws them. What a view that masks its children and not itself
2242
+ * needs, as `<gradient-text>` is.
2243
+ */
2244
+ paintOn(node, child) {
2245
+ node.paintsOn = child;
2246
+ this.markProps(node, false);
2247
+ this.markProps(child, false);
2248
+ }
2249
+ /**
2250
+ * Keep a prop the node already has as an attribute: on the node for a selector such as
2251
+ * `:host([variant='primary'])` to match, and left out of what is committed to the native view.
2252
+ * It is committed again once the prop is next written. What the platform adapter does with a
2253
+ * static attribute that is an input of the host's component.
2254
+ */
2255
+ keepAsAttribute(node, key) {
2256
+ if (!(key in node.props))
2257
+ return;
2258
+ (node.attributeOnly ??= new Set()).add(key);
2259
+ this.markProps(node, false);
2260
+ }
1972
2261
  /**
1973
2262
  * Whether a node's inline style can change what it or anything below it resolves to: it sets,
1974
2263
  * or until now set, a property its children inherit.
@@ -2072,11 +2361,35 @@ export class Engine {
2072
2361
  // state, though nothing about the sibling moved. Only sheets that ask about siblings pay.
2073
2362
  if (this.structuralSheets)
2074
2363
  this.markLaterSiblings(node);
2364
+ if (this.hasSheets)
2365
+ this.markBeneath(node.parent);
2075
2366
  }
2076
2367
  // The root has no parent to carry the mark, so it carries its own: `.dark` on the root alone
2077
2368
  // restyles everything beneath it.
2078
2369
  this.markPath(node.parent ?? node);
2079
2370
  }
2371
+ /** Start matching ancestors again on a change, once a sheet that uses `:has()` is in play. */
2372
+ watchHas(sheet) {
2373
+ if (!sheet?.has || this.hasSheets)
2374
+ return;
2375
+ this.hasSheets = true;
2376
+ this.styles.tracksHas = true;
2377
+ // What is already resolved kept no record of the rules it matched: resolve it again. Nothing
2378
+ // is, when the sheet is the one the engine was made with.
2379
+ if (!this.root)
2380
+ return;
2381
+ this.root.styleDirty = true;
2382
+ this.markPath(this.root);
2383
+ }
2384
+ /**
2385
+ * Something at or beneath `from` changed: every node from there up may match a `:has()` rule
2386
+ * it did not, or stop matching one. They are matched again rather than marked for restyling,
2387
+ * which would restyle everything under them. Only sheets that use `:has()` pay.
2388
+ */
2389
+ markBeneath(from) {
2390
+ for (let node = from; node; node = node.parent)
2391
+ node.hasDirty = true;
2392
+ }
2080
2393
  markLaterSiblings(node) {
2081
2394
  const siblings = node.parent?.children;
2082
2395
  if (!siblings)
@@ -2099,6 +2412,8 @@ export class Engine {
2099
2412
  if (wasOrIsEmptyWithout(node, moved))
2100
2413
  this.markLaterSiblings(node);
2101
2414
  }
2415
+ if (this.hasSheets)
2416
+ this.markBeneath(node);
2102
2417
  this.markPath(node.parent);
2103
2418
  }
2104
2419
  /** Walk up until we hit a node already known to have a dirty subtree. */
@@ -2304,11 +2619,17 @@ export class Engine {
2304
2619
  const props = { ...DEFAULT_PROPS[viewName], ...node.defaultStyle };
2305
2620
  Object.assign(props, this.styles.resolve(node, this.styleEpoch).style);
2306
2621
  const resolved = props['pointerEvents'];
2307
- writeOwnProps(node.props, props);
2622
+ writeOwnProps(node.props, props, node.attributeOnly);
2308
2623
  withTextContent(node, viewName, props);
2309
2624
  const cascaded = props['transform'];
2310
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
+ }
2311
2629
  nativePointerEvents(node, style, resolved);
2630
+ // Before an image's own size: `fit-content` is no size, so the image's is what it gets.
2631
+ fitContent(node, style);
2632
+ percentHeight(node, style);
2312
2633
  const intrinsic = node.props[INTRINSIC_SIZE];
2313
2634
  if (intrinsic)
2314
2635
  applyIntrinsicSize(style, intrinsic);
@@ -2322,8 +2643,53 @@ export class Engine {
2322
2643
  alignMultiline(viewName, style);
2323
2644
  const merged = composeTransform(node, this.animated(node, this.transitioned(node, style)));
2324
2645
  centreSingleLine(viewName, merged, this.fontScale);
2646
+ this.movePaint(node, merged);
2325
2647
  return merged;
2326
2648
  }
2649
+ /**
2650
+ * Leave a node's background out of what it commits when a child paints it, and give that child
2651
+ * its parent's: the cascade's, and over it the parent's inline style.
2652
+ */
2653
+ movePaint(node, merged) {
2654
+ if (node.paintsOn)
2655
+ for (const key of PAINT_KEYS)
2656
+ delete merged[key];
2657
+ const from = node.parent?.paintsOn === node ? node.parent : null;
2658
+ if (!from)
2659
+ return;
2660
+ const paint = flattenStyle(from.props['style'], {
2661
+ ...this.styles.resolve(from, this.styleEpoch).style,
2662
+ });
2663
+ for (const key of PAINT_KEYS)
2664
+ if (paint[key] !== undefined)
2665
+ merged[key] = paint[key];
2666
+ }
2667
+ /**
2668
+ * A container whose direction or alignment changed since a child with a `fit-content` size
2669
+ * read it, or whose height stopped or started being one a percentage is taken of since a child
2670
+ * with a percentage height asked: those children are merged again. A change to a container
2671
+ * marks nothing beneath it, since none of these is inherited.
2672
+ */
2673
+ refitChildren(node) {
2674
+ // Both are asked, and both remembered, before either has the children merged again.
2675
+ const basis = node.heightBasis;
2676
+ const based = basis !== undefined && definiteHeight(node) !== basis;
2677
+ const fitted = node.fitContainer !== undefined && containerOf(node) !== node.fitContainer;
2678
+ if (fitted)
2679
+ node.fitContainer = containerOf(node);
2680
+ if (based || fitted)
2681
+ this.refit(node);
2682
+ }
2683
+ /** Have a container's children merged again, through any `display: contents` among them. */
2684
+ refit(container) {
2685
+ for (const child of container.children) {
2686
+ if (child.kind !== 'element')
2687
+ continue;
2688
+ child.propsDirty = true;
2689
+ if (containerOf(child) === 'contents')
2690
+ this.refit(child);
2691
+ }
2692
+ }
2327
2693
  /**
2328
2694
  * The text size cap that lays a paragraph or text input out again once a face it names has
2329
2695
  * registered: a step per face above the app's own cap where it set one (below 1 is none, on
@@ -2397,6 +2763,8 @@ export class Engine {
2397
2763
  sheetReplaced(sheet, next) {
2398
2764
  if (sheet === next && this.knownSheets.has(sheet))
2399
2765
  return;
2766
+ this.unwatchActive(sheet);
2767
+ this.watchActive(next);
2400
2768
  const at = this.sheetOrder.indexOf(sheet);
2401
2769
  if (at === -1) {
2402
2770
  this.registerSheet(next);
@@ -2459,11 +2827,17 @@ export class Engine {
2459
2827
  }
2460
2828
  /** Mark every element under `node` committed with a family, so it is matched again. */
2461
2829
  markFontText(node) {
2830
+ const named = (text) => {
2831
+ if (typeof text?.committed?.props['fontFamily'] === 'string')
2832
+ this.markProps(text, false);
2833
+ };
2462
2834
  for (const child of node.children) {
2835
+ // The paragraph of a run of text written straight into a view is kept on the text.
2836
+ if (child.kind === 'text')
2837
+ named(child.box);
2463
2838
  if (child.kind !== 'element')
2464
2839
  continue;
2465
- if (typeof child.committed?.props['fontFamily'] === 'string')
2466
- this.markProps(child, false);
2840
+ named(child);
2467
2841
  this.markFontText(child);
2468
2842
  }
2469
2843
  }
@@ -2510,6 +2884,7 @@ export class Engine {
2510
2884
  handOverToScroll(node, spec) {
2511
2885
  if (!node.playing)
2512
2886
  return;
2887
+ this.stopNative(node, node.playing);
2513
2888
  if (node.playing.spec.name !== spec.name)
2514
2889
  this.cancelPlaying(node);
2515
2890
  node.playing = undefined;
@@ -2521,9 +2896,88 @@ export class Engine {
2521
2896
  */
2522
2897
  cancelPlaying(node) {
2523
2898
  const running = node.playing;
2899
+ if (running)
2900
+ this.stopNative(node, running);
2524
2901
  if (running && !running.done)
2525
2902
  this.emitTransition(node, 'topAnimationcancel', running.spec.name);
2526
2903
  }
2904
+ /** Stop on native each animation whose view has left the tree. */
2905
+ releasePlayedNatively() {
2906
+ for (const node of [...this.playedNatively]) {
2907
+ if (this.topOf(node) === this.root)
2908
+ continue;
2909
+ this.stopNative(node, node.playing);
2910
+ node.playing = undefined;
2911
+ this.markProps(node, false);
2912
+ }
2913
+ }
2914
+ /** Every node whose animation native is playing, with no frame of it in JavaScript. */
2915
+ playedNatively = new Set();
2916
+ /**
2917
+ * Hand an animation that has just started to native, where native can play it: one that moves
2918
+ * only opacity and transforms, starts at once, and repeats a whole number of times. Native then
2919
+ * runs it frame by frame, and JavaScript hears of it again when it ends. Anything else is
2920
+ * played from here, a commit a frame. See `NativeScrollDriver.play`.
2921
+ */
2922
+ playNatively(node, running, props) {
2923
+ const { spec } = running;
2924
+ if (!this.scrollDriver || spec.delay !== 0 || spec.duration <= 0)
2925
+ return false;
2926
+ const { channels, held, span } = clockChannels(running.tracks, spec, props);
2927
+ if (held.length || !(channels.opacity || channels.transform.length))
2928
+ return false;
2929
+ // There and back is one native animation: an odd number of ways has no whole number of them.
2930
+ const count = spec.iterations === null ? -1 : spec.iterations / span;
2931
+ if (count !== -1 && !(Number.isInteger(count) && count > 0))
2932
+ return false;
2933
+ const length = Math.max(2, Math.round((spec.duration * span) / NATIVE_FRAME) + 1);
2934
+ const frames = Array.from({ length }, (_, i) => i / (length - 1));
2935
+ const native = this.scrollDriver.play(node, channels, { frames, toValue: span, iterations: count }, (view) => this.tagOf(view), () => this.endedNatively(node, running));
2936
+ if (!native)
2937
+ return false;
2938
+ running.native = native;
2939
+ // As Animated does: Fabric flattens a view that only lays out, and then there is no native
2940
+ // view for the animation to move.
2941
+ running.values = { ...running.values, collapsable: false };
2942
+ this.playedNatively.add(node);
2943
+ return true;
2944
+ }
2945
+ /**
2946
+ * Native reached the end of an animation it was playing. The frame it ends at, or the resting
2947
+ * style where it does not fill, is committed before the view is let go, so nothing shows of
2948
+ * the frame it started at, which is what the view had been committed with.
2949
+ */
2950
+ endedNatively(node, running) {
2951
+ if (node.playing !== running || !running.native)
2952
+ return;
2953
+ const { native, spec } = running;
2954
+ running.native = undefined;
2955
+ this.playedNatively.delete(node);
2956
+ const end = running.start + spec.duration * (spec.iterations ?? 1);
2957
+ this.advancePlayer(node, Math.max(this.now(), end));
2958
+ this.commit();
2959
+ native.stop();
2960
+ }
2961
+ /** Take an animation back from native, which stops it there. The caller plays it on, or not. */
2962
+ stopNative(node, running) {
2963
+ if (!running.native)
2964
+ return;
2965
+ running.native.stop();
2966
+ running.native = undefined;
2967
+ this.playedNatively.delete(node);
2968
+ }
2969
+ /**
2970
+ * Play on from JavaScript an animation native was playing, from where its clock has got to:
2971
+ * what a pause, other frames or a view made again need, none of which native can be told of.
2972
+ */
2973
+ backToScript(node, running) {
2974
+ if (!running.native)
2975
+ return;
2976
+ this.stopNative(node, running);
2977
+ running.values = sample(running, this.now()).values;
2978
+ if (!running.done && running.pausedAt === undefined)
2979
+ this.playing.add(node);
2980
+ }
2527
2981
  /** Start the clock on an animation, unless the node is already playing this one. */
2528
2982
  startPlaying(node, spec, frames, props) {
2529
2983
  const current = node.playing;
@@ -2531,6 +2985,8 @@ export class Engine {
2531
2985
  if (!current || !sameAnimation(current.spec, spec)) {
2532
2986
  if (current && current.spec.name !== spec.name)
2533
2987
  this.cancelPlaying(node);
2988
+ else if (current)
2989
+ this.stopNative(node, current);
2534
2990
  const started = {
2535
2991
  spec,
2536
2992
  tracks: tracksOf(frames, props, inherited),
@@ -2551,12 +3007,16 @@ export class Engine {
2551
3007
  node.playing = started;
2552
3008
  if (spec.paused)
2553
3009
  started.pausedAt = this.now();
2554
- else
3010
+ else if (!this.playNatively(node, started, props))
2555
3011
  this.playing.add(node);
2556
3012
  this.emitTransition(node, 'topAnimationstart', spec.name);
2557
3013
  return;
2558
3014
  }
3015
+ // A view made again is another view: the one native was moving is gone.
3016
+ if (node.committed === null)
3017
+ this.backToScript(node, current);
2559
3018
  if (this.playedFrames.get(current) !== frames || inherited !== current.inherited) {
3019
+ this.backToScript(node, current);
2560
3020
  this.reframe(node, current, frames, props, inherited);
2561
3021
  }
2562
3022
  this.playOrPause(node, node.playing, spec);
@@ -2594,6 +3054,7 @@ export class Engine {
2594
3054
  current.spec = spec;
2595
3055
  const now = this.now();
2596
3056
  if (spec.paused && current.pausedAt === undefined && !current.done) {
3057
+ this.backToScript(node, current);
2597
3058
  current.pausedAt = now;
2598
3059
  this.playing.delete(node);
2599
3060
  }
@@ -2904,7 +3365,8 @@ export class Engine {
2904
3365
  *
2905
3366
  * A stop's colour is a colour like any other, but it is two levels down and nothing else looks
2906
3367
  * there: without this the device is handed the string `rgb(255, 0, 0)` where it wants a number,
2907
- * and paints nothing.
3368
+ * and paints nothing. The stops are first rewritten so native's interpolation paints what
3369
+ * CSS's premultiplied one does (see `premultipliedStops`).
2908
3370
  */
2909
3371
  processGradients(value) {
2910
3372
  if (!Array.isArray(value))
@@ -2915,7 +3377,7 @@ export class Engine {
2915
3377
  return entry;
2916
3378
  return {
2917
3379
  ...gradient,
2918
- colorStops: gradient.colorStops.map((stop) => ({
3380
+ colorStops: premultipliedStops(gradient.colorStops).map((stop) => ({
2919
3381
  ...stop,
2920
3382
  color: this.color(stop.color),
2921
3383
  })),
@@ -2937,7 +3399,9 @@ export class Engine {
2937
3399
  if (this.isClean(node) && !styleChanged)
2938
3400
  return node.committed.handle;
2939
3401
  node.styleCommitted = style;
2940
- const viewName = viewNameOf(node);
3402
+ repaint(node);
3403
+ this.refitChildren(node);
3404
+ const viewName = committedViewName(node);
2941
3405
  this.forgetRenamed(node, viewName);
2942
3406
  const childHandles = this.reconcileChildren(node, viewName, style);
2943
3407
  noteTouches(node, style);
@@ -3276,7 +3740,10 @@ export class Engine {
3276
3740
  * layout bug. On the first commit of any known element with no component behind it, say so.
3277
3741
  */
3278
3742
  checkClaimed(node) {
3279
- if (node.kind !== 'element' || node.claimed || !PRIMITIVE_NAMES.has(node.name))
3743
+ // The paragraph the engine wraps loose text in is its own: no template wrote it.
3744
+ if (node.kind !== 'element' || node.claimed || node.anonymous)
3745
+ return;
3746
+ if (!PRIMITIVE_NAMES.has(node.name))
3280
3747
  return;
3281
3748
  if (this.reported.has(node.name))
3282
3749
  return;