chat-layout 1.1.0-2 → 1.1.0-4

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
@@ -13,6 +13,7 @@ import {
13
13
  type Context,
14
14
  type DynValue,
15
15
  type HitTest,
16
+ type InlineSpan,
16
17
  type Node,
17
18
  type RenderFeedback,
18
19
  } from "chat-layout";
@@ -131,18 +132,66 @@ if (context == null) {
131
132
  const ctx: C = context;
132
133
  ctx.scale(devicePixelRatio, devicePixelRatio);
133
134
 
134
- type ChatItem = {
135
+ type ReplyPreview = {
136
+ sender: string;
137
+ content: string | InlineSpan<C>[];
138
+ };
139
+
140
+ type BaseChatItem = {
141
+ id: number;
135
142
  sender: string;
136
- content: string;
137
- reply?: {
138
- sender: string;
139
- content: string;
140
- };
141
143
  };
142
144
 
145
+ type MessageItem = BaseChatItem & {
146
+ kind: "message";
147
+ content: string | InlineSpan<C>[];
148
+ reply?: ReplyPreview;
149
+ };
150
+
151
+ type RevokedItem = BaseChatItem & {
152
+ kind: "revoked";
153
+ original: MessageItem;
154
+ };
155
+
156
+ type ChatItem = MessageItem | RevokedItem;
157
+
158
+ const richTextMessage: InlineSpan<C>[] = [
159
+ { text: "现在这个 chat example 可以直接展示 " },
160
+ { text: "rich text", font: "700 16px system-ui", style: "#0f766e" },
161
+ { text: " 了,支持 " },
162
+ { text: "颜色", style: "#2563eb" },
163
+ { text: "、" },
164
+ { text: "粗体", font: "700 16px system-ui", style: "#b91c1c" },
165
+ { text: ",以及 " },
166
+ { text: "inline code", font: "15px ui-monospace, SFMono-Regular, Consolas, monospace", style: "#7c3aed" },
167
+ { text: " 这样的片段混排。" },
168
+ ];
169
+
170
+ const richReplyPreview: InlineSpan<C>[] = [
171
+ { text: "回复预览里也能用 " },
172
+ { text: "rich text", font: "700 13px system-ui", style: "#0f766e" },
173
+ { text: ",比如 " },
174
+ { text: "关键词高亮", style: "#2563eb" },
175
+ { text: " 和 " },
176
+ { text: "code()", font: "12px ui-monospace, SFMono-Regular, Consolas, monospace", style: "#7c3aed" },
177
+ {
178
+ text: ",超长内容仍然会按原来的两行省略规则收起,不需要额外处理。",
179
+ },
180
+ ];
181
+
143
182
  let currentHover: ChatItem | undefined;
183
+ const REPLACE_ANIMATION_DURATION = 320;
184
+
185
+ function revokeMessage(item: MessageItem): RevokedItem {
186
+ return {
187
+ id: item.id,
188
+ sender: item.sender,
189
+ kind: "revoked",
190
+ original: item,
191
+ };
192
+ }
144
193
 
145
- class HoverDetector extends Wrapper<C> {
194
+ class ItemDetector extends Wrapper<C> {
146
195
  constructor(
147
196
  inner: Node<C>,
148
197
  readonly item: ChatItem,
@@ -150,21 +199,65 @@ class HoverDetector extends Wrapper<C> {
150
199
  super(inner);
151
200
  }
152
201
 
153
- hittest(_ctx: Context<C>, _test: HitTest): boolean {
202
+ hittest(_ctx: Context<C>, test: HitTest): boolean {
154
203
  currentHover = this.item;
204
+ if (test.type === "click") {
205
+ const index = list.items.indexOf(this.item);
206
+ if (index < 0) {
207
+ return true;
208
+ }
209
+ const nextItem = this.item.kind === "revoked" ? this.item.original : revokeMessage(this.item);
210
+ currentHover = nextItem;
211
+ list.replace(index, nextItem, {
212
+ duration: REPLACE_ANIMATION_DURATION,
213
+ });
214
+ }
155
215
  return true;
156
216
  }
157
217
  }
158
218
 
159
219
  const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
220
+ if (item.kind === "revoked") {
221
+ return new ItemDetector(
222
+ new Place(
223
+ new PaddingBox(
224
+ new RoundedBox(
225
+ new Text(`${item.sender}已撤回一条消息`, {
226
+ lineHeight: 18,
227
+ font: "14px system-ui",
228
+ style: () => (currentHover?.id === item.id ? "#525252" : "#666"),
229
+ overflow: "ellipsis",
230
+ }),
231
+ {
232
+ top: 10,
233
+ bottom: 10,
234
+ left: 12,
235
+ right: 12,
236
+ radii: 999,
237
+ fill: () => (currentHover?.id === item.id ? "#d9d9d9" : "#ececec"),
238
+ stroke: () => (currentHover?.id === item.id ? "#bcbcbc" : "#d3d3d3"),
239
+ },
240
+ ),
241
+ {
242
+ top: 8,
243
+ bottom: 8,
244
+ left: 4,
245
+ right: 4,
246
+ },
247
+ ),
248
+ {
249
+ align: item.sender === "A" ? "end" : "start",
250
+ },
251
+ ),
252
+ item,
253
+ );
254
+ }
255
+
160
256
  const senderLine = new Flex<C>(
161
257
  [
162
- new HoverDetector(
163
- new Circle(15, {
164
- fill: "blue",
165
- }),
166
- item,
167
- ),
258
+ new Circle(15, {
259
+ fill: "blue",
260
+ }),
168
261
  new RoundedBox(
169
262
  new Text(item.sender, {
170
263
  lineHeight: 15,
@@ -177,7 +270,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
177
270
  left: 0,
178
271
  right: 0,
179
272
  radii: 2,
180
- fill: () => (currentHover === item ? "red" : "transparent"),
273
+ fill: () => (currentHover?.id === item.id ? "red" : "transparent"),
181
274
  },
182
275
  ),
183
276
  ],
@@ -209,12 +302,12 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
209
302
  new Text(item.reply.sender, {
210
303
  lineHeight: 14,
211
304
  font: "11px system-ui",
212
- style: () => (currentHover === item ? "#4d4d4d" : "#666"),
305
+ style: () => (currentHover?.id === item.id ? "#4d4d4d" : "#666"),
213
306
  }),
214
307
  new MultilineText(item.reply.content, {
215
308
  lineHeight: 16,
216
309
  font: "13px system-ui",
217
- style: () => (currentHover === item ? "#222" : "#444"),
310
+ style: () => (currentHover?.id === item.id ? "#222" : "#444"),
218
311
  align: "start",
219
312
  overflow: "ellipsis",
220
313
  overflowWrap: "anywhere",
@@ -233,7 +326,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
233
326
  left: 8,
234
327
  right: 8,
235
328
  radii: 6,
236
- fill: () => (currentHover === item ? "#c2c2c2" : "#e2e2e2"),
329
+ fill: () => (currentHover?.id === item.id ? "#c2c2c2" : "#e2e2e2"),
237
330
  },
238
331
  ),
239
332
  { alignSelf: "stretch" },
@@ -255,7 +348,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
255
348
  left: 10,
256
349
  right: 10,
257
350
  radii: 8,
258
- fill: () => (currentHover === item ? "#aaa" : "#ccc"),
351
+ fill: () => (currentHover?.id === item.id ? "#aaa" : "#ccc"),
259
352
  });
260
353
 
261
354
  const body = new Flex<C>([senderLine, content], {
@@ -289,34 +382,45 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
289
382
  right: 4,
290
383
  });
291
384
 
292
- return new Place(padded, {
293
- align: item.sender === "A" ? "end" : "start",
294
- });
385
+ return new ItemDetector(
386
+ new Place(padded, {
387
+ align: item.sender === "A" ? "end" : "start",
388
+ }),
389
+ item,
390
+ );
295
391
  });
296
392
 
297
393
  const list = new ListState<ChatItem>([
298
394
  {
395
+ id: 1,
396
+ kind: "message",
299
397
  sender: "A",
300
398
  content:
301
399
  "hello world chat layout message render bubble timeline virtualized canvas",
302
400
  },
303
401
  {
402
+ id: 2,
403
+ kind: "message",
304
404
  sender: "B",
305
- content: "aaaa",
405
+ content: richTextMessage,
306
406
  reply: {
307
407
  sender: "A",
308
408
  content: "hello world chat layout message render",
309
409
  },
310
410
  },
311
- { sender: "B", content: "aaaabbb" },
312
- { sender: "B", content: "测试中文" },
411
+ { id: 3, kind: "message", sender: "B", content: "aaaabbb" },
412
+ { id: 4, kind: "message", sender: "B", content: "测试中文" },
313
413
  {
414
+ id: 5,
415
+ kind: "message",
314
416
  sender: "A",
315
417
  content:
316
418
  "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
317
419
  },
318
- { sender: "B", content: "测试aa中文aaa" },
420
+ { id: 6, kind: "message", sender: "B", content: "测试aa中文aaa" },
319
421
  {
422
+ id: 7,
423
+ kind: "message",
320
424
  sender: "A",
321
425
  content: randomText(8),
322
426
  reply: {
@@ -326,20 +430,22 @@ const list = new ListState<ChatItem>([
326
430
  },
327
431
  },
328
432
  {
433
+ id: 8,
434
+ kind: "message",
329
435
  sender: "B",
330
436
  content: "这里是一条会展示回复预览省略效果的消息。",
331
437
  reply: {
332
438
  sender: "A",
333
- content:
334
- "这是一条非常长的回复预览,用来演示 MultilineText 在 chat example 里的末尾 ellipsis 能力。它应该被限制在两行之内,而不是把整个气泡一路撑到天花板。",
439
+ content: richReplyPreview,
335
440
  },
336
441
  },
337
- { sender: "B", content: randomText(5) },
442
+ { id: 9, kind: "message", sender: "B", content: randomText(5) },
338
443
  ]);
339
444
  const renderer = new ChatRenderer(ctx, {
340
445
  renderItem,
341
446
  list,
342
447
  });
448
+ let nextMessageId = list.items.length + 1;
343
449
 
344
450
  function drawFrame(): void {
345
451
  const feedback: RenderFeedback = {
@@ -383,6 +489,22 @@ canvas.addEventListener("pointermove", (e) => {
383
489
  }
384
490
  });
385
491
 
492
+ canvas.addEventListener("pointerleave", () => {
493
+ currentHover = undefined;
494
+ });
495
+
496
+ canvas.addEventListener("click", (e) => {
497
+ const { top, left } = canvas.getBoundingClientRect();
498
+ const result = renderer.hittest({
499
+ x: e.clientX - left,
500
+ y: e.clientY - top,
501
+ type: "click",
502
+ });
503
+ if (!result) {
504
+ currentHover = undefined;
505
+ }
506
+ });
507
+
386
508
  function randomText(words: number): string {
387
509
  const out: string[] = [];
388
510
  for (let i = 0; i < words; i += 1) {
@@ -393,6 +515,8 @@ function randomText(words: number): string {
393
515
 
394
516
  button("unshift", () => {
395
517
  list.unshift({
518
+ id: nextMessageId++,
519
+ kind: "message",
396
520
  sender: Math.random() < 0.5 ? "A" : "B",
397
521
  content: randomText(10 + Math.floor(200 * Math.random())),
398
522
  });
@@ -400,6 +524,8 @@ button("unshift", () => {
400
524
 
401
525
  button("push", () => {
402
526
  list.push({
527
+ id: nextMessageId++,
528
+ kind: "message",
403
529
  sender: Math.random() < 0.5 ? "A" : "B",
404
530
  content: randomText(10 + Math.floor(200 * Math.random())),
405
531
  });
@@ -420,3 +546,14 @@ button("jump latest (no anim)", () => {
420
546
  animated: false,
421
547
  });
422
548
  });
549
+
550
+ button("revoke first", () => {
551
+ const item = list.items.find((entry): entry is MessageItem => entry.kind === "message");
552
+ if (item == null) {
553
+ return;
554
+ }
555
+ const index = list.items.indexOf(item);
556
+ list.replace(index, revokeMessage(item), {
557
+ duration: REPLACE_ANIMATION_DURATION,
558
+ });
559
+ });
package/index.d.mts CHANGED
@@ -81,6 +81,21 @@ interface TextStyleOptions<C extends CanvasRenderingContext2D> {
81
81
  /** Default: break-word; use anywhere when min-content should honor grapheme break opportunities. */
82
82
  overflowWrap?: TextOverflowWrapMode;
83
83
  }
84
+ /**
85
+ * A span-like inline text fragment used by rich multi-line text.
86
+ */
87
+ interface InlineSpan<C extends CanvasRenderingContext2D> {
88
+ /** Source text contained in this inline fragment. */
89
+ text: string;
90
+ /** Canvas font string override for this fragment. Falls back to the node-level font. */
91
+ font?: string;
92
+ /** Fill style override for this fragment. Falls back to the node-level style. */
93
+ style?: DynValue<C, string>;
94
+ /** Optional break hint forwarded to pretext rich-inline layout. */
95
+ break?: "normal" | "never";
96
+ /** Optional extra occupied width forwarded to pretext rich-inline layout. */
97
+ extraWidth?: number;
98
+ }
84
99
  /**
85
100
  * Options for multi-line text nodes.
86
101
  */
@@ -352,15 +367,16 @@ declare class Place<C extends CanvasRenderingContext2D> extends Wrapper<C> {
352
367
  //#region src/nodes/text.d.ts
353
368
  /**
354
369
  * Draws wrapped text using the configured line height and alignment.
370
+ * Accepts either a plain string or an array of `InlineSpan` items for mixed inline styles.
355
371
  */
356
372
  declare class MultilineText<C extends CanvasRenderingContext2D> implements Node<C> {
357
- readonly text: string;
373
+ readonly text: string | InlineSpan<C>[];
358
374
  readonly options: MultilineTextOptions<C>;
359
375
  /**
360
- * @param text Source text to measure and draw.
376
+ * @param text Source text to measure and draw. Pass an `InlineSpan[]` for mixed inline styles.
361
377
  * @param options Text layout and drawing options.
362
378
  */
363
- constructor(text: string, options: MultilineTextOptions<C>);
379
+ constructor(text: string | InlineSpan<C>[], options: MultilineTextOptions<C>);
364
380
  measure(ctx: Context<C>): Box;
365
381
  measureMinContent(ctx: Context<C>): Box;
366
382
  draw(ctx: Context<C>, x: number, y: number): boolean;
@@ -448,13 +464,20 @@ declare class DebugRenderer<C extends CanvasRenderingContext2D> extends BaseRend
448
464
  /**
449
465
  * Mutable list state shared with virtualized renderers.
450
466
  */
467
+ interface ReplaceListItemAnimationOptions {
468
+ /** Animation duration in milliseconds. */
469
+ duration?: number;
470
+ }
451
471
  declare class ListState<T extends {}> {
472
+ #private;
452
473
  /** Pixel offset from the anchored item edge. */
453
474
  offset: number;
454
475
  /** Anchor item index, or `undefined` to use the renderer default. */
455
476
  position: number | undefined;
456
477
  /** Items currently managed by the renderer. */
457
- items: T[];
478
+ get items(): T[];
479
+ /** Replaces the full item collection while preserving scroll state. */
480
+ set items(value: T[]);
458
481
  /**
459
482
  * @param items Initial list items.
460
483
  */
@@ -467,6 +490,10 @@ declare class ListState<T extends {}> {
467
490
  push(...items: T[]): void;
468
491
  /** Appends an array of items. */
469
492
  pushAll(items: T[]): void;
493
+ /**
494
+ * Replaces an existing item by index.
495
+ */
496
+ replace(index: number, item: T, animation?: ReplaceListItemAnimationOptions): void;
470
497
  /**
471
498
  * Sets the current anchor item and pixel offset.
472
499
  */
@@ -530,6 +557,10 @@ interface JumpToOptions {
530
557
  /** Called after the jump completes or finishes animating. */
531
558
  onComplete?: () => void;
532
559
  }
560
+ type VirtualizedResolvedItem<C extends CanvasRenderingContext2D> = {
561
+ draw: (y: number) => boolean;
562
+ hittest: (test: HitTest, y: number) => boolean;
563
+ };
533
564
  /**
534
565
  * Shared base class for virtualized list renderers.
535
566
  */
@@ -541,6 +572,10 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
541
572
  static readonly MIN_JUMP_DURATION = 160;
542
573
  static readonly MAX_JUMP_DURATION = 420;
543
574
  static readonly JUMP_DURATION_PER_ITEM = 28;
575
+ constructor(graphics: C, options: {
576
+ renderItem: (item: T) => Node<C>;
577
+ list: ListState<T>;
578
+ });
544
579
  /** Current anchor item index. */
545
580
  get position(): number | undefined;
546
581
  /** Updates the current anchor item index. */
@@ -569,23 +604,24 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
569
604
  jumpTo(index: number, options?: JumpToOptions): void;
570
605
  protected _resetRenderFeedback(feedback?: RenderFeedback): void;
571
606
  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;
607
+ protected _renderDrawList(list: VisibleWindow<VirtualizedResolvedItem<C>>["drawList"], shift: number, feedback?: RenderFeedback): boolean;
608
+ protected _renderVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem<C>>, feedback?: RenderFeedback): boolean;
609
+ protected _hittestVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem<C>>, test: HitTest): boolean;
579
610
  protected _prepareRender(): boolean;
580
611
  protected _finishRender(requestRedraw: boolean): boolean;
581
612
  protected _clampItemIndex(index: number): number;
582
613
  protected _getItemHeight(index: number): number;
614
+ protected _resolveItem(item: T, index: number, now: number): {
615
+ value: VirtualizedResolvedItem<C>;
616
+ height: number;
617
+ };
583
618
  protected _getAnchorAtOffset(index: number, offset: number): number;
584
619
  protected abstract _normalizeListState(state: VisibleListState): NormalizedListState;
585
620
  protected abstract _readAnchor(state: NormalizedListState): number;
586
621
  protected abstract _applyAnchor(anchor: number): void;
587
622
  protected abstract _getDefaultJumpBlock(): NonNullable<JumpToOptions["block"]>;
588
623
  protected abstract _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
624
+ protected abstract _getAnimatedLayerOffset(slotHeight: number, nodeHeight: number): number;
589
625
  }
590
626
  //#endregion
591
627
  //#region src/renderer/virtualized/chat.d.ts
@@ -599,6 +635,7 @@ declare class ChatRenderer<C extends CanvasRenderingContext2D, T extends {}> ext
599
635
  protected _readAnchor(state: NormalizedListState): number;
600
636
  protected _applyAnchor(anchor: number): void;
601
637
  protected _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
638
+ protected _getAnimatedLayerOffset(slotHeight: number, nodeHeight: number): number;
602
639
  render(feedback?: RenderFeedback): boolean;
603
640
  hittest(test: HitTest): boolean;
604
641
  }
@@ -614,9 +651,10 @@ declare class TimelineRenderer<C extends CanvasRenderingContext2D, T extends {}>
614
651
  protected _readAnchor(state: NormalizedListState): number;
615
652
  protected _applyAnchor(anchor: number): void;
616
653
  protected _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
654
+ protected _getAnimatedLayerOffset(_slotHeight: number, _nodeHeight: number): number;
617
655
  render(feedback?: RenderFeedback): boolean;
618
656
  hittest(test: HitTest): boolean;
619
657
  }
620
658
  //#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 };
659
+ 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, Text, TextAlign, TextEllipsisPosition, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
622
660
  //# sourceMappingURL=index.d.mts.map