chat-layout 1.2.0-5 → 1.2.0-6

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/example/chat.ts CHANGED
@@ -513,6 +513,8 @@ function drawFrame(): void {
513
513
  maxIdx: Number.NaN,
514
514
  min: Number.NaN,
515
515
  max: Number.NaN,
516
+ canAutoFollowTop: false,
517
+ canAutoFollowBottom: false,
516
518
  };
517
519
  renderer.render(feedback);
518
520
 
@@ -644,7 +646,7 @@ button("unshift", () => {
644
646
  ],
645
647
  {
646
648
  duration: INSERT_ANIMATION_DURATION,
647
- followIfAtBoundary: true,
649
+ autoFollow: true,
648
650
  },
649
651
  );
650
652
  });
@@ -661,7 +663,7 @@ button("push", () => {
661
663
  ],
662
664
  {
663
665
  distance: 24,
664
- followIfAtBoundary: true,
666
+ autoFollow: true,
665
667
  },
666
668
  );
667
669
  });
package/index.d.mts CHANGED
@@ -19,6 +19,10 @@ interface RenderFeedback {
19
19
  min: number;
20
20
  /** Largest visible continuous item position, expressed in item coordinates rather than pixels. */
21
21
  max: number;
22
+ /** Whether the current viewport may auto-follow inserts at the visual top edge. */
23
+ canAutoFollowTop: boolean;
24
+ /** Whether the current viewport may auto-follow inserts at the visual bottom edge. */
25
+ canAutoFollowBottom: boolean;
22
26
  }
23
27
  /**
24
28
  * The main axis direction used by flex containers.
@@ -523,7 +527,7 @@ interface InsertListItemsAnimationOptions {
523
527
  /** Enter offset in pixels measured from the final resting position. */
524
528
  distance?: number;
525
529
  /** Auto-follow the insertion edge when the viewport was already pinned there. */
526
- followIfAtBoundary?: boolean;
530
+ autoFollow?: boolean;
527
531
  }
528
532
  type PushListItemsAnimationOptions = InsertListItemsAnimationOptions;
529
533
  type UnshiftListItemsAnimationOptions = InsertListItemsAnimationOptions;
@@ -593,6 +597,10 @@ declare function memoRenderItemBy<C extends CanvasRenderingContext2D, T, K>(keyO
593
597
  };
594
598
  //#endregion
595
599
  //#region src/renderer/virtualized/base-types.d.ts
600
+ type AutoFollowCapabilities = {
601
+ top: boolean;
602
+ bottom: boolean;
603
+ };
596
604
  /** Per-item draw/hittest callbacks produced by the resolver. */
597
605
  type VirtualizedResolvedItem = {
598
606
  draw: (y: number) => boolean;
@@ -690,10 +698,19 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
690
698
  * Scrolls the viewport to the requested item index.
691
699
  */
692
700
  jumpTo(index: number, options?: JumpToOptions): void;
701
+ /**
702
+ * Scrolls the viewport to the visual top edge and arms top auto-follow immediately.
703
+ */
704
+ jumpToTop(options?: JumpToOptions): void;
705
+ /**
706
+ * Scrolls the viewport to the visual bottom edge and arms bottom auto-follow immediately.
707
+ */
708
+ jumpToBottom(options?: JumpToOptions): void;
693
709
  protected _resetRenderFeedback(feedback?: RenderFeedback): void;
694
710
  protected _accumulateRenderFeedback(feedback: RenderFeedback, idx: number, top: number, height: number): void;
695
711
  protected _renderDrawList(list: VisibleWindow<VirtualizedResolvedItem>["drawList"], shift: number, feedback?: RenderFeedback): boolean;
696
712
  protected _renderVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem>, feedback?: RenderFeedback, extraShift?: number): boolean;
713
+ protected _readAutoFollowCapabilities(window: VisibleWindow<VirtualizedResolvedItem>, extraShift?: number): AutoFollowCapabilities;
697
714
  protected _readVisibleRange(top: number, height: number): {
698
715
  top: number;
699
716
  bottom: number;
package/index.mjs CHANGED
@@ -3661,7 +3661,7 @@ function normalizeInsertAnimation(animation) {
3661
3661
  if (duration == null) return;
3662
3662
  const normalizedAnimation = { duration };
3663
3663
  if (typeof animation?.distance === "number" && Number.isFinite(animation.distance)) normalizedAnimation.distance = Math.max(0, animation.distance);
3664
- if (animation?.followIfAtBoundary === true) normalizedAnimation.followIfAtBoundary = true;
3664
+ if (animation?.autoFollow === true) normalizedAnimation.autoFollow = true;
3665
3665
  return normalizedAnimation;
3666
3666
  }
3667
3667
  var ListState = class {
@@ -3981,18 +3981,21 @@ function prepareFrameSession(params) {
3981
3981
  //#endregion
3982
3982
  //#region src/renderer/virtualized/jump-controller.ts
3983
3983
  var JumpController = class {
3984
- #autoFollowLatch;
3984
+ #confirmedAutoFollowTop = false;
3985
+ #confirmedAutoFollowBottom = false;
3985
3986
  #controlledState;
3986
3987
  #jumpAnimation;
3987
3988
  #lastCommittedState;
3988
3989
  #hasPendingListChange = false;
3990
+ #pendingBoundaryJumpTop = false;
3991
+ #pendingBoundaryJumpBottom = false;
3989
3992
  #options;
3990
3993
  constructor(options) {
3991
3994
  this.#options = options;
3992
3995
  }
3993
3996
  beforeFrame() {
3994
3997
  const currentState = this.#options.readListState();
3995
- if (!this.#hasPendingListChange && this.#jumpAnimation == null && this.#lastCommittedState != null && !sameState(this.#lastCommittedState, currentState.position, currentState.offset)) this.#clearAutoFollowLatch();
3998
+ if (!this.#hasPendingListChange && this.#jumpAnimation == null && this.#lastCommittedState != null && !sameState(this.#lastCommittedState, currentState.position, currentState.offset)) this.#clearPendingBoundaryJumps();
3996
3999
  this.#hasPendingListChange = false;
3997
4000
  }
3998
4001
  prepare(now) {
@@ -4003,7 +4006,7 @@ var JumpController = class {
4003
4006
  return false;
4004
4007
  }
4005
4008
  if (this.#controlledState != null && !sameState(this.#controlledState, this.#options.readListState().position, this.#options.readListState().offset)) {
4006
- this.#clearAutoFollowLatch();
4009
+ this.#clearPendingBoundaryJumps();
4007
4010
  this.#cancelJumpAnimation();
4008
4011
  return false;
4009
4012
  }
@@ -4033,28 +4036,53 @@ var JumpController = class {
4033
4036
  };
4034
4037
  }
4035
4038
  jumpTo(index, options = {}) {
4036
- this.#clearAutoFollowLatch();
4039
+ this.#clearPendingBoundaryJumps();
4037
4040
  if (this.#options.getItemCount() === 0) {
4038
4041
  this.#cancelJumpAnimation();
4039
4042
  return;
4040
4043
  }
4041
4044
  this.#startJumpToIndex(index, options, { kind: "manual" });
4042
4045
  }
4046
+ jumpToBoundary(boundary, options = {}) {
4047
+ this.#clearPendingBoundaryJumps();
4048
+ if (this.#options.getItemCount() === 0) {
4049
+ this.#cancelJumpAnimation();
4050
+ return;
4051
+ }
4052
+ this.#armBoundaryJump(boundary);
4053
+ this.#startJumpToIndex(boundary === "bottom" ? this.#options.getItemCount() - 1 : 0, {
4054
+ ...options,
4055
+ block: boundary === "bottom" ? "end" : "start"
4056
+ }, {
4057
+ kind: "boundary-jump",
4058
+ boundary
4059
+ });
4060
+ }
4061
+ syncAutoFollowCapabilities(capabilities) {
4062
+ this.#confirmedAutoFollowTop = capabilities.top;
4063
+ this.#confirmedAutoFollowBottom = capabilities.bottom;
4064
+ this.#clearPendingBoundaryJumps();
4065
+ return this.getEffectiveAutoFollowCapabilities();
4066
+ }
4067
+ getEffectiveAutoFollowCapabilities() {
4068
+ return {
4069
+ top: this.#hasEffectiveAutoFollowCapability("top"),
4070
+ bottom: this.#hasEffectiveAutoFollowCapability("bottom")
4071
+ };
4072
+ }
4043
4073
  handleListStateChange(change) {
4044
4074
  this.#hasPendingListChange = true;
4045
4075
  const followChange = this.#resolveAutoFollowChange(change);
4046
- const canChainAutoFollow = followChange != null ? this.#shouldChainAutoFollow(followChange.direction, followChange.animation) : false;
4047
- const canLatchAutoFollow = followChange != null ? this.#shouldLatchAutoFollow(followChange.direction, followChange.count, followChange.animation) : false;
4048
- const canSnapshotAutoFollow = followChange != null ? this.#shouldAutoFollowFromSnapshot(followChange.direction, followChange.count, followChange.animation) : false;
4049
- if (followChange != null && (canSnapshotAutoFollow || canChainAutoFollow || canLatchAutoFollow)) {
4076
+ const canChainAutoFollow = followChange != null ? this.#shouldChainAutoFollow(followChange.boundary) : false;
4077
+ const canCapabilityAutoFollow = followChange != null ? this.#shouldAutoFollowFromCapability(followChange.boundary, followChange.direction, followChange.count) : false;
4078
+ if (followChange != null && (canChainAutoFollow || canCapabilityAutoFollow)) {
4050
4079
  if (canChainAutoFollow) this.#rebaseJumpAnchorForBoundaryInsert(followChange.direction, followChange.count, getNow());
4051
- this.#autoFollowLatch = followChange.direction;
4052
- this.#startJumpToIndex(followChange.direction === "push" ? this.#options.getItemCount() - 1 : 0, {
4053
- block: followChange.direction === "push" ? "end" : "start",
4080
+ this.#startJumpToIndex(followChange.boundary === "bottom" ? this.#options.getItemCount() - 1 : 0, {
4081
+ block: followChange.boundary === "bottom" ? "end" : "start",
4054
4082
  duration: followChange.animation?.duration
4055
4083
  }, {
4056
4084
  kind: "auto-follow",
4057
- direction: followChange.direction
4085
+ boundary: followChange.boundary
4058
4086
  });
4059
4087
  return {
4060
4088
  ...followChange.change,
@@ -4106,8 +4134,9 @@ var JumpController = class {
4106
4134
  #resolveAutoFollowChange(change) {
4107
4135
  switch (change.type) {
4108
4136
  case "push":
4109
- case "unshift": return change.animation?.followIfAtBoundary === true ? {
4137
+ case "unshift": return change.animation?.autoFollow === true ? {
4110
4138
  change,
4139
+ boundary: change.type === "push" ? "bottom" : "top",
4111
4140
  direction: change.type,
4112
4141
  count: change.count,
4113
4142
  animation: change.animation
@@ -4115,21 +4144,11 @@ var JumpController = class {
4115
4144
  default: return;
4116
4145
  }
4117
4146
  }
4118
- #shouldAutoFollowFromSnapshot(direction, count, animation) {
4119
- if (animation?.followIfAtBoundary !== true) return false;
4120
- return this.#options.canAutoFollowBoundaryInsert(direction, count, this.#options.readListState().position, this.#options.readListState().offset);
4121
- }
4122
- #shouldLatchAutoFollow(direction, count, animation) {
4123
- if (animation?.followIfAtBoundary !== true) return false;
4124
- if (!this.#matchesLastCommittedStateAfterBoundaryInsert(direction, count)) {
4125
- this.#clearAutoFollowLatch();
4126
- return false;
4127
- }
4128
- return this.#autoFollowLatch === direction;
4147
+ #shouldAutoFollowFromCapability(boundary, direction, count) {
4148
+ return this.#hasEffectiveAutoFollowCapability(boundary) && this.#matchesLastCommittedStateAfterBoundaryInsert(direction, count);
4129
4149
  }
4130
- #shouldChainAutoFollow(direction, animation) {
4131
- if (animation?.followIfAtBoundary !== true) return false;
4132
- return this.#jumpAnimation?.source.kind === "auto-follow" ? this.#jumpAnimation.source.direction === direction : false;
4150
+ #shouldChainAutoFollow(boundary) {
4151
+ return this.#readJumpBoundary() === boundary;
4133
4152
  }
4134
4153
  #rebaseJumpAnchorForBoundaryInsert(direction, count, now) {
4135
4154
  const animation = this.#jumpAnimation;
@@ -4148,8 +4167,22 @@ var JumpController = class {
4148
4167
  offset: state.offset
4149
4168
  }, this.#options.readListState().position, this.#options.readListState().offset);
4150
4169
  }
4151
- #clearAutoFollowLatch() {
4152
- this.#autoFollowLatch = void 0;
4170
+ #hasEffectiveAutoFollowCapability(boundary) {
4171
+ const animationBoundary = this.#readJumpBoundary();
4172
+ return boundary === "top" ? this.#confirmedAutoFollowTop || this.#pendingBoundaryJumpTop || animationBoundary === "top" : this.#confirmedAutoFollowBottom || this.#pendingBoundaryJumpBottom || animationBoundary === "bottom";
4173
+ }
4174
+ #readJumpBoundary() {
4175
+ const source = this.#jumpAnimation?.source;
4176
+ if (source == null || source.kind === "manual") return;
4177
+ return source.boundary;
4178
+ }
4179
+ #armBoundaryJump(boundary) {
4180
+ this.#pendingBoundaryJumpTop = boundary === "top";
4181
+ this.#pendingBoundaryJumpBottom = boundary === "bottom";
4182
+ }
4183
+ #clearPendingBoundaryJumps() {
4184
+ this.#pendingBoundaryJumpTop = false;
4185
+ this.#pendingBoundaryJumpBottom = false;
4153
4186
  }
4154
4187
  };
4155
4188
  //#endregion
@@ -4157,14 +4190,21 @@ var JumpController = class {
4157
4190
  var VisibilitySnapshot = class {
4158
4191
  #drawnItems = /* @__PURE__ */ new Set();
4159
4192
  #visibleItems = /* @__PURE__ */ new Set();
4193
+ #previousVisibleItems = /* @__PURE__ */ new Set();
4160
4194
  #hasSnapshot = false;
4161
4195
  #snapshotState;
4196
+ #previousSnapshotState;
4162
4197
  #emptyState;
4163
4198
  #coversShortList = false;
4164
4199
  #topGap = 0;
4165
4200
  #bottomGap = 0;
4166
4201
  #atStartBoundary = false;
4167
4202
  #atEndBoundary = false;
4203
+ #currentExtraShift = 0;
4204
+ #minDrawnIndex = Number.POSITIVE_INFINITY;
4205
+ #maxDrawnIndex = Number.NEGATIVE_INFINITY;
4206
+ #topBoundaryItem;
4207
+ #bottomBoundaryItem;
4168
4208
  get coversShortList() {
4169
4209
  return this.#hasSnapshot && this.#snapshotState != null && this.#coversShortList;
4170
4210
  }
@@ -4174,22 +4214,58 @@ var VisibilitySnapshot = class {
4174
4214
  get bottomGap() {
4175
4215
  return this.#bottomGap;
4176
4216
  }
4217
+ get previousState() {
4218
+ return this.#previousSnapshotState;
4219
+ }
4220
+ get currentExtraShift() {
4221
+ return this.#currentExtraShift;
4222
+ }
4223
+ readDrawnIndexRange() {
4224
+ if (!Number.isFinite(this.#minDrawnIndex) || !Number.isFinite(this.#maxDrawnIndex)) return;
4225
+ return {
4226
+ minIndex: this.#minDrawnIndex,
4227
+ maxIndex: this.#maxDrawnIndex
4228
+ };
4229
+ }
4230
+ readBoundaryItem(boundary) {
4231
+ return boundary === "top" ? this.#topBoundaryItem : this.#bottomBoundaryItem;
4232
+ }
4177
4233
  capture(window, _resolutionPath, items, viewportHeight, snapshotState, extraShift, readVisibleRange) {
4234
+ this.#previousVisibleItems = this.#visibleItems;
4235
+ this.#previousSnapshotState = this.#snapshotState;
4178
4236
  const nextDrawnItems = /* @__PURE__ */ new Set();
4179
4237
  const nextVisibleItems = /* @__PURE__ */ new Set();
4180
4238
  let minVisibleIndex = Number.POSITIVE_INFINITY;
4181
4239
  let maxVisibleIndex = Number.NEGATIVE_INFINITY;
4182
4240
  let topMostY = Number.POSITIVE_INFINITY;
4183
4241
  let bottomMostY = Number.NEGATIVE_INFINITY;
4242
+ let nextMinDrawnIndex = Number.POSITIVE_INFINITY;
4243
+ let nextMaxDrawnIndex = Number.NEGATIVE_INFINITY;
4244
+ let nextTopBoundaryItem;
4245
+ let nextBottomBoundaryItem;
4246
+ let nextTopBoundaryY = Number.POSITIVE_INFINITY;
4247
+ let nextBottomBoundaryY = Number.NEGATIVE_INFINITY;
4184
4248
  const effectiveShift = window.shift + extraShift;
4185
4249
  for (const { idx, offset, height } of window.drawList) {
4186
4250
  minVisibleIndex = Math.min(minVisibleIndex, idx);
4187
4251
  maxVisibleIndex = Math.max(maxVisibleIndex, idx);
4252
+ nextMinDrawnIndex = Math.min(nextMinDrawnIndex, idx);
4253
+ nextMaxDrawnIndex = Math.max(nextMaxDrawnIndex, idx);
4188
4254
  const y = offset + effectiveShift;
4189
4255
  topMostY = Math.min(topMostY, y);
4190
4256
  bottomMostY = Math.max(bottomMostY, y + height);
4191
4257
  const item = items[idx];
4192
- if (item != null) nextDrawnItems.add(item);
4258
+ if (item != null) {
4259
+ nextDrawnItems.add(item);
4260
+ if (y < nextTopBoundaryY) {
4261
+ nextTopBoundaryY = y;
4262
+ nextTopBoundaryItem = item;
4263
+ }
4264
+ if (y + height > nextBottomBoundaryY) {
4265
+ nextBottomBoundaryY = y + height;
4266
+ nextBottomBoundaryItem = item;
4267
+ }
4268
+ }
4193
4269
  if (item == null || readVisibleRange(y, height) == null) continue;
4194
4270
  nextVisibleItems.add(item);
4195
4271
  }
@@ -4197,6 +4273,11 @@ var VisibilitySnapshot = class {
4197
4273
  this.#visibleItems = nextVisibleItems;
4198
4274
  this.#hasSnapshot = true;
4199
4275
  this.#snapshotState = snapshotState;
4276
+ this.#currentExtraShift = extraShift;
4277
+ this.#minDrawnIndex = nextMinDrawnIndex;
4278
+ this.#maxDrawnIndex = nextMaxDrawnIndex;
4279
+ this.#topBoundaryItem = nextTopBoundaryItem;
4280
+ this.#bottomBoundaryItem = nextBottomBoundaryItem;
4200
4281
  this.#emptyState = items.length === 0 && window.drawList.length === 0 ? snapshotState : void 0;
4201
4282
  const contentHeight = bottomMostY - topMostY;
4202
4283
  this.#coversShortList = window.drawList.length > 0 && items.length > 0 && window.drawList.length === items.length && minVisibleIndex === 0 && maxVisibleIndex === items.length - 1 && topMostY >= -Number.EPSILON && bottomMostY <= viewportHeight + Number.EPSILON && contentHeight < viewportHeight - Number.EPSILON;
@@ -4228,20 +4309,30 @@ var VisibilitySnapshot = class {
4228
4309
  isVisible(item) {
4229
4310
  return this.#visibleItems.has(item);
4230
4311
  }
4312
+ wasVisible(item) {
4313
+ return this.#previousVisibleItems.has(item);
4314
+ }
4231
4315
  tracks(item, retention) {
4232
4316
  return retention === "drawn" ? this.#drawnItems.has(item) : this.#visibleItems.has(item);
4233
4317
  }
4234
4318
  reset() {
4235
4319
  this.#drawnItems.clear();
4236
4320
  this.#visibleItems.clear();
4321
+ this.#previousVisibleItems.clear();
4237
4322
  this.#hasSnapshot = false;
4238
4323
  this.#snapshotState = void 0;
4324
+ this.#previousSnapshotState = void 0;
4239
4325
  this.#emptyState = void 0;
4240
4326
  this.#coversShortList = false;
4241
4327
  this.#topGap = 0;
4242
4328
  this.#bottomGap = 0;
4243
4329
  this.#atStartBoundary = false;
4244
4330
  this.#atEndBoundary = false;
4331
+ this.#currentExtraShift = 0;
4332
+ this.#minDrawnIndex = Number.POSITIVE_INFINITY;
4333
+ this.#maxDrawnIndex = Number.NEGATIVE_INFINITY;
4334
+ this.#topBoundaryItem = void 0;
4335
+ this.#bottomBoundaryItem = void 0;
4245
4336
  }
4246
4337
  #matchesStateAfterBoundaryInsert(direction, count, position, offset) {
4247
4338
  const snapshotState = this.#snapshotState;
@@ -4629,6 +4720,19 @@ function handleTransitionStateChange(store, snapshot, currentViewportTranslateY,
4629
4720
  }
4630
4721
  //#endregion
4631
4722
  //#region src/renderer/virtualized/base-transition.ts
4723
+ function remapAnchorAfterDeletes(anchor, deletedIndices) {
4724
+ if (!Number.isFinite(anchor) || deletedIndices.length === 0) return anchor;
4725
+ const sortedIndices = [...deletedIndices].filter((index) => Number.isFinite(index) && index >= 0).sort((a, b) => a - b);
4726
+ let removedBeforeAnchor = 0;
4727
+ for (const index of sortedIndices) {
4728
+ if (anchor > index + 1) {
4729
+ removedBeforeAnchor += 1;
4730
+ continue;
4731
+ }
4732
+ if (anchor >= index) return index - removedBeforeAnchor;
4733
+ }
4734
+ return anchor - removedBeforeAnchor;
4735
+ }
4632
4736
  var TransitionController = class {
4633
4737
  #store = new TransitionStore();
4634
4738
  #snapshot = new VisibilitySnapshot();
@@ -4636,8 +4740,8 @@ var TransitionController = class {
4636
4740
  captureVisibilitySnapshot(window, resolutionPath, items, viewportHeight, snapshotState, extraShift, readVisibleRange) {
4637
4741
  this.#snapshot.capture(window, resolutionPath, items, viewportHeight, snapshotState, extraShift, readVisibleRange);
4638
4742
  }
4639
- pruneInvisible(lifecycle) {
4640
- return this.pruneInvisibleAt(getNow(), lifecycle);
4743
+ pruneInvisible(ctx, lifecycle) {
4744
+ return this.pruneInvisibleAt(getNow(), ctx, lifecycle);
4641
4745
  }
4642
4746
  prepare(now, lifecycle) {
4643
4747
  this.settle(now, lifecycle);
@@ -4673,8 +4777,9 @@ var TransitionController = class {
4673
4777
  this.#cleanupViewportTranslateAnimation(now);
4674
4778
  return changed;
4675
4779
  }
4676
- pruneInvisibleAt(now, lifecycle) {
4677
- return this.#settleTransitions(this.#store.findInvisible(this.#snapshot), now, lifecycle);
4780
+ pruneInvisibleAt(now, ctx, lifecycle) {
4781
+ const removals = this.#store.findInvisible(this.#snapshot);
4782
+ return this.#settleTransitions(removals, now, lifecycle, this.#resolveNaturalBoundarySnap(removals, now, ctx, lifecycle));
4678
4783
  }
4679
4784
  reset() {
4680
4785
  this.#store.reset();
@@ -4686,16 +4791,58 @@ var TransitionController = class {
4686
4791
  if (animation == null) return;
4687
4792
  if (getProgress(animation.startTime, animation.duration, now) >= 1) this.#viewportTranslateAnimation = void 0;
4688
4793
  }
4689
- #settleTransitions(removals, now, lifecycle) {
4794
+ #settleTransitions(removals, now, lifecycle, boundarySnap) {
4690
4795
  if (removals.length === 0) return false;
4691
4796
  const anchor = lifecycle.captureVisualAnchor(now);
4797
+ const completedDeleteIndices = [];
4692
4798
  for (const { item, transition } of removals) {
4799
+ if (transition.kind === "delete") {
4800
+ const index = lifecycle.readItemIndex(item);
4801
+ if (index >= 0) completedDeleteIndices.push(index);
4802
+ }
4693
4803
  this.#store.delete(item);
4694
4804
  if (transition.kind === "delete") lifecycle.onDeleteComplete(item);
4695
4805
  }
4696
- if (anchor != null && Number.isFinite(anchor)) lifecycle.restoreVisualAnchor(anchor);
4806
+ if (anchor != null && Number.isFinite(anchor)) lifecycle.restoreVisualAnchor(remapAnchorAfterDeletes(anchor, completedDeleteIndices));
4807
+ if (boundarySnap != null) lifecycle.snapItemToViewportBoundary(boundarySnap.item, boundarySnap.boundary);
4697
4808
  return true;
4698
4809
  }
4810
+ #resolveNaturalBoundarySnap(removals, now, ctx, lifecycle) {
4811
+ const previousState = this.#snapshot.previousState;
4812
+ const drawnRange = this.#snapshot.readDrawnIndexRange();
4813
+ if (previousState == null || drawnRange == null) return;
4814
+ const naturalIndices = [];
4815
+ for (const { item, transition } of removals) {
4816
+ if (transition.kind !== "update" && transition.kind !== "delete") continue;
4817
+ const index = lifecycle.readItemIndex(item);
4818
+ if (index < 0 || !this.#snapshot.wasVisible(item)) return;
4819
+ if (this.#isTransitionVisibleInState(index, previousState, now, this.#snapshot.currentExtraShift, ctx)) return;
4820
+ naturalIndices.push(index);
4821
+ }
4822
+ if (naturalIndices.length === 0) return;
4823
+ if (naturalIndices.every((index) => index < drawnRange.minIndex)) {
4824
+ const item = this.#snapshot.readBoundaryItem("top");
4825
+ return item == null ? void 0 : {
4826
+ item,
4827
+ boundary: "top"
4828
+ };
4829
+ }
4830
+ if (naturalIndices.every((index) => index > drawnRange.maxIndex)) {
4831
+ const item = this.#snapshot.readBoundaryItem("bottom");
4832
+ return item == null ? void 0 : {
4833
+ item,
4834
+ boundary: "bottom"
4835
+ };
4836
+ }
4837
+ }
4838
+ #isTransitionVisibleInState(index, state, now, extraShift, ctx) {
4839
+ const solution = ctx.resolveVisibleWindowForState(state, now);
4840
+ for (const entry of solution.window.drawList) {
4841
+ if (entry.idx !== index) continue;
4842
+ return ctx.readVisibleRange(entry.offset + solution.window.shift + extraShift, entry.height) != null;
4843
+ }
4844
+ return false;
4845
+ }
4699
4846
  };
4700
4847
  //#endregion
4701
4848
  //#region src/renderer/virtualized/base.ts
@@ -4722,8 +4869,7 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4722
4869
  getDefaultJumpBlock: this._getDefaultJumpBlock.bind(this),
4723
4870
  getTargetAnchor: this._getTargetAnchor.bind(this),
4724
4871
  clampItemIndex: this._clampItemIndex.bind(this),
4725
- getItemHeight: this._getItemHeight.bind(this),
4726
- canAutoFollowBoundaryInsert: (direction, count, position, offset) => this.#transitionController.canAutoFollowBoundaryInsert(direction, count, position, offset)
4872
+ getItemHeight: this._getItemHeight.bind(this)
4727
4873
  });
4728
4874
  subscribeListState(options.list, this, (owner, change) => {
4729
4875
  owner.#handleListStateChange(change);
@@ -4767,7 +4913,12 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4767
4913
  captureVisibleItemSnapshot: (solution, extraShift) => this._captureVisibleItemSnapshot(solution, extraShift),
4768
4914
  pruneTransitionAnimations: (window, frameNow) => this._pruneTransitionAnimations(window, frameNow)
4769
4915
  });
4916
+ const autoFollowCapabilities = this.#jumpController.syncAutoFollowCapabilities(this._readAutoFollowCapabilities(frame.solution.window, frame.viewportTranslateY));
4770
4917
  const requestRedraw = this._renderVisibleWindow(frame.solution.window, feedback, frame.viewportTranslateY);
4918
+ if (feedback != null) {
4919
+ feedback.canAutoFollowTop = autoFollowCapabilities.top;
4920
+ feedback.canAutoFollowBottom = autoFollowCapabilities.bottom;
4921
+ }
4771
4922
  this._commitListState(frame.solution.normalizedState);
4772
4923
  return this._finishRender(keepAnimating || requestRedraw || frame.requestSettleRedraw);
4773
4924
  }
@@ -4783,6 +4934,7 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4783
4934
  captureVisibleItemSnapshot: (solution, extraShift) => this._captureVisibleItemSnapshot(solution, extraShift),
4784
4935
  pruneTransitionAnimations: (window, frameNow) => this._pruneTransitionAnimations(window, frameNow)
4785
4936
  });
4937
+ this.#jumpController.syncAutoFollowCapabilities(this._readAutoFollowCapabilities(frame.solution.window, frame.viewportTranslateY));
4786
4938
  return this._hittestVisibleWindow(frame.solution.window, test, frame.viewportTranslateY);
4787
4939
  }
4788
4940
  _readListState() {
@@ -4805,12 +4957,26 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4805
4957
  jumpTo(index, options = {}) {
4806
4958
  this.#jumpController.jumpTo(index, options);
4807
4959
  }
4960
+ /**
4961
+ * Scrolls the viewport to the visual top edge and arms top auto-follow immediately.
4962
+ */
4963
+ jumpToTop(options = {}) {
4964
+ this.#jumpController.jumpToBoundary("top", options);
4965
+ }
4966
+ /**
4967
+ * Scrolls the viewport to the visual bottom edge and arms bottom auto-follow immediately.
4968
+ */
4969
+ jumpToBottom(options = {}) {
4970
+ this.#jumpController.jumpToBoundary("bottom", options);
4971
+ }
4808
4972
  _resetRenderFeedback(feedback) {
4809
4973
  if (feedback == null) return;
4810
4974
  feedback.minIdx = NaN;
4811
4975
  feedback.maxIdx = NaN;
4812
4976
  feedback.min = NaN;
4813
4977
  feedback.max = NaN;
4978
+ feedback.canAutoFollowTop = false;
4979
+ feedback.canAutoFollowBottom = false;
4814
4980
  }
4815
4981
  _accumulateRenderFeedback(feedback, idx, top, height) {
4816
4982
  const visibleRange = this._readVisibleRange(top, height);
@@ -4837,6 +5003,29 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4837
5003
  this._resetRenderFeedback(feedback);
4838
5004
  return this._renderDrawList(window.drawList, window.shift + extraShift, feedback);
4839
5005
  }
5006
+ _readAutoFollowCapabilities(window, extraShift = 0) {
5007
+ if (window.drawList.length === 0 || this.items.length === 0) return {
5008
+ top: false,
5009
+ bottom: false
5010
+ };
5011
+ let minIndex = Number.POSITIVE_INFINITY;
5012
+ let maxIndex = Number.NEGATIVE_INFINITY;
5013
+ let topMostY = Number.POSITIVE_INFINITY;
5014
+ let bottomMostY = Number.NEGATIVE_INFINITY;
5015
+ const effectiveShift = window.shift + extraShift;
5016
+ for (const { idx, offset, height } of window.drawList) {
5017
+ minIndex = Math.min(minIndex, idx);
5018
+ maxIndex = Math.max(maxIndex, idx);
5019
+ const y = offset + effectiveShift;
5020
+ topMostY = Math.min(topMostY, y);
5021
+ bottomMostY = Math.max(bottomMostY, y + height);
5022
+ }
5023
+ const viewportHeight = this.graphics.canvas.clientHeight;
5024
+ return {
5025
+ top: minIndex === 0 && topMostY >= -Number.EPSILON,
5026
+ bottom: maxIndex === this.items.length - 1 && bottomMostY <= viewportHeight + Number.EPSILON
5027
+ };
5028
+ }
4840
5029
  _readVisibleRange(top, height) {
4841
5030
  if (!Number.isFinite(top) || !Number.isFinite(height) || height <= 0) return;
4842
5031
  const viewportHeight = this.graphics.canvas.clientHeight;
@@ -4849,7 +5038,7 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4849
5038
  };
4850
5039
  }
4851
5040
  _pruneTransitionAnimations(_window, now) {
4852
- return this.#transitionController.pruneInvisibleAt(now, this.#getTransitionLifecycleAdapter());
5041
+ return this.#transitionController.pruneInvisibleAt(now, this.#getTransitionPlanningAdapter(), this.#getTransitionLifecycleAdapter());
4853
5042
  }
4854
5043
  _hittestVisibleWindow(window, test, extraShift = 0) {
4855
5044
  for (const { value: item, offset, height } of window.drawList) {
@@ -4893,6 +5082,11 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4893
5082
  if (!Number.isFinite(anchor) || this.items.length <= 0) return;
4894
5083
  this._applyAnchor(anchor);
4895
5084
  }
5085
+ #snapItemToViewportBoundary(item, boundary) {
5086
+ const index = this.items.indexOf(item);
5087
+ if (index < 0) return;
5088
+ this._applyAnchor(this._getTargetAnchor(index, boundary === "top" ? "start" : "end"));
5089
+ }
4896
5090
  _resolveItem(item, _index, now) {
4897
5091
  return this.#transitionController.resolveItem(item, now, this.#getTransitionRenderAdapter(), this.#getTransitionLifecycleAdapter());
4898
5092
  }
@@ -4903,7 +5097,9 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4903
5097
  return {
4904
5098
  onDeleteComplete: this.#handleDeleteComplete.bind(this),
4905
5099
  captureVisualAnchor: this._readAnchorAt.bind(this),
4906
- restoreVisualAnchor: this._restoreAnchor.bind(this)
5100
+ restoreVisualAnchor: this._restoreAnchor.bind(this),
5101
+ readItemIndex: (item) => this.items.indexOf(item),
5102
+ snapItemToViewportBoundary: this.#snapItemToViewportBoundary.bind(this)
4907
5103
  };
4908
5104
  }
4909
5105
  #getVirtualizedRuntime() {
@@ -4914,7 +5110,8 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4914
5110
  renderItem: this.options.renderItem,
4915
5111
  measureNode: this.measureRootNode.bind(this),
4916
5112
  readVisibleRange: this._readVisibleRange.bind(this),
4917
- resolveVisibleWindow: () => this._resolveVisibleWindow(getNow())
5113
+ resolveVisibleWindow: () => this._resolveVisibleWindow(getNow()),
5114
+ resolveVisibleWindowForState: (state, now) => this._resolveVisibleWindowForState(state, now)
4918
5115
  };
4919
5116
  }
4920
5117
  #getTransitionRenderAdapter() {