@ng-native/fabric 0.6.0 → 0.8.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
@@ -10,12 +10,12 @@
10
10
  import { boundDeclaration, faded, fills, inlineInherited, setsInherited, StyleResolver, usesActive, elementsAtEnd, placeReadElsewhere, siblingReach, } from "./css.js";
11
11
  import { applyAria } from "./aria-props.js";
12
12
  import { STYLED_ELEMENTS } from "./element-styles.js";
13
- import { animationEvent, bezier, interpolate, readsInheritedColour, sample, step, tracksOf, } from "./transition.js";
13
+ import { aimsElsewhere, animationEvent, bezier, interpolate, readsInheritedColour, sample, step, steppedKeys, tracksOf, } from "./transition.js";
14
14
  import { isFormsInput } from "./forms-inputs.js";
15
15
  import { transformList } from "./inline-transform.js";
16
16
  import { tokenFromValue } from "./inline-token.js";
17
17
  import { NativeScrollDriver, scrollFeed, } from "./native-drive.js";
18
- import { clockChannels, firstFrame, movesByShare, rangeOf, scrollChannels, sharesAsPoints, } from "./scroll-animation.js";
18
+ import { clockChannels, colourChannels, firstFrame, movesByShare, rangeOf, scrollChannels, sharesAsPoints, } from "./scroll-animation.js";
19
19
  import { FontFaces } from "./font-faces.js";
20
20
  import { premultipliedStops } from "./premultiplied-stops.js";
21
21
  /**
@@ -389,6 +389,51 @@ const VIRTUAL_TEXT = 'VirtualText';
389
389
  const DEFAULT_VIEW = 'View';
390
390
  /** A top-level text, the one view that aligns its lines. A nested text is a `VirtualText`. */
391
391
  const PARAGRAPH = 'Paragraph';
392
+ /**
393
+ * The box Yoga takes a view's baseline from where the view has none of its own: its first child
394
+ * that is laid out in its flow. Words written straight into the view are the paragraph the
395
+ * engine makes around them, and a child that is not displayed has no view to ask.
396
+ */
397
+ /**
398
+ * A view with a transition is a native view for as long as it has one, as an `Animated.View` is.
399
+ *
400
+ * React Native gives a view a native view of its own for an opacity under 1, a transform or a
401
+ * background, and takes it away when the last such reason goes. A wrapper that fades in was one
402
+ * for the length of the fade and none after, and Fabric moved everything inside it to the view
403
+ * above as the fade ended. Being moved takes a focused field out of the window for a moment, and
404
+ * it lost its focus: a field focused as its container faded in blurred when the fade finished.
405
+ *
406
+ * A floor, as `keepNative` is: a `collapsable` written on the element is left alone. Not for
407
+ * `transition: none`, which transitions nothing.
408
+ */
409
+ function keepForTransition(node, props, spec) {
410
+ if (viewNameOf(node) !== DEFAULT_VIEW || props['collapsable'] !== undefined)
411
+ return;
412
+ if (Object.keys(spec).length > 0)
413
+ props['collapsable'] = false;
414
+ }
415
+ function firstInFlow(node) {
416
+ for (const child of node.children ?? []) {
417
+ // Not the paragraph itself, which holds the words it was made around.
418
+ if (child.kind === 'text' && child.box)
419
+ return child.box === node ? undefined : child.box;
420
+ if (child.kind !== 'element' || ownLayout(child, 'display') === 'none')
421
+ continue;
422
+ if (ownLayout(child, 'position') !== 'absolute')
423
+ return child;
424
+ }
425
+ return undefined;
426
+ }
427
+ const isNode = (node) => node !== undefined;
428
+ /** Whether a row lays a child out among its others, and so can take its baseline from it. */
429
+ const inFlow = (child) => child.kind === 'element' && ownLayout(child, 'position') !== 'absolute';
430
+ /** The text field a row's box takes its baseline from: itself, or the first in its flow down. */
431
+ function fieldOnBaseline(item) {
432
+ let at = item;
433
+ while (at?.kind === 'element' && !TEXT_INPUTS.has(viewNameOf(at)))
434
+ at = firstInFlow(at);
435
+ return at?.kind === 'element' ? at : undefined;
436
+ }
392
437
  const textDirection = (value) => value === 'ltr' || value === 'rtl' ? value : undefined;
393
438
  /**
394
439
  * What iOS scales a text's `lineHeight` by: the system text size, capped by the text's own
@@ -433,6 +478,57 @@ function rowsTall(props, fontScale) {
433
478
  const lines = rows * line * textScale(props, fontScale);
434
479
  props['height'] = edges.reduce((sum, part) => sum + part, lines);
435
480
  }
481
+ /** A field's own minimum height: none where it says `auto`, which a web element starts with. */
482
+ const ownMinimum = (minHeight) => (minHeight === 'auto' ? 0 : (minHeight ?? 0));
483
+ /** How tall the system's font is, as a share of its size: its rise over the baseline and fall under. */
484
+ const FONT_LINE = 0.95 + 0.24;
485
+ /**
486
+ * How tall a field is by its line: the line, padding and border of a border-box field, Yoga's
487
+ * default, and its content alone for a content-box one, which Yoga adds the padding and border
488
+ * to itself. In a row aligned by baseline some of the line is kept as padding: `lineBaseline`.
489
+ */
490
+ function lineBox(props, box, onBaseline, fontScale) {
491
+ const half = onBaseline ? lineBaseline(props, box[0], box, fontScale) : 0;
492
+ if (props['boxSizing'] === 'content-box')
493
+ return box[0] - 2 * half;
494
+ return box.reduce((sum, part) => sum + part, 0);
495
+ }
496
+ /**
497
+ * `lineBaseline` for a field a height sizes: the room is what the height leaves its text, and
498
+ * the field stays that height. A content-box field's height is its content's, so what is now
499
+ * padding comes out of it.
500
+ */
501
+ function heightBaseline(props, height, onBaseline, fontScale) {
502
+ if (!onBaseline)
503
+ return;
504
+ const edges = [...blockEdge(props, 'Top', 'Start'), ...blockEdge(props, 'Bottom', 'End')];
505
+ if (!edges.every((part) => typeof part === 'number'))
506
+ return;
507
+ const content = props['boxSizing'] === 'content-box';
508
+ const room = content ? height : edges.reduce((left, part) => left - part, height);
509
+ const half = lineBaseline(props, room, [room, ...edges], fontScale);
510
+ if (content)
511
+ props['height'] = height - 2 * half;
512
+ }
513
+ /**
514
+ * Give a field with its line height left out the baseline of a paragraph in a line as tall, for
515
+ * a field in a row aligned by baseline: half the room the line has over the font is kept as
516
+ * padding over and under the text. React Native says a field's baseline from its text and its
517
+ * top padding, so without this it is the font's own, and the row lifts the paragraphs beside the
518
+ * field to it: a form field's prefix and suffix sit higher than what is typed. Answers the half.
519
+ *
520
+ * ponytail: the font is taken to be as tall as the system's, `FONT_LINE` of its size. Read from
521
+ * the font if a field in another face is seen a point off its prefix.
522
+ */
523
+ function lineBaseline(props, line, [, top, , bottom], fontScale) {
524
+ const size = props['fontSize'];
525
+ if (typeof size !== 'number')
526
+ return 0;
527
+ const half = Math.max(0, (line - size * textScale(props, fontScale) * FONT_LINE) / 2);
528
+ props['paddingTop'] = top + half;
529
+ props['paddingBottom'] = bottom + half;
530
+ return half;
531
+ }
436
532
  /**
437
533
  * Centre the text of a single-line text field that has a line height, as Chrome centres an
438
534
  * input's, keeping the height the line height gives it.
@@ -453,7 +549,7 @@ function rowsTall(props, fontScale) {
453
549
  * height alone for a content-box one. A null `height` is no height. A value that is not a number
454
550
  * cannot be added up, and leaves the field as it was.
455
551
  */
456
- function centreSingleLine(viewName, props, fontScale) {
552
+ function centreSingleLine(viewName, props, fontScale, onBaseline = false) {
457
553
  const lineHeight = props['lineHeight'];
458
554
  if (!TEXT_INPUTS.has(viewName) || typeof lineHeight !== 'number')
459
555
  return;
@@ -462,6 +558,7 @@ function centreSingleLine(viewName, props, fontScale) {
462
558
  const height = props['height'];
463
559
  if (typeof height === 'number') {
464
560
  delete props['lineHeight'];
561
+ heightBaseline(props, height, onBaseline, fontScale);
465
562
  return;
466
563
  }
467
564
  // A percentage is a fixed height only where the parent's is definite, known at layout alone.
@@ -473,15 +570,12 @@ function centreSingleLine(viewName, props, fontScale) {
473
570
  const edge = (side, logical) => blockEdge(props, side, logical);
474
571
  const line = lineHeight * textScale(props, fontScale);
475
572
  const box = [line, ...edge('Top', 'Start'), ...edge('Bottom', 'End')];
476
- const own = props['minHeight'] ?? 0;
573
+ const own = ownMinimum(props['minHeight']);
477
574
  const max = props['maxHeight'] ?? Infinity;
478
575
  if (![...box, own, max].every((part) => typeof part === 'number'))
479
576
  return;
480
577
  delete props['lineHeight'];
481
- // A content-box field's minHeight is its content's: Yoga adds the padding and border itself.
482
- const content = props['boxSizing'] === 'content-box'
483
- ? line
484
- : box.reduce((sum, part) => sum + part, 0);
578
+ const content = lineBox(props, box, onBaseline, fontScale);
485
579
  props['minHeight'] = Math.max(own, Math.min(content, max));
486
580
  }
487
581
  const NO_STYLE = {};
@@ -500,6 +594,44 @@ function inlineOf(node) {
500
594
  delete inline[key];
501
595
  return inline;
502
596
  }
597
+ /**
598
+ * What a node's `@keyframes` animation sets this frame, less each property a rule it matches
599
+ * declared `!important`: an important author declaration is above the animation in the cascade.
600
+ * A transition is above both, so `transitioned` takes no such care.
601
+ */
602
+ function playedOver(node) {
603
+ const values = node.playing?.values;
604
+ const important = node.styleCache?.important;
605
+ if (!values || !important)
606
+ return values ?? NO_STYLE;
607
+ const played = { ...values };
608
+ for (const key of important)
609
+ delete played[key];
610
+ return played;
611
+ }
612
+ /**
613
+ * The properties among an animation's tracks that a rule `node` matches declared `!important`,
614
+ * which native is not handed: what native plays it sets on the view, over what the rule says.
615
+ */
616
+ function pinnedIn(node, tracks) {
617
+ const important = node.styleCache?.important;
618
+ return important ? [...tracks.keys()].filter((key) => important.has(key)) : [];
619
+ }
620
+ /** Whether native is playing an animation whose important properties are no longer the ones left out. */
621
+ function pinsChanged(node, running) {
622
+ if (!running.native)
623
+ return false;
624
+ return pinnedIn(node, running.tracks).join(' ') !== running.pinned?.join(' ');
625
+ }
626
+ /** `tracks` less the properties named. */
627
+ function unpinned(tracks, pinned) {
628
+ if (!pinned.length)
629
+ return tracks;
630
+ const kept = new Map(tracks);
631
+ for (const key of pinned)
632
+ kept.delete(key);
633
+ return kept;
634
+ }
503
635
  /**
504
636
  * What a container's own style says for `key`, in the order `mergeProps` applies them: a
505
637
  * component's override, inline, a prop, its stylesheet, its default.
@@ -544,6 +676,10 @@ const STRETCHES = new Set([undefined, null, 'auto', 'stretch']);
544
676
  * kept, since it stops the stretch already.
545
677
  */
546
678
  function fitContent(node, props) {
679
+ const capped = {
680
+ row: capAtContent(props, 'width', 'maxWidth'),
681
+ column: capAtContent(props, 'height', 'maxHeight'),
682
+ };
547
683
  const width = props['width'] === 'fit-content';
548
684
  const height = props['height'] === 'fit-content';
549
685
  if (!width && !height)
@@ -558,11 +694,46 @@ function fitContent(node, props) {
558
694
  const container = containerOf(parent);
559
695
  parent.fitContainer = container;
560
696
  const [direction, align] = container.split(' ');
697
+ if (capped[direction === 'row' ? 'row' : 'column'])
698
+ withoutGrowth(props);
561
699
  const across = direction === 'row' ? height : width;
562
700
  if (across && align === 'stretch' && STRETCHES.has(props['alignSelf'])) {
563
701
  props['alignSelf'] = 'flex-start';
564
702
  }
565
703
  }
704
+ /**
705
+ * A box capped at its content along its container's main axis, where it would grow: a browser
706
+ * clamps a growing item by its maximum, and there is no such cap to hand Yoga. So it does not
707
+ * grow, and starts from its content, which is as far as the cap would let it get. It may still
708
+ * shrink.
709
+ */
710
+ function withoutGrowth(props) {
711
+ if (typeof props['flex'] === 'number' && props['flex'] > 0) {
712
+ delete props['flex'];
713
+ props['flexShrink'] ??= 1;
714
+ }
715
+ delete props['flexGrow'];
716
+ delete props['flexBasis'];
717
+ }
718
+ /**
719
+ * `max-width: max-content`, and the same of a height: no bigger than its content, which for a
720
+ * box that would fill its container is `fit-content`. Yoga has no such cap. Answers whether the
721
+ * box is the size of its content for it: one that would fill, or one that was `fit-content` as
722
+ * written.
723
+ *
724
+ * ponytail: a size in points is left as it is, since nothing here knows how big the content
725
+ * is. Measure it if a box is ever given both.
726
+ */
727
+ function capAtContent(props, size, cap) {
728
+ if (props[cap] !== 'max-content')
729
+ return false;
730
+ delete props[cap];
731
+ const own = props[size];
732
+ const fills = own == null || own === 'auto' || (typeof own === 'string' && own.endsWith('%'));
733
+ if (fills)
734
+ props[size] = 'fit-content';
735
+ return fills || own === 'fit-content';
736
+ }
566
737
  const isPercent = (value) => typeof value === 'string' && value.endsWith('%');
567
738
  /** Whether a `flex-basis` is a length or a percentage of something: not zero, and not `auto`. */
568
739
  const isSizedBasis = (basis) => isPercent(basis)
@@ -1204,6 +1375,69 @@ function repaint(node) {
1204
1375
  }
1205
1376
  /** A sixtieth of a second: how far apart the frames native is given for an animation are. */
1206
1377
  const NATIVE_FRAME = 1000 / 60;
1378
+ /**
1379
+ * What native eases a set of transitions on one clock by: a colour by its own channels, opacity
1380
+ * and a transform as an animation of them is. Nothing where any of them is no value native can
1381
+ * ease, which leaves them all to JavaScript.
1382
+ */
1383
+ function easedChannels(tracks, spec, props) {
1384
+ const tinted = new Map([...tracks].filter(([key]) => EASED_COLOURS.has(key)));
1385
+ const moved = new Map([...tracks].filter(([key]) => !EASED_COLOURS.has(key)));
1386
+ const colors = colourChannels(tinted, spec);
1387
+ const { channels, held } = clockChannels(moved, spec, props);
1388
+ if (!colors || held.length)
1389
+ return null;
1390
+ const any = channels.opacity || channels.transform.length || tinted.size;
1391
+ return any ? { ...channels, colors } : null;
1392
+ }
1393
+ /** The sides a length is written for, and the two it is written for at once. */
1394
+ const EDGES = ['', 'Top', 'Right', 'Bottom', 'Left', 'Horizontal', 'Vertical'];
1395
+ /**
1396
+ * The properties of a transition that resize or move a box, which native eases by laying the
1397
+ * view out where it ends: see `layEased`.
1398
+ */
1399
+ const LAID_OUT = new Set([
1400
+ ...['width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight'],
1401
+ ...['top', 'right', 'bottom', 'left', 'flexBasis'],
1402
+ ...EDGES.map((edge) => `margin${edge}`),
1403
+ ]);
1404
+ /** Every prop that can resize or move a box, a view's own or another's. */
1405
+ const MOVES_BOXES = new Set([
1406
+ ...LAID_OUT,
1407
+ ...EDGES.map((edge) => `padding${edge}`),
1408
+ ...['borderWidth', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'],
1409
+ ...['flex', 'flexGrow', 'flexShrink', 'flexDirection', 'flexWrap', 'alignItems', 'alignSelf'],
1410
+ ...['alignContent', 'justifyContent', 'gap', 'rowGap', 'columnGap', 'display', 'position'],
1411
+ ...['aspectRatio', 'fontSize', 'lineHeight', 'fontFamily', 'fontWeight', 'letterSpacing'],
1412
+ ...['numberOfLines', 'start', 'end'],
1413
+ ]);
1414
+ /** The curves a layout animation has, as the timing function each is. */
1415
+ const LAYOUT_CURVES = [
1416
+ ['linear', [0, 0, 1, 1]],
1417
+ ['easeIn', [0.42, 0, 1, 1]],
1418
+ ['easeOut', [0, 0, 0.58, 1]],
1419
+ ['easeInEaseOut', [0.42, 0, 0.58, 1]],
1420
+ ];
1421
+ /** The curve of a layout animation nearest a timing function, by where each is along the way. */
1422
+ function layoutCurve(easing) {
1423
+ const along = [0.2, 0.4, 0.6, 0.8];
1424
+ const apart = (curve) => along.reduce((sum, at) => sum + (bezier(curve, at) - bezier(easing, at)) ** 2, 0);
1425
+ let nearest = LAYOUT_CURVES[0];
1426
+ for (const curve of LAYOUT_CURVES)
1427
+ if (apart(curve[1]) < apart(nearest[1]))
1428
+ nearest = curve;
1429
+ return nearest[0];
1430
+ }
1431
+ /** The properties of a transition native can play: see `easeNatively`. */
1432
+ const EASED_COLOURS = new Set([
1433
+ 'backgroundColor',
1434
+ 'borderColor',
1435
+ 'borderTopColor',
1436
+ 'borderRightColor',
1437
+ 'borderBottomColor',
1438
+ 'borderLeftColor',
1439
+ ]);
1440
+ const NATIVELY_EASED = new Set(['opacity', 'transform', ...EASED_COLOURS]);
1207
1441
  /** The style keys that are a view's background. */
1208
1442
  const PAINT_KEYS = [
1209
1443
  'backgroundColor',
@@ -1759,6 +1993,16 @@ const now = () => globalThis.performance?.now?.() ?? Date.now();
1759
1993
  function sameHandles(a, b) {
1760
1994
  return a.length === b.length && a.every((handle, i) => handle === b[i]);
1761
1995
  }
1996
+ /**
1997
+ * Whether a node could be the element a compound is written for, by its classes alone: its own,
1998
+ * and those of one alternative of each `:is()` on it, which is where Tailwind names a group or
1999
+ * a peer. A compound with no class in either could be any element.
2000
+ */
2001
+ function couldBe(compound, node) {
2002
+ if (!compound.classes.every((name) => node.classes?.has(name)))
2003
+ return false;
2004
+ return (compound.is ?? []).every((alternatives) => alternatives.some((option) => couldBe(option, node)));
2005
+ }
1762
2006
  const FRAME_BUDGET_MS = 8;
1763
2007
  export class Engine {
1764
2008
  /** Scopes the CSS resolver's memo. Bumped once per commit. */
@@ -1959,9 +2203,7 @@ export class Engine {
1959
2203
  const { own, elsewhere } = this.activeUse;
1960
2204
  if (!own && !elsewhere.length)
1961
2205
  return;
1962
- // By its classes alone: a compound with none could be any element.
1963
- const asked = elsewhere.some((compound) => compound.classes.every((name) => node.classes?.has(name)));
1964
- if (asked)
2206
+ if (elsewhere.some((compound) => couldBe(compound, node)))
1965
2207
  return this.markProps(node);
1966
2208
  node.stateDirty = true;
1967
2209
  this.markPath(node.parent ?? node);
@@ -2456,6 +2698,7 @@ export class Engine {
2456
2698
  // as. Native gives a view one parent for its life, and aborts on a second.
2457
2699
  if (node.committed)
2458
2700
  this.forgetCommitted(node);
2701
+ this.stopEased(node);
2459
2702
  node.transitions = undefined;
2460
2703
  if (node.playing)
2461
2704
  this.stopNative(node, node.playing);
@@ -2501,7 +2744,10 @@ export class Engine {
2501
2744
  // inline `direction` is the exception, because the paragraphs below it align by it.
2502
2745
  // And a child that takes one of this node's own values, an `inherit`, follows it.
2503
2746
  const heirs = node.styleCache?.heirs === true;
2504
- this.markProps(node, key !== 'style' || this.inlineReachesStyle(node) || heirs);
2747
+ // And only a prop some selector names: `aria-valuenow` on every tick of a progress bar, a
2748
+ // field's text on every key, match nothing they did not.
2749
+ const read = key === 'style' ? this.inlineReachesStyle(node) : this.styles.reads(key);
2750
+ this.markProps(node, read || heirs);
2505
2751
  }
2506
2752
  /**
2507
2753
  * Paint a node's background on one of its children instead of on the node: the node keeps its
@@ -2811,11 +3057,15 @@ export class Engine {
2811
3057
  // Faces registered before the walk, the global sheet's among them, reach every node in it.
2812
3058
  this.facesAdded = false;
2813
3059
  const set = this.fabric.createChildSet(this.rootTag);
2814
- for (const child of this.visibleChildren(this.root)) {
2815
- if (this.withheld(child))
2816
- continue;
2817
- this.fabric.appendChildToSet(set, this.reconcileUnder(this.root, child));
3060
+ let handles = this.rootHandles();
3061
+ // Again, where transitions it laid out where they end are to be eased from JavaScript.
3062
+ if (this.layOut()) {
3063
+ handles = this.rootHandles();
3064
+ // What that found resized is the transitions themselves, at the frame they are eased to.
3065
+ this.movesOthers = false;
2818
3066
  }
3067
+ for (const handle of handles)
3068
+ this.fabric.appendChildToSet(set, handle);
2819
3069
  this.clearFlags(this.root);
2820
3070
  this.announceCommit();
2821
3071
  this.fabric.completeRoot(this.rootTag, set);
@@ -3054,7 +3304,11 @@ export class Engine {
3054
3304
  alignMultiline(viewName, style);
3055
3305
  rowsTall(style, this.fontScale);
3056
3306
  const merged = composeTransform(node, this.animated(node, this.transitioned(node, style)));
3057
- centreSingleLine(viewName, merged, this.fontScale);
3307
+ // On iOS: Android's field says the baseline of the line it is sized and centred by.
3308
+ // Still under the row that marked it: a row taken away is not committed again to unmark it.
3309
+ const row = node.onBaseline;
3310
+ const onBaseline = viewName === 'TextInput' && row !== undefined && isWithin(node, row);
3311
+ centreSingleLine(viewName, merged, this.fontScale, onBaseline);
3058
3312
  // After a transition, which eases the basis as the basis it was written as.
3059
3313
  basisAsSize(node, merged);
3060
3314
  // After the basis, which is a size given where it is committed as one.
@@ -3298,7 +3552,7 @@ export class Engine {
3298
3552
  if (spec.timeline)
3299
3553
  return this.scrollAnimated(node, spec, frames, props);
3300
3554
  this.startPlaying(node, spec, frames, props);
3301
- return Object.assign(props, node.playing?.values ?? {});
3555
+ return Object.assign(props, playedOver(node));
3302
3556
  }
3303
3557
  /**
3304
3558
  * A clock animation the scroll plays instead: the same animation where only the timeline moved,
@@ -3324,7 +3578,7 @@ export class Engine {
3324
3578
  if (running && !running.done)
3325
3579
  this.emitTransition(node, 'topAnimationcancel', running.spec.name);
3326
3580
  }
3327
- /** Stop on native each animation whose view has left the tree. */
3581
+ /** Stop on native each animation and transition whose view has left the tree. */
3328
3582
  releasePlayedNatively() {
3329
3583
  for (const node of [...this.playedNatively]) {
3330
3584
  if (this.topOf(node) === this.root)
@@ -3333,6 +3587,10 @@ export class Engine {
3333
3587
  node.playing = undefined;
3334
3588
  this.markProps(node, false);
3335
3589
  }
3590
+ for (const node of [...this.easedNatively]) {
3591
+ if (this.topOf(node) !== this.root)
3592
+ this.stopEased(node);
3593
+ }
3336
3594
  }
3337
3595
  /** Every node whose animation native is playing, with no frame of it in JavaScript. */
3338
3596
  playedNatively = new Set();
@@ -3404,7 +3662,9 @@ export class Engine {
3404
3662
  const sized = this.scrollDriver ? this.sized(node, running, props) : null;
3405
3663
  if (!this.scrollDriver || !sized)
3406
3664
  return false;
3407
- const { channels, held, span } = clockChannels(sized.tracks, spec, sized.resting);
3665
+ const pinned = pinnedIn(node, sized.tracks);
3666
+ const tracks = unpinned(sized.tracks, pinned);
3667
+ const { channels, held, span } = clockChannels(tracks, spec, sized.resting);
3408
3668
  if (held.length || !(channels.opacity || channels.transform.length))
3409
3669
  return false;
3410
3670
  // There and back is one native animation: an odd number of ways has no whole number of them.
@@ -3417,6 +3677,7 @@ export class Engine {
3417
3677
  if (!native)
3418
3678
  return false;
3419
3679
  running.native = native;
3680
+ running.pinned = pinned;
3420
3681
  // As Animated does: Fabric flattens a view that only lays out, and then there is no native
3421
3682
  // view for the animation to move.
3422
3683
  running.values = { ...running.values, collapsable: false };
@@ -3493,8 +3754,9 @@ export class Engine {
3493
3754
  this.emitTransition(node, 'topAnimationstart', spec.name);
3494
3755
  return;
3495
3756
  }
3496
- // A view made again is another view: the one native was moving is gone.
3497
- if (node.committed === null)
3757
+ // A view made again is another view: the one native was moving is gone. And one whose
3758
+ // important declarations changed is moving what it now must not, or not what it now may.
3759
+ if (node.committed === null || pinsChanged(node, current))
3498
3760
  this.backToScript(node, current);
3499
3761
  if (this.playedFrames.get(current) !== frames || inherited !== current.inherited) {
3500
3762
  this.backToScript(node, current);
@@ -3564,25 +3826,28 @@ export class Engine {
3564
3826
  scrollAnimated(node, spec, frames, props) {
3565
3827
  const current = node.scrolled;
3566
3828
  const inherited = this.inheritedColour(node, frames);
3567
- if (current?.frames === frames && sameAnimation(current.spec, spec)) {
3829
+ const same = current?.frames === frames && sameAnimation(current.spec, spec);
3830
+ if (same && pinnedIn(node, current.tracks).join(' ') === current.pinned.join(' ')) {
3568
3831
  return Object.assign(props, this.rescrolled(node, current, frames, props, inherited));
3569
3832
  }
3570
3833
  this.stopScrolled(node);
3571
3834
  this.handOverToScroll(node, spec);
3572
3835
  const tracks = tracksOf(frames, props, inherited);
3836
+ const pinned = pinnedIn(node, tracks);
3837
+ const played = unpinned(tracks, pinned);
3573
3838
  const source = this.scrollSourceOf(node);
3574
3839
  const extent = source ? (this.scrollExtents.get(source)?.[spec.timeline] ?? null) : null;
3575
3840
  const range = rangeOf(spec, extent);
3576
- const first = firstFrame(tracks, spec, range);
3841
+ const first = firstFrame(played, spec, range);
3577
3842
  const resting = { ...props };
3578
3843
  if (!source && this.dev)
3579
3844
  this.reportUnscrolled(spec.name);
3580
- const drive = source ? this.driveScrolled(node, source, spec, tracks, resting, range) : null;
3845
+ const drive = source ? this.driveScrolled(node, source, spec, played, resting, range) : null;
3581
3846
  // As Animated does: Fabric flattens a view that only lays out, and then there is no native
3582
3847
  // view for the animation to move. Committed with the first frame, so it stays put.
3583
3848
  if (drive)
3584
3849
  first['collapsable'] = false;
3585
- node.scrolled = { spec, frames, tracks, resting, first, source, drive, inherited };
3850
+ node.scrolled = { spec, frames, tracks, pinned, resting, first, source, drive, inherited };
3586
3851
  return Object.assign(props, first);
3587
3852
  }
3588
3853
  /**
@@ -3659,7 +3924,7 @@ export class Engine {
3659
3924
  if (!scrolled?.drive)
3660
3925
  continue;
3661
3926
  const range = rangeOf(scrolled.spec, extent[scrolled.spec.timeline]);
3662
- scrolled.drive.update(scrollChannels(scrolled.tracks, scrolled.spec, scrolled.resting, range).channels);
3927
+ scrolled.drive.update(scrollChannels(unpinned(scrolled.tracks, scrolled.pinned), scrolled.spec, scrolled.resting, range).channels);
3663
3928
  }
3664
3929
  }
3665
3930
  reportUnscrolled(name) {
@@ -3685,29 +3950,290 @@ export class Engine {
3685
3950
  `has loaded, so the block has to exist somewhere by the time the animation starts.`);
3686
3951
  }
3687
3952
  transitioned(node, props) {
3688
- // Nobody saw what the last commit gave a view it created, so this is where the view starts.
3689
- if (node.bornIn >= this.unseen)
3690
- node.transitions = undefined;
3953
+ this.unseenTransitions(node);
3691
3954
  const spec = transitionSpec(props);
3692
3955
  if (!spec && !node.transitions)
3693
3956
  return props;
3957
+ if (spec)
3958
+ keepForTransition(node, props, spec);
3694
3959
  const state = (node.transitions ??= new Map());
3695
3960
  const now = this.now();
3696
- // The properties a transition *names*, even where the node has none yet. A property is only
3697
- // transitioned once it has been seen settling, and one that does not exist is never seen - so
3698
- // `transition: transform` with no transform until a class adds one would arrive instantly,
3699
- // which is the commonest way to write one. `all` names nothing, so it keeps the old rule.
3700
- const named = spec ? Object.keys(spec).filter((key) => key !== 'all') : [];
3701
- for (const key of new Set([...Object.keys(props), ...named])) {
3961
+ this.takeBackEased(node, state, props, spec, now);
3962
+ this.takeBackLaid(state, props, spec, now);
3963
+ const started = [];
3964
+ for (const key of steppedKeys(props, spec, state)) {
3702
3965
  if (step(state, key, props, spec?.[key] ?? spec?.['all'], now)) {
3703
3966
  this.running.add(node);
3967
+ started.push(key);
3704
3968
  this.emitTransition(node, 'topTransitionstart', key);
3705
3969
  }
3706
3970
  }
3971
+ this.easeNatively(node, state, started, props, now);
3707
3972
  if (state.size === 0)
3708
3973
  node.transitions = undefined;
3709
3974
  return props;
3710
3975
  }
3976
+ /** Nobody saw what the last commit gave a view it created, so this is where the view starts. */
3977
+ unseenTransitions(node) {
3978
+ if (node.bornIn < this.unseen)
3979
+ return;
3980
+ this.stopEased(node);
3981
+ node.transitions = undefined;
3982
+ }
3983
+ /** The elements native is playing a transition of. */
3984
+ easedNatively = new Set();
3985
+ /**
3986
+ * Hand to native each transition that just started and that it can play: opacity and
3987
+ * transforms, with no wait before they start. Those that start together, take as long and ease
3988
+ * alike share a clock. Nothing of them runs in JavaScript until native says they have ended:
3989
+ * a press that fades or slides something is a commit to start it and a commit to end it.
3990
+ *
3991
+ * ponytail: a colour is eased from JavaScript still, a commit a frame, and so is anything
3992
+ * that waits. Native has a colour node for the first; give the driver a channel for it.
3993
+ */
3994
+ easeNatively(node, state, started, props, now) {
3995
+ if (started.length)
3996
+ this.startEased(node, state, started, props, now);
3997
+ this.layEased(node, state, started, props, now);
3998
+ const inScript = [...state.values()].some((eased) => !eased.done && !eased.native && !eased.laid);
3999
+ if (!inScript)
4000
+ this.running.delete(node);
4001
+ // As Animated does: Fabric flattens a view that only lays out, and then there is no native
4002
+ // view for a transition to move.
4003
+ if (this.easedNatively.has(node))
4004
+ props['collapsable'] = false;
4005
+ }
4006
+ /** The root's children as the views they are committed as, each reconciled. */
4007
+ rootHandles() {
4008
+ const handles = [];
4009
+ for (const child of this.visibleChildren(this.root)) {
4010
+ if (!this.withheld(child))
4011
+ handles.push(this.reconcileUnder(this.root, child));
4012
+ }
4013
+ return handles;
4014
+ }
4015
+ /** The transitions this commit lays out where they end, for native to move the views to. */
4016
+ laying = [];
4017
+ /** Whether this commit resizes or moves a box that is in no such transition. */
4018
+ movesOthers = false;
4019
+ /**
4020
+ * Commit where it ends each transition of a size or a place that just started, for native to
4021
+ * move the view there: a layout animation, which the commit asks for once it is known that
4022
+ * nothing else in it moves. One still on its way is committed where it ends too.
4023
+ *
4024
+ * A width eased from JavaScript is a commit a frame, and a commit lays the whole screen out.
4025
+ */
4026
+ layEased(node, state, started, props, now) {
4027
+ if (!this.fabric.configureNextLayoutAnimation)
4028
+ return;
4029
+ for (const key of started) {
4030
+ const eased = state.get(key);
4031
+ if (!LAID_OUT.has(key) || eased.start > now || eased.duration <= 0)
4032
+ continue;
4033
+ eased.laid = true;
4034
+ this.laying.push({ node, key, eased });
4035
+ }
4036
+ for (const [key, eased] of state)
4037
+ if (eased.laid && !eased.done)
4038
+ props[key] = eased.to;
4039
+ }
4040
+ /**
4041
+ * Before a commit is handed over: ask native to move what it lays out, or take the
4042
+ * transitions back to ease them from JavaScript where the commit moves anything else, and
4043
+ * where they would not all take as long. Answers whether the tree has to be reconciled again.
4044
+ */
4045
+ layOut() {
4046
+ const laying = this.laying;
4047
+ const others = this.movesOthers;
4048
+ this.laying = [];
4049
+ this.movesOthers = false;
4050
+ if (!laying.length)
4051
+ return false;
4052
+ const [first] = laying;
4053
+ const alike = laying.every(({ eased }) => eased.duration === first.eased.duration);
4054
+ if (others || !alike) {
4055
+ for (const { node, eased } of laying) {
4056
+ eased.laid = undefined;
4057
+ this.running.add(node);
4058
+ }
4059
+ for (const { node } of laying)
4060
+ this.markProps(node, false);
4061
+ return true;
4062
+ }
4063
+ const { duration, easing } = first.eased;
4064
+ const nothing = () => undefined;
4065
+ this.fabric.configureNextLayoutAnimation({ duration, update: { type: layoutCurve(easing) } }, nothing, nothing);
4066
+ for (const { node, key, eased } of laying) {
4067
+ setTimeout(() => this.endLaid(node, key, eased), duration);
4068
+ }
4069
+ return false;
4070
+ }
4071
+ /**
4072
+ * Note a commit that resizes or moves a box in no transition native lays out: a prop of one
4073
+ * that moves boxes, children that came or went, text longer or shorter than it was.
4074
+ */
4075
+ noteMoved(node, payload, resized, words) {
4076
+ if (this.movesOthers)
4077
+ return;
4078
+ if (resized)
4079
+ this.movesOthers = true;
4080
+ for (const key in payload) {
4081
+ const eased = node.transitions?.get(key);
4082
+ if (eased?.laid && !eased.done)
4083
+ continue;
4084
+ // Text as long as it was is taken to be as wide, as a grid takes it: see `shapeOf`.
4085
+ const longer = key === 'text' && String(payload[key]).length !== String(words).length;
4086
+ if (MOVES_BOXES.has(key) || longer)
4087
+ this.movesOthers = true;
4088
+ }
4089
+ }
4090
+ /** A transition native laid out has had its time: it is where it ends, and says so. */
4091
+ endLaid(node, key, eased) {
4092
+ if (!eased.laid || eased.done || node.transitions?.get(key) !== eased)
4093
+ return;
4094
+ Object.assign(eased, { current: eased.to, done: true, laid: undefined });
4095
+ this.emitTransition(node, 'topTransitionend', key);
4096
+ // No commit comes of it, which is what tells listeners otherwise: it is where it ends.
4097
+ this.flushTransitionEvents();
4098
+ // And what a listener did, taking away an element that has left, is committed.
4099
+ if (this.pending)
4100
+ this.commit();
4101
+ }
4102
+ /**
4103
+ * Bring a transition native laid out to where its clock has got to, where it is sent another
4104
+ * way or its rule has gone: that is where it goes on from.
4105
+ */
4106
+ takeBackLaid(state, props, spec, now) {
4107
+ for (const [key, eased] of state) {
4108
+ if (!eased.laid || eased.done)
4109
+ continue;
4110
+ const kept = spec?.[key] ?? spec?.['all'];
4111
+ if (kept && !aimsElsewhere(eased, key, props))
4112
+ continue;
4113
+ const progress = Math.min(1, Math.max(0, (now - eased.start) / eased.duration));
4114
+ eased.current = interpolate(eased.from, eased.to, bezier(eased.easing, progress));
4115
+ eased.laid = undefined;
4116
+ }
4117
+ }
4118
+ startEased(node, state, started, props, now) {
4119
+ if (!this.scrollDriver)
4120
+ return;
4121
+ const clocks = new Map();
4122
+ for (const key of started) {
4123
+ const eased = state.get(key);
4124
+ if (!NATIVELY_EASED.has(key) || eased.start > now || eased.duration <= 0)
4125
+ continue;
4126
+ const clock = `${eased.duration} ${eased.easing.join()}`;
4127
+ clocks.set(clock, [...(clocks.get(clock) ?? []), key]);
4128
+ }
4129
+ for (const keys of clocks.values())
4130
+ this.playEased(node, state, keys, props);
4131
+ }
4132
+ playEased(node, state, keys, props) {
4133
+ const first = state.get(keys[0]);
4134
+ const tracks = new Map(keys.map((key) => {
4135
+ const { current, to, easing } = state.get(key);
4136
+ return [
4137
+ key,
4138
+ [
4139
+ { offset: 0, value: current, easing },
4140
+ { offset: 1, value: to },
4141
+ ],
4142
+ ];
4143
+ }));
4144
+ const spec = {
4145
+ name: '',
4146
+ duration: first.duration,
4147
+ delay: 0,
4148
+ easing: first.easing,
4149
+ iterations: 1,
4150
+ fill: 'both',
4151
+ };
4152
+ const channels = easedChannels(tracks, spec, props);
4153
+ if (!channels)
4154
+ return;
4155
+ const length = Math.max(2, Math.round(first.duration / NATIVE_FRAME) + 1);
4156
+ const frames = Array.from({ length }, (_, i) => i / (length - 1));
4157
+ const clock = { keys, stop: () => undefined };
4158
+ const native = this.scrollDriver.play(node, channels, { frames, toValue: 1, iterations: 1 }, (view) => this.tagOf(view), () => this.endedEased(node, clock));
4159
+ if (!native)
4160
+ return;
4161
+ clock.stop = () => native.stop();
4162
+ for (const key of keys)
4163
+ state.get(key).native = clock;
4164
+ this.easedNatively.add(node);
4165
+ }
4166
+ /**
4167
+ * Native reached the end of the transitions on one clock. Where they end is committed before
4168
+ * the view is let go, so nothing shows of where they started, which is what it was committed
4169
+ * with.
4170
+ */
4171
+ endedEased(node, clock) {
4172
+ const state = node.transitions;
4173
+ const keys = clock.keys.filter((key) => state?.get(key)?.native === clock);
4174
+ for (const key of keys) {
4175
+ const eased = state.get(key);
4176
+ Object.assign(eased, { current: eased.to, done: true, native: undefined });
4177
+ this.emitTransition(node, 'topTransitionend', key);
4178
+ this.markProps(node, false);
4179
+ }
4180
+ this.forgetEased(node);
4181
+ if (keys.length)
4182
+ this.commit();
4183
+ clock.stop();
4184
+ }
4185
+ /** No longer one native is easing, once none of its transitions is native's. */
4186
+ forgetEased(node) {
4187
+ for (const eased of node.transitions?.values() ?? [])
4188
+ if (eased.native)
4189
+ return;
4190
+ this.easedNatively.delete(node);
4191
+ }
4192
+ /**
4193
+ * Take back from native each transition it can no longer play as it was started: one sent
4194
+ * another way, one whose rule has gone, any of a view that was made again. Each is brought to
4195
+ * where its clock has got to, which is where it goes on from, here or on native again.
4196
+ */
4197
+ takeBackEased(node, state, props, spec, now) {
4198
+ if (!this.easedNatively.has(node))
4199
+ return;
4200
+ const remade = node.committed === null;
4201
+ for (const [key, eased] of state) {
4202
+ if (!eased.native)
4203
+ continue;
4204
+ const kept = spec?.[key] ?? spec?.['all'];
4205
+ if (remade || !kept || aimsElsewhere(eased, key, props)) {
4206
+ this.backToScriptEased(node, eased.native, now);
4207
+ }
4208
+ }
4209
+ }
4210
+ /** Stop a clock native runs, and go on from JavaScript with each transition it was playing. */
4211
+ backToScriptEased(node, clock, now) {
4212
+ for (const key of clock.keys) {
4213
+ const eased = node.transitions?.get(key);
4214
+ if (eased?.native !== clock)
4215
+ continue;
4216
+ eased.native = undefined;
4217
+ const progress = Math.min(1, Math.max(0, (now - eased.start) / eased.duration));
4218
+ eased.current = interpolate(eased.from, eased.to, bezier(eased.easing, progress));
4219
+ this.running.add(node);
4220
+ }
4221
+ clock.stop();
4222
+ this.forgetEased(node);
4223
+ }
4224
+ /** Let go of what native is easing on an element that is going, or gone. */
4225
+ stopEased(node) {
4226
+ if (!this.easedNatively.delete(node))
4227
+ return;
4228
+ const clocks = new Set();
4229
+ for (const eased of node.transitions?.values() ?? []) {
4230
+ if (eased.native)
4231
+ clocks.add(eased.native);
4232
+ eased.native = undefined;
4233
+ }
4234
+ for (const clock of clocks)
4235
+ clock.stop();
4236
+ }
3711
4237
  /**
3712
4238
  * Whether an animation that has finished goes on holding its last frame. With a forwards fill
3713
4239
  * it does, for good: without one the element falls back to whatever the cascade gives it, and
@@ -3867,6 +4393,58 @@ export class Engine {
3867
4393
  };
3868
4394
  });
3869
4395
  }
4396
+ /**
4397
+ * Mark the text field each box of a row aligned by baseline takes its baseline from, before
4398
+ * the field is merged, and unmark it when the row is aligned another way.
4399
+ */
4400
+ noteBaselineFields(row) {
4401
+ const aligned = ownLayout(row, 'alignItems') === 'baseline';
4402
+ const before = row.baselineFields;
4403
+ if (!aligned && !before)
4404
+ return;
4405
+ const now = new Set(aligned ? row.children.filter(inFlow).map(fieldOnBaseline).filter(isNode) : []);
4406
+ // One that is no longer this row's, and that no other row has marked since.
4407
+ for (const field of before ?? []) {
4408
+ if (!now.has(field) && field.onBaseline === row)
4409
+ this.onBaselineOf(field, undefined);
4410
+ }
4411
+ for (const field of now)
4412
+ if (field.onBaseline !== row)
4413
+ this.onBaselineOf(field, row);
4414
+ row.baselineFields = now.size ? now : undefined;
4415
+ }
4416
+ onBaselineOf(field, row) {
4417
+ field.onBaseline = row;
4418
+ this.markProps(field, false);
4419
+ }
4420
+ /**
4421
+ * Have the text a row aligned by baseline takes a baseline from measured again, where that
4422
+ * text is inside one of the row's boxes and the row is committed again. React Native keeps
4423
+ * what a paragraph measured on the one copy of its node that was measured, and Yoga copies a
4424
+ * node it lays out around: a row that is laid out again asks a box for its baseline, the box
4425
+ * asks the paragraph in it, and a copy that was never measured works it out then, on a node
4426
+ * that is by then not to be changed. A debug build stops there. Committed to be measured
4427
+ * again (`remeasure`), the paragraph is measured in this commit and has the answer.
4428
+ *
4429
+ * ponytail: the first box in the flow at each level, which is the one Yoga asks unless
4430
+ * another is aligned by baseline itself. A row a commit lays out again without the engine
4431
+ * cloning it, as its room changing does, is not reached: clone on a layout event if one is
4432
+ * seen to stop there.
4433
+ */
4434
+ freshBaselines(row) {
4435
+ if (!row.committed || ownLayout(row, 'alignItems') !== 'baseline')
4436
+ return;
4437
+ for (const item of row.children) {
4438
+ let at = firstInFlow(item);
4439
+ while (at?.committed && committedViewName(at) !== PARAGRAPH)
4440
+ at = firstInFlow(at);
4441
+ if (!at?.committed)
4442
+ continue;
4443
+ at.remeasure = true;
4444
+ for (let up = at; up && up !== row; up = up.parent)
4445
+ up.subtreeDirty = true;
4446
+ }
4447
+ }
3870
4448
  /**
3871
4449
  * Copy a list of gradients, running every stop's colour through the host's converter.
3872
4450
  *
@@ -3910,6 +4488,8 @@ export class Engine {
3910
4488
  this.refitChildren(node);
3911
4489
  const viewName = committedViewName(node);
3912
4490
  this.forgetRenamed(node, viewName);
4491
+ this.noteBaselineFields(node);
4492
+ this.freshBaselines(node);
3913
4493
  const childHandles = this.reconcileChildren(node, viewName, style);
3914
4494
  noteTouches(node, style);
3915
4495
  const previous = node.committed;
@@ -3925,6 +4505,7 @@ export class Engine {
3925
4505
  const props = propsMoved ? this.mergeProps(node, viewName) : previous.props;
3926
4506
  const propsPayload = propsMoved ? this.propsPayload(node, previous.props, props) : null;
3927
4507
  const childrenChanged = this.childrenMoved(node, childHandles, previous.childHandles);
4508
+ this.noteMoved(node, propsPayload, childHandles.length !== previous.childHandles.length, previous.props['text']);
3928
4509
  let handle = previous.handle;
3929
4510
  if (childrenChanged || propsPayload) {
3930
4511
  handle = this.clone(previous.handle, propsPayload && this.processed(propsPayload), childrenChanged ? childHandles : null);