chat-layout 1.2.0-9 → 1.3.0-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/README.md CHANGED
@@ -194,6 +194,24 @@ Type-check:
194
194
  bun run typecheck
195
195
  ```
196
196
 
197
+ Run tests:
198
+
199
+ ```bash
200
+ bun run test
201
+ ```
202
+
203
+ Run tests with coverage:
204
+
205
+ ```bash
206
+ bun run test:coverage
207
+ ```
208
+
209
+ Run the local verification bundle:
210
+
211
+ ```bash
212
+ bun run check
213
+ ```
214
+
197
215
  Build distributable files:
198
216
 
199
217
  ```bash
package/example/chat.ts CHANGED
@@ -10,13 +10,13 @@ import {
10
10
  ShrinkWrap,
11
11
  Text,
12
12
  Wrapper,
13
+ initRenderFeedback,
13
14
  memoRenderItem,
14
15
  type Context,
15
16
  type DynValue,
16
17
  type HitTest,
17
18
  type InlineSpan,
18
19
  type Node,
19
- type RenderFeedback,
20
20
  } from "chat-layout";
21
21
 
22
22
  const sampleWords = [
@@ -507,16 +507,9 @@ const renderer = new ListRenderer(ctx, {
507
507
  });
508
508
  renderer.padding = { top: 32, bottom: 32 };
509
509
  let nextMessageId = list.items.length + 1;
510
+ const feedback = initRenderFeedback();
510
511
 
511
512
  function drawFrame(): void {
512
- const feedback: RenderFeedback = {
513
- minIdx: Number.NaN,
514
- maxIdx: Number.NaN,
515
- min: Number.NaN,
516
- max: Number.NaN,
517
- canAutoFollowTop: false,
518
- canAutoFollowBottom: false,
519
- };
520
513
  renderer.render(feedback);
521
514
 
522
515
  ctx.save();
@@ -680,17 +673,17 @@ button("push", () => {
680
673
  });
681
674
 
682
675
  button("jump middle", () => {
683
- renderer.jumpTo(Math.floor(list.items.length / 2));
676
+ list.scrollTo(Math.floor(list.items.length / 2));
684
677
  });
685
678
 
686
679
  button("jump middle (center)", () => {
687
- renderer.jumpTo(Math.floor(list.items.length / 2), {
680
+ list.scrollTo(Math.floor(list.items.length / 2), {
688
681
  block: "center",
689
682
  });
690
683
  });
691
684
 
692
685
  button("jump latest (no anim)", () => {
693
- renderer.jumpTo(list.items.length - 1, {
686
+ list.scrollTo(list.items.length - 1, {
694
687
  animated: false,
695
688
  });
696
689
  });
package/index.d.mts CHANGED
@@ -24,6 +24,10 @@ interface RenderFeedback {
24
24
  /** Whether the current viewport may auto-follow inserts at the visual bottom edge. */
25
25
  canAutoFollowBottom: boolean;
26
26
  }
27
+ /**
28
+ * Creates or resets a render feedback object to its default empty state.
29
+ */
30
+ declare function initRenderFeedback(feedback?: Partial<RenderFeedback>): RenderFeedback;
27
31
  /**
28
32
  * The main axis direction used by flex containers.
29
33
  */
@@ -529,6 +533,16 @@ interface InsertListItemsAnimationOptions {
529
533
  }
530
534
  type PushListItemsAnimationOptions = InsertListItemsAnimationOptions;
531
535
  type UnshiftListItemsAnimationOptions = InsertListItemsAnimationOptions;
536
+ interface ScrollToOptions {
537
+ /** Whether to animate the jump. Defaults to `true`. */
538
+ animated?: boolean;
539
+ /** Which edge of the item should align with the viewport. */
540
+ block?: "start" | "center" | "end";
541
+ /** Animation duration in milliseconds. */
542
+ duration?: number;
543
+ /** Called after the scroll completes or finishes animating. */
544
+ onComplete?: () => void;
545
+ }
532
546
  type ListScrollMutationSource = "external" | "internal";
533
547
  type ListScrollStatePatch = {
534
548
  position?: number | undefined;
@@ -576,6 +590,16 @@ declare class ListState<T extends {}> {
576
590
  reset(items?: T[]): void;
577
591
  /** Applies a relative pixel scroll delta. */
578
592
  applyScroll(delta: number): void;
593
+ /** Scrolls the viewport to the requested item index. */
594
+ scrollTo(index: number, options?: ScrollToOptions): void;
595
+ /**
596
+ * Scrolls the viewport to the visual top edge and arms top auto-follow immediately.
597
+ */
598
+ scrollToTop(options?: ScrollToOptions): void;
599
+ /**
600
+ * Scrolls the viewport to the visual bottom edge and arms bottom auto-follow immediately.
601
+ */
602
+ scrollToBottom(options?: ScrollToOptions): void;
579
603
  [WRITE_LIST_SCROLL_STATE](patch: ListScrollStatePatch, source: ListScrollMutationSource): void;
580
604
  }
581
605
  //#endregion
@@ -661,19 +685,6 @@ type VirtualizedResolvedItem = {
661
685
  };
662
686
  //#endregion
663
687
  //#region src/renderer/virtualized/base.d.ts
664
- /**
665
- * Options for programmatic scrolling to a target item.
666
- */
667
- interface JumpToOptions {
668
- /** Whether to animate the jump. Defaults to `true`. */
669
- animated?: boolean;
670
- /** Which edge of the item should align with the viewport. */
671
- block?: "start" | "center" | "end";
672
- /** Animation duration in milliseconds. */
673
- duration?: number;
674
- /** Called after the jump completes or finishes animating. */
675
- onComplete?: () => void;
676
- }
677
688
  /**
678
689
  * Shared base class for virtualized list renderers.
679
690
  */
@@ -708,18 +719,6 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
708
719
  protected _readListState(): VisibleListState;
709
720
  protected _resolveVisibleWindow(now: number): VisibleWindowResult<VirtualizedResolvedItem>;
710
721
  protected _commitListState(state: NormalizedListState): void;
711
- /**
712
- * Scrolls the viewport to the requested item index.
713
- */
714
- jumpTo(index: number, options?: JumpToOptions): void;
715
- /**
716
- * Scrolls the viewport to the visual top edge and arms top auto-follow immediately.
717
- */
718
- jumpToTop(options?: JumpToOptions): void;
719
- /**
720
- * Scrolls the viewport to the visual bottom edge and arms bottom auto-follow immediately.
721
- */
722
- jumpToBottom(options?: JumpToOptions): void;
723
722
  protected _resetRenderFeedback(feedback?: RenderFeedback): void;
724
723
  protected _accumulateRenderFeedback(feedback: RenderFeedback, idx: number, top: number, height: number): void;
725
724
  protected _renderDrawList(list: VisibleWindow<VirtualizedResolvedItem>["drawList"], shift: number, feedback?: RenderFeedback): boolean;
@@ -752,8 +751,8 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
752
751
  protected abstract _resolveVisibleWindowForState(state: VisibleListState, now: number): VisibleWindowResult<VirtualizedResolvedItem>;
753
752
  protected abstract _readAnchor(state: NormalizedListState, readItemHeight: (index: number) => number): number;
754
753
  protected abstract _applyAnchor(anchor: number): void;
755
- protected abstract _getDefaultJumpBlock(): NonNullable<JumpToOptions["block"]>;
756
- protected abstract _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
754
+ protected abstract _getDefaultJumpBlock(): NonNullable<ScrollToOptions["block"]>;
755
+ protected abstract _getTargetAnchor(index: number, block: NonNullable<ScrollToOptions["block"]>): number;
757
756
  protected _getViewportMetrics(): ListViewportMetrics;
758
757
  }
759
758
  //#endregion
@@ -772,12 +771,12 @@ declare class ListRenderer<C extends CanvasRenderingContext2D, T extends {}> ext
772
771
  set padding(value: ListPadding);
773
772
  protected _getLayoutOptions(): ResolvedListLayoutOptions;
774
773
  protected _resolveVisibleWindowForState(state: VisibleListState, now: number): VisibleWindowResult<VirtualizedResolvedItem>;
775
- protected _getDefaultJumpBlock(): NonNullable<JumpToOptions["block"]>;
774
+ protected _getDefaultJumpBlock(): NonNullable<ScrollToOptions["block"]>;
776
775
  protected _normalizeListState(state: VisibleListState): NormalizedListState;
777
776
  protected _readAnchor(state: NormalizedListState, readItemHeight: (index: number) => number): number;
778
777
  protected _applyAnchor(anchor: number): void;
779
- protected _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
778
+ protected _getTargetAnchor(index: number, block: NonNullable<ScrollToOptions["block"]>): number;
780
779
  }
781
780
  //#endregion
782
- export { Axis, BaseRenderer, Box, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DeleteListItemAnimationOptions, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, InlineSpan, InsertListItemsAnimationOptions, JumpToOptions, LayoutConstraints, LayoutRect, ListAnchorMode, ListLayoutOptions, ListPadding, ListRenderer, ListRendererOptions, ListState, ListUnderflowAlign, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, PushListItemsAnimationOptions, RenderFeedback, RendererOptions, ShrinkWrap, Text, TextAlign, TextEllipsisPosition, TextJustifyMode, TextJustifyOptions, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, UnshiftListItemsAnimationOptions, UpdateListItemAnimationOptions, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
781
+ export { Axis, BaseRenderer, Box, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DeleteListItemAnimationOptions, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, InlineSpan, InsertListItemsAnimationOptions, LayoutConstraints, LayoutRect, ListAnchorMode, ListLayoutOptions, ListPadding, ListRenderer, ListRendererOptions, ListState, ListUnderflowAlign, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, PushListItemsAnimationOptions, RenderFeedback, RendererOptions, ScrollToOptions, ShrinkWrap, Text, TextAlign, TextEllipsisPosition, TextJustifyMode, TextJustifyOptions, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, UnshiftListItemsAnimationOptions, UpdateListItemAnimationOptions, VirtualizedRenderer, Wrapper, initRenderFeedback, memoRenderItem, memoRenderItemBy };
783
782
  //# sourceMappingURL=index.d.mts.map
package/index.mjs CHANGED
@@ -3576,11 +3576,13 @@ var DebugRenderer = class extends BaseRenderer {
3576
3576
  //#endregion
3577
3577
  //#region src/renderer/list-state.ts
3578
3578
  const listStateChangeQueues = /* @__PURE__ */ new WeakMap();
3579
+ const listScrollCommandQueues = /* @__PURE__ */ new WeakMap();
3579
3580
  const listScrollMutations = /* @__PURE__ */ new WeakMap();
3580
3581
  const WRITE_LIST_SCROLL_STATE = Symbol("writeListScrollState");
3581
3582
  const FINALIZE_LIST_DELETE = Symbol("finalizeListDelete");
3582
3583
  const LIST_STATE_CHANGE_TIME = Symbol("listStateChangeTime");
3583
3584
  const LIST_STATE_CHANGE_SNAPSHOT = Symbol("listStateChangeSnapshot");
3585
+ const LIST_SCROLL_COMMAND_TIME = Symbol("listScrollCommandTime");
3584
3586
  function normalizePosition(value) {
3585
3587
  return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : void 0;
3586
3588
  }
@@ -3616,6 +3618,14 @@ function writeInternalListScrollState(list, state) {
3616
3618
  function finalizeInternalListDelete(list, item) {
3617
3619
  list[FINALIZE_LIST_DELETE](item);
3618
3620
  }
3621
+ function normalizeScrollToOptions(options) {
3622
+ const normalized = {};
3623
+ if (typeof options?.animated === "boolean") normalized.animated = options.animated;
3624
+ if (options?.block === "start" || options?.block === "center" || options?.block === "end") normalized.block = options.block;
3625
+ if (options?.duration != null && Number.isFinite(options.duration)) normalized.duration = options.duration;
3626
+ if (typeof options?.onComplete === "function") normalized.onComplete = options.onComplete;
3627
+ return normalized;
3628
+ }
3619
3629
  function enqueueListStateChange(list, change) {
3620
3630
  const key = list;
3621
3631
  let queue = listStateChangeQueues.get(key);
@@ -3638,6 +3648,20 @@ function enqueueListStateChange(list, change) {
3638
3648
  });
3639
3649
  queue.push(timestampedChange);
3640
3650
  }
3651
+ function enqueueListScrollCommand(list, command) {
3652
+ const key = list;
3653
+ let queue = listScrollCommandQueues.get(key);
3654
+ if (queue == null) {
3655
+ queue = [];
3656
+ listScrollCommandQueues.set(key, queue);
3657
+ }
3658
+ const timestampedCommand = command;
3659
+ Object.defineProperty(timestampedCommand, LIST_SCROLL_COMMAND_TIME, {
3660
+ value: performance.now(),
3661
+ configurable: true
3662
+ });
3663
+ queue.push(timestampedCommand);
3664
+ }
3641
3665
  function drainInternalListStateChanges(list) {
3642
3666
  const key = list;
3643
3667
  const queue = listStateChangeQueues.get(key);
@@ -3645,12 +3669,22 @@ function drainInternalListStateChanges(list) {
3645
3669
  listStateChangeQueues.delete(key);
3646
3670
  return queue;
3647
3671
  }
3672
+ function drainInternalListScrollCommands(list) {
3673
+ const key = list;
3674
+ const queue = listScrollCommandQueues.get(key);
3675
+ if (queue == null || queue.length === 0) return [];
3676
+ listScrollCommandQueues.delete(key);
3677
+ return queue;
3678
+ }
3648
3679
  function readInternalListStateChangeTime(change) {
3649
3680
  return change[LIST_STATE_CHANGE_TIME];
3650
3681
  }
3651
3682
  function readInternalListStateChangeSnapshot(change) {
3652
3683
  return change[LIST_STATE_CHANGE_SNAPSHOT];
3653
3684
  }
3685
+ function readInternalListScrollCommandTime(command) {
3686
+ return command[LIST_SCROLL_COMMAND_TIME];
3687
+ }
3654
3688
  function isObjectIdentityCandidate(value) {
3655
3689
  return typeof value === "object" && value !== null || typeof value === "function";
3656
3690
  }
@@ -3834,6 +3868,34 @@ var ListState = class {
3834
3868
  applyScroll(delta) {
3835
3869
  this.#writeScrollState({ offset: this.#offset + delta }, "external");
3836
3870
  }
3871
+ /** Scrolls the viewport to the requested item index. */
3872
+ scrollTo(index, options = {}) {
3873
+ enqueueListScrollCommand(this, {
3874
+ type: "index",
3875
+ index,
3876
+ options: normalizeScrollToOptions(options)
3877
+ });
3878
+ }
3879
+ /**
3880
+ * Scrolls the viewport to the visual top edge and arms top auto-follow immediately.
3881
+ */
3882
+ scrollToTop(options = {}) {
3883
+ enqueueListScrollCommand(this, {
3884
+ type: "boundary",
3885
+ boundary: "top",
3886
+ options: normalizeScrollToOptions(options)
3887
+ });
3888
+ }
3889
+ /**
3890
+ * Scrolls the viewport to the visual bottom edge and arms bottom auto-follow immediately.
3891
+ */
3892
+ scrollToBottom(options = {}) {
3893
+ enqueueListScrollCommand(this, {
3894
+ type: "boundary",
3895
+ boundary: "bottom",
3896
+ options: normalizeScrollToOptions(options)
3897
+ });
3898
+ }
3837
3899
  [WRITE_LIST_SCROLL_STATE](patch, source) {
3838
3900
  this.#writeScrollState(patch, source);
3839
3901
  }
@@ -3918,6 +3980,21 @@ function memoRenderItemBy(keyOf, renderItem, options = {}) {
3918
3980
  });
3919
3981
  }
3920
3982
  //#endregion
3983
+ //#region src/types.ts
3984
+ /**
3985
+ * Creates or resets a render feedback object to its default empty state.
3986
+ */
3987
+ function initRenderFeedback(feedback = {}) {
3988
+ const target = feedback;
3989
+ target.minIdx = NaN;
3990
+ target.maxIdx = NaN;
3991
+ target.min = NaN;
3992
+ target.max = NaN;
3993
+ target.canAutoFollowTop = false;
3994
+ target.canAutoFollowBottom = false;
3995
+ return target;
3996
+ }
3997
+ //#endregion
3921
3998
  //#region src/renderer/virtualized/frame-session.ts
3922
3999
  function prepareFrameSession(params) {
3923
4000
  let solution = params.resolveVisibleWindow(params.now);
@@ -4097,16 +4174,16 @@ var JumpController = class JumpController {
4097
4174
  commit(state) {
4098
4175
  this.#lastHandledScrollMutationVersion = this.#options.readScrollMutation().version;
4099
4176
  }
4100
- jumpTo(index, options = {}) {
4177
+ jumpTo(index, options = {}, now = getNow()) {
4101
4178
  this.#clearPendingTransitionSettleReconcile();
4102
4179
  this.#clearPendingPostJumpBoundary();
4103
4180
  if (this.#options.getItemCount() === 0) {
4104
4181
  this.#cancelJumpAnimation();
4105
4182
  return;
4106
4183
  }
4107
- this.#startJumpToIndex(index, options);
4184
+ this.#startJumpToIndex(index, options, now);
4108
4185
  }
4109
- jumpToBoundary(boundary, options = {}) {
4186
+ jumpToBoundary(boundary, options = {}, now = getNow()) {
4110
4187
  this.#clearPendingTransitionSettleReconcile();
4111
4188
  this.#clearPendingPostJumpBoundary();
4112
4189
  this.#armAutoFollowBoundary(boundary, "jump-to-boundary");
@@ -4117,7 +4194,7 @@ var JumpController = class JumpController {
4117
4194
  this.#startJumpToIndex(boundary === "bottom" ? this.#options.getItemCount() - 1 : 0, {
4118
4195
  ...options,
4119
4196
  block: boundary === "bottom" ? "end" : "start"
4120
- });
4197
+ }, now);
4121
4198
  }
4122
4199
  beginAutoFollowBoundaryObservation(boundary) {
4123
4200
  if (boundary === "top") {
@@ -5389,8 +5466,9 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
5389
5466
  /** Renders the current visible window. */
5390
5467
  render(feedback) {
5391
5468
  this.#drainPendingListStateChanges();
5392
- this.#jumpController.beforeFrame();
5393
5469
  this.#jumpController.noteViewportWidth(this.graphics.canvas.clientWidth);
5470
+ this.#drainPendingListScrollCommands();
5471
+ this.#jumpController.beforeFrame();
5394
5472
  const now = getNow();
5395
5473
  const keepAnimating = this._prepareRender(now);
5396
5474
  const { clientWidth: viewportWidth, clientHeight: viewportHeight } = this.graphics.canvas;
@@ -5413,8 +5491,9 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
5413
5491
  /** Hit-tests the current visible window. */
5414
5492
  hittest(test) {
5415
5493
  this.#drainPendingListStateChanges();
5416
- this.#jumpController.beforeFrame();
5417
5494
  this.#jumpController.noteViewportWidth(this.graphics.canvas.clientWidth);
5495
+ this.#drainPendingListScrollCommands();
5496
+ this.#jumpController.beforeFrame();
5418
5497
  const now = getNow();
5419
5498
  this.#transitionController.settle(now, this.#getTransitionLifecycleAdapter());
5420
5499
  const frame = prepareFrameSession({
@@ -5439,32 +5518,9 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
5439
5518
  writeInternalListScrollState(this.options.list, state);
5440
5519
  this.#jumpController.commit(state);
5441
5520
  }
5442
- /**
5443
- * Scrolls the viewport to the requested item index.
5444
- */
5445
- jumpTo(index, options = {}) {
5446
- this.#jumpController.jumpTo(index, options);
5447
- }
5448
- /**
5449
- * Scrolls the viewport to the visual top edge and arms top auto-follow immediately.
5450
- */
5451
- jumpToTop(options = {}) {
5452
- this.#jumpController.jumpToBoundary("top", options);
5453
- }
5454
- /**
5455
- * Scrolls the viewport to the visual bottom edge and arms bottom auto-follow immediately.
5456
- */
5457
- jumpToBottom(options = {}) {
5458
- this.#jumpController.jumpToBoundary("bottom", options);
5459
- }
5460
5521
  _resetRenderFeedback(feedback) {
5461
5522
  if (feedback == null) return;
5462
- feedback.minIdx = NaN;
5463
- feedback.maxIdx = NaN;
5464
- feedback.min = NaN;
5465
- feedback.max = NaN;
5466
- feedback.canAutoFollowTop = false;
5467
- feedback.canAutoFollowBottom = false;
5523
+ initRenderFeedback(feedback);
5468
5524
  }
5469
5525
  _accumulateRenderFeedback(feedback, idx, top, height) {
5470
5526
  const visibleRange = this._readVisibleRange(top, height);
@@ -5662,6 +5718,17 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
5662
5718
  for (const change of changes) this.#handleListStateChange(change, readInternalListStateChangeTime(change), readInternalListStateChangeSnapshot(change));
5663
5719
  }
5664
5720
  }
5721
+ #handleListScrollCommand(command, now) {
5722
+ if (command.type === "boundary") {
5723
+ this.#jumpController.jumpToBoundary(command.boundary, command.options, now);
5724
+ return;
5725
+ }
5726
+ this.#jumpController.jumpTo(command.index, command.options, now);
5727
+ }
5728
+ #drainPendingListScrollCommands() {
5729
+ const commands = drainInternalListScrollCommands(this.options.list);
5730
+ for (const command of commands) this.#handleListScrollCommand(command, readInternalListScrollCommandTime(command));
5731
+ }
5665
5732
  };
5666
5733
  //#endregion
5667
5734
  //#region src/renderer/virtualized/anchor-model.ts
@@ -5780,6 +5847,6 @@ var ListRenderer = class extends VirtualizedRenderer {
5780
5847
  }
5781
5848
  };
5782
5849
  //#endregion
5783
- export { BaseRenderer, DebugRenderer, Fixed, Flex, FlexItem, Group, ListRenderer, ListState, MultilineText, PaddingBox, Place, ShrinkWrap, Text, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
5850
+ export { BaseRenderer, DebugRenderer, Fixed, Flex, FlexItem, Group, ListRenderer, ListState, MultilineText, PaddingBox, Place, ShrinkWrap, Text, VirtualizedRenderer, Wrapper, initRenderFeedback, memoRenderItem, memoRenderItemBy };
5784
5851
 
5785
5852
  //# sourceMappingURL=index.mjs.map