chat-layout 1.2.0-0 → 1.2.0-2

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
@@ -7,13 +7,14 @@ The current v2-style APIs are:
7
7
  - `Flex`: row/column layout
8
8
  - `FlexItem`: explicit `grow` / `shrink` / `alignSelf`
9
9
  - `Place`: place a single child at `start` / `center` / `end`
10
+ - `ShrinkWrap`: search the narrowest width that keeps the current height stable
10
11
  - `MultilineText`: text layout with logical `align` or physical `physicalAlign`
11
12
  - `ChatRenderer` + `ListState`: virtualized chat rendering
12
13
  - `memoRenderItem` / `memoRenderItemBy`: item render memoization
13
14
 
14
15
  ## Quick example
15
16
 
16
- Use `Flex` to build structure, `FlexItem` to control resize behavior, and `Place` to align the final bubble:
17
+ Use `Flex` to build structure, `FlexItem` to control resize behavior, `ShrinkWrap` to keep the bubble as narrow as possible without adding lines, and `Place` to align the final bubble:
17
18
 
18
19
  ```ts
19
20
  const bubble = new RoundedBox(
@@ -26,17 +27,27 @@ const bubble = new RoundedBox(
26
27
  { top: 6, bottom: 6, left: 10, right: 10, radii: 8, fill: "#ccc" },
27
28
  );
28
29
 
30
+ const body = new ShrinkWrap(
31
+ new Flex(
32
+ [senderLine, bubble],
33
+ { direction: "column", gap: 4, alignItems: item.sender === "A" ? "end" : "start" },
34
+ ),
35
+ );
36
+
29
37
  const row = new Flex(
30
38
  [
31
39
  avatar,
32
- new FlexItem(bubble, { grow: 1, shrink: 1 }),
40
+ new FlexItem(
41
+ new Place(body, {
42
+ align: item.sender === "A" ? "end" : "start",
43
+ }),
44
+ { grow: 1, shrink: 1 },
45
+ ),
33
46
  ],
34
47
  { direction: "row", gap: 4, reverse: item.sender === "A" },
35
48
  );
36
49
 
37
- return new Place(row, {
38
- align: item.sender === "A" ? "end" : "start",
39
- });
50
+ return row;
40
51
  ```
41
52
 
42
53
  See [example/chat.ts](./example/chat.ts) for a full chat example.
@@ -47,6 +58,7 @@ See [example/chat.ts](./example/chat.ts) for a full chat example.
47
58
  - `maxWidth` / `maxHeight` limit measurement, but do not automatically make children fill the cross axis.
48
59
  - Use `alignItems: "stretch"` or `alignSelf: "stretch"` when a child should fill the computed cross size.
49
60
  - `Place` is the simplest way to align a single bubble left, center, or right.
61
+ - `ShrinkWrap` is useful when a bubble sits inside a growable slot but should still collapse to the narrowest width that preserves its current line count.
50
62
  - `MultilineText.align` uses logical values: `start`, `center`, `end`.
51
63
  - `MultilineText.physicalAlign` uses physical values: `left`, `center`, `right`.
52
64
  - `Text` and `MultilineText` default to `whiteSpace: "normal"`, using the library's canvas-first collapsible whitespace behavior.
@@ -97,12 +109,39 @@ Notes:
97
109
  - `overflowWrap: "break-word"` keeps the current min-content behavior; `overflowWrap: "anywhere"` lets long unspaced strings shrink inside flex layouts such as chat bubbles.
98
110
  - Current `measureMinContent()` behavior stays compatibility-first: ellipsis affects constrained measurement/drawing, but does not lower the min-content shrink floor by itself.
99
111
 
112
+ ## Text justification
113
+
114
+ `MultilineText` supports two-end justification (justify) as a draw-phase decoration. It does not affect measurement or layout:
115
+
116
+ ```ts
117
+ const justified = new MultilineText(paragraph, {
118
+ lineHeight: 20,
119
+ font: "16px system-ui",
120
+ color: "#111",
121
+ 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
125
+ });
126
+ ```
127
+
128
+ Notes:
129
+
130
+ - `justify: true` is equivalent to `"inter-word"` mode, which expands spaces between words via `ctx.wordSpacing`.
131
+ - `"inter-character"` mode distributes extra space after every character via `ctx.letterSpacing`.
132
+ - Requires browser support for `CanvasRenderingContext2D.wordSpacing` / `letterSpacing`. When unsupported, justify is silently disabled.
133
+ - Lines that exceed `justifyGapThreshold`, have no expandable gaps, or are the last line (unless `justifyLastLine: true`) fall back to `align` / `physicalAlign`.
134
+ - `overflow: "ellipsis"` truncated lines are never justified.
135
+ - `measure()` and `measureMinContent()` are not affected by justify options.
136
+ - Works with both plain text and `InlineSpan[]` rich text.
137
+
100
138
  ## Shrink behavior
101
139
 
102
140
  - `FlexItemOptions.shrink` defaults to `0`, so old layouts keep their previous behavior unless you opt in.
103
141
  - Shrink only applies when there is a finite main-axis constraint and total content size overflows it.
104
142
  - Overflow is redistributed by `shrink * basis`; today `basis` is internal-only and always `"auto"`.
105
143
  - Custom nodes can implement `measureMinContent()` for better shrink results.
144
+ - `ShrinkWrap` complements flex shrink: it keeps probing narrower `maxWidth` values until the child would become taller, then uses the last safe width as the final layout.
106
145
  - Known limitation: column shrink with `MultilineText` does not clip drawing by itself.
107
146
 
108
147
  ## Migration notes
package/example/chat.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  MultilineText,
8
8
  PaddingBox,
9
9
  Place,
10
+ ShrinkWrap,
10
11
  Text,
11
12
  Wrapper,
12
13
  memoRenderItem,
@@ -36,6 +37,37 @@ const sampleWords = [
36
37
  "history",
37
38
  ];
38
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
+
39
71
  type C = CanvasRenderingContext2D;
40
72
 
41
73
  class RoundedBox extends PaddingBox<C> {
@@ -289,6 +321,8 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
289
321
  color: "black",
290
322
  align: "start",
291
323
  overflowWrap: "anywhere",
324
+ justify: "inter-character",
325
+ justifyGapThreshold: 0.2,
292
326
  }),
293
327
  { alignSelf: "start" },
294
328
  );
@@ -357,7 +391,11 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
357
391
  alignItems: item.sender === "A" ? "end" : "start",
358
392
  });
359
393
 
360
- const alignedBody = new Place<C>(body, {
394
+ const shrinkWrappedBody = new ShrinkWrap<C>(body, {
395
+ preferredMinWidth: 160,
396
+ });
397
+
398
+ const alignedBody = new Place<C>(shrinkWrappedBody, {
361
399
  align: item.sender === "A" ? "end" : "start",
362
400
  });
363
401
 
@@ -505,7 +543,7 @@ canvas.addEventListener("click", (e) => {
505
543
  }
506
544
  });
507
545
 
508
- function randomText(words: number): string {
546
+ function randomEnglishText(words: number): string {
509
547
  const out: string[] = [];
510
548
  for (let i = 0; i < words; i += 1) {
511
549
  out.push(sampleWords[Math.floor(Math.random() * sampleWords.length)]);
@@ -513,6 +551,52 @@ function randomText(words: number): string {
513
551
  return out.join(" ");
514
552
  }
515
553
 
554
+ function randomChineseText(words: number): string {
555
+ const out: string[] = [];
556
+ for (let i = 0; i < words; i += 1) {
557
+ out.push(
558
+ sampleChinesePhrases[
559
+ Math.floor(Math.random() * sampleChinesePhrases.length)
560
+ ],
561
+ );
562
+ }
563
+ return out.join("");
564
+ }
565
+
566
+ function randomMixedText(words: number): string {
567
+ const out: string[] = [];
568
+ for (let i = 0; i < words; i += 1) {
569
+ const mode = Math.random();
570
+ if (mode < 0.2) {
571
+ out.push(sampleWords[Math.floor(Math.random() * sampleWords.length)]);
572
+ continue;
573
+ }
574
+ if (mode < 0.65) {
575
+ out.push(
576
+ sampleChinesePhrases[
577
+ Math.floor(Math.random() * sampleChinesePhrases.length)
578
+ ],
579
+ );
580
+ continue;
581
+ }
582
+ out.push(
583
+ sampleMixedPhrases[Math.floor(Math.random() * sampleMixedPhrases.length)],
584
+ );
585
+ }
586
+ return out.join(Math.random() < 0.5 ? " " : "");
587
+ }
588
+
589
+ function randomText(words: number): string {
590
+ const mode = Math.random();
591
+ if (mode < 0.3) {
592
+ return randomEnglishText(words);
593
+ }
594
+ if (mode < 0.6) {
595
+ return randomChineseText(words);
596
+ }
597
+ return randomMixedText(words);
598
+ }
599
+
516
600
  button("unshift", () => {
517
601
  list.unshift({
518
602
  id: nextMessageId++,
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. */
@@ -364,6 +391,24 @@ declare class Place<C extends CanvasRenderingContext2D> extends Wrapper<C> {
364
391
  hittest(ctx: Context<C>, test: HitTest): boolean;
365
392
  }
366
393
  //#endregion
394
+ //#region src/nodes/shrinkwrap.d.ts
395
+ interface ShrinkWrapOptions {
396
+ tolerance?: number;
397
+ preferredMinWidth?: number;
398
+ }
399
+ /**
400
+ * Shrinks a single child to the narrowest width that does not increase its reference height.
401
+ */
402
+ declare class ShrinkWrap<C extends CanvasRenderingContext2D> extends Wrapper<C> {
403
+ #private;
404
+ readonly options: ShrinkWrapOptions;
405
+ constructor(inner: Node<C>, options?: ShrinkWrapOptions);
406
+ measure(ctx: Context<C>): Box;
407
+ measureMinContent(ctx: Context<C>): Box;
408
+ draw(ctx: Context<C>, x: number, y: number): boolean;
409
+ hittest(ctx: Context<C>, test: HitTest): boolean;
410
+ }
411
+ //#endregion
367
412
  //#region src/nodes/text.d.ts
368
413
  /**
369
414
  * Draws wrapped text using the configured line height and alignment.
@@ -658,5 +703,5 @@ declare class TimelineRenderer<C extends CanvasRenderingContext2D, T extends {}>
658
703
  hittest(test: HitTest): boolean;
659
704
  }
660
705
  //#endregion
661
- 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 };
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 };
662
707
  //# sourceMappingURL=index.d.mts.map