chat-layout 1.2.0-1 → 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:
@@ -109,6 +113,32 @@ Notes:
109
113
  - `overflowWrap: "break-word"` keeps the current min-content behavior; `overflowWrap: "anywhere"` lets long unspaced strings shrink inside flex layouts such as chat bubbles.
110
114
  - Current `measureMinContent()` behavior stays compatibility-first: ellipsis affects constrained measurement/drawing, but does not lower the min-content shrink floor by itself.
111
115
 
116
+ ## Text justification
117
+
118
+ `MultilineText` supports two-end justification (justify) as a draw-phase decoration. It does not affect measurement or layout:
119
+
120
+ ```ts
121
+ const justified = new MultilineText(paragraph, {
122
+ lineHeight: 20,
123
+ font: "16px system-ui",
124
+ color: "#111",
125
+ align: "start",
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
129
+ });
130
+ ```
131
+
132
+ Notes:
133
+
134
+ - `justify: true` is equivalent to `"inter-word"` mode, which expands spaces between words via `ctx.wordSpacing`.
135
+ - `"inter-character"` mode distributes extra space after every character via `ctx.letterSpacing`.
136
+ - Requires browser support for `CanvasRenderingContext2D.wordSpacing` / `letterSpacing`. When unsupported, justify is silently disabled.
137
+ - Lines that exceed `justifyGapThreshold`, have no expandable gaps, or are the last line (unless `justifyLastLine: true`) fall back to `align` / `physicalAlign`.
138
+ - `overflow: "ellipsis"` truncated lines are never justified.
139
+ - `measure()` and `measureMinContent()` are not affected by justify options.
140
+ - Works with both plain text and `InlineSpan[]` rich text.
141
+
112
142
  ## Shrink behavior
113
143
 
114
144
  - `FlexItemOptions.shrink` defaults to `0`, so old layouts keep their previous behavior unless you opt in.
package/example/chat.ts CHANGED
@@ -37,6 +37,37 @@ const sampleWords = [
37
37
  "history",
38
38
  ];
39
39
 
40
+ const sampleChinesePhrases = [
41
+ "你好",
42
+ "收到",
43
+ "没问题",
44
+ "等一下",
45
+ "马上来",
46
+ "这个效果不错",
47
+ "刚刚更新了",
48
+ "看起来可以",
49
+ "再确认一下",
50
+ "这里有点奇怪",
51
+ "我先试试",
52
+ "辛苦了",
53
+ "已经修好了",
54
+ "周会上再聊",
55
+ "我发你截图",
56
+ ];
57
+
58
+ const sampleMixedPhrases = [
59
+ "hello 这条消息现在支持中文了",
60
+ "chat layout 这里需要再看一下",
61
+ "render 完成后记得刷新 preview",
62
+ "这段 mixed text 会测试自动换行 behavior",
63
+ "reply preview 里也放一点中文 content",
64
+ "今天的 build 已经 green 了",
65
+ "这个 bubble 的 spacing 感觉更自然",
66
+ "virtualized 列表滚动起来还是很顺",
67
+ "先 push 一个 demo message 看效果",
68
+ "中文 English mixed 排版更接近真实聊天",
69
+ ];
70
+
40
71
  type C = CanvasRenderingContext2D;
41
72
 
42
73
  class RoundedBox extends PaddingBox<C> {
@@ -164,7 +195,11 @@ const richTextMessage: InlineSpan<C>[] = [
164
195
  { text: "、" },
165
196
  { text: "粗体", font: "700 16px system-ui", color: "#b91c1c" },
166
197
  { text: ",以及 " },
167
- { 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
+ },
168
203
  { text: " 这样的片段混排。" },
169
204
  ];
170
205
 
@@ -174,7 +209,11 @@ const richReplyPreview: InlineSpan<C>[] = [
174
209
  { text: ",比如 " },
175
210
  { text: "关键词高亮", color: "#2563eb" },
176
211
  { text: " 和 " },
177
- { 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
+ },
178
217
  {
179
218
  text: ",超长内容仍然会按原来的两行省略规则收起,不需要额外处理。",
180
219
  },
@@ -203,13 +242,15 @@ class ItemDetector extends Wrapper<C> {
203
242
  hittest(_ctx: Context<C>, test: HitTest): boolean {
204
243
  currentHover = this.item;
205
244
  if (test.type === "click") {
206
- const index = list.items.indexOf(this.item);
207
- if (index < 0) {
245
+ if (!list.items.includes(this.item)) {
208
246
  return true;
209
247
  }
210
- 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);
211
252
  currentHover = nextItem;
212
- list.replace(index, nextItem, {
253
+ list.update(this.item, nextItem, {
213
254
  duration: REPLACE_ANIMATION_DURATION,
214
255
  });
215
256
  }
@@ -235,8 +276,10 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
235
276
  left: 12,
236
277
  right: 12,
237
278
  radii: 999,
238
- fill: () => (currentHover?.id === item.id ? "#d9d9d9" : "#ececec"),
239
- 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",
240
283
  },
241
284
  ),
242
285
  {
@@ -290,6 +333,8 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
290
333
  color: "black",
291
334
  align: "start",
292
335
  overflowWrap: "anywhere",
336
+ justify: "inter-character",
337
+ justifyGapThreshold: 0.2,
293
338
  }),
294
339
  { alignSelf: "start" },
295
340
  );
@@ -510,7 +555,7 @@ canvas.addEventListener("click", (e) => {
510
555
  }
511
556
  });
512
557
 
513
- function randomText(words: number): string {
558
+ function randomEnglishText(words: number): string {
514
559
  const out: string[] = [];
515
560
  for (let i = 0; i < words; i += 1) {
516
561
  out.push(sampleWords[Math.floor(Math.random() * sampleWords.length)]);
@@ -518,6 +563,52 @@ function randomText(words: number): string {
518
563
  return out.join(" ");
519
564
  }
520
565
 
566
+ function randomChineseText(words: number): string {
567
+ const out: string[] = [];
568
+ for (let i = 0; i < words; i += 1) {
569
+ out.push(
570
+ sampleChinesePhrases[
571
+ Math.floor(Math.random() * sampleChinesePhrases.length)
572
+ ],
573
+ );
574
+ }
575
+ return out.join("");
576
+ }
577
+
578
+ function randomMixedText(words: number): string {
579
+ const out: string[] = [];
580
+ for (let i = 0; i < words; i += 1) {
581
+ const mode = Math.random();
582
+ if (mode < 0.2) {
583
+ out.push(sampleWords[Math.floor(Math.random() * sampleWords.length)]);
584
+ continue;
585
+ }
586
+ if (mode < 0.65) {
587
+ out.push(
588
+ sampleChinesePhrases[
589
+ Math.floor(Math.random() * sampleChinesePhrases.length)
590
+ ],
591
+ );
592
+ continue;
593
+ }
594
+ out.push(
595
+ sampleMixedPhrases[Math.floor(Math.random() * sampleMixedPhrases.length)],
596
+ );
597
+ }
598
+ return out.join(Math.random() < 0.5 ? " " : "");
599
+ }
600
+
601
+ function randomText(words: number): string {
602
+ const mode = Math.random();
603
+ if (mode < 0.3) {
604
+ return randomEnglishText(words);
605
+ }
606
+ if (mode < 0.6) {
607
+ return randomChineseText(words);
608
+ }
609
+ return randomMixedText(words);
610
+ }
611
+
521
612
  button("unshift", () => {
522
613
  list.unshift({
523
614
  id: nextMessageId++,
@@ -553,12 +644,13 @@ button("jump latest (no anim)", () => {
553
644
  });
554
645
 
555
646
  button("revoke first", () => {
556
- 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
+ );
557
650
  if (item == null) {
558
651
  return;
559
652
  }
560
- const index = list.items.indexOf(item);
561
- list.replace(index, revokeMessage(item), {
653
+ list.update(item, revokeMessage(item), {
562
654
  duration: REPLACE_ANIMATION_DURATION,
563
655
  });
564
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
@@ -96,10 +96,37 @@ interface InlineSpan<C extends CanvasRenderingContext2D> {
96
96
  /** Optional extra occupied width appended after the span's rendered text. */
97
97
  extraWidth?: number;
98
98
  }
99
+ /**
100
+ * Two-end justification mode for multi-line text.
101
+ * `"inter-word"` expands only collapsible spaces.
102
+ * `"inter-character"` is script-aware and may combine `wordSpacing` with `letterSpacing`.
103
+ */
104
+ type TextJustifyMode = "inter-word" | "inter-character";
105
+ /**
106
+ * Options controlling two-end justification behavior.
107
+ */
108
+ interface TextJustifyOptions {
109
+ /**
110
+ * Enable two-end justification. Default: false.
111
+ * `true` uses "inter-word" mode.
112
+ * `"inter-character"` may combine `wordSpacing` and `letterSpacing` internally.
113
+ */
114
+ justify?: boolean | TextJustifyMode;
115
+ /**
116
+ * Whether to justify the last line as well. Default: false.
117
+ */
118
+ justifyLastLine?: boolean;
119
+ /**
120
+ * Maximum ratio of a single gap relative to the average word/char width.
121
+ * Lines exceeding this threshold fall back to normal alignment.
122
+ * Default: 2.0. Set to Infinity to disable.
123
+ */
124
+ justifyGapThreshold?: number;
125
+ }
99
126
  /**
100
127
  * Options for multi-line text nodes.
101
128
  */
102
- interface MultilineTextOptions<C extends CanvasRenderingContext2D> extends TextStyleOptions<C> {
129
+ interface MultilineTextOptions<C extends CanvasRenderingContext2D> extends TextStyleOptions<C>, TextJustifyOptions {
103
130
  /** Logical alignment that matches `Place.align`. */
104
131
  align?: TextAlign;
105
132
  /** Explicit physical alignment when left/right semantics are required. */
@@ -482,7 +509,7 @@ declare class DebugRenderer<C extends CanvasRenderingContext2D> extends BaseRend
482
509
  /**
483
510
  * Mutable list state shared with virtualized renderers.
484
511
  */
485
- interface ReplaceListItemAnimationOptions {
512
+ interface UpdateListItemAnimationOptions {
486
513
  /** Animation duration in milliseconds. */
487
514
  duration?: number;
488
515
  }
@@ -509,9 +536,9 @@ declare class ListState<T extends {}> {
509
536
  /** Appends an array of items. */
510
537
  pushAll(items: T[]): void;
511
538
  /**
512
- * Replaces an existing item by index.
539
+ * Updates an existing item by object identity.
513
540
  */
514
- replace(index: number, item: T, animation?: ReplaceListItemAnimationOptions): void;
541
+ update(targetItem: T, nextItem: T, animation?: UpdateListItemAnimationOptions): void;
515
542
  /**
516
543
  * Sets the current anchor item and pixel offset.
517
544
  */
@@ -631,7 +658,7 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
631
658
  protected _finishRender(requestRedraw: boolean): boolean;
632
659
  protected _clampItemIndex(index: number): number;
633
660
  protected _getItemHeight(index: number): number;
634
- protected _resolveItem(item: T, index: number, now: number): {
661
+ protected _resolveItem(item: T, _index: number, now: number): {
635
662
  value: VirtualizedResolvedItem<C>;
636
663
  height: number;
637
664
  };
@@ -676,5 +703,5 @@ declare class TimelineRenderer<C extends CanvasRenderingContext2D, T extends {}>
676
703
  hittest(test: HitTest): boolean;
677
704
  }
678
705
  //#endregion
679
- 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, 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 };
680
707
  //# sourceMappingURL=index.d.mts.map