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 +44 -5
- package/example/chat.ts +86 -2
- package/index.d.mts +47 -2
- package/index.mjs +473 -79
- package/index.mjs.map +1 -1
- package/package.json +1 -1
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(
|
|
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
|
|
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
|
|
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
|
|
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
|