@ng-native/fabric 0.12.0 → 0.14.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, hasAbove, placeReadElsewhere, siblingReach, } from "./css.js";
10
+ import { boundDeclaration, boundRelativeLength, CSS_SPACE, isNegativeLineHeight, 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";
@@ -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, colourChannels, firstFrame, movesByShare, rangeOf, scrollChannels, sharesAsPoints, } from "./scroll-animation.js";
18
+ import { clockChannels, colourChannels, firstFrame, heldTransform, 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
  /**
@@ -854,6 +854,133 @@ function stillTouched(props) {
854
854
  props['opacity'] = TOUCHED_ALPHA;
855
855
  return props;
856
856
  }
857
+ /** Every prop `setOwned` can set, as a payload that changes them all. */
858
+ const RESET_OWNED = { opacity: undefined, transform: undefined };
859
+ /** As Animated does: a view native sets values on is kept, or there is no view to set them on. */
860
+ function keptForNative(node, props) {
861
+ if (node.owned && props['collapsable'] === undefined)
862
+ props['collapsable'] = false;
863
+ }
864
+ /**
865
+ * How a paragraph is truncated, which native does by its props: one the engine made around a
866
+ * view's own words is truncated as the view says, as a browser truncates a block's own text.
867
+ */
868
+ const TRUNCATION = ['numberOfLines', 'ellipsizeMode'];
869
+ /**
870
+ * The truncation each paragraph made around a view's words was merged with, as the view said it.
871
+ * Not what it was committed with, which has the paragraph's own defaults in it.
872
+ */
873
+ const truncatedBy = new WeakMap();
874
+ /** What a view says of how its own words are truncated. */
875
+ const truncationOf = (parent) => TRUNCATION.map((key) => ownLayout(parent, key) ?? null);
876
+ /** Whether a paragraph made around a view's words is truncated other than the view now says. */
877
+ function truncationMoved(box, parent) {
878
+ const merged = truncatedBy.get(box);
879
+ if (!merged)
880
+ return false;
881
+ return truncationOf(parent).some((said, at) => said !== merged[at]);
882
+ }
883
+ function truncatedAs(box, props) {
884
+ if (!box.anonymous || !box.parent)
885
+ return;
886
+ const said = truncationOf(box.parent);
887
+ truncatedBy.set(box, said);
888
+ TRUNCATION.forEach((key, at) => {
889
+ if (said[at] !== null)
890
+ props[key] = said[at];
891
+ });
892
+ }
893
+ /**
894
+ * Stretch the lines of a wrapping flex container that says nothing of `align-content`, as CSS
895
+ * starts them (`normal`, which is `stretch` in a flex container). Yoga starts them at
896
+ * `flex-start`, as tall as what is in them, where an item that stretches across its line has
897
+ * no size. A container that does not wrap has one line, which Yoga stretches already.
898
+ */
899
+ function wrappedLinesStretch(props) {
900
+ const wraps = props['flexWrap'] === 'wrap' || props['flexWrap'] === 'wrap-reverse';
901
+ if (wraps && props['alignContent'] == null)
902
+ props['alignContent'] = 'stretch';
903
+ }
904
+ /**
905
+ * A flex item that stretches across its line, with its size across the line `auto`, as CSS has
906
+ * it: as wide as a column, or as tall as a row, where Yoga takes the ratio over the stretch. In a
907
+ * column, a height given leaves the ratio nothing to work out. In a row, the line is as tall as
908
+ * the ratio makes the item from its width, at least, which is then the item's least height.
909
+ *
910
+ * ponytail: a width in points that does not grow, in a row, with any least or most height in
911
+ * points. One that grows, or is a share, keeps the ratio, as Yoga has it. A plain view only: an image keeps its picture's proportions.
912
+ */
913
+ function stretchedOverRatio(node, props) {
914
+ if (!isSet(props['aspectRatio']) || props['position'] === 'absolute')
915
+ return;
916
+ if (viewNameOf(node) !== DEFAULT_VIEW)
917
+ return;
918
+ const parent = layoutParent(node);
919
+ if (!parent)
920
+ return;
921
+ const container = containerOf(parent);
922
+ // Remembered on the container, so the item is merged again when its direction changes.
923
+ parent.fitContainer = container;
924
+ const [direction, align] = container.split(' ');
925
+ if (align !== 'stretch' || !stretchesAcross(props, direction === 'row'))
926
+ return;
927
+ if (direction === 'row')
928
+ ratioAsLeast(props);
929
+ else if (isSet(props['height']) && props['height'] !== 'auto')
930
+ delete props['aspectRatio'];
931
+ }
932
+ /** Whether an item stretches across its line: its own size and margins across it are `auto`. */
933
+ function stretchesAcross(props, row) {
934
+ if (!STRETCHES.has(props['alignSelf']))
935
+ return false;
936
+ const size = row ? 'height' : 'width';
937
+ if (isSet(props[size]) && props[size] !== 'auto')
938
+ return false;
939
+ const sides = row
940
+ ? MARGINS_ACROSS_ROW
941
+ : props['direction'] === 'rtl'
942
+ ? MARGINS_ACROSS_COLUMN_RTL
943
+ : MARGINS_ACROSS_COLUMN;
944
+ return sides.every((keys) => keys.map((key) => props[key]).find(isSet) !== 'auto');
945
+ }
946
+ /** The margins across a line, a side each, as Yoga reads them: the first set wins. */
947
+ const MARGINS_ACROSS_ROW = [
948
+ ['marginTop', 'marginVertical', 'margin'],
949
+ ['marginBottom', 'marginVertical', 'margin'],
950
+ ];
951
+ const MARGINS_ACROSS_COLUMN = [
952
+ ['marginStart', 'marginLeft', 'marginHorizontal', 'margin'],
953
+ ['marginEnd', 'marginRight', 'marginHorizontal', 'margin'],
954
+ ];
955
+ /** Right to left, where the start is the right side. */
956
+ const MARGINS_ACROSS_COLUMN_RTL = [
957
+ ['marginEnd', 'marginLeft', 'marginHorizontal', 'margin'],
958
+ ['marginStart', 'marginRight', 'marginHorizontal', 'margin'],
959
+ ];
960
+ /** In a row, the height a ratio gives a width in points, as the item's least height. */
961
+ function ratioAsLeast(props) {
962
+ const [width, ratio] = [props['width'], props['aspectRatio']];
963
+ if (typeof width !== 'number' || typeof ratio !== 'number' || ratio <= 0)
964
+ return;
965
+ const [least, most] = [props['minHeight'], props['maxHeight']];
966
+ const inPoints = (bound) => !isSet(bound) || typeof bound === 'number';
967
+ if (props['boxSizing'] === 'content-box' || !inPoints(least) || !inPoints(most))
968
+ return;
969
+ if (sizedAlong(props))
970
+ return;
971
+ props['minHeight'] = heldTo(width / ratio, least, most);
972
+ delete props['aspectRatio'];
973
+ }
974
+ /** Whether an item's width is not what it is along its line: it has a basis, or it grows. */
975
+ function sizedAlong(props) {
976
+ const basis = props['flexBasis'];
977
+ return (isSet(basis) && basis !== 'auto') || Number(props['flexGrow'] ?? props['flex'] ?? 0) > 0;
978
+ }
979
+ /** A height held to the least and most given, which CSS keeps: the least wins over the most. */
980
+ function heldTo(height, least, most) {
981
+ const under = typeof most === 'number' ? Math.min(height, most) : height;
982
+ return typeof least === 'number' ? Math.max(under, least) : under;
983
+ }
857
984
  /**
858
985
  * Leave `aspect-ratio` out for a box with both a width and a height. CSS takes the ratio only
859
986
  * for a size that is `auto`; Yoga takes it over one of two that are given, the height in a
@@ -879,6 +1006,20 @@ function colourless(props, side, flipped) {
879
1006
  * Each side a border has: its width, its colour, and the logical colour React Native takes for
880
1007
  * it too, read left to right and, `flipped`, right to left.
881
1008
  */
1009
+ const BORDER_DRAWN = /^border\w*(Width|Style)$/;
1010
+ const BORDER_COLOUR = /^border\w*Color$/;
1011
+ /**
1012
+ * Send a border's colours again with an update that gives it a width or a style: Android draws
1013
+ * the border black when that update does not carry its colour, though it was given one before.
1014
+ * A box made without a border and given one later, a file attachment that is done, has one.
1015
+ */
1016
+ function borderColoursAgain(payload, props) {
1017
+ if (!Object.keys(payload).some((key) => BORDER_DRAWN.test(key)))
1018
+ return;
1019
+ for (const key in props)
1020
+ if (BORDER_COLOUR.test(key) && !(key in payload))
1021
+ payload[key] = props[key];
1022
+ }
882
1023
  const BORDER_SIDES = [
883
1024
  ['Top', 'BlockStart', 'BlockStart'],
884
1025
  ['Right', 'End', 'Start'],
@@ -970,14 +1111,63 @@ function lineTall(props, run, fontScale) {
970
1111
  }
971
1112
  /** The points of padding and border above and below a view's content, where each is a length. */
972
1113
  function aroundALine(props) {
973
- const edge = (...keys) => {
1114
+ return around(props, 'Height');
1115
+ }
1116
+ /**
1117
+ * The props each side's padding and border are read from, across a height and across a width,
1118
+ * the first set winning, as React Native and then Yoga read them: an alias of React Native's over
1119
+ * Yoga's own, a logical side over the physical one, and the start on the left unless the box is
1120
+ * right to left. A block's start and end are under the top and bottom.
1121
+ */
1122
+ function sides(props, size) {
1123
+ if (size === 'Height') {
1124
+ const block = ['paddingBlock', 'paddingVertical', 'padding'];
1125
+ return [
1126
+ ['paddingTop', 'paddingBlockStart', ...block],
1127
+ ['borderTopWidth', 'borderWidth'],
1128
+ ['paddingBottom', 'paddingBlockEnd', ...block],
1129
+ ['borderBottomWidth', 'borderWidth'],
1130
+ ];
1131
+ }
1132
+ const rtl = props['direction'] === 'rtl';
1133
+ const inline = ['paddingInline', 'paddingHorizontal', 'padding'];
1134
+ return ['Left', 'Right'].flatMap((physical, at) => {
1135
+ const logical = (at === 0) !== rtl ? 'Start' : 'End';
1136
+ return [
1137
+ [`paddingInline${logical}`, `padding${logical}`, `padding${physical}`, ...inline],
1138
+ [`border${logical}Width`, `border${physical}Width`, 'borderWidth'],
1139
+ ];
1140
+ });
1141
+ }
1142
+ /** The points of padding and border across a view's height or width, where each is a length. */
1143
+ function around(props, size) {
1144
+ let total = 0;
1145
+ for (const keys of sides(props, size)) {
974
1146
  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'));
1147
+ if (typeof found === 'number')
1148
+ total += found;
1149
+ }
1150
+ return total;
1151
+ }
1152
+ /** The most and the least of each size, by name: asked of every node merged. */
1153
+ const MOST = { Height: 'maxHeight', Width: 'maxWidth' };
1154
+ const LEAST = { Height: 'minHeight', Width: 'minWidth' };
1155
+ /**
1156
+ * A most size under the least: under a `min-height`, or a border box's padding and border, which
1157
+ * CSS lets win (CSS 2.2, 10.7). React Native's Yoga holds the box to its most, and the box around
1158
+ * it to that, so the most is raised to the least. A most and a least in points: a share on either
1159
+ * side is left to Yoga.
1160
+ */
1161
+ function leastWins(props, size) {
1162
+ const most = props[MOST[size]];
1163
+ if (typeof most !== 'number')
1164
+ return;
1165
+ const written = props[LEAST[size]];
1166
+ let least = typeof written === 'number' ? written : 0;
1167
+ if (props['boxSizing'] !== 'content-box')
1168
+ least = Math.max(least, around(props, size));
1169
+ if (most < least)
1170
+ props[MOST[size]] = least;
981
1171
  }
982
1172
  /**
983
1173
  * Where a line may break: at a space, after a hyphen, and anywhere in a script written without
@@ -1096,8 +1286,18 @@ export function registerViewName(elementName, viewName, defaultProps, options) {
1096
1286
  LOOSE_TEXT[name] = options.looseText;
1097
1287
  else
1098
1288
  delete LOOSE_TEXT[name];
1289
+ if (options?.fontNames)
1290
+ FONT_NAMES[name] = options.fontNames;
1291
+ else
1292
+ delete FONT_NAMES[name];
1099
1293
  }
1100
1294
  }
1295
+ /** How each view that names fonts in its own props rewrites them. See `ViewNameOptions`. */
1296
+ const FONT_NAMES = {};
1297
+ /** Rewrites the fonts a view's own props name, for a view that says how. See `ViewNameOptions`. */
1298
+ function renameFonts(viewName, props, postScriptName) {
1299
+ FONT_NAMES[viewName]?.(props, postScriptName);
1300
+ }
1101
1301
  /** The element loose text in a view is drawn by, for the views that say. See `ViewNameOptions`. */
1102
1302
  const LOOSE_TEXT = {};
1103
1303
  /** The registered names that give way to a component's own host. See `ViewNameOptions`. */
@@ -1245,6 +1445,10 @@ const SCROLL_VIEWS = new Set(['ScrollView', 'AndroidHorizontalScrollView']);
1245
1445
  const HOISTS = {};
1246
1446
  /** The `ancestorName`s, so a commit asks only those nodes what to hoist into them. */
1247
1447
  const HOIST_TARGETS = new Set();
1448
+ /** The `ancestorName`s that commit only what is hoisted into them. See `HoistOptions.only`. */
1449
+ const HOIST_ONLY = new Set();
1450
+ /** Element name -> the ancestor names it never hoists past. See `HoistOptions.stopAt`. */
1451
+ const HOIST_STOPS = {};
1248
1452
  /**
1249
1453
  * Commit every `elementName` as a direct child of its nearest `ancestorName`, wherever the
1250
1454
  * template put it, e.g. `registerHoist('native-header', 'screen')`.
@@ -1263,6 +1467,12 @@ export function registerHoist(elementName, ancestorName, options = {}) {
1263
1467
  HOIST_TARGETS.add(ancestorName);
1264
1468
  if (options.standIn)
1265
1469
  STAND_INS[elementName] = options.standIn;
1470
+ if (options.only)
1471
+ HOIST_ONLY.add(ancestorName);
1472
+ if (options.stopAt)
1473
+ HOIST_STOPS[elementName] = new Set(options.stopAt);
1474
+ else
1475
+ delete HOIST_STOPS[elementName];
1266
1476
  }
1267
1477
  /** Element name -> the props it leaves behind in its ancestor. See `HoistOptions.standIn`. */
1268
1478
  const STAND_INS = {};
@@ -1295,10 +1505,12 @@ function paragraphText(node) {
1295
1505
  // Text written straight into a view is a paragraph of its own, and all of it.
1296
1506
  if (root === null || !isTextElement(root))
1297
1507
  return node.box ? trimEnds(node.text) : node.text;
1508
+ // A text element a view stands in for, which places what it holds: no paragraph runs past it.
1509
+ const inParagraph = (above) => isTextElement(above) && !aligningText(above);
1298
1510
  // The commonest paragraph by far: one run and nothing else, so it is both the first and last.
1299
- if (root.children.length === 1 && !isTextElement(root.parent))
1511
+ if (root.children.length === 1 && !inParagraph(root.parent))
1300
1512
  return trimEnds(node.text);
1301
- while (isTextElement(root.parent))
1513
+ while (inParagraph(root.parent))
1302
1514
  root = root.parent;
1303
1515
  const runs = [];
1304
1516
  const collect = (from) => {
@@ -1326,12 +1538,12 @@ function paragraphText(node) {
1326
1538
  function isTextElement(node) {
1327
1539
  if (node === null || node.kind !== 'element')
1328
1540
  return false;
1329
- if (node.name === 'text')
1541
+ if (node.tag === 'text')
1330
1542
  return true;
1331
1543
  // One lookup settles nearly every other element, which is a view.
1332
- if (registeredViewName(node.name) !== PARAGRAPH)
1544
+ if (registeredViewName(node.tag) !== PARAGRAPH)
1333
1545
  return false;
1334
- return !TEXT_ELEMENTS.has(node.name) || holdsOnlyText(node);
1546
+ return !TEXT_ELEMENTS.has(node.tag) || holdsOnlyText(node);
1335
1547
  }
1336
1548
  /** Whether a text element holds text and nothing else. One that holds nothing holds no text. */
1337
1549
  function holdsOnlyText(node) {
@@ -1351,25 +1563,48 @@ const ALIGNS = new Set(['center', 'flex-end', 'space-around', 'space-evenly']);
1351
1563
  * alignment moves, so such an element commits as a view, and its text as the paragraph any view's
1352
1564
  * loose text is. Read from the node's own style on each reconcile, which a change to it brings
1353
1565
  * about, so nothing is kept to go stale. The run can be a text element of its own, a label in a
1354
- * span, which is then the item placed. A `<text>` is a paragraph whatever its style, and an
1355
- * element holding more than the one run is left as it was.
1566
+ * span, which is then the item placed. So is a column of text elements, each an item under the
1567
+ * last. A `<text>` is a paragraph whatever its style, and any other element holding more than
1568
+ * the one run is left as it was.
1356
1569
  */
1357
1570
  function aligningText(node) {
1358
- if (node.kind !== 'element' || node.name === 'text' || !TEXT_ELEMENTS.has(node.name)) {
1571
+ if (node.kind !== 'element' || node.tag === 'text' || !TEXT_ELEMENTS.has(node.tag)) {
1359
1572
  return false;
1360
1573
  }
1361
- if (node.children.length !== 1 || !oneRun(node.children[0]))
1574
+ const stacked = stacksRuns(node);
1575
+ if (!stacked && (node.children.length !== 1 || !oneRun(node.children[0])))
1362
1576
  return false;
1363
1577
  // Under a text element it is a span of that paragraph, unless that one is a view placing it.
1364
1578
  if (isTextElement(node.parent) && !aligningText(node.parent))
1365
1579
  return false;
1580
+ return placesByFlex(node, stacked);
1581
+ }
1582
+ /** Whether a flex container's own style places the run it holds, or stacks the runs it holds. */
1583
+ function placesByFlex(node, stacked) {
1366
1584
  if (ownLayout(node, 'display') !== 'flex')
1367
1585
  return false;
1586
+ // A column of them: each a line of its own, where one paragraph would run them on.
1587
+ if (stacked)
1588
+ return String(ownLayout(node, 'flexDirection') ?? 'column').startsWith('column');
1368
1589
  return (ALIGNS.has(ownLayout(node, 'alignItems')) ||
1369
1590
  ALIGNS.has(ownLayout(node, 'justifyContent')));
1370
1591
  }
1592
+ /**
1593
+ * Whether a text element holds two or more text elements and nothing else but whitespace and
1594
+ * the anchors control flow leaves: a label around a name and a hint, which a flex column has one under the other.
1595
+ */
1596
+ function stacksRuns(node) {
1597
+ let runs = 0;
1598
+ for (const child of node.children) {
1599
+ if (child.kind === 'element' && TEXT_ELEMENTS.has(child.tag))
1600
+ runs++;
1601
+ else if (child.kind === 'text' ? trimEnds(child.text) : child.kind !== 'anchor')
1602
+ return false;
1603
+ }
1604
+ return runs > 1;
1605
+ }
1371
1606
  /** One run of text to place: text itself, or a text element around some, a label in a span. */
1372
- const oneRun = (child) => child.kind === 'text' || (child.kind === 'element' && TEXT_ELEMENTS.has(child.name));
1607
+ const oneRun = (child) => child.kind === 'text' || (child.kind === 'element' && TEXT_ELEMENTS.has(child.tag));
1373
1608
  /** The view a node is committed as: `viewNameOf`, but for a text element that aligns its text. */
1374
1609
  const committedViewName = (node) => aligningText(node) ? DEFAULT_VIEW : viewNameOf(node);
1375
1610
  /**
@@ -1388,14 +1623,14 @@ export function viewNameOf(node) {
1388
1623
  // Committing a nested `<text>` as another Paragraph gives a paragraph inside a paragraph,
1389
1624
  // which lays out as a separate block instead of flowing inline.
1390
1625
  // A component of the app's own under a name a view set registered: its host is a plain view.
1391
- if (node.componentHost && !node.nativeView && YIELDING.has(node.name))
1626
+ if (node.componentHost && !node.nativeView && YIELDING.has(node.tag))
1392
1627
  return DEFAULT_VIEW;
1393
- const registered = registeredViewName(node.name);
1628
+ const registered = registeredViewName(node.tag);
1394
1629
  // Nearly every element: a view of some kind, by its name alone.
1395
1630
  if (registered !== PARAGRAPH)
1396
1631
  return registered ?? DEFAULT_VIEW;
1397
1632
  // One of HTML's text elements that holds something other than text: a view.
1398
- if (node.name !== 'text' && TEXT_ELEMENTS.has(node.name) && !holdsOnlyText(node)) {
1633
+ if (node.tag !== 'text' && TEXT_ELEMENTS.has(node.tag) && !holdsOnlyText(node)) {
1399
1634
  return DEFAULT_VIEW;
1400
1635
  }
1401
1636
  // The paragraph the engine makes for a view's loose text is under a view, whatever the name
@@ -1718,6 +1953,12 @@ function reportHover(host) {
1718
1953
  hoverReported = true;
1719
1954
  console.warn('[angular-native] @defer (on hover) fires when a pointer enters the trigger, which takes a trackpad or a mouse (an iPad, or an Android device with one), or when the trigger takes focus. On a phone it waits for focus alone; add another trigger, such as on interaction or on viewport.');
1720
1955
  }
1956
+ /** A bound value kept as a declaration the cascade settles: a `var()` or a relative length. */
1957
+ function boundFrom(prop, text, on) {
1958
+ return /^\s*var\(/i.test(text)
1959
+ ? boundDeclaration(prop, tokenFromValue(text), on)
1960
+ : boundRelativeLength(prop, text, on);
1961
+ }
1721
1962
  /** Whether a value is one of the engine's own nodes. */
1722
1963
  export function isEngineNode(value) {
1723
1964
  return value instanceof RetainedNode;
@@ -1777,17 +2018,18 @@ class RetainedNode {
1777
2018
  transitions;
1778
2019
  playing;
1779
2020
  scrolled;
2021
+ owned;
1780
2022
  kind;
1781
- name;
2023
+ tag;
1782
2024
  /** See `HostNode.host`. */
1783
2025
  host;
1784
- constructor(kind, name, host) {
2026
+ constructor(kind, tag, host) {
1785
2027
  this.kind = kind;
1786
- this.name = name;
2028
+ this.tag = tag;
1787
2029
  this.host = host;
1788
2030
  }
1789
2031
  get tagName() {
1790
- return this.name;
2032
+ return this.tag;
1791
2033
  }
1792
2034
  /** Angular's dev-mode assertion compares this against `Node.ELEMENT_NODE`. */
1793
2035
  get nodeType() {
@@ -2214,7 +2456,7 @@ export class Engine {
2214
2456
  this.processColor = options.processColor ?? ((value) => value);
2215
2457
  this.resolveAssetSource = options.resolveAssetSource ?? ((value) => value);
2216
2458
  this.scrollDriver = options.nativeAnimated
2217
- ? new NativeScrollDriver(options.nativeAnimated)
2459
+ ? new NativeScrollDriver(options.nativeAnimated, (view, tag, keys) => this.touchedNatively(view, tag, keys))
2218
2460
  : null;
2219
2461
  // Eagerly, not on the first listener. Fabric holds exactly one event handler and
2220
2462
  // `ReactFabric` installs React's at module scope, so until we claim it every native event
@@ -2262,15 +2504,29 @@ export class Engine {
2262
2504
  */
2263
2505
  setActiveChain(from, active) {
2264
2506
  let changed = false;
2265
- for (let node = from; node; node = node.parent) {
2507
+ const set = (node) => {
2266
2508
  if (node.active === active)
2267
- continue;
2509
+ return;
2268
2510
  node.active = active;
2269
2511
  this.markActive(node);
2270
2512
  changed = true;
2513
+ };
2514
+ for (let node = from; node; node = node.parent) {
2515
+ set(node);
2516
+ if (active)
2517
+ this.activeNodes.add(node);
2518
+ }
2519
+ if (!active) {
2520
+ // Every node the press set, too: one the pressed node was moved or removed from mid-press
2521
+ // is no longer above it.
2522
+ for (const node of this.activeNodes)
2523
+ set(node);
2524
+ this.activeNodes.clear();
2271
2525
  }
2272
2526
  return changed;
2273
2527
  }
2528
+ /** The nodes a press has set `:active` on, to clear whatever happens to the tree meanwhile. */
2529
+ activeNodes = new Set();
2274
2530
  /** Whether any sheet styles a pressed element, and each compound that asks about one from another. */
2275
2531
  activeUse = {
2276
2532
  own: false,
@@ -2649,8 +2905,38 @@ export class Engine {
2649
2905
  return;
2650
2906
  if (reach.own)
2651
2907
  node.ownDirty = true;
2652
- if (this.styles.reachesUnder(reach))
2908
+ if (this.styles.reachesUnder(reach)) {
2909
+ this.classesAbove += 1;
2653
2910
  this.markUnder(node, reach);
2911
+ }
2912
+ }
2913
+ /**
2914
+ * How many class changes have restyled what is under an element. `markUnder` walks the tree as
2915
+ * it is, so an element out of it then is not reached: one that comes back after any is matched
2916
+ * again, with all that is under it.
2917
+ */
2918
+ classesAbove = 0;
2919
+ /**
2920
+ * The count when each element went out of the tree, for the ones still out. `detach` sets it
2921
+ * for a node that only moves too, so `markReturned` runs after it and clears that.
2922
+ */
2923
+ outSince = new WeakMap();
2924
+ /** Have matched again a node coming back into the tree that missed a class change above it. */
2925
+ markReturned(child) {
2926
+ const since = this.outSince.get(child);
2927
+ if (since === undefined)
2928
+ return;
2929
+ this.outSince.delete(child);
2930
+ if (since !== this.classesAbove)
2931
+ this.markAllUnder(child);
2932
+ }
2933
+ markAllUnder(node) {
2934
+ if (node.kind !== 'element')
2935
+ return;
2936
+ node.ownDirty = true;
2937
+ this.markPath(node);
2938
+ for (const child of node.children)
2939
+ this.markAllUnder(child);
2654
2940
  }
2655
2941
  /**
2656
2942
  * Have matched again each element under a node that one of `subjects`' rules could be for. One
@@ -2687,6 +2973,7 @@ export class Engine {
2687
2973
  * the web's mount point has, unless the app's `:host` sizes it otherwise.
2688
2974
  */
2689
2975
  setDefaultStyle(node, style) {
2976
+ this.noteEasing(style);
2690
2977
  node.defaultStyle = style;
2691
2978
  // Under what the cascade comes to, and read by no selector: nothing is matched again.
2692
2979
  this.markProps(node, false);
@@ -2696,7 +2983,8 @@ export class Engine {
2696
2983
  * another order are a change too, to a selector that reads the class attribute, `[class^="a"]`.
2697
2984
  */
2698
2985
  setClasses(node, value) {
2699
- const names = value.split(/\s+/).filter(Boolean);
2986
+ // By HTML's spaces, which are CSS's: a vertical tab or a no-break space is part of a name.
2987
+ const names = value.split(CSS_SPACE).filter(Boolean);
2700
2988
  const before = node.classes;
2701
2989
  node.classes = names.length ? new Set(names) : null;
2702
2990
  // Each class that came or went: one in both changes nothing.
@@ -2719,6 +3007,7 @@ export class Engine {
2719
3007
  appendChild(parent, child) {
2720
3008
  this.markAdopted(child, parent);
2721
3009
  this.detach(child);
3010
+ this.markReturned(child);
2722
3011
  child.parent = parent;
2723
3012
  parent.children.push(child);
2724
3013
  if (child.dormantHoists)
@@ -2729,6 +3018,7 @@ export class Engine {
2729
3018
  insertBefore(parent, child, ref) {
2730
3019
  this.markAdopted(child, parent);
2731
3020
  this.detach(child);
3021
+ this.markReturned(child);
2732
3022
  child.parent = parent;
2733
3023
  const at = ref ? parent.children.indexOf(ref) : -1;
2734
3024
  if (at < 0)
@@ -2759,6 +3049,7 @@ export class Engine {
2759
3049
  return;
2760
3050
  target.children.splice(at, 1);
2761
3051
  child.parent = null;
3052
+ this.outSince.set(child, this.classesAbove);
2762
3053
  this.removedSinceCommit.add(child);
2763
3054
  this.markStructure(target, child, at);
2764
3055
  this.markTextContent(target, child);
@@ -2846,7 +3137,26 @@ export class Engine {
2846
3137
  }
2847
3138
  }
2848
3139
  if (this.focusedNode && this.topOf(this.focusedNode) !== this.root)
2849
- this.focusedNode = null;
3140
+ this.dropFocus();
3141
+ this.dropDetachedPress();
3142
+ }
3143
+ /** A pressed node out of the tree gets no release: native has no view to end the touch on. */
3144
+ dropDetachedPress() {
3145
+ const pressed = this.currentResponder;
3146
+ if (!pressed || this.topOf(pressed) === this.root)
3147
+ return;
3148
+ this.setActiveChain(pressed, false);
3149
+ this.currentResponder = null;
3150
+ }
3151
+ /**
3152
+ * Forget the focused node, which has left the tree without a blur: off the node as well as the
3153
+ * engine, or a `:focus` rule styles it again when it is put back.
3154
+ */
3155
+ dropFocus() {
3156
+ const node = this.focusedNode;
3157
+ node.focused = false;
3158
+ this.markProps(node);
3159
+ this.focusedNode = null;
2850
3160
  }
2851
3161
  /** Forget the committed views of each node removed since the last commit that is still out. */
2852
3162
  forgetDetached() {
@@ -2893,7 +3203,7 @@ export class Engine {
2893
3203
  node.kept = undefined;
2894
3204
  // A field popped with the keyboard up never sends its blur.
2895
3205
  if (this.focusedNode === node)
2896
- this.focusedNode = null;
3206
+ this.dropFocus();
2897
3207
  }
2898
3208
  parentNode(node) {
2899
3209
  return node.parent;
@@ -2904,7 +3214,45 @@ export class Engine {
2904
3214
  return null;
2905
3215
  return siblings[siblings.indexOf(node) + 1] ?? null;
2906
3216
  }
3217
+ /**
3218
+ * Whether any declaration the engine was given transitions or animates: until one does, a
3219
+ * node's style is committed as it is, with nothing asked of what eases it. See `eased`.
3220
+ */
3221
+ eases = false;
3222
+ noteEasing(declarations) {
3223
+ if (this.eases || !declarations)
3224
+ return;
3225
+ for (const key in declarations) {
3226
+ if (key.startsWith('$transition') || key.startsWith('$animation'))
3227
+ this.eases = true;
3228
+ }
3229
+ }
3230
+ /** A node's style as its transitions and animation have it: a declaration asks for one. */
3231
+ eased(node, style) {
3232
+ if (!this.eases)
3233
+ return style;
3234
+ return this.animated(node, this.transitioned(node, style));
3235
+ }
3236
+ /** Note a sheet any of whose rules transitions or animates: see `eases`. */
3237
+ noteSheetEasing(sheet) {
3238
+ for (const rule of this.eases ? [] : sheet.rules) {
3239
+ this.noteEasing(rule.declarations);
3240
+ this.noteEasing(rule.important);
3241
+ // One a token holds: `animation: var(--motion)`.
3242
+ for (const deferred of rule.deferred ?? []) {
3243
+ this.noteEasing(Object.fromEntries(deferred.props.map((key) => [key, true])));
3244
+ }
3245
+ }
3246
+ }
3247
+ /** Note a prop, or a style set as one, that transitions or animates: see `eases`. */
3248
+ noteEasingProp(key, value) {
3249
+ this.noteEasing({ [key]: value });
3250
+ if (key === 'style' || key === STYLE_OVERRIDE)
3251
+ this.noteEasing(flattenStyle(value, {}));
3252
+ }
2907
3253
  setProp(node, key, value) {
3254
+ if (!this.eases)
3255
+ this.noteEasingProp(key, value);
2908
3256
  // A prop written again is a binding's, and is committed as any other is.
2909
3257
  if (node.attributeOnly?.delete(key))
2910
3258
  this.markProps(node, false);
@@ -2998,19 +3346,26 @@ export class Engine {
2998
3346
  * has changed for the props diff to notice.
2999
3347
  */
3000
3348
  styleChanged(node) {
3001
- this.markProps(node, this.inlineReachesStyle(node));
3349
+ if (!this.eases)
3350
+ this.noteEasingProp('style', node.props['style']);
3351
+ // As `setProp` restyles for a new style object: a child that takes one of its values, an
3352
+ // `inherit`, follows the one changed in place too.
3353
+ this.markProps(node, this.inlineReachesStyle(node) || node.styleCache?.heirs === true);
3002
3354
  this.markParagraph(node);
3003
3355
  }
3004
3356
  /**
3005
- * A bound declaration, `[style.color]`, whose value may be a `var()`. One that is, is kept as a
3006
- * declaration the cascade settles with the node's tokens, as a rule's is, and true is answered:
3007
- * the text is no value to put in the node's style. Any other value forgets what was kept.
3357
+ * A bound declaration, `[style.color]`, whose value may be a `var()`, or a length in `em` or a
3358
+ * viewport unit. One that is, is kept as a declaration the cascade settles with the node's
3359
+ * tokens, font size and viewport, as a rule's is, and true is answered: the text is no value to
3360
+ * put in the node's style. A negative `line-height` is dropped, with a warning, and true is
3361
+ * answered too. Any other value forgets what was kept.
3008
3362
  */
3009
3363
  setBoundStyle(node, prop, value) {
3010
- const text = typeof value === 'string' && /^\s*var\(/i.test(value) ? value : undefined;
3011
- const bound = text === undefined ? null : boundDeclaration(prop, tokenFromValue(text), node.name);
3364
+ const text = typeof value === 'string' ? value : undefined;
3365
+ const refused = text !== undefined && this.refusedBound(node, prop, text);
3366
+ const bound = text === undefined || refused ? null : boundFrom(prop, text, node.tag);
3012
3367
  if (!bound && !node.boundByProp?.has(prop))
3013
- return false;
3368
+ return refused;
3014
3369
  const byProp = (node.boundByProp ??= new Map());
3015
3370
  if (bound)
3016
3371
  byProp.set(prop, bound);
@@ -3018,7 +3373,14 @@ export class Engine {
3018
3373
  byProp.delete(prop);
3019
3374
  node.boundStyle = byProp.size ? [...byProp.values()] : null;
3020
3375
  this.markProps(node);
3021
- return bound !== null;
3376
+ return bound !== null || refused;
3377
+ }
3378
+ /** Whether a bound value is one CSS has none of, which is dropped with a warning. */
3379
+ refusedBound(node, prop, text) {
3380
+ if (!isNegativeLineHeight(prop, text))
3381
+ return false;
3382
+ this.reportDroppedDeclaration('line-height', text, node.tag, 'it is negative');
3383
+ return true;
3022
3384
  }
3023
3385
  /**
3024
3386
  * Set a custom property on a node, `--tint` from `[style.--tint]`, or remove it with `null`.
@@ -3340,8 +3702,17 @@ export class Engine {
3340
3702
  this.settleKeyframes();
3341
3703
  if (facesAdded)
3342
3704
  this.rematchFonts();
3705
+ this.blurHidden();
3343
3706
  return true;
3344
3707
  }
3708
+ /** A field went out of view focused, in the commit running: see `undisplayed`. */
3709
+ blurAfterCommit = false;
3710
+ blurHidden() {
3711
+ if (!this.blurAfterCommit)
3712
+ return;
3713
+ this.blurAfterCommit = false;
3714
+ this.setFocused(null);
3715
+ }
3345
3716
  beforeCommit = null;
3346
3717
  announceCommit() {
3347
3718
  const run = this.beforeCommit;
@@ -3531,6 +3902,8 @@ export class Engine {
3531
3902
  const resolved = props['pointerEvents'];
3532
3903
  writeOwnProps(node.props, props, node.attributeOnly);
3533
3904
  withTextContent(node, viewName, props);
3905
+ renameFonts(viewName, props, this.postScriptName);
3906
+ truncatedAs(node, props);
3534
3907
  const cascaded = props['transform'];
3535
3908
  const inline = inlineOf(node);
3536
3909
  const style = boundTransform(node, Object.assign(props, inline), cascaded);
@@ -3556,7 +3929,7 @@ export class Engine {
3556
3929
  this.capForFonts(node, style);
3557
3930
  alignMultiline(viewName, style);
3558
3931
  rowsTall(style, this.fontScale);
3559
- const merged = composeTransform(node, this.animated(node, this.transitioned(node, style)));
3932
+ const merged = composeTransform(node, this.eased(node, style));
3560
3933
  // On iOS: Android's field says the baseline of the line it is sized and centred by.
3561
3934
  // Still under the row that marked it: a row taken away is not committed again to unmark it.
3562
3935
  const row = node.onBaseline;
@@ -3566,6 +3939,10 @@ export class Engine {
3566
3939
  basisAsSize(node, merged);
3567
3940
  // After the basis, which is a size given where it is committed as one.
3568
3941
  ratioForAutoSize(merged);
3942
+ wrappedLinesStretch(merged);
3943
+ stretchedOverRatio(node, merged);
3944
+ leastWins(merged, 'Height');
3945
+ leastWins(merged, 'Width');
3569
3946
  noOutline(merged);
3570
3947
  this.borderInText(node, merged);
3571
3948
  // Last, on what is committed: an override or an animation can hide a box, or place it.
@@ -3577,6 +3954,7 @@ export class Engine {
3577
3954
  this.movePaint(node, merged);
3578
3955
  if (node.raised !== undefined)
3579
3956
  merged['zIndex'] ??= node.raised;
3957
+ keptForNative(node, merged);
3580
3958
  return merged;
3581
3959
  }
3582
3960
  /**
@@ -3770,6 +4148,7 @@ export class Engine {
3770
4148
  if (!sheet || this.knownSheets.has(sheet))
3771
4149
  return;
3772
4150
  this.knownSheets.add(sheet);
4151
+ this.noteSheetEasing(sheet);
3773
4152
  this.styles.noteSheet(sheet);
3774
4153
  this.sheetOrder.push(sheet);
3775
4154
  for (const name of Object.keys(sheet.keyframes ?? {})) {
@@ -3800,6 +4179,7 @@ export class Engine {
3800
4179
  this.knownSheets.delete(sheet);
3801
4180
  if (next && !this.knownSheets.has(next)) {
3802
4181
  this.knownSheets.add(next);
4182
+ this.noteSheetEasing(next);
3803
4183
  this.styles.noteSheet(next);
3804
4184
  this.sheetOrder[at] = next;
3805
4185
  }
@@ -3842,6 +4222,7 @@ export class Engine {
3842
4222
  }
3843
4223
  /** Every `@font-face` seen, global as on the web: a face declared in one sheet serves all. */
3844
4224
  fontFaces = new FontFaces();
4225
+ postScriptName = (name) => this.fontFaces.postScriptName(name);
3845
4226
  /** Whether a sheet first used in this commit declared a face. See `rematchFonts`. */
3846
4227
  facesAdded = false;
3847
4228
  /**
@@ -3865,7 +4246,10 @@ export class Engine {
3865
4246
  named(child.box);
3866
4247
  if (child.kind !== 'element')
3867
4248
  continue;
3868
- named(child);
4249
+ if (child.committed && FONT_NAMES[child.committed.viewName])
4250
+ this.markProps(child, false);
4251
+ else
4252
+ named(child);
3869
4253
  this.markFontText(child);
3870
4254
  }
3871
4255
  }
@@ -4081,8 +4465,9 @@ export class Engine {
4081
4465
  }
4082
4466
  /**
4083
4467
  * Native reached the end of an animation it was playing. The frame it ends at, or the resting
4084
- * style where it does not fill, is committed before the view is let go, so nothing shows of
4085
- * the frame it started at, which is what the view had been committed with.
4468
+ * style where it does not fill, is committed, and set on native where the animation set the
4469
+ * prop (see `setOwned`). The view is let go first, which puts nothing back on Fabric: it is
4470
+ * drawn where the animation left it until then.
4086
4471
  */
4087
4472
  endedNatively(node, running) {
4088
4473
  if (node.playing !== running || !running.native)
@@ -4091,9 +4476,62 @@ export class Engine {
4091
4476
  running.native = undefined;
4092
4477
  this.playedNatively.delete(node);
4093
4478
  const end = running.start + spec.duration * (spec.iterations ?? 1);
4479
+ native.stop();
4094
4480
  this.advancePlayer(node, Math.max(this.now(), end));
4095
4481
  this.commit();
4096
- native.stop();
4482
+ // The commit sets only what it changed, and the style the view rests at can be the frame it
4483
+ // was committed with as it played: native has drawn it elsewhere since.
4484
+ const committed = node.committed;
4485
+ if (node.owned && committed)
4486
+ this.setOwned(node, RESET_OWNED, committed.props);
4487
+ }
4488
+ /** A graph of native's was connected to a view: see `Touched`. */
4489
+ touchedNatively(node, tag, keys) {
4490
+ const taken = keys.filter((key) => key === 'opacity' || key === 'transform');
4491
+ if (!taken.length)
4492
+ return;
4493
+ if (node.owned?.tag !== tag)
4494
+ node.owned = { tag, keys: new Set() };
4495
+ for (const key of taken)
4496
+ node.owned.keys.add(key);
4497
+ }
4498
+ /**
4499
+ * Set on native a change of a prop a graph of native's has set on the view before, while no
4500
+ * graph of its holds it: the commit cannot change it (see `Touched`). The payload is committed
4501
+ * as well, so the shadow node is as the view is drawn. A transform native cannot take as
4502
+ * numbers, a matrix, is left committed alone.
4503
+ */
4504
+ setOwned(node, payload, props) {
4505
+ const owned = node.owned;
4506
+ const tag = node.committed?.tag;
4507
+ if (tag === undefined || owned.tag !== tag || !this.scrollDriver)
4508
+ return;
4509
+ const held = this.scrollDriver.held(node);
4510
+ const free = (key) => key in payload && owned.keys.has(key) && !held.has(key);
4511
+ const values = {};
4512
+ if (free('opacity')) {
4513
+ const opacity = props['opacity'];
4514
+ values.opacity = typeof opacity === 'number' ? opacity : 1;
4515
+ }
4516
+ if (free('transform')) {
4517
+ const transform = heldTransform(props['transform'], () => this.boxSize(node));
4518
+ if (transform)
4519
+ values.transform = transform;
4520
+ }
4521
+ if (values.opacity !== undefined || values.transform) {
4522
+ this.scrollDriver.set(node, tag, values);
4523
+ }
4524
+ }
4525
+ /** The size a view is laid out at, where it has been. */
4526
+ boxSize(node) {
4527
+ let size;
4528
+ this.measure(node, (frame) => (size = frame));
4529
+ return size;
4530
+ }
4531
+ /** Forget the props native set on a node's view, which is gone or made again. */
4532
+ disown(node) {
4533
+ node.owned = undefined;
4534
+ this.scrollDriver?.forget(node);
4097
4535
  }
4098
4536
  /** Take an animation back from native, which stops it there. The caller plays it on, or not. */
4099
4537
  stopNative(node, running) {
@@ -4369,8 +4807,9 @@ export class Engine {
4369
4807
  this.takeBackEased(node, state, props, spec, now);
4370
4808
  this.takeBackLaid(state, props, spec, now);
4371
4809
  const started = [];
4810
+ const before = spec && this.paintedBefore(node);
4372
4811
  for (const key of steppedKeys(props, spec, state)) {
4373
- if (step(state, key, props, spec?.[key] ?? spec?.['all'], now)) {
4812
+ if (step(state, key, props, spec?.[key] ?? spec?.['all'], now, before)) {
4374
4813
  this.running.add(node);
4375
4814
  started.push(key);
4376
4815
  this.emitTransition(node, 'topTransitionstart', key);
@@ -4381,6 +4820,29 @@ export class Engine {
4381
4820
  node.transitions = undefined;
4382
4821
  return props;
4383
4822
  }
4823
+ /**
4824
+ * What a view was last painted with, by property, for a transition that arrives with the change
4825
+ * it eases: CSS takes the transition from the style after the change and eases from the value
4826
+ * before it. Nothing for a view nobody has seen yet, which starts where it is. The individual
4827
+ * transforms are read back from the parts the painted transform was put together from.
4828
+ */
4829
+ paintedBefore(node) {
4830
+ const painted = node.committed?.props;
4831
+ // A view that paints its background on a child does not commit it as its own.
4832
+ if (!painted || node.paintsOn)
4833
+ return undefined;
4834
+ if (node.bornIn !== undefined && node.bornIn >= this.unseen)
4835
+ return undefined;
4836
+ const composed = node.composedTransform;
4837
+ if (!composed || painted['transform'] !== composed.value)
4838
+ return (key) => painted[key];
4839
+ return (key) => {
4840
+ if (key === 'transform')
4841
+ return composed.parts[3];
4842
+ const part = INDIVIDUAL_TRANSFORMS.indexOf(key);
4843
+ return part === -1 ? painted[key] : composed.parts[part];
4844
+ };
4845
+ }
4384
4846
  /** Nobody saw what the last commit gave a view it created, so this is where the view starts. */
4385
4847
  unseenTransitions(node) {
4386
4848
  // One with no commit it was born in has been seen, whatever was committed since.
@@ -4439,6 +4901,13 @@ export class Engine {
4439
4901
  const eased = state.get(key);
4440
4902
  if (!LAID_OUT.has(key) || eased.start > now || eased.duration <= 0)
4441
4903
  continue;
4904
+ if (this.sentOnAgain(node, key, eased, now)) {
4905
+ // Where it ends, at once: see `sentOnAgain`.
4906
+ Object.assign(eased, { current: eased.to, done: true });
4907
+ this.emitTransition(node, 'topTransitionend', key);
4908
+ props[key] = eased.to;
4909
+ continue;
4910
+ }
4442
4911
  eased.laid = true;
4443
4912
  this.laying.push({ node, key, eased });
4444
4913
  }
@@ -4446,6 +4915,23 @@ export class Engine {
4446
4915
  if (eased.laid && !eased.done)
4447
4916
  props[key] = eased.to;
4448
4917
  }
4918
+ /** When each size in transition was last started, and how many starts in a row came soon. */
4919
+ laidStarts = new WeakMap();
4920
+ /**
4921
+ * Whether a transition of a size is one sent on again and again, each before the last had
4922
+ * ended: a progress bar that steps every 50ms and eases each step over 150. Native moves the
4923
+ * view, and lays the screen out, every frame while a layout animation runs, so one started at
4924
+ * every step is one that never stops. The first, and one sent back before it ends, are
4925
+ * played; from the second sent on in a row, each is committed where it ends, and the steps
4926
+ * are the motion. Played again once the size has been left alone for as long as it lasts.
4927
+ */
4928
+ sentOnAgain(node, key, eased, now) {
4929
+ const starts = this.laidStarts.get(node) ?? this.laidStarts.set(node, new Map()).get(node);
4930
+ const last = starts.get(key);
4931
+ const soon = last && now - last.at < eased.duration ? last.soon + 1 : 0;
4932
+ starts.set(key, { at: now, soon });
4933
+ return soon >= 2;
4934
+ }
4449
4935
  /** The animations this commit lays out at their last frame, for native to move the views to. */
4450
4936
  laidPlayed = [];
4451
4937
  /**
@@ -4725,6 +5211,17 @@ export class Engine {
4725
5211
  this.lettingGo.add(node);
4726
5212
  return true;
4727
5213
  }
5214
+ /** Whether a node, or an element it is in, is `display: none`. */
5215
+ boxless(node) {
5216
+ for (let at = node; at; at = at.parent) {
5217
+ if (at.kind !== 'element')
5218
+ continue;
5219
+ this.styles.resolve(at, this.styleEpoch);
5220
+ if (ownLayout(at, 'display') === 'none')
5221
+ return true;
5222
+ }
5223
+ return false;
5224
+ }
4728
5225
  /** One `@keyframes` player, one frame on. */
4729
5226
  advancePlayer(node, now) {
4730
5227
  const running = node.playing;
@@ -4736,6 +5233,17 @@ export class Engine {
4736
5233
  this.playing.delete(node);
4737
5234
  return;
4738
5235
  }
5236
+ // One with no box, or in a box with none, paints nothing, and an endless one would keep the
5237
+ // frame loop going for good. It is cancelled, as a browser cancels it, and starts again when
5238
+ // the node is shown, as it is made again.
5239
+ if (this.boxless(node)) {
5240
+ this.cancelPlaying(node);
5241
+ this.playing.delete(node);
5242
+ node.playing = undefined;
5243
+ // A commit, for its `animationcancel` to be sent after, and to drop its last frame.
5244
+ this.markProps(node, false);
5245
+ return;
5246
+ }
4739
5247
  // Frames that say nothing paint nothing: a keyframe's one declaration refused at build time
4740
5248
  // leaves these. No frame of it is committed, and one that never ends has nothing left to do.
4741
5249
  const idle = running.tracks.size === 0;
@@ -4996,6 +5504,10 @@ export class Engine {
4996
5504
  /** What a clone sends: the props that changed, and a text input's font once it registered. */
4997
5505
  propsPayload(node, previous, props) {
4998
5506
  const payload = diffProps(previous, props);
5507
+ if (payload)
5508
+ borderColoursAgain(payload, props);
5509
+ if (payload && node.owned)
5510
+ this.setOwned(node, payload, props);
4999
5511
  if (payload && this.fontsRefreshed && this.fontResends.delete(node)) {
5000
5512
  payload['fontFamily'] = props['fontFamily'];
5001
5513
  }
@@ -5024,10 +5536,17 @@ export class Engine {
5024
5536
  */
5025
5537
  reconcileChildren(node, viewName, style) {
5026
5538
  const context = style?.context;
5027
- if (HOIST_TARGETS.has(node.name))
5539
+ if (HOIST_ONLY.has(node.tag))
5540
+ return this.reconcileOnlyHoisted(node);
5541
+ if (HOIST_TARGETS.has(node.tag))
5028
5542
  return this.reconcileHoistTarget(node, context);
5029
5543
  const handles = [];
5030
5544
  for (const child of node.children) {
5545
+ const clean = this.cleanUnder(node, child, context);
5546
+ if (clean) {
5547
+ handles.push(clean);
5548
+ continue;
5549
+ }
5031
5550
  if (child.kind === 'anchor' || this.committedElsewhere(child) || this.withheld(child)) {
5032
5551
  continue;
5033
5552
  }
@@ -5071,7 +5590,7 @@ export class Engine {
5071
5590
  return null;
5072
5591
  let box = text.box;
5073
5592
  // One made for another element, before the view was registered again, is done with.
5074
- if (box && box.name !== drawnBy) {
5593
+ if (box && box.tag !== drawnBy) {
5075
5594
  if (box.committed)
5076
5595
  this.forgetCommitted(box);
5077
5596
  box = undefined;
@@ -5083,6 +5602,9 @@ export class Engine {
5083
5602
  text.box = box;
5084
5603
  }
5085
5604
  box.parent = parent;
5605
+ // Merged before the view is, so the view's truncation is read here: see `truncatedAs`.
5606
+ if (truncationMoved(box, parent))
5607
+ box.propsDirty = true;
5086
5608
  // The text is not under the box in the tree, so a change to it never marked the box.
5087
5609
  if (!this.isClean(text)) {
5088
5610
  box.subtreeDirty = true;
@@ -5143,10 +5665,12 @@ export class Engine {
5143
5665
  return false;
5144
5666
  if (child.committed) {
5145
5667
  this.forgetCommitted(child);
5146
- // A field in it has no view to send its blur from.
5668
+ // A field in it has no view to send its blur from. Blurred once this commit is done: a
5669
+ // `:focus` rule that hid it then no longer holds, and the commit that follows shows it.
5147
5670
  if (isWithin(this.focusedNode, child))
5148
- this.setFocused(null);
5671
+ this.blurAfterCommit = true;
5149
5672
  }
5673
+ settlePaths(child);
5150
5674
  return true;
5151
5675
  }
5152
5676
  /**
@@ -5177,13 +5701,13 @@ export class Engine {
5177
5701
  // which RNS keeps clear by never letting the header config be the first.
5178
5702
  const direct = [];
5179
5703
  for (const child of node.children) {
5180
- if (child.kind === 'anchor' || this.committedElsewhere(child) || this.withheld(child)) {
5704
+ const taken = this.takenByTarget(node, child, context);
5705
+ if (taken === null)
5181
5706
  continue;
5182
- }
5183
- if (this.hoisted.has(child))
5707
+ if (taken === 'direct')
5184
5708
  direct.push(child);
5185
5709
  else
5186
- handles.push(this.reconcileUnder(node, child, context));
5710
+ handles.push(taken);
5187
5711
  const moved = hoisted?.get(child);
5188
5712
  for (const written of moved ?? []) {
5189
5713
  if (!this.undisplayedUnder(node, written))
@@ -5196,6 +5720,33 @@ export class Engine {
5196
5720
  this.standIn(node, landed, handles);
5197
5721
  return handles;
5198
5722
  }
5723
+ /**
5724
+ * `reconcileHoistTarget` for a node that commits only what is hoisted into it
5725
+ * (`HoistOptions.only`): those nodes, in the order they were written, and none of its own.
5726
+ */
5727
+ reconcileOnlyHoisted(node) {
5728
+ const handles = [];
5729
+ const hoisted = this.hoistedInto(node);
5730
+ const landed = new Set();
5731
+ for (const child of node.children) {
5732
+ if (child.kind === 'anchor' || this.committedElsewhere(child) || this.withheld(child)) {
5733
+ continue;
5734
+ }
5735
+ if (this.hoisted.has(child)) {
5736
+ handles.push(this.land(node, child, landed));
5737
+ continue;
5738
+ }
5739
+ for (const written of hoisted?.get(child) ?? []) {
5740
+ if (!this.undisplayedUnder(node, written))
5741
+ handles.push(this.land(node, written, landed));
5742
+ }
5743
+ // Not committed, so the marks a change left on the way down to it are cleared here.
5744
+ settlePaths(child);
5745
+ }
5746
+ if (node.kept)
5747
+ this.standIn(node, landed, handles);
5748
+ return handles;
5749
+ }
5199
5750
  /**
5200
5751
  * A child's handle as a child of `parent`, created again when it was committed under another.
5201
5752
  *
@@ -5228,8 +5779,19 @@ export class Engine {
5228
5779
  forgetCommitted(node) {
5229
5780
  node.committed = null;
5230
5781
  node.committedUnder = null;
5782
+ // Another view, with another tag, which no graph has set anything on.
5783
+ if (node.owned)
5784
+ this.disown(node);
5231
5785
  node.styleCommitted = null;
5232
5786
  node.kept = undefined;
5787
+ // A scroll-driven animation is bound to native views by their tags: its own, and its scroll
5788
+ // view's. Made again, each has another, so what drives it starts again with the new ones.
5789
+ if (node.scrolled?.drive)
5790
+ this.stopScrolled(node);
5791
+ for (const follower of this.scrollTimelines.get(node) ?? []) {
5792
+ this.stopScrolled(follower);
5793
+ this.markProps(follower, false);
5794
+ }
5233
5795
  if (node.box?.committed)
5234
5796
  this.forgetCommitted(node.box);
5235
5797
  for (const child of node.children) {
@@ -5246,9 +5808,9 @@ export class Engine {
5246
5808
  * keeps for its name when it has one (`HoistOptions.standIn`), and keep the view it commits as.
5247
5809
  */
5248
5810
  land(target, child, landed) {
5249
- if (!STAND_INS[child.name])
5811
+ if (!STAND_INS[child.tag])
5250
5812
  return this.reconcileUnder(target, child);
5251
- const kept = target.kept?.get(child.name);
5813
+ const kept = target.kept?.get(child.tag);
5252
5814
  if (kept && kept.node !== child && child.committed === null && !isWithin(kept.node, target)) {
5253
5815
  // A node taken out without being destroyed would otherwise come back holding this tag too.
5254
5816
  if (kept.node)
@@ -5258,8 +5820,8 @@ export class Engine {
5258
5820
  }
5259
5821
  const handle = this.reconcile(child);
5260
5822
  child.committedUnder = target;
5261
- (target.kept ??= new Map()).set(child.name, { node: child, committed: child.committed });
5262
- landed.add(child.name);
5823
+ (target.kept ??= new Map()).set(child.tag, { node: child, committed: child.committed });
5824
+ landed.add(child.tag);
5263
5825
  return handle;
5264
5826
  }
5265
5827
  /**
@@ -5283,6 +5845,33 @@ export class Engine {
5283
5845
  handles.push(kept.committed.handle);
5284
5846
  }
5285
5847
  }
5848
+ /**
5849
+ * What a hoist target takes of a child written in it: the child's handle, `direct` for one
5850
+ * hoisted into it, which goes after its siblings, or null for none.
5851
+ */
5852
+ takenByTarget(node, child, context) {
5853
+ if (this.hoisted.has(child)) {
5854
+ if (this.committedElsewhere(child) || this.withheld(child))
5855
+ return null;
5856
+ return 'direct';
5857
+ }
5858
+ const clean = this.cleanUnder(node, child, context);
5859
+ if (clean)
5860
+ return clean;
5861
+ if (child.kind === 'anchor' || this.withheld(child))
5862
+ return null;
5863
+ return this.reconcileUnder(node, child, context);
5864
+ }
5865
+ /**
5866
+ * A clean element's handle under `parent`, before anything else is asked of it: one committed
5867
+ * there and unchanged since was displayed and in its place, and is still. The siblings of what
5868
+ * changed are most of what a commit steps over: a list one row of which ticks.
5869
+ */
5870
+ cleanUnder(parent, child, context) {
5871
+ if (child.kind !== 'element' || child.committedUnder !== parent)
5872
+ return null;
5873
+ return this.stillCommitted(child, context);
5874
+ }
5286
5875
  /** A clean child's committed handle, when its style still holds under its parent's. */
5287
5876
  stillCommitted(child, context) {
5288
5877
  const committed = child.committed;
@@ -5305,11 +5894,13 @@ export class Engine {
5305
5894
  hoistedInto(node) {
5306
5895
  let found = null;
5307
5896
  for (const candidate of this.hoisted) {
5308
- if (candidate.parent === node || HOISTS[candidate.name] !== node.name)
5897
+ if (candidate.parent === node || HOISTS[candidate.tag] !== node.tag)
5309
5898
  continue;
5310
5899
  let written = candidate;
5311
5900
  let above = candidate.parent;
5312
- while (above && above !== node && above.name !== node.name) {
5901
+ while (above && above !== node && above.tag !== node.tag) {
5902
+ if (HOIST_STOPS[candidate.tag]?.has(above.tag))
5903
+ break;
5313
5904
  written = above;
5314
5905
  above = above.parent;
5315
5906
  }
@@ -5326,12 +5917,14 @@ export class Engine {
5326
5917
  }
5327
5918
  /** Whether a hoisted node commits further up than the parent it was written in. */
5328
5919
  hoistsAbove(node) {
5329
- const target = HOISTS[node.name];
5330
- if (node.parent?.name === target)
5920
+ const target = HOISTS[node.tag];
5921
+ if (node.parent?.tag === target)
5331
5922
  return false;
5332
- for (let above = node.parent?.parent; above; above = above.parent) {
5333
- if (above.name === target)
5923
+ for (let above = node.parent; above; above = above.parent) {
5924
+ if (above.tag === target)
5334
5925
  return true;
5926
+ if (HOIST_STOPS[node.tag]?.has(above.tag))
5927
+ return false;
5335
5928
  }
5336
5929
  return false;
5337
5930
  }
@@ -5360,13 +5953,13 @@ export class Engine {
5360
5953
  // The paragraph the engine wraps loose text in is its own: no template wrote it.
5361
5954
  if (node.kind !== 'element' || node.claimed || node.anonymous)
5362
5955
  return;
5363
- if (!PRIMITIVE_NAMES.has(node.name))
5956
+ if (!PRIMITIVE_NAMES.has(node.tag))
5364
5957
  return;
5365
- if (this.reported.has(node.name))
5958
+ if (this.reported.has(node.tag))
5366
5959
  return;
5367
- this.reported.add(node.name);
5368
- console.error(`[angular-native] <${node.name}> is used in a template that does not import ` +
5369
- `${className(node.name)}. Add it to the component's \`imports\` from ` +
5960
+ this.reported.add(node.tag);
5961
+ console.error(`[angular-native] <${node.tag}> is used in a template that does not import ` +
5962
+ `${className(node.tag)}. Add it to the component's \`imports\` from ` +
5370
5963
  `'@ng-native/components'; without it the element has none of the component's inputs ` +
5371
5964
  `or behavior, so a prop written on it stays the text it was written as.`);
5372
5965
  }
@@ -5389,6 +5982,19 @@ export class Engine {
5389
5982
  ` scope defines, so the declaration is dropped, as a browser drops it. Define ${name} on` +
5390
5983
  ` :root or an ancestor, or give the var() a fallback.`);
5391
5984
  }
5985
+ droppedDeclarations = new Set();
5986
+ /**
5987
+ * A declaration in a style, written or bound, that is no value its property takes, so it is
5988
+ * dropped as a browser drops it. Said once per element name and declaration, in development.
5989
+ */
5990
+ reportDroppedDeclaration(prop, value, on, why) {
5991
+ const report = `${on} ${prop}: ${value}`;
5992
+ if (!this.dev || this.droppedDeclarations.has(report))
5993
+ return;
5994
+ this.droppedDeclarations.add(report);
5995
+ console.warn(`[angular-native] ${prop}: ${value}, in a style on <${on}>, is no value ${prop} takes:` +
5996
+ ` ${why}. The declaration is dropped, as a browser drops it.`);
5997
+ }
5392
5998
  unreadDisplays = new Set();
5393
5999
  /**
5394
6000
  * Said once per token and value: the compiler refuses `display: grid` written out, but a token
@@ -5404,23 +6010,23 @@ export class Engine {
5404
6010
  ` and contents, and reads block, inline, inline-block, flow-root and inline-flex as flex.`);
5405
6011
  }
5406
6012
  checkProps(node) {
5407
- if (!node.claimed || !PRIMITIVE_NAMES.has(node.name))
6013
+ if (!node.claimed || !PRIMITIVE_NAMES.has(node.tag))
5408
6014
  return;
5409
- const declared = DECLARED_PROPS.get(node.name);
6015
+ const declared = DECLARED_PROPS.get(node.tag);
5410
6016
  if (!declared)
5411
6017
  return;
5412
6018
  for (const key of Object.keys(node.props)) {
5413
6019
  if (this.isDeclared(declared, key, node.props[key]))
5414
6020
  continue;
5415
- const report = `${node.name} ${key}`;
6021
+ const report = `${node.tag} ${key}`;
5416
6022
  if (this.reported.has(report))
5417
6023
  continue;
5418
6024
  this.reported.add(report);
5419
6025
  const meant = [...declared].find((name) => name.toLowerCase() === key.toLowerCase());
5420
- console.warn(`[angular-native] <${node.name}> has no prop '${key}', so native ignores it.` +
6026
+ console.warn(`[angular-native] <${node.tag}> has no prop '${key}', so native ignores it.` +
5421
6027
  (meant ? ` Did you mean '${meant}'?` : '') +
5422
- ` Check the spelling against the ${className(node.name)} page, or declare it with` +
5423
- ` declareNativeProps('${node.name}', ['${key}']) if the native view does read it.`);
6028
+ ` Check the spelling against the ${className(node.tag)} page, or declare it with` +
6029
+ ` declareNativeProps('${node.tag}', ['${key}']) if the native view does read it.`);
5424
6030
  }
5425
6031
  }
5426
6032
  isDeclared(declared, key, value) {
@@ -5460,12 +6066,12 @@ export class Engine {
5460
6066
  * the trade for not holding a registry per component, and the typo is the case that matters.
5461
6067
  */
5462
6068
  checkKnown(node) {
5463
- if (node.kind !== 'element' || node.componentHost || node.name in VIEW_NAMES)
6069
+ if (node.kind !== 'element' || node.componentHost || node.tag in VIEW_NAMES)
5464
6070
  return;
5465
- if (this.declaredElements.has(node.name) || this.reported.has(node.name))
6071
+ if (this.declaredElements.has(node.tag) || this.reported.has(node.tag))
5466
6072
  return;
5467
- this.reported.add(node.name);
5468
- console.error(`[angular-native] <${node.name}> is not a known element: no component, directive or ` +
6073
+ this.reported.add(node.tag);
6074
+ console.error(`[angular-native] <${node.tag}> is not a known element: no component, directive or ` +
5469
6075
  `registered native view claims it, so it renders as an empty view. Check the spelling, ` +
5470
6076
  `or import the component that owns it.`);
5471
6077
  }
@@ -5605,7 +6211,7 @@ export class Engine {
5605
6211
  */
5606
6212
  get focused() {
5607
6213
  if (this.focusedNode && !this.isAttached(this.focusedNode))
5608
- this.focusedNode = null;
6214
+ this.dropFocus();
5609
6215
  return this.focusedNode;
5610
6216
  }
5611
6217
  /** Whether a node is still part of the tree, rather than in a subtree that was removed. */
@@ -5973,7 +6579,7 @@ export class Engine {
5973
6579
  for (let n = target; n; n = DIRECT_EVENTS.has(topLevelType) ? null : n.parent) {
5974
6580
  matched += n.listeners?.get(topLevelType)?.size ?? 0;
5975
6581
  }
5976
- console.log('[event]', topLevelType, target?.name ?? '(no handle)', matched);
6582
+ console.log('[event]', topLevelType, target?.tag ?? '(no handle)', matched);
5977
6583
  }
5978
6584
  /**
5979
6585
  * `:focus` follows the native events rather than being inferred from a tap, so it is right for a
@@ -6032,4 +6638,17 @@ export class Engine {
6032
6638
  this.removeChild(child.parent, child);
6033
6639
  }
6034
6640
  }
6641
+ /**
6642
+ * Clear the marks that lead a commit down to a change, under a node no commit goes into: one with
6643
+ * no box. Left set, a later change under it stops climbing at them, as it stops at any mark already
6644
+ * there, and never reaches the root: nothing is committed, and an ancestor's `:has()` reading into
6645
+ * it is not asked again. What is under it has no views, so it is reconciled whole when it is shown.
6646
+ */
6647
+ function settlePaths(node) {
6648
+ if (!node.subtreeDirty)
6649
+ return;
6650
+ node.subtreeDirty = false;
6651
+ for (const child of node.children)
6652
+ settlePaths(child);
6653
+ }
6035
6654
  //# sourceMappingURL=engine.js.map