chat-layout 1.1.0-2 → 1.1.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/example/chat.ts CHANGED
@@ -131,18 +131,42 @@ if (context == null) {
131
131
  const ctx: C = context;
132
132
  ctx.scale(devicePixelRatio, devicePixelRatio);
133
133
 
134
- type ChatItem = {
134
+ type ReplyPreview = {
135
135
  sender: string;
136
136
  content: string;
137
- reply?: {
138
- sender: string;
139
- content: string;
140
- };
141
137
  };
142
138
 
139
+ type BaseChatItem = {
140
+ id: number;
141
+ sender: string;
142
+ };
143
+
144
+ type MessageItem = BaseChatItem & {
145
+ kind: "message";
146
+ content: string;
147
+ reply?: ReplyPreview;
148
+ };
149
+
150
+ type RevokedItem = BaseChatItem & {
151
+ kind: "revoked";
152
+ original: MessageItem;
153
+ };
154
+
155
+ type ChatItem = MessageItem | RevokedItem;
156
+
143
157
  let currentHover: ChatItem | undefined;
158
+ const REPLACE_ANIMATION_DURATION = 320;
159
+
160
+ function revokeMessage(item: MessageItem): RevokedItem {
161
+ return {
162
+ id: item.id,
163
+ sender: item.sender,
164
+ kind: "revoked",
165
+ original: item,
166
+ };
167
+ }
144
168
 
145
- class HoverDetector extends Wrapper<C> {
169
+ class ItemDetector extends Wrapper<C> {
146
170
  constructor(
147
171
  inner: Node<C>,
148
172
  readonly item: ChatItem,
@@ -150,21 +174,65 @@ class HoverDetector extends Wrapper<C> {
150
174
  super(inner);
151
175
  }
152
176
 
153
- hittest(_ctx: Context<C>, _test: HitTest): boolean {
177
+ hittest(_ctx: Context<C>, test: HitTest): boolean {
154
178
  currentHover = this.item;
179
+ if (test.type === "click") {
180
+ const index = list.items.indexOf(this.item);
181
+ if (index < 0) {
182
+ return true;
183
+ }
184
+ const nextItem = this.item.kind === "revoked" ? this.item.original : revokeMessage(this.item);
185
+ currentHover = nextItem;
186
+ list.replace(index, nextItem, {
187
+ duration: REPLACE_ANIMATION_DURATION,
188
+ });
189
+ }
155
190
  return true;
156
191
  }
157
192
  }
158
193
 
159
194
  const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
195
+ if (item.kind === "revoked") {
196
+ return new ItemDetector(
197
+ new Place(
198
+ new PaddingBox(
199
+ new RoundedBox(
200
+ new Text(`${item.sender}已撤回一条消息`, {
201
+ lineHeight: 18,
202
+ font: "14px system-ui",
203
+ style: () => (currentHover?.id === item.id ? "#525252" : "#666"),
204
+ overflow: "ellipsis",
205
+ }),
206
+ {
207
+ top: 10,
208
+ bottom: 10,
209
+ left: 12,
210
+ right: 12,
211
+ radii: 999,
212
+ fill: () => (currentHover?.id === item.id ? "#d9d9d9" : "#ececec"),
213
+ stroke: () => (currentHover?.id === item.id ? "#bcbcbc" : "#d3d3d3"),
214
+ },
215
+ ),
216
+ {
217
+ top: 8,
218
+ bottom: 8,
219
+ left: 4,
220
+ right: 4,
221
+ },
222
+ ),
223
+ {
224
+ align: item.sender === "A" ? "end" : "start",
225
+ },
226
+ ),
227
+ item,
228
+ );
229
+ }
230
+
160
231
  const senderLine = new Flex<C>(
161
232
  [
162
- new HoverDetector(
163
- new Circle(15, {
164
- fill: "blue",
165
- }),
166
- item,
167
- ),
233
+ new Circle(15, {
234
+ fill: "blue",
235
+ }),
168
236
  new RoundedBox(
169
237
  new Text(item.sender, {
170
238
  lineHeight: 15,
@@ -177,7 +245,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
177
245
  left: 0,
178
246
  right: 0,
179
247
  radii: 2,
180
- fill: () => (currentHover === item ? "red" : "transparent"),
248
+ fill: () => (currentHover?.id === item.id ? "red" : "transparent"),
181
249
  },
182
250
  ),
183
251
  ],
@@ -209,12 +277,12 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
209
277
  new Text(item.reply.sender, {
210
278
  lineHeight: 14,
211
279
  font: "11px system-ui",
212
- style: () => (currentHover === item ? "#4d4d4d" : "#666"),
280
+ style: () => (currentHover?.id === item.id ? "#4d4d4d" : "#666"),
213
281
  }),
214
282
  new MultilineText(item.reply.content, {
215
283
  lineHeight: 16,
216
284
  font: "13px system-ui",
217
- style: () => (currentHover === item ? "#222" : "#444"),
285
+ style: () => (currentHover?.id === item.id ? "#222" : "#444"),
218
286
  align: "start",
219
287
  overflow: "ellipsis",
220
288
  overflowWrap: "anywhere",
@@ -233,7 +301,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
233
301
  left: 8,
234
302
  right: 8,
235
303
  radii: 6,
236
- fill: () => (currentHover === item ? "#c2c2c2" : "#e2e2e2"),
304
+ fill: () => (currentHover?.id === item.id ? "#c2c2c2" : "#e2e2e2"),
237
305
  },
238
306
  ),
239
307
  { alignSelf: "stretch" },
@@ -255,7 +323,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
255
323
  left: 10,
256
324
  right: 10,
257
325
  radii: 8,
258
- fill: () => (currentHover === item ? "#aaa" : "#ccc"),
326
+ fill: () => (currentHover?.id === item.id ? "#aaa" : "#ccc"),
259
327
  });
260
328
 
261
329
  const body = new Flex<C>([senderLine, content], {
@@ -289,18 +357,25 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
289
357
  right: 4,
290
358
  });
291
359
 
292
- return new Place(padded, {
293
- align: item.sender === "A" ? "end" : "start",
294
- });
360
+ return new ItemDetector(
361
+ new Place(padded, {
362
+ align: item.sender === "A" ? "end" : "start",
363
+ }),
364
+ item,
365
+ );
295
366
  });
296
367
 
297
368
  const list = new ListState<ChatItem>([
298
369
  {
370
+ id: 1,
371
+ kind: "message",
299
372
  sender: "A",
300
373
  content:
301
374
  "hello world chat layout message render bubble timeline virtualized canvas",
302
375
  },
303
376
  {
377
+ id: 2,
378
+ kind: "message",
304
379
  sender: "B",
305
380
  content: "aaaa",
306
381
  reply: {
@@ -308,15 +383,19 @@ const list = new ListState<ChatItem>([
308
383
  content: "hello world chat layout message render",
309
384
  },
310
385
  },
311
- { sender: "B", content: "aaaabbb" },
312
- { sender: "B", content: "测试中文" },
386
+ { id: 3, kind: "message", sender: "B", content: "aaaabbb" },
387
+ { id: 4, kind: "message", sender: "B", content: "测试中文" },
313
388
  {
389
+ id: 5,
390
+ kind: "message",
314
391
  sender: "A",
315
392
  content:
316
393
  "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
317
394
  },
318
- { sender: "B", content: "测试aa中文aaa" },
395
+ { id: 6, kind: "message", sender: "B", content: "测试aa中文aaa" },
319
396
  {
397
+ id: 7,
398
+ kind: "message",
320
399
  sender: "A",
321
400
  content: randomText(8),
322
401
  reply: {
@@ -326,6 +405,8 @@ const list = new ListState<ChatItem>([
326
405
  },
327
406
  },
328
407
  {
408
+ id: 8,
409
+ kind: "message",
329
410
  sender: "B",
330
411
  content: "这里是一条会展示回复预览省略效果的消息。",
331
412
  reply: {
@@ -334,12 +415,13 @@ const list = new ListState<ChatItem>([
334
415
  "这是一条非常长的回复预览,用来演示 MultilineText 在 chat example 里的末尾 ellipsis 能力。它应该被限制在两行之内,而不是把整个气泡一路撑到天花板。",
335
416
  },
336
417
  },
337
- { sender: "B", content: randomText(5) },
418
+ { id: 9, kind: "message", sender: "B", content: randomText(5) },
338
419
  ]);
339
420
  const renderer = new ChatRenderer(ctx, {
340
421
  renderItem,
341
422
  list,
342
423
  });
424
+ let nextMessageId = list.items.length + 1;
343
425
 
344
426
  function drawFrame(): void {
345
427
  const feedback: RenderFeedback = {
@@ -383,6 +465,22 @@ canvas.addEventListener("pointermove", (e) => {
383
465
  }
384
466
  });
385
467
 
468
+ canvas.addEventListener("pointerleave", () => {
469
+ currentHover = undefined;
470
+ });
471
+
472
+ canvas.addEventListener("click", (e) => {
473
+ const { top, left } = canvas.getBoundingClientRect();
474
+ const result = renderer.hittest({
475
+ x: e.clientX - left,
476
+ y: e.clientY - top,
477
+ type: "click",
478
+ });
479
+ if (!result) {
480
+ currentHover = undefined;
481
+ }
482
+ });
483
+
386
484
  function randomText(words: number): string {
387
485
  const out: string[] = [];
388
486
  for (let i = 0; i < words; i += 1) {
@@ -393,6 +491,8 @@ function randomText(words: number): string {
393
491
 
394
492
  button("unshift", () => {
395
493
  list.unshift({
494
+ id: nextMessageId++,
495
+ kind: "message",
396
496
  sender: Math.random() < 0.5 ? "A" : "B",
397
497
  content: randomText(10 + Math.floor(200 * Math.random())),
398
498
  });
@@ -400,6 +500,8 @@ button("unshift", () => {
400
500
 
401
501
  button("push", () => {
402
502
  list.push({
503
+ id: nextMessageId++,
504
+ kind: "message",
403
505
  sender: Math.random() < 0.5 ? "A" : "B",
404
506
  content: randomText(10 + Math.floor(200 * Math.random())),
405
507
  });
@@ -420,3 +522,14 @@ button("jump latest (no anim)", () => {
420
522
  animated: false,
421
523
  });
422
524
  });
525
+
526
+ button("revoke first", () => {
527
+ const item = list.items.find((entry): entry is MessageItem => entry.kind === "message");
528
+ if (item == null) {
529
+ return;
530
+ }
531
+ const index = list.items.indexOf(item);
532
+ list.replace(index, revokeMessage(item), {
533
+ duration: REPLACE_ANIMATION_DURATION,
534
+ });
535
+ });
package/index.d.mts CHANGED
@@ -448,13 +448,20 @@ declare class DebugRenderer<C extends CanvasRenderingContext2D> extends BaseRend
448
448
  /**
449
449
  * Mutable list state shared with virtualized renderers.
450
450
  */
451
+ interface ReplaceListItemAnimationOptions {
452
+ /** Animation duration in milliseconds. */
453
+ duration?: number;
454
+ }
451
455
  declare class ListState<T extends {}> {
456
+ #private;
452
457
  /** Pixel offset from the anchored item edge. */
453
458
  offset: number;
454
459
  /** Anchor item index, or `undefined` to use the renderer default. */
455
460
  position: number | undefined;
456
461
  /** Items currently managed by the renderer. */
457
- items: T[];
462
+ get items(): T[];
463
+ /** Replaces the full item collection while preserving scroll state. */
464
+ set items(value: T[]);
458
465
  /**
459
466
  * @param items Initial list items.
460
467
  */
@@ -467,6 +474,10 @@ declare class ListState<T extends {}> {
467
474
  push(...items: T[]): void;
468
475
  /** Appends an array of items. */
469
476
  pushAll(items: T[]): void;
477
+ /**
478
+ * Replaces an existing item by index.
479
+ */
480
+ replace(index: number, item: T, animation?: ReplaceListItemAnimationOptions): void;
470
481
  /**
471
482
  * Sets the current anchor item and pixel offset.
472
483
  */
@@ -530,6 +541,10 @@ interface JumpToOptions {
530
541
  /** Called after the jump completes or finishes animating. */
531
542
  onComplete?: () => void;
532
543
  }
544
+ type VirtualizedResolvedItem<C extends CanvasRenderingContext2D> = {
545
+ draw: (y: number) => boolean;
546
+ hittest: (test: HitTest, y: number) => boolean;
547
+ };
533
548
  /**
534
549
  * Shared base class for virtualized list renderers.
535
550
  */
@@ -541,6 +556,10 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
541
556
  static readonly MIN_JUMP_DURATION = 160;
542
557
  static readonly MAX_JUMP_DURATION = 420;
543
558
  static readonly JUMP_DURATION_PER_ITEM = 28;
559
+ constructor(graphics: C, options: {
560
+ renderItem: (item: T) => Node<C>;
561
+ list: ListState<T>;
562
+ });
544
563
  /** Current anchor item index. */
545
564
  get position(): number | undefined;
546
565
  /** Updates the current anchor item index. */
@@ -569,23 +588,24 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
569
588
  jumpTo(index: number, options?: JumpToOptions): void;
570
589
  protected _resetRenderFeedback(feedback?: RenderFeedback): void;
571
590
  protected _accumulateRenderFeedback(feedback: RenderFeedback, idx: number, top: number, height: number): void;
572
- protected _renderDrawList(list: VisibleWindow<Node<C>>["drawList"], shift: number, feedback?: RenderFeedback): boolean;
573
- protected _renderVisibleWindow(window: VisibleWindow<Node<C>>, feedback?: RenderFeedback): boolean;
574
- protected _hittestVisibleWindow(window: VisibleWindow<Node<C>>, test: {
575
- x: number;
576
- y: number;
577
- type: "click" | "auxclick" | "hover";
578
- }): boolean;
591
+ protected _renderDrawList(list: VisibleWindow<VirtualizedResolvedItem<C>>["drawList"], shift: number, feedback?: RenderFeedback): boolean;
592
+ protected _renderVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem<C>>, feedback?: RenderFeedback): boolean;
593
+ protected _hittestVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem<C>>, test: HitTest): boolean;
579
594
  protected _prepareRender(): boolean;
580
595
  protected _finishRender(requestRedraw: boolean): boolean;
581
596
  protected _clampItemIndex(index: number): number;
582
597
  protected _getItemHeight(index: number): number;
598
+ protected _resolveItem(item: T, index: number, now: number): {
599
+ value: VirtualizedResolvedItem<C>;
600
+ height: number;
601
+ };
583
602
  protected _getAnchorAtOffset(index: number, offset: number): number;
584
603
  protected abstract _normalizeListState(state: VisibleListState): NormalizedListState;
585
604
  protected abstract _readAnchor(state: NormalizedListState): number;
586
605
  protected abstract _applyAnchor(anchor: number): void;
587
606
  protected abstract _getDefaultJumpBlock(): NonNullable<JumpToOptions["block"]>;
588
607
  protected abstract _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
608
+ protected abstract _getAnimatedLayerOffset(slotHeight: number, nodeHeight: number): number;
589
609
  }
590
610
  //#endregion
591
611
  //#region src/renderer/virtualized/chat.d.ts
@@ -599,6 +619,7 @@ declare class ChatRenderer<C extends CanvasRenderingContext2D, T extends {}> ext
599
619
  protected _readAnchor(state: NormalizedListState): number;
600
620
  protected _applyAnchor(anchor: number): void;
601
621
  protected _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
622
+ protected _getAnimatedLayerOffset(slotHeight: number, nodeHeight: number): number;
602
623
  render(feedback?: RenderFeedback): boolean;
603
624
  hittest(test: HitTest): boolean;
604
625
  }
@@ -614,9 +635,10 @@ declare class TimelineRenderer<C extends CanvasRenderingContext2D, T extends {}>
614
635
  protected _readAnchor(state: NormalizedListState): number;
615
636
  protected _applyAnchor(anchor: number): void;
616
637
  protected _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
638
+ protected _getAnimatedLayerOffset(_slotHeight: number, _nodeHeight: number): number;
617
639
  render(feedback?: RenderFeedback): boolean;
618
640
  hittest(test: HitTest): boolean;
619
641
  }
620
642
  //#endregion
621
- export { Axis, BaseRenderer, Box, ChatRenderer, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, JumpToOptions, LayoutConstraints, LayoutRect, ListState, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, RenderFeedback, RendererOptions, Text, TextAlign, TextEllipsisPosition, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
643
+ export { Axis, BaseRenderer, Box, ChatRenderer, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, JumpToOptions, LayoutConstraints, LayoutRect, ListState, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, RenderFeedback, RendererOptions, ReplaceListItemAnimationOptions, Text, TextAlign, TextEllipsisPosition, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
622
644
  //# sourceMappingURL=index.d.mts.map