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 +45 -15
- package/example/chat.ts +104 -12
- package/example/test.ts +4 -2
- package/index.d.mts +33 -6
- package/index.mjs +437 -117
- package/index.mjs.map +1 -1
- package/package.json +4 -1
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
207
|
-
if (index < 0) {
|
|
245
|
+
if (!list.items.includes(this.item)) {
|
|
208
246
|
return true;
|
|
209
247
|
}
|
|
210
|
-
const nextItem =
|
|
248
|
+
const nextItem =
|
|
249
|
+
this.item.kind === "revoked"
|
|
250
|
+
? this.item.original
|
|
251
|
+
: revokeMessage(this.item);
|
|
211
252
|
currentHover = nextItem;
|
|
212
|
-
list.
|
|
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: () =>
|
|
239
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
51
|
-
|
|
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
|
|
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
|
-
*
|
|
539
|
+
* Updates an existing item by object identity.
|
|
513
540
|
*/
|
|
514
|
-
|
|
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,
|
|
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,
|
|
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
|