chat-layout 1.2.0-3 → 1.2.0-5

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
@@ -9,7 +9,7 @@ The current v2-style APIs are:
9
9
  - `Place`: place a single child at `start` / `center` / `end`
10
10
  - `ShrinkWrap`: search the narrowest width that keeps the current height stable
11
11
  - `MultilineText`: text layout with logical `align` or physical `physicalAlign`
12
- - `ChatRenderer` + `ListState`: virtualized chat rendering
12
+ - `ListRenderer` + `ListState`: virtualized chat or timeline rendering
13
13
  - `memoRenderItem` / `memoRenderItemBy`: item render memoization
14
14
 
15
15
  ## Quick example
@@ -53,6 +53,29 @@ return row;
53
53
 
54
54
  See [example/chat.ts](./example/chat.ts) for a full chat example.
55
55
 
56
+ ## List insert animation
57
+
58
+ `pushAll()` and `unshiftAll()` can opt into short-list insertion animations. They only animate when the previous rendered frame still had spare space below the last item; otherwise they fall back to the normal hard cut:
59
+
60
+ ```ts
61
+ list.pushAll([nextMessage], {
62
+ distance: 24, // duration defaults to 220ms when animation options are present
63
+ });
64
+
65
+ list.unshiftAll([olderMessage], {
66
+ duration: 220,
67
+ });
68
+ ```
69
+
70
+ To make chat-style inserts automatically follow the latest visible edge, pass `followIfAtBoundary: true`. When the viewport was already pinned to that edge, the insert behaves like a conditional `jumpTo()` instead of combining with the enter animation:
71
+
72
+ ```ts
73
+ list.pushAll([nextMessage], {
74
+ followIfAtBoundary: true,
75
+ duration: 220,
76
+ });
77
+ ```
78
+
56
79
  ## Layout notes
57
80
 
58
81
  - `Flex` handles the main axis only. It shrink-wraps on the cross axis unless you opt into stretch behavior.
package/example/chat.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
- ChatRenderer,
3
2
  Flex,
4
3
  FlexItem,
5
4
  Fixed,
5
+ ListRenderer,
6
6
  ListState,
7
7
  MultilineText,
8
8
  PaddingBox,
@@ -221,6 +221,7 @@ const richReplyPreview: InlineSpan<C>[] = [
221
221
 
222
222
  let currentHover: ChatItem | undefined;
223
223
  const REPLACE_ANIMATION_DURATION = 320;
224
+ const INSERT_ANIMATION_DURATION = 220;
224
225
 
225
226
  function revokeMessage(item: MessageItem): RevokedItem {
226
227
  return {
@@ -253,6 +254,13 @@ class ItemDetector extends Wrapper<C> {
253
254
  list.update(this.item, nextItem, {
254
255
  duration: REPLACE_ANIMATION_DURATION,
255
256
  });
257
+ } else if (test.type === "auxclick") {
258
+ if (!list.items.includes(this.item)) {
259
+ return true;
260
+ }
261
+ list.delete(this.item, {
262
+ duration: REPLACE_ANIMATION_DURATION,
263
+ });
256
264
  }
257
265
  return true;
258
266
  }
@@ -491,7 +499,9 @@ const list = new ListState<ChatItem>([
491
499
  },
492
500
  { id: 9, kind: "message", sender: "B", content: randomText(5) },
493
501
  ]);
494
- const renderer = new ChatRenderer(ctx, {
502
+ const renderer = new ListRenderer(ctx, {
503
+ anchorMode: "bottom",
504
+ underflowAlign: "top",
495
505
  renderItem,
496
506
  list,
497
507
  });
@@ -555,6 +565,19 @@ canvas.addEventListener("click", (e) => {
555
565
  }
556
566
  });
557
567
 
568
+ canvas.addEventListener("contextmenu", (e) => {
569
+ e.preventDefault();
570
+ const { top, left } = canvas.getBoundingClientRect();
571
+ const result = renderer.hittest({
572
+ x: e.clientX - left,
573
+ y: e.clientY - top,
574
+ type: "auxclick",
575
+ });
576
+ if (!result) {
577
+ currentHover = undefined;
578
+ }
579
+ });
580
+
558
581
  function randomEnglishText(words: number): string {
559
582
  const out: string[] = [];
560
583
  for (let i = 0; i < words; i += 1) {
@@ -610,21 +633,37 @@ function randomText(words: number): string {
610
633
  }
611
634
 
612
635
  button("unshift", () => {
613
- list.unshift({
614
- id: nextMessageId++,
615
- kind: "message",
616
- sender: Math.random() < 0.5 ? "A" : "B",
617
- content: randomText(10 + Math.floor(200 * Math.random())),
618
- });
636
+ list.unshiftAll(
637
+ [
638
+ {
639
+ id: nextMessageId++,
640
+ kind: "message",
641
+ sender: Math.random() < 0.5 ? "A" : "B",
642
+ content: randomText(10 + Math.floor(200 * Math.random())),
643
+ },
644
+ ],
645
+ {
646
+ duration: INSERT_ANIMATION_DURATION,
647
+ followIfAtBoundary: true,
648
+ },
649
+ );
619
650
  });
620
651
 
621
652
  button("push", () => {
622
- list.push({
623
- id: nextMessageId++,
624
- kind: "message",
625
- sender: Math.random() < 0.5 ? "A" : "B",
626
- content: randomText(10 + Math.floor(200 * Math.random())),
627
- });
653
+ list.pushAll(
654
+ [
655
+ {
656
+ id: nextMessageId++,
657
+ kind: "message",
658
+ sender: Math.random() < 0.5 ? "A" : "B",
659
+ content: randomText(10 + Math.floor(200 * Math.random())),
660
+ },
661
+ ],
662
+ {
663
+ distance: 24,
664
+ followIfAtBoundary: true,
665
+ },
666
+ );
628
667
  });
629
668
 
630
669
  button("jump middle", () => {
package/index.d.mts CHANGED
@@ -513,6 +513,20 @@ interface UpdateListItemAnimationOptions {
513
513
  /** Animation duration in milliseconds. */
514
514
  duration?: number;
515
515
  }
516
+ interface DeleteListItemAnimationOptions {
517
+ /** Animation duration in milliseconds. */
518
+ duration?: number;
519
+ }
520
+ interface InsertListItemsAnimationOptions {
521
+ /** Animation duration in milliseconds. */
522
+ duration?: number;
523
+ /** Enter offset in pixels measured from the final resting position. */
524
+ distance?: number;
525
+ /** Auto-follow the insertion edge when the viewport was already pinned there. */
526
+ followIfAtBoundary?: boolean;
527
+ }
528
+ type PushListItemsAnimationOptions = InsertListItemsAnimationOptions;
529
+ type UnshiftListItemsAnimationOptions = InsertListItemsAnimationOptions;
516
530
  declare class ListState<T extends {}> {
517
531
  #private;
518
532
  /** Pixel offset from the anchored item edge. */
@@ -530,15 +544,23 @@ declare class ListState<T extends {}> {
530
544
  /** Prepends one or more items. */
531
545
  unshift(...items: T[]): void;
532
546
  /** Prepends an array of items. */
533
- unshiftAll(items: T[]): void;
547
+ unshiftAll(items: T[], animation?: UnshiftListItemsAnimationOptions): void;
534
548
  /** Appends one or more items. */
535
549
  push(...items: T[]): void;
536
550
  /** Appends an array of items. */
537
- pushAll(items: T[]): void;
551
+ pushAll(items: T[], animation?: PushListItemsAnimationOptions): void;
538
552
  /**
539
553
  * Updates an existing item by object identity.
540
554
  */
541
555
  update(targetItem: T, nextItem: T, animation?: UpdateListItemAnimationOptions): void;
556
+ /**
557
+ * Starts deleting an existing item by object identity.
558
+ */
559
+ delete(item: T, animation?: DeleteListItemAnimationOptions): void;
560
+ /**
561
+ * Finalizes a pending delete by removing the item from the list.
562
+ */
563
+ finalizeDelete(item: T): void;
542
564
  /**
543
565
  * Sets the current anchor item and pixel offset.
544
566
  */
@@ -570,7 +592,24 @@ declare function memoRenderItemBy<C extends CanvasRenderingContext2D, T, K>(keyO
570
592
  resetKey: (key: K) => boolean;
571
593
  };
572
594
  //#endregion
595
+ //#region src/renderer/virtualized/base-types.d.ts
596
+ /** Per-item draw/hittest callbacks produced by the resolver. */
597
+ type VirtualizedResolvedItem = {
598
+ draw: (y: number) => boolean;
599
+ hittest: (test: HitTest, y: number) => boolean;
600
+ };
601
+ //#endregion
573
602
  //#region src/renderer/virtualized/solver.d.ts
603
+ type ListAnchorMode = "top" | "bottom";
604
+ type ListUnderflowAlign = "top" | "bottom";
605
+ interface ListLayoutOptions {
606
+ anchorMode?: ListAnchorMode;
607
+ underflowAlign?: ListUnderflowAlign;
608
+ }
609
+ interface ResolvedListLayoutOptions {
610
+ anchorMode: ListAnchorMode;
611
+ underflowAlign: ListUnderflowAlign;
612
+ }
574
613
  interface VisibleListState {
575
614
  position?: number;
576
615
  offset: number;
@@ -589,6 +628,11 @@ interface VisibleWindow<T> {
589
628
  drawList: VisibleWindowEntry<T>[];
590
629
  shift: number;
591
630
  }
631
+ interface VisibleWindowResult<T> {
632
+ normalizedState: NormalizedListState;
633
+ resolutionPath: number[];
634
+ window: VisibleWindow<T>;
635
+ }
592
636
  //#endregion
593
637
  //#region src/renderer/virtualized/base.d.ts
594
638
  /**
@@ -604,10 +648,6 @@ interface JumpToOptions {
604
648
  /** Called after the jump completes or finishes animating. */
605
649
  onComplete?: () => void;
606
650
  }
607
- type VirtualizedResolvedItem<C extends CanvasRenderingContext2D> = {
608
- draw: (y: number) => boolean;
609
- hittest: (test: HitTest, y: number) => boolean;
610
- };
611
651
  /**
612
652
  * Shared base class for virtualized list renderers.
613
653
  */
@@ -618,7 +658,7 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
618
658
  #private;
619
659
  static readonly MIN_JUMP_DURATION = 160;
620
660
  static readonly MAX_JUMP_DURATION = 420;
621
- static readonly JUMP_DURATION_PER_ITEM = 28;
661
+ static readonly JUMP_DURATION_PER_PIXEL = 0.7;
622
662
  constructor(graphics: C, options: {
623
663
  renderItem: (item: T) => Node<C>;
624
664
  list: ListState<T>;
@@ -636,14 +676,15 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
636
676
  /** Replaces the current item collection. */
637
677
  set items(value: T[]);
638
678
  /** Renders the current visible window. */
639
- abstract render(feedback?: RenderFeedback): boolean;
679
+ render(feedback?: RenderFeedback): boolean;
640
680
  /** Hit-tests the current visible window. */
641
- abstract hittest(test: {
681
+ hittest(test: {
642
682
  x: number;
643
683
  y: number;
644
684
  type: "click" | "auxclick" | "hover";
645
685
  }): boolean;
646
686
  protected _readListState(): VisibleListState;
687
+ protected _resolveVisibleWindow(now: number): VisibleWindowResult<VirtualizedResolvedItem>;
647
688
  protected _commitListState(state: NormalizedListState): void;
648
689
  /**
649
690
  * Scrolls the viewport to the requested item index.
@@ -651,57 +692,54 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
651
692
  jumpTo(index: number, options?: JumpToOptions): void;
652
693
  protected _resetRenderFeedback(feedback?: RenderFeedback): void;
653
694
  protected _accumulateRenderFeedback(feedback: RenderFeedback, idx: number, top: number, height: number): void;
654
- protected _renderDrawList(list: VisibleWindow<VirtualizedResolvedItem<C>>["drawList"], shift: number, feedback?: RenderFeedback): boolean;
655
- protected _renderVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem<C>>, feedback?: RenderFeedback): boolean;
656
- protected _hittestVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem<C>>, test: HitTest): boolean;
657
- protected _prepareRender(): boolean;
695
+ protected _renderDrawList(list: VisibleWindow<VirtualizedResolvedItem>["drawList"], shift: number, feedback?: RenderFeedback): boolean;
696
+ protected _renderVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem>, feedback?: RenderFeedback, extraShift?: number): boolean;
697
+ protected _readVisibleRange(top: number, height: number): {
698
+ top: number;
699
+ bottom: number;
700
+ } | undefined;
701
+ protected _pruneTransitionAnimations(_window: VisibleWindow<unknown>, now: number): boolean;
702
+ protected _hittestVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem>, test: HitTest, extraShift?: number): boolean;
703
+ protected _captureVisibleItemSnapshot(solution: VisibleWindowResult<unknown>, extraShift?: number): void;
704
+ protected _prepareRender(now: number): boolean;
658
705
  protected _finishRender(requestRedraw: boolean): boolean;
659
706
  protected _clampItemIndex(index: number): number;
660
707
  protected _getItemHeight(index: number): number;
708
+ protected _getItemHeightAt(index: number, now: number): number;
709
+ protected _readAnchorAt(now: number): number | undefined;
710
+ protected _restoreAnchor(anchor: number): void;
661
711
  protected _resolveItem(item: T, _index: number, now: number): {
662
- value: VirtualizedResolvedItem<C>;
712
+ value: VirtualizedResolvedItem;
663
713
  height: number;
664
714
  };
665
- protected _getAnchorAtOffset(index: number, offset: number): number;
666
715
  protected abstract _normalizeListState(state: VisibleListState): NormalizedListState;
667
- protected abstract _readAnchor(state: NormalizedListState): number;
716
+ protected abstract _getLayoutOptions(): ResolvedListLayoutOptions;
717
+ protected abstract _resolveVisibleWindowForState(state: VisibleListState, now: number): VisibleWindowResult<VirtualizedResolvedItem>;
718
+ protected abstract _readAnchor(state: NormalizedListState, readItemHeight: (index: number) => number): number;
668
719
  protected abstract _applyAnchor(anchor: number): void;
669
720
  protected abstract _getDefaultJumpBlock(): NonNullable<JumpToOptions["block"]>;
670
721
  protected abstract _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
671
- protected abstract _getAnimatedLayerOffset(slotHeight: number, nodeHeight: number): number;
672
722
  }
673
723
  //#endregion
674
- //#region src/renderer/virtualized/chat.d.ts
675
- /**
676
- * Virtualized renderer anchored to the bottom, suitable for chat-style UIs.
677
- */
678
- declare class ChatRenderer<C extends CanvasRenderingContext2D, T extends {}> extends VirtualizedRenderer<C, T> {
679
- #private;
680
- protected _getDefaultJumpBlock(): NonNullable<JumpToOptions["block"]>;
681
- protected _normalizeListState(state: VisibleListState): NormalizedListState;
682
- protected _readAnchor(state: NormalizedListState): number;
683
- protected _applyAnchor(anchor: number): void;
684
- protected _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
685
- protected _getAnimatedLayerOffset(slotHeight: number, nodeHeight: number): number;
686
- render(feedback?: RenderFeedback): boolean;
687
- hittest(test: HitTest): boolean;
724
+ //#region src/renderer/virtualized/list.d.ts
725
+ interface ListRendererOptions<C extends CanvasRenderingContext2D, T extends {}> extends ListLayoutOptions {
726
+ renderItem: (item: T) => Node<C>;
727
+ list: ListState<T>;
688
728
  }
689
- //#endregion
690
- //#region src/renderer/virtualized/timeline.d.ts
691
729
  /**
692
- * Virtualized renderer anchored to the top, suitable for timeline-style UIs.
730
+ * Virtualized list renderer with configurable anchor semantics.
693
731
  */
694
- declare class TimelineRenderer<C extends CanvasRenderingContext2D, T extends {}> extends VirtualizedRenderer<C, T> {
732
+ declare class ListRenderer<C extends CanvasRenderingContext2D, T extends {}> extends VirtualizedRenderer<C, T> {
695
733
  #private;
734
+ constructor(graphics: C, options: ListRendererOptions<C, T>);
735
+ protected _getLayoutOptions(): ResolvedListLayoutOptions;
736
+ protected _resolveVisibleWindowForState(state: VisibleListState, now: number): VisibleWindowResult<VirtualizedResolvedItem>;
696
737
  protected _getDefaultJumpBlock(): NonNullable<JumpToOptions["block"]>;
697
738
  protected _normalizeListState(state: VisibleListState): NormalizedListState;
698
- protected _readAnchor(state: NormalizedListState): number;
739
+ protected _readAnchor(state: NormalizedListState, readItemHeight: (index: number) => number): number;
699
740
  protected _applyAnchor(anchor: number): void;
700
741
  protected _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
701
- protected _getAnimatedLayerOffset(_slotHeight: number, _nodeHeight: number): number;
702
- render(feedback?: RenderFeedback): boolean;
703
- hittest(test: HitTest): boolean;
704
742
  }
705
743
  //#endregion
706
- export { Axis, BaseRenderer, Box, ChatRenderer, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, InlineSpan, JumpToOptions, LayoutConstraints, LayoutRect, ListState, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, RenderFeedback, RendererOptions, ShrinkWrap, Text, TextAlign, TextEllipsisPosition, TextJustifyMode, TextJustifyOptions, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, TimelineRenderer, UpdateListItemAnimationOptions, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
744
+ 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, 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 };
707
745
  //# sourceMappingURL=index.d.mts.map