@ng-native/fabric 0.9.0 → 0.11.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.d.ts CHANGED
@@ -323,6 +323,10 @@ export interface EngineNode extends HostNode {
323
323
  baselineFields?: Set<EngineNode>;
324
324
  /** Commit this node again with its children so Fabric measures it again. `remeasureText`. */
325
325
  remeasure?: true;
326
+ /** On a paragraph, whether its text was one word when it was last merged: `wholeWords`. */
327
+ oneWord?: boolean;
328
+ /** The z-index this view is drawn at for a box in it, where it has none: `Engine.raise`. */
329
+ raised?: number;
326
330
  /**
327
331
  * A modal host committed as visible on iOS whose dismissal native has not yet reported. It
328
332
  * stays committed after `visible` goes false until `topDismiss`, as React Native's Modal.js
@@ -830,6 +834,12 @@ export declare class Engine implements HostEngine {
830
834
  * changes it too, by keeping or dropping the space before it.
831
835
  */
832
836
  private markTextContent;
837
+ /**
838
+ * Text came, went or changed in a paragraph: where it has become one word, or stopped being
839
+ * one, the paragraph is merged again, since that is one line or as many as it takes. Asked
840
+ * of the paragraph's outermost element, which is the view; any other change leaves it be.
841
+ */
842
+ private markWords;
833
843
  /** A subtree that went out of the tree is back in: its hoisted nodes commit again. */
834
844
  private wakeHoists;
835
845
  /**
@@ -870,6 +880,11 @@ export declare class Engine implements HostEngine {
870
880
  parentNode(node: EngineNode): EngineNode | null;
871
881
  nextSibling(node: EngineNode): EngineNode | null;
872
882
  setProp(node: EngineNode, key: string, value: unknown): void;
883
+ /**
884
+ * Have the paragraph a run of text is in merged again: it is as tall as the tallest line of
885
+ * its runs, by a line height or a text scaling set on one of them. See `lineTall`.
886
+ */
887
+ private markParagraph;
873
888
  /**
874
889
  * Paint a node's background on one of its children instead of on the node: the node keeps its
875
890
  * `background-color` and `background-image` in the cascade, where a stylesheet wrote them, and
@@ -926,6 +941,23 @@ export declare class Engine implements HostEngine {
926
941
  * which would restyle everything under them. Only sheets that use `:has()` pay.
927
942
  */
928
943
  private markBeneath;
944
+ /**
945
+ * The boxes rules ask `:has()` of above the node they style, `.card:has(.x) .title`, by the
946
+ * sheet that asks: a sheet replaced or taken away asks no more.
947
+ */
948
+ private readonly hasReaders;
949
+ /** The nodes something changed beneath since the last commit, where any sheet has such a rule. */
950
+ private readonly changedBeneath;
951
+ /**
952
+ * Have matched again what is under each box that changed beneath and that such a rule could
953
+ * be asking: the nodes the rule is for, and no other. Once for a commit, however many changes
954
+ * there were beneath the box: a table gains its rows one at a time.
955
+ */
956
+ private markUnderHas;
957
+ /**
958
+ * What `node` matches changed, which the elements after it can read: as many of them as a rule
959
+ * steps along from one, every one for a `~`, and none where no sheet steps at all.
960
+ */
929
961
  private markLaterSiblings;
930
962
  /** `node`'s child list changed: `moved` came into it at `at`, or went out of it from there. */
931
963
  private markStructure;
@@ -1056,6 +1088,40 @@ export declare class Engine implements HostEngine {
1056
1088
  /** What a touch on an element that keeps a drag holds off, while it lasts: see `holdSwipe`. */
1057
1089
  private heldFor;
1058
1090
  private mergeProps;
1091
+ /**
1092
+ * The z-index a view is drawn at for the boxes in it: the highest of theirs. A browser draws a
1093
+ * box with a z-index over what follows the boxes it is in, and a native view is ordered among
1094
+ * the views beside it and no further, so each view the box is in is raised as far. Read from
1095
+ * what its children committed, which for a raised child is what raised it. A child with a
1096
+ * z-index of its own answers with that and nothing from inside it, as the stacking context it
1097
+ * is. Only a plain view is raised: a scroll view clips what is in it, and a screen is ordered
1098
+ * by its stack.
1099
+ *
1100
+ * ponytail: a z-index is the one thing read as making a stacking context. Opacity and a
1101
+ * transform make one in a browser too; read them here if a box is ever raised past one.
1102
+ */
1103
+ private raisedBy;
1104
+ /** Read how far a view is raised, once its children are committed: its props are, if it moved. */
1105
+ private raise;
1106
+ /**
1107
+ * Draw a border that has a width and no colour in the colour of the text, as a browser does:
1108
+ * `border-color` starts as `currentColor`, and native starts it as black. Each side with a
1109
+ * width, where nothing gave that side a colour. Left to native where there is no text colour
1110
+ * to take, which is black in a browser too.
1111
+ */
1112
+ private borderInText;
1113
+ /**
1114
+ * The tallest line, as the system text size draws it, of the runs of text in a paragraph:
1115
+ * none is 0. A run scales as what it is in does, `within`, but for what it says of that
1116
+ * itself: one that does not scale, or stops at a size, beside text that does.
1117
+ */
1118
+ private tallestRun;
1119
+ /**
1120
+ * Keep a paragraph whose text has nowhere to wrap on one line, as a browser does: a word is
1121
+ * not broken inside, and what does not fit is past the edge. Native breaks a word wider than
1122
+ * its line between any two letters. Cut at the edge, where no rule says how it ends.
1123
+ */
1124
+ private wholeWords;
1059
1125
  /**
1060
1126
  * Leave a node's background out of what it commits when a child paints it, and give that child
1061
1127
  * its parent's: the cascade's, and over it the parent's inline style.
package/dist/engine.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * because a persistent parent holds references to specific child handles. That bubble is
8
8
  * inherent and is what React's own Fabric renderer does.
9
9
  */
10
- import { boundDeclaration, faded, fills, inlineInherited, setsInherited, StyleResolver, usesActive, elementsAtEnd, placeReadElsewhere, siblingReach, } from "./css.js";
10
+ import { boundDeclaration, faded, fills, inlineInherited, setsInherited, StyleResolver, usesActive, elementsAtEnd, hasAbove, placeReadElsewhere, siblingReach, } from "./css.js";
11
11
  import { applyAria } from "./aria-props.js";
12
12
  import { STYLED_ELEMENTS } from "./element-styles.js";
13
13
  import { aimsElsewhere, animationEvent, bezier, interpolate, readsInheritedColour, sample, step, steppedKeys, tracksOf, } from "./transition.js";
@@ -867,6 +867,29 @@ function ratioForAutoSize(props) {
867
867
  delete props['aspectRatio'];
868
868
  }
869
869
  const OUTLINE_KEYS = ['outlineWidth', 'outlineStyle', 'outlineColor', 'outlineOffset'];
870
+ /** Whether a side of a border has a width and nothing that gives it a colour. */
871
+ function colourless(props, side, flipped) {
872
+ const width = props[side.width] ?? props['borderWidth'];
873
+ if (typeof width !== 'number' || width <= 0)
874
+ return false;
875
+ const given = [side.color, flipped ? side.flipped : side.logical, 'borderColor'];
876
+ return given.every((key) => props[key] == null);
877
+ }
878
+ /**
879
+ * Each side a border has: its width, its colour, and the logical colour React Native takes for
880
+ * it too, read left to right and, `flipped`, right to left.
881
+ */
882
+ const BORDER_SIDES = [
883
+ ['Top', 'BlockStart', 'BlockStart'],
884
+ ['Right', 'End', 'Start'],
885
+ ['Bottom', 'BlockEnd', 'BlockEnd'],
886
+ ['Left', 'Start', 'End'],
887
+ ].map(([side, logical, flipped]) => ({
888
+ width: `border${side}Width`,
889
+ color: `border${side}Color`,
890
+ logical: `border${logical}Color`,
891
+ flipped: `border${flipped}Color`,
892
+ }));
870
893
  /**
871
894
  * Send nothing for an outline of no width, which is what `outline: none` is: it draws nothing,
872
895
  * and a view with no outline is the same view. Sent, it is an update that is not one, and on
@@ -918,6 +941,64 @@ function alignMultiline(viewName, props) {
918
941
  return;
919
942
  props['textAlignVertical'] ??= 'top';
920
943
  }
944
+ /** The props of a run of text that change how tall its line is drawn. */
945
+ const SIZES_A_RUN = new Set(['style', 'allowFontScaling', 'maxFontSizeMultiplier']);
946
+ /**
947
+ * Keep a paragraph as tall as one of its lines, as a browser's line box is whatever holds it.
948
+ * Yoga measures an item no taller than the room inside its container, and the text view cuts
949
+ * its letters off there: a 24 point line in a row with 16 points between its paddings. The
950
+ * line is the paragraph's own or a taller one of a run in it, `run`, which is scaled already. With it goes what is
951
+ * around the text and inside the height Yoga gives a view, its padding and border above and
952
+ * below, but for a content box. Only where a line height is a length, and nothing gives the
953
+ * paragraph a height of its own.
954
+ *
955
+ * The line is as the system text size draws it: native scales a line height with the text, so
956
+ * a line held to its points at a smaller size is shorter than its box and sits at the top of
957
+ * it, over what a row centres it beside.
958
+ */
959
+ function lineTall(props, run, fontScale) {
960
+ const own = props['lineHeight'];
961
+ const line = Math.max(typeof own === 'number' ? own * textScale(props, fontScale) : 0, run);
962
+ if (line <= 0)
963
+ return;
964
+ const unset = (value) => value == null || value === 'auto';
965
+ if (!unset(props['height']) || !unset(props['minHeight']))
966
+ return;
967
+ const around = props['boxSizing'] === 'content-box' ? 0 : aroundALine(props);
968
+ // To a thousandth of a point: a product of two fractions is rarely the number it reads as.
969
+ props['minHeight'] = Math.round((line + around) * 1000) / 1000;
970
+ }
971
+ /** The points of padding and border above and below a view's content, where each is a length. */
972
+ function aroundALine(props) {
973
+ const edge = (...keys) => {
974
+ const found = keys.map((key) => props[key]).find((value) => value != null);
975
+ return typeof found === 'number' ? found : 0;
976
+ };
977
+ return (edge('paddingTop', 'paddingVertical', 'padding') +
978
+ edge('paddingBottom', 'paddingVertical', 'padding') +
979
+ edge('borderTopWidth', 'borderWidth') +
980
+ edge('borderBottomWidth', 'borderWidth'));
981
+ }
982
+ /**
983
+ * Where a line may break: at a space, after a hyphen, and anywhere in a script written without
984
+ * spaces. A soft hyphen and a zero-width space are places to break that draw nothing.
985
+ */
986
+ const BREAKS = /[\s\-\u00ad\u200b\u2010\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/;
987
+ /** Whether a paragraph's text, all its runs together, is one word: something, with no break. */
988
+ function unbroken(node) {
989
+ let text = '';
990
+ const collect = (from) => {
991
+ for (const child of from.children) {
992
+ if (child.kind === 'text')
993
+ text += child.text;
994
+ else if (isTextElement(child))
995
+ collect(child);
996
+ }
997
+ };
998
+ collect(node);
999
+ const words = trimEnds(text);
1000
+ return words !== '' && !BREAKS.test(words);
1001
+ }
921
1002
  /**
922
1003
  * Resolve a paragraph's `text-align` against its direction, as CSS does.
923
1004
  *
@@ -1574,6 +1655,7 @@ function noteTouches(node, style) {
1574
1655
  if (value === 'none')
1575
1656
  node.propsDirty = true;
1576
1657
  }
1658
+ const hasClasses = (node, names) => names.every((name) => node.classes?.has(name));
1577
1659
  /** Whether `node` is `ancestor` or somewhere under it. */
1578
1660
  function isWithin(node, ancestor) {
1579
1661
  for (let at = node; at; at = at.parent)
@@ -1677,6 +1759,8 @@ class RetainedNode {
1677
1759
  committedUnder = null;
1678
1760
  kept = undefined;
1679
1761
  remeasure = undefined;
1762
+ oneWord = undefined;
1763
+ raised = undefined;
1680
1764
  propsDirty = false;
1681
1765
  structureDirty = false;
1682
1766
  subtreeDirty = false;
@@ -2352,12 +2436,16 @@ export class Engine {
2352
2436
  */
2353
2437
  fontsSettled(families) {
2354
2438
  let held = false;
2439
+ const release = (node) => {
2440
+ if (node?.heldFamily === undefined || !families.has(node.heldFamily))
2441
+ return;
2442
+ this.markProps(node, false);
2443
+ held = true;
2444
+ };
2355
2445
  const visit = (node) => {
2356
2446
  for (const child of node.children) {
2357
- if (child.heldFamily !== undefined && families.has(child.heldFamily)) {
2358
- this.markProps(child, false);
2359
- held = true;
2360
- }
2447
+ // The paragraph of a run of text written straight into a view is kept on the text.
2448
+ release(child.kind === 'text' ? child.box : child);
2361
2449
  visit(child);
2362
2450
  }
2363
2451
  };
@@ -2366,22 +2454,28 @@ export class Engine {
2366
2454
  this.commit();
2367
2455
  }
2368
2456
  fontsRegistered(families) {
2457
+ /** Lay `node` out again where it is text naming one of the families, saying whether it was. */
2458
+ const refresh = (node) => {
2459
+ // As it is drawn: a text element placing its text is a view, with the paragraph inside it.
2460
+ const viewName = committedViewName(node);
2461
+ const input = TEXT_INPUTS.has(viewName);
2462
+ const named = (viewName === PARAGRAPH || input) && namesFamily(node, families);
2463
+ if (!node.committed || !named)
2464
+ return false;
2465
+ this.fontRefreshes.set(node, (this.fontRefreshes.get(node) ?? 0) + 1);
2466
+ if (input)
2467
+ this.fontResends.add(node);
2468
+ this.fontsRefreshed = true;
2469
+ this.markProps(node, false);
2470
+ return true;
2471
+ };
2369
2472
  const visit = (node) => {
2370
2473
  for (const child of node.children) {
2371
- if (child.kind !== 'element')
2372
- continue;
2373
- const viewName = viewNameOf(child);
2374
- const input = TEXT_INPUTS.has(viewName);
2375
- if (child.committed && (viewName === PARAGRAPH || input) && namesFamily(child, families)) {
2376
- this.fontRefreshes.set(child, (this.fontRefreshes.get(child) ?? 0) + 1);
2377
- if (input)
2378
- this.fontResends.add(child);
2379
- this.fontsRefreshed = true;
2380
- this.markProps(child, false);
2381
- }
2382
- else {
2474
+ // The paragraph of a run of text written straight into a view is kept on the text.
2475
+ if (child.kind === 'text' && child.box)
2476
+ refresh(child.box);
2477
+ if (child.kind === 'element' && !refresh(child))
2383
2478
  visit(child);
2384
- }
2385
2479
  }
2386
2480
  };
2387
2481
  visit(this.root);
@@ -2588,7 +2682,8 @@ export class Engine {
2588
2682
  */
2589
2683
  setDefaultStyle(node, style) {
2590
2684
  node.defaultStyle = style;
2591
- this.markProps(node);
2685
+ // Under what the cascade comes to, and read by no selector: nothing is matched again.
2686
+ this.markProps(node, false);
2592
2687
  }
2593
2688
  /**
2594
2689
  * `class="a b"` from a template. Replaces the set rather than adding to it. The same classes in
@@ -2667,8 +2762,27 @@ export class Engine {
2667
2762
  * changes it too, by keeping or dropping the space before it.
2668
2763
  */
2669
2764
  markTextContent(parent, child) {
2670
- if (child.kind !== 'anchor' && takesTextAsProp(parent))
2765
+ if (child.kind === 'anchor')
2766
+ return;
2767
+ if (takesTextAsProp(parent))
2671
2768
  this.markProps(parent);
2769
+ else if (isTextElement(parent))
2770
+ this.markWords(parent, child.kind === 'element');
2771
+ }
2772
+ /**
2773
+ * Text came, went or changed in a paragraph: where it has become one word, or stopped being
2774
+ * one, the paragraph is merged again, since that is one line or as many as it takes. Asked
2775
+ * of the paragraph's outermost element, which is the view; any other change leaves it be.
2776
+ */
2777
+ markWords(within, run = false) {
2778
+ let root = within;
2779
+ while (isTextElement(root.parent))
2780
+ root = root.parent;
2781
+ // A run that came or went can be the tallest line of the paragraph: see `lineTall`.
2782
+ // ponytail: one restyled to another line height is not; mark its paragraph if seen cut.
2783
+ const words = root.oneWord !== undefined && unbroken(root) !== root.oneWord;
2784
+ if (run || words)
2785
+ this.markProps(root, false);
2672
2786
  }
2673
2787
  /** A subtree that went out of the tree is back in: its hoisted nodes commit again. */
2674
2788
  wakeHoists(node) {
@@ -2762,6 +2876,11 @@ export class Engine {
2762
2876
  this.stopNative(node, node.playing);
2763
2877
  node.playing = undefined;
2764
2878
  this.stopScrolled(node);
2879
+ // And what follows it, where it is the scroll view: each starts again if it is put back.
2880
+ for (const follower of this.scrollTimelines.get(node) ?? []) {
2881
+ this.stopScrolled(follower);
2882
+ this.markProps(follower, false);
2883
+ }
2765
2884
  this.running.delete(node);
2766
2885
  this.playing.delete(node);
2767
2886
  this.hoisted.delete(node);
@@ -2806,6 +2925,19 @@ export class Engine {
2806
2925
  // field's text on every key, match nothing they did not.
2807
2926
  const read = key === 'style' ? this.inlineReachesStyle(node) : this.styles.reads(key);
2808
2927
  this.markProps(node, read || heirs);
2928
+ if (SIZES_A_RUN.has(key))
2929
+ this.markParagraph(node);
2930
+ }
2931
+ /**
2932
+ * Have the paragraph a run of text is in merged again: it is as tall as the tallest line of
2933
+ * its runs, by a line height or a text scaling set on one of them. See `lineTall`.
2934
+ */
2935
+ markParagraph(run) {
2936
+ let paragraph = run;
2937
+ while (paragraph.parent && isTextElement(paragraph.parent))
2938
+ paragraph = paragraph.parent;
2939
+ if (paragraph !== run)
2940
+ this.markProps(paragraph, false);
2809
2941
  }
2810
2942
  /**
2811
2943
  * Paint a node's background on one of its children instead of on the node: the node keeps its
@@ -2861,6 +2993,7 @@ export class Engine {
2861
2993
  */
2862
2994
  styleChanged(node) {
2863
2995
  this.markProps(node, this.inlineReachesStyle(node));
2996
+ this.markParagraph(node);
2864
2997
  }
2865
2998
  /**
2866
2999
  * A bound declaration, `[style.color]`, whose value may be a `var()`. One that is, is kept as a
@@ -2956,7 +3089,11 @@ export class Engine {
2956
3089
  }
2957
3090
  /** Start matching ancestors again on a change, once a sheet that uses `:has()` is in play. */
2958
3091
  watchHas(sheet) {
2959
- if (!sheet?.has || this.hasSheets)
3092
+ if (!sheet?.has)
3093
+ return;
3094
+ if (!this.hasReaders.has(sheet))
3095
+ this.hasReaders.set(sheet, hasAbove(sheet));
3096
+ if (this.hasSheets)
2960
3097
  return;
2961
3098
  this.hasSheets = true;
2962
3099
  this.styles.tracksHas = true;
@@ -2973,15 +3110,53 @@ export class Engine {
2973
3110
  * which would restyle everything under them. Only sheets that use `:has()` pay.
2974
3111
  */
2975
3112
  markBeneath(from) {
2976
- for (let node = from; node; node = node.parent)
3113
+ for (let node = from; node; node = node.parent) {
2977
3114
  node.hasDirty = true;
3115
+ if (this.hasReaders.size)
3116
+ this.changedBeneath.add(node);
3117
+ }
2978
3118
  }
3119
+ /**
3120
+ * The boxes rules ask `:has()` of above the node they style, `.card:has(.x) .title`, by the
3121
+ * sheet that asks: a sheet replaced or taken away asks no more.
3122
+ */
3123
+ hasReaders = new Map();
3124
+ /** The nodes something changed beneath since the last commit, where any sheet has such a rule. */
3125
+ changedBeneath = new Set();
3126
+ /**
3127
+ * Have matched again what is under each box that changed beneath and that such a rule could
3128
+ * be asking: the nodes the rule is for, and no other. Once for a commit, however many changes
3129
+ * there were beneath the box: a table gains its rows one at a time.
3130
+ */
3131
+ markUnderHas() {
3132
+ if (!this.changedBeneath.size)
3133
+ return;
3134
+ for (const node of this.changedBeneath) {
3135
+ for (const readers of this.hasReaders.values()) {
3136
+ for (const { classes, subjects } of readers) {
3137
+ if (classes.every((name) => node.classes?.has(name)))
3138
+ this.markUnder(node, subjects);
3139
+ }
3140
+ }
3141
+ }
3142
+ this.changedBeneath.clear();
3143
+ }
3144
+ /**
3145
+ * What `node` matches changed, which the elements after it can read: as many of them as a rule
3146
+ * steps along from one, every one for a `~`, and none where no sheet steps at all.
3147
+ */
2979
3148
  markLaterSiblings(node) {
2980
- const siblings = node.parent?.children;
3149
+ let left = this.structuralAfter;
3150
+ const siblings = left > 0 ? node.parent?.children : undefined;
2981
3151
  if (!siblings)
2982
3152
  return;
2983
- for (let i = siblings.indexOf(node) + 1; i < siblings.length; i++) {
2984
- siblings[i].styleDirty = true;
3153
+ // Each matched again, and left as it was with all under it where it matches what it did:
3154
+ // see `markPlace`.
3155
+ for (let i = siblings.indexOf(node) + 1; i < siblings.length && left > 0; i++) {
3156
+ const sibling = siblings[i];
3157
+ this.markPlace(sibling);
3158
+ if (sibling.kind === 'element')
3159
+ left--;
2985
3160
  }
2986
3161
  }
2987
3162
  /** `node`'s child list changed: `moved` came into it at `at`, or went out of it from there. */
@@ -3056,9 +3231,16 @@ export class Engine {
3056
3231
  * which every such compound it could be asks for: a compound with none could be any element.
3057
3232
  */
3058
3233
  placeReadFromUnder(node) {
3059
- for (const compound of this.placeElsewhere) {
3060
- if (compound.classes.every((name) => node.classes?.has(name)))
3234
+ for (const { compound, above, child } of this.placeElsewhere) {
3235
+ if (!hasClasses(node, compound.classes))
3236
+ continue;
3237
+ // And by what the rule has it in, which a compound with no class is told from.
3238
+ if (!above?.length)
3061
3239
  return true;
3240
+ for (let up = node.parent; up; up = child ? null : up.parent) {
3241
+ if (hasClasses(up, above))
3242
+ return true;
3243
+ }
3062
3244
  }
3063
3245
  return false;
3064
3246
  }
@@ -3109,6 +3291,7 @@ export class Engine {
3109
3291
  // completeRoot that changes nothing: during a fling that is one wasted native commit per
3110
3292
  // frame, since any listener firing marks the view for refresh.
3111
3293
  this.settleCreated();
3294
+ this.markUnderHas();
3112
3295
  if (!this.root.structureDirty && !this.root.subtreeDirty)
3113
3296
  return false;
3114
3297
  const started = now();
@@ -3358,8 +3541,11 @@ export class Engine {
3358
3541
  flattenStyle(node.props[STYLE_OVERRIDE], style);
3359
3542
  this.fontFaces.apply(style);
3360
3543
  holdLoadingFamily(node, style);
3361
- if (viewName === PARAGRAPH)
3544
+ if (viewName === PARAGRAPH) {
3362
3545
  alignText(style, this.directionOf(node, style));
3546
+ this.wholeWords(node, style);
3547
+ lineTall(style, this.tallestRun(node, style, 0), this.fontScale);
3548
+ }
3363
3549
  if (this.fontsRefreshed)
3364
3550
  this.capForFonts(node, style);
3365
3551
  alignMultiline(viewName, style);
@@ -3375,6 +3561,7 @@ export class Engine {
3375
3561
  // After the basis, which is a size given where it is committed as one.
3376
3562
  ratioForAutoSize(merged);
3377
3563
  noOutline(merged);
3564
+ this.borderInText(node, merged);
3378
3565
  // Last, on what is committed: an override or an animation can hide a box, or place it.
3379
3566
  hiddenOutOfFlow(merged);
3380
3567
  delete merged['touchAction'];
@@ -3382,8 +3569,103 @@ export class Engine {
3382
3569
  stillTouched(merged);
3383
3570
  this.heldFor(node, merged);
3384
3571
  this.movePaint(node, merged);
3572
+ if (node.raised !== undefined)
3573
+ merged['zIndex'] ??= node.raised;
3385
3574
  return merged;
3386
3575
  }
3576
+ /**
3577
+ * The z-index a view is drawn at for the boxes in it: the highest of theirs. A browser draws a
3578
+ * box with a z-index over what follows the boxes it is in, and a native view is ordered among
3579
+ * the views beside it and no further, so each view the box is in is raised as far. Read from
3580
+ * what its children committed, which for a raised child is what raised it. A child with a
3581
+ * z-index of its own answers with that and nothing from inside it, as the stacking context it
3582
+ * is. Only a plain view is raised: a scroll view clips what is in it, and a screen is ordered
3583
+ * by its stack.
3584
+ *
3585
+ * ponytail: a z-index is the one thing read as making a stacking context. Opacity and a
3586
+ * transform make one in a browser too; read them here if a box is ever raised past one.
3587
+ */
3588
+ raisedBy(node, viewName) {
3589
+ if (viewName !== DEFAULT_VIEW)
3590
+ return undefined;
3591
+ let over = 0;
3592
+ for (const child of node.children) {
3593
+ const z = child.committed?.props['zIndex'];
3594
+ if (typeof z !== 'number' || z <= over)
3595
+ continue;
3596
+ if (!this.committedElsewhere(child) && !this.withheld(child))
3597
+ over = z;
3598
+ }
3599
+ return over || undefined;
3600
+ }
3601
+ /** Read how far a view is raised, once its children are committed: its props are, if it moved. */
3602
+ raise(node, viewName) {
3603
+ const raised = this.raisedBy(node, viewName);
3604
+ if (raised === node.raised)
3605
+ return;
3606
+ node.raised = raised;
3607
+ node.propsDirty = true;
3608
+ }
3609
+ /**
3610
+ * Draw a border that has a width and no colour in the colour of the text, as a browser does:
3611
+ * `border-color` starts as `currentColor`, and native starts it as black. Each side with a
3612
+ * width, where nothing gave that side a colour. Left to native where there is no text colour
3613
+ * to take, which is black in a browser too.
3614
+ */
3615
+ borderInText(node, props) {
3616
+ let text;
3617
+ // The side a logical colour is for is the other one where text is read right to left.
3618
+ const flipped = (props['borderStartColor'] != null || props['borderEndColor'] != null) &&
3619
+ this.styles.readsRightToLeft(props);
3620
+ for (const side of BORDER_SIDES) {
3621
+ if (!colourless(props, side, flipped))
3622
+ continue;
3623
+ text ??= props['color'] ?? this.styles.resolve(node, this.styleEpoch).inherited['color'];
3624
+ if (text == null)
3625
+ return;
3626
+ props[side.color] = text;
3627
+ }
3628
+ }
3629
+ /**
3630
+ * The tallest line, as the system text size draws it, of the runs of text in a paragraph:
3631
+ * none is 0. A run scales as what it is in does, `within`, but for what it says of that
3632
+ * itself: one that does not scale, or stops at a size, beside text that does.
3633
+ */
3634
+ tallestRun(node, within, tallest) {
3635
+ for (const child of node.children) {
3636
+ if (!isTextElement(child))
3637
+ continue;
3638
+ const scaling = {
3639
+ allowFontScaling: child.props['allowFontScaling'] ?? within['allowFontScaling'],
3640
+ maxFontSizeMultiplier: child.props['maxFontSizeMultiplier'] ?? within['maxFontSizeMultiplier'],
3641
+ };
3642
+ const line = inlineOf(child)['lineHeight'] ??
3643
+ this.styles.resolve(child, this.styleEpoch).style['lineHeight'];
3644
+ const drawn = typeof line === 'number' ? line * textScale(scaling, this.fontScale) : 0;
3645
+ tallest = this.tallestRun(child, scaling, Math.max(tallest, drawn));
3646
+ }
3647
+ return tallest;
3648
+ }
3649
+ /**
3650
+ * Keep a paragraph whose text has nowhere to wrap on one line, as a browser does: a word is
3651
+ * not broken inside, and what does not fit is past the edge. Native breaks a word wider than
3652
+ * its line between any two letters. Cut at the edge, where no rule says how it ends.
3653
+ */
3654
+ wholeWords(node, props) {
3655
+ // Remembered, for its text changing to be told from its text changing what this answers.
3656
+ node.oneWord = props['numberOfLines'] == null ? unbroken(node) : undefined;
3657
+ if (!node.oneWord)
3658
+ return;
3659
+ props['numberOfLines'] = 1;
3660
+ const ended = [
3661
+ this.styles.resolve(node, this.styleEpoch).style,
3662
+ node.props,
3663
+ inlineOf(node),
3664
+ flattenStyle(node.props[STYLE_OVERRIDE], {}),
3665
+ ];
3666
+ if (ended.every((from) => from['ellipsizeMode'] == null))
3667
+ props['ellipsizeMode'] = 'clip';
3668
+ }
3387
3669
  /**
3388
3670
  * Leave a node's background out of what it commits when a child paints it, and give that child
3389
3671
  * its parent's: the cascade's, and over it the parent's inline style.
@@ -3500,6 +3782,8 @@ export class Engine {
3500
3782
  sheetReplaced(sheet, next) {
3501
3783
  if (sheet === next && this.knownSheets.has(sheet))
3502
3784
  return;
3785
+ this.hasReaders.delete(sheet);
3786
+ this.watchHas(next);
3503
3787
  this.unwatchActive(sheet);
3504
3788
  this.watchActive(next);
3505
3789
  const at = this.sheetOrder.indexOf(sheet);
@@ -3927,7 +4211,7 @@ export class Engine {
3927
4211
  /** Every node playing a scroll-driven animation, by the scroll view that plays it. */
3928
4212
  scrollTimelines = new Map();
3929
4213
  /** How far each such scroll view scrolls, along each axis, once one of its events has said. */
3930
- scrollExtents = new Map();
4214
+ scrollExtents = new WeakMap();
3931
4215
  /**
3932
4216
  * An animation played by the nearest scroll view's offset: `animation-timeline: scroll()`.
3933
4217
  *
@@ -4013,8 +4297,13 @@ export class Engine {
4013
4297
  return;
4014
4298
  node.scrolled = undefined;
4015
4299
  scrolled.drive?.stop();
4016
- if (scrolled.source)
4017
- this.scrollTimelines.get(scrolled.source)?.delete(node);
4300
+ const playing = scrolled.source && this.scrollTimelines.get(scrolled.source);
4301
+ if (!playing)
4302
+ return;
4303
+ playing.delete(node);
4304
+ // The last to follow this scroll view: a key left here keeps it, and all it is linked to.
4305
+ if (!playing.size)
4306
+ this.scrollTimelines.delete(scrolled.source);
4018
4307
  }
4019
4308
  /**
4020
4309
  * A scroll view has said how far it scrolls. Every animation on it with a range that depends on
@@ -4672,6 +4961,7 @@ export class Engine {
4672
4961
  this.noteBaselineFields(node);
4673
4962
  this.freshBaselines(node);
4674
4963
  const childHandles = this.reconcileChildren(node, viewName, style);
4964
+ this.raise(node, viewName);
4675
4965
  noteTouches(node, style);
4676
4966
  const previous = node.committed;
4677
4967
  this.notePresented(node, viewName);
@@ -4782,8 +5072,12 @@ export class Engine {
4782
5072
  }
4783
5073
  box.parent = parent;
4784
5074
  // The text is not under the box in the tree, so a change to it never marked the box.
4785
- if (!this.isClean(text))
5075
+ if (!this.isClean(text)) {
4786
5076
  box.subtreeDirty = true;
5077
+ // Nor had it merged again where the text became one word, or stopped being one.
5078
+ if (box.oneWord !== undefined && unbroken(box) !== box.oneWord)
5079
+ box.propsDirty = true;
5080
+ }
4787
5081
  return box;
4788
5082
  }
4789
5083
  /** Remember an iOS modal host committed as showing, so hiding it waits for `topDismiss`. */