chat-layout 1.2.0-2 → 1.2.0-3

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
@@ -28,10 +28,11 @@ const bubble = new RoundedBox(
28
28
  );
29
29
 
30
30
  const body = new ShrinkWrap(
31
- new Flex(
32
- [senderLine, bubble],
33
- { direction: "column", gap: 4, alignItems: item.sender === "A" ? "end" : "start" },
34
- ),
31
+ new Flex([senderLine, bubble], {
32
+ direction: "column",
33
+ gap: 4,
34
+ alignItems: item.sender === "A" ? "end" : "start",
35
+ }),
35
36
  );
36
37
 
37
38
  const row = new Flex(
@@ -74,17 +75,20 @@ See [example/chat.ts](./example/chat.ts) for a full chat example.
74
75
  Single-line `Text` can ellipsize at the start, end, or middle when a finite width constraint is present:
75
76
 
76
77
  ```ts
77
- const title = new Text([
78
- { text: "Extremely long " },
79
- { text: "thread title", font: "700 16px system-ui", color: "#0f766e" },
80
- { text: " that should not blow out the row" },
81
- ], {
82
- lineHeight: 20,
83
- font: "16px system-ui",
84
- color: "#111",
85
- overflow: "ellipsis",
86
- ellipsisPosition: "middle",
87
- });
78
+ const title = new Text(
79
+ [
80
+ { text: "Extremely long " },
81
+ { text: "thread title", font: "700 16px system-ui", color: "#0f766e" },
82
+ { text: " that should not blow out the row" },
83
+ ],
84
+ {
85
+ lineHeight: 20,
86
+ font: "16px system-ui",
87
+ color: "#111",
88
+ overflow: "ellipsis",
89
+ ellipsisPosition: "middle",
90
+ },
91
+ );
88
92
  ```
89
93
 
90
94
  Multi-line `MultilineText` can cap the visible line count and convert the last visible line to an end ellipsis:
@@ -119,9 +123,9 @@ const justified = new MultilineText(paragraph, {
119
123
  font: "16px system-ui",
120
124
  color: "#111",
121
125
  align: "start",
122
- justify: true, // or "inter-word" | "inter-character"
123
- justifyLastLine: false, // default: last line uses normal alignment
124
- justifyGapThreshold: 2.0, // max gap ratio before fallback
126
+ justify: true, // or "inter-word" | "inter-character"
127
+ justifyLastLine: false, // default: last line uses normal alignment
128
+ justifyGapThreshold: 2.0, // max gap ratio before fallback
125
129
  });
126
130
  ```
127
131
 
package/example/chat.ts CHANGED
@@ -195,7 +195,11 @@ const richTextMessage: InlineSpan<C>[] = [
195
195
  { text: "、" },
196
196
  { text: "粗体", font: "700 16px system-ui", color: "#b91c1c" },
197
197
  { text: ",以及 " },
198
- { text: "inline code", font: "15px ui-monospace, SFMono-Regular, Consolas, monospace", color: "#7c3aed" },
198
+ {
199
+ text: "inline code",
200
+ font: "15px ui-monospace, SFMono-Regular, Consolas, monospace",
201
+ color: "#7c3aed",
202
+ },
199
203
  { text: " 这样的片段混排。" },
200
204
  ];
201
205
 
@@ -205,7 +209,11 @@ const richReplyPreview: InlineSpan<C>[] = [
205
209
  { text: ",比如 " },
206
210
  { text: "关键词高亮", color: "#2563eb" },
207
211
  { text: " 和 " },
208
- { text: "code()", font: "12px ui-monospace, SFMono-Regular, Consolas, monospace", color: "#7c3aed" },
212
+ {
213
+ text: "code()",
214
+ font: "12px ui-monospace, SFMono-Regular, Consolas, monospace",
215
+ color: "#7c3aed",
216
+ },
209
217
  {
210
218
  text: ",超长内容仍然会按原来的两行省略规则收起,不需要额外处理。",
211
219
  },
@@ -234,13 +242,15 @@ class ItemDetector extends Wrapper<C> {
234
242
  hittest(_ctx: Context<C>, test: HitTest): boolean {
235
243
  currentHover = this.item;
236
244
  if (test.type === "click") {
237
- const index = list.items.indexOf(this.item);
238
- if (index < 0) {
245
+ if (!list.items.includes(this.item)) {
239
246
  return true;
240
247
  }
241
- const nextItem = this.item.kind === "revoked" ? this.item.original : revokeMessage(this.item);
248
+ const nextItem =
249
+ this.item.kind === "revoked"
250
+ ? this.item.original
251
+ : revokeMessage(this.item);
242
252
  currentHover = nextItem;
243
- list.replace(index, nextItem, {
253
+ list.update(this.item, nextItem, {
244
254
  duration: REPLACE_ANIMATION_DURATION,
245
255
  });
246
256
  }
@@ -266,8 +276,10 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
266
276
  left: 12,
267
277
  right: 12,
268
278
  radii: 999,
269
- fill: () => (currentHover?.id === item.id ? "#d9d9d9" : "#ececec"),
270
- stroke: () => (currentHover?.id === item.id ? "#bcbcbc" : "#d3d3d3"),
279
+ fill: () =>
280
+ currentHover?.id === item.id ? "#d9d9d9" : "#ececec",
281
+ stroke: () =>
282
+ currentHover?.id === item.id ? "#bcbcbc" : "#d3d3d3",
271
283
  },
272
284
  ),
273
285
  {
@@ -632,12 +644,13 @@ button("jump latest (no anim)", () => {
632
644
  });
633
645
 
634
646
  button("revoke first", () => {
635
- const item = list.items.find((entry): entry is MessageItem => entry.kind === "message");
647
+ const item = list.items.find(
648
+ (entry): entry is MessageItem => entry.kind === "message",
649
+ );
636
650
  if (item == null) {
637
651
  return;
638
652
  }
639
- const index = list.items.indexOf(item);
640
- list.replace(index, revokeMessage(item), {
653
+ list.update(item, revokeMessage(item), {
641
654
  duration: REPLACE_ANIMATION_DURATION,
642
655
  });
643
656
  });
package/example/test.ts CHANGED
@@ -47,8 +47,10 @@ class RoundedBox extends PaddingBox<C> {
47
47
  // Reuse the current layout constraints so the background matches wrapped text.
48
48
  const { width, height } = ctx.measureNode(this, ctx.constraints);
49
49
  ctx.with((g) => {
50
- const fill = this.fill == null ? undefined : ctx.resolveDynValue(this.fill);
51
- const stroke = this.stroke == null ? undefined : ctx.resolveDynValue(this.stroke);
50
+ const fill =
51
+ this.fill == null ? undefined : ctx.resolveDynValue(this.fill);
52
+ const stroke =
53
+ this.stroke == null ? undefined : ctx.resolveDynValue(this.stroke);
52
54
  g.beginPath();
53
55
  g.roundRect(x, y, width, height, this.radii);
54
56
  if (fill != null) {
package/index.d.mts CHANGED
@@ -509,7 +509,7 @@ declare class DebugRenderer<C extends CanvasRenderingContext2D> extends BaseRend
509
509
  /**
510
510
  * Mutable list state shared with virtualized renderers.
511
511
  */
512
- interface ReplaceListItemAnimationOptions {
512
+ interface UpdateListItemAnimationOptions {
513
513
  /** Animation duration in milliseconds. */
514
514
  duration?: number;
515
515
  }
@@ -536,9 +536,9 @@ declare class ListState<T extends {}> {
536
536
  /** Appends an array of items. */
537
537
  pushAll(items: T[]): void;
538
538
  /**
539
- * Replaces an existing item by index.
539
+ * Updates an existing item by object identity.
540
540
  */
541
- replace(index: number, item: T, animation?: ReplaceListItemAnimationOptions): void;
541
+ update(targetItem: T, nextItem: T, animation?: UpdateListItemAnimationOptions): void;
542
542
  /**
543
543
  * Sets the current anchor item and pixel offset.
544
544
  */
@@ -658,7 +658,7 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
658
658
  protected _finishRender(requestRedraw: boolean): boolean;
659
659
  protected _clampItemIndex(index: number): number;
660
660
  protected _getItemHeight(index: number): number;
661
- protected _resolveItem(item: T, index: number, now: number): {
661
+ protected _resolveItem(item: T, _index: number, now: number): {
662
662
  value: VirtualizedResolvedItem<C>;
663
663
  height: number;
664
664
  };
@@ -703,5 +703,5 @@ declare class TimelineRenderer<C extends CanvasRenderingContext2D, T extends {}>
703
703
  hittest(test: HitTest): boolean;
704
704
  }
705
705
  //#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, ReplaceListItemAnimationOptions, ShrinkWrap, Text, TextAlign, TextEllipsisPosition, TextJustifyMode, TextJustifyOptions, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
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 };
707
707
  //# sourceMappingURL=index.d.mts.map
package/index.mjs CHANGED
@@ -3625,6 +3625,24 @@ function subscribeListState(list, owner, listener) {
3625
3625
  token
3626
3626
  });
3627
3627
  }
3628
+ function isObjectIdentityCandidate(value) {
3629
+ return typeof value === "object" && value !== null || typeof value === "function";
3630
+ }
3631
+ function assertUniqueItemReferences(items, existingItems) {
3632
+ const seen = /* @__PURE__ */ new Set();
3633
+ if (existingItems != null) {
3634
+ for (const item of existingItems) if (isObjectIdentityCandidate(item)) seen.add(item);
3635
+ }
3636
+ for (const item of items) {
3637
+ if (!isObjectIdentityCandidate(item)) continue;
3638
+ if (seen.has(item)) throw new Error("ListState items must use unique object references.");
3639
+ seen.add(item);
3640
+ }
3641
+ }
3642
+ function normalizeUpdateAnimation(animation) {
3643
+ if (animation == null) return;
3644
+ return Number.isFinite(animation.duration) ? { duration: animation.duration } : {};
3645
+ }
3628
3646
  var ListState = class {
3629
3647
  #items;
3630
3648
  /** Pixel offset from the anchored item edge. */
@@ -3637,14 +3655,18 @@ var ListState = class {
3637
3655
  }
3638
3656
  /** Replaces the full item collection while preserving scroll state. */
3639
3657
  set items(value) {
3640
- this.#items = [...value];
3658
+ const nextItems = [...value];
3659
+ assertUniqueItemReferences(nextItems);
3660
+ this.#items = nextItems;
3641
3661
  emitListStateChange(this, { type: "set" });
3642
3662
  }
3643
3663
  /**
3644
3664
  * @param items Initial list items.
3645
3665
  */
3646
3666
  constructor(items = []) {
3647
- this.#items = [...items];
3667
+ const nextItems = [...items];
3668
+ assertUniqueItemReferences(nextItems);
3669
+ this.#items = nextItems;
3648
3670
  }
3649
3671
  /** Prepends one or more items. */
3650
3672
  unshift(...items) {
@@ -3653,6 +3675,7 @@ var ListState = class {
3653
3675
  /** Prepends an array of items. */
3654
3676
  unshiftAll(items) {
3655
3677
  if (items.length === 0) return;
3678
+ assertUniqueItemReferences(items, this.#items);
3656
3679
  if (this.position != null) this.position += items.length;
3657
3680
  this.#items = items.concat(this.#items);
3658
3681
  emitListStateChange(this, {
@@ -3667,6 +3690,7 @@ var ListState = class {
3667
3690
  /** Appends an array of items. */
3668
3691
  pushAll(items) {
3669
3692
  if (items.length === 0) return;
3693
+ assertUniqueItemReferences(items, this.#items);
3670
3694
  this.#items.push(...items);
3671
3695
  emitListStateChange(this, {
3672
3696
  type: "push",
@@ -3674,19 +3698,21 @@ var ListState = class {
3674
3698
  });
3675
3699
  }
3676
3700
  /**
3677
- * Replaces an existing item by index.
3701
+ * Updates an existing item by object identity.
3678
3702
  */
3679
- replace(index, item, animation) {
3680
- const normalizedIndex = Number.isFinite(index) ? Math.trunc(index) : NaN;
3681
- if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= this.#items.length) throw new RangeError(`replace() index ${index} is out of range for list length ${this.#items.length}.`);
3682
- const prevItem = this.#items[normalizedIndex];
3683
- this.#items[normalizedIndex] = item;
3703
+ update(targetItem, nextItem, animation) {
3704
+ if (!isObjectIdentityCandidate(targetItem) || !isObjectIdentityCandidate(nextItem)) throw new TypeError("update() only supports object items.");
3705
+ if (targetItem === nextItem) throw new Error("update() requires nextItem to be a new object reference.");
3706
+ const index = this.#items.indexOf(targetItem);
3707
+ if (index < 0) throw new Error("update() targetItem is not present in the list.");
3708
+ if (this.#items.includes(nextItem)) throw new Error("update() nextItem is already present in the list.");
3709
+ const prevItem = this.#items[index];
3710
+ this.#items[index] = nextItem;
3684
3711
  emitListStateChange(this, {
3685
- type: "replace",
3686
- index: normalizedIndex,
3712
+ type: "update",
3687
3713
  prevItem,
3688
- nextItem: item,
3689
- animation: animation != null && Number.isFinite(animation.duration) ? { duration: animation.duration } : animation == null ? void 0 : {}
3714
+ nextItem,
3715
+ animation: normalizeUpdateAnimation(animation)
3690
3716
  });
3691
3717
  }
3692
3718
  /**
@@ -3700,7 +3726,9 @@ var ListState = class {
3700
3726
  * Replaces all items and clears scroll state.
3701
3727
  */
3702
3728
  reset(items = []) {
3703
- this.#items = [...items];
3729
+ const nextItems = [...items];
3730
+ assertUniqueItemReferences(nextItems);
3731
+ this.#items = nextItems;
3704
3732
  this.offset = 0;
3705
3733
  this.position = void 0;
3706
3734
  emitListStateChange(this, { type: "reset" });
@@ -3808,7 +3836,8 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
3808
3836
  static JUMP_DURATION_PER_ITEM = 28;
3809
3837
  #controlledState;
3810
3838
  #jumpAnimation;
3811
- #replacementAnimations = /* @__PURE__ */ new Map();
3839
+ #replacementAnimations = /* @__PURE__ */ new WeakMap();
3840
+ #activeReplacementItems = /* @__PURE__ */ new Set();
3812
3841
  #nextReplacementLayerKey = 0;
3813
3842
  constructor(graphics, options) {
3814
3843
  super(graphics, options);
@@ -3971,14 +4000,14 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
3971
4000
  }
3972
4001
  _getItemHeight(index) {
3973
4002
  const now = getNow();
3974
- const replacement = this.#readReplacementAnimation(index, now);
3975
- if (replacement != null) return this.#sampleReplacementHeight(replacement, now);
3976
4003
  const item = this.items[index];
4004
+ const replacement = this.#readReplacementAnimation(item, now);
4005
+ if (replacement != null) return this.#sampleReplacementHeight(replacement, now);
3977
4006
  const node = this.options.renderItem(item);
3978
4007
  return this.measureRootNode(node).height;
3979
4008
  }
3980
- _resolveItem(item, index, now) {
3981
- const replacement = this.#readReplacementAnimation(index, now);
4009
+ _resolveItem(item, _index, now) {
4010
+ const replacement = this.#readReplacementAnimation(item, now);
3982
4011
  if (replacement == null) {
3983
4012
  const node = this.options.renderItem(item);
3984
4013
  return {
@@ -4050,24 +4079,26 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4050
4079
  #isLayerComplete(layer, now) {
4051
4080
  return getProgress(layer.startTime, layer.duration, now) >= 1 && Math.abs(layer.toAlpha - this.#sampleLayerAlpha(layer, now)) <= ALPHA_EPSILON;
4052
4081
  }
4053
- #readReplacementAnimation(index, now) {
4054
- const animation = this.#replacementAnimations.get(index);
4082
+ #readReplacementAnimation(item, now) {
4083
+ const animation = this.#replacementAnimations.get(item);
4055
4084
  if (animation == null) return;
4056
4085
  const currentLayer = animation.layers.find((layer) => layer.key === animation.currentLayerKey);
4057
4086
  if (currentLayer == null) {
4058
- this.#replacementAnimations.delete(index);
4087
+ this.#replacementAnimations.delete(item);
4088
+ this.#activeReplacementItems.delete(item);
4059
4089
  return;
4060
4090
  }
4061
4091
  animation.layers = animation.layers.filter((layer) => layer.key === animation.currentLayerKey || !this.#isLayerComplete(layer, now));
4062
4092
  if (getProgress(animation.startTime, animation.duration, now) >= 1 && this.#isLayerComplete(currentLayer, now) && animation.layers.length === 1) {
4063
- this.#replacementAnimations.delete(index);
4093
+ this.#replacementAnimations.delete(item);
4094
+ this.#activeReplacementItems.delete(item);
4064
4095
  return;
4065
4096
  }
4066
4097
  return animation;
4067
4098
  }
4068
4099
  #prepareReplacementAnimations(now) {
4069
4100
  let keepAnimating = false;
4070
- for (const index of [...this.#replacementAnimations.keys()]) if (this.#readReplacementAnimation(index, now) != null) keepAnimating = true;
4101
+ for (const item of [...this.#activeReplacementItems]) if (this.#readReplacementAnimation(item, now) != null) keepAnimating = true;
4071
4102
  return keepAnimating;
4072
4103
  }
4073
4104
  #drawReplacementLayers(layers, slotHeight, y) {
@@ -4093,38 +4124,34 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4093
4124
  }
4094
4125
  #handleListStateChange(change) {
4095
4126
  switch (change.type) {
4096
- case "replace":
4097
- this.#handleReplace(change.index, change.prevItem, change.nextItem, change.animation?.duration);
4127
+ case "update":
4128
+ this.#handleUpdate(change.prevItem, change.nextItem, change.animation?.duration);
4098
4129
  break;
4099
- case "unshift": {
4100
- if (change.count <= 0 || this.#replacementAnimations.size === 0) return;
4101
- const shifted = /* @__PURE__ */ new Map();
4102
- for (const [index, animation] of this.#replacementAnimations) shifted.set(index + change.count, animation);
4103
- this.#replacementAnimations = shifted;
4104
- break;
4105
- }
4130
+ case "unshift":
4106
4131
  case "push": break;
4107
4132
  case "reset":
4108
4133
  case "set":
4109
- this.#replacementAnimations.clear();
4134
+ this.#replacementAnimations = /* @__PURE__ */ new WeakMap();
4135
+ this.#activeReplacementItems.clear();
4110
4136
  break;
4111
4137
  }
4112
4138
  }
4113
- #handleReplace(index, prevItem, nextItem, duration) {
4114
- const normalizedDuration = Number.isFinite(duration) ? Math.max(0, duration) : 0;
4139
+ #handleUpdate(prevItem, nextItem, duration) {
4140
+ const normalizedDuration = Math.max(0, typeof duration === "number" && Number.isFinite(duration) ? duration : 0);
4115
4141
  if (normalizedDuration <= 0) {
4116
- this.#replacementAnimations.delete(index);
4142
+ this.#replacementAnimations.delete(prevItem);
4143
+ this.#activeReplacementItems.delete(prevItem);
4117
4144
  return;
4118
4145
  }
4119
4146
  const now = getNow();
4120
4147
  const nextNode = this.options.renderItem(nextItem);
4121
4148
  const nextHeight = this.measureRootNode(nextNode).height;
4122
- const animation = this.#readReplacementAnimation(index, now);
4149
+ const animation = this.#readReplacementAnimation(prevItem, now);
4123
4150
  if (animation == null) {
4124
4151
  const prevNode = this.options.renderItem(prevItem);
4125
4152
  const outgoing = this.#createReplacementLayer(prevNode, 1, 0, now, normalizedDuration);
4126
4153
  const incoming = this.#createReplacementLayer(nextNode, 0, 1, now, normalizedDuration);
4127
- this.#replacementAnimations.set(index, {
4154
+ this.#replacementAnimations.set(nextItem, {
4128
4155
  currentLayerKey: incoming.key,
4129
4156
  layers: [outgoing, incoming],
4130
4157
  fromHeight: this.measureRootNode(prevNode).height,
@@ -4132,6 +4159,8 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4132
4159
  startTime: now,
4133
4160
  duration: normalizedDuration
4134
4161
  });
4162
+ this.#activeReplacementItems.delete(prevItem);
4163
+ this.#activeReplacementItems.add(nextItem);
4135
4164
  return;
4136
4165
  }
4137
4166
  const currentLayer = animation.layers.find((layer) => layer.key === animation.currentLayerKey);
@@ -4141,7 +4170,8 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4141
4170
  if (currentAlpha > ALPHA_EPSILON) layers.push(this.#createReplacementLayer(currentNode, currentAlpha, 0, now, normalizedDuration));
4142
4171
  const incoming = this.#createReplacementLayer(nextNode, 0, 1, now, normalizedDuration);
4143
4172
  layers.push(incoming);
4144
- this.#replacementAnimations.set(index, {
4173
+ this.#replacementAnimations.delete(prevItem);
4174
+ this.#replacementAnimations.set(nextItem, {
4145
4175
  currentLayerKey: incoming.key,
4146
4176
  layers,
4147
4177
  fromHeight: this.#sampleReplacementHeight(animation, now),
@@ -4149,6 +4179,8 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
4149
4179
  startTime: now,
4150
4180
  duration: normalizedDuration
4151
4181
  });
4182
+ this.#activeReplacementItems.delete(prevItem);
4183
+ this.#activeReplacementItems.add(nextItem);
4152
4184
  }
4153
4185
  };
4154
4186
  //#endregion