chat-layout 1.0.0-1 → 1.0.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 +60 -187
- package/example/build.ts +12 -0
- package/example/chat.ts +403 -0
- package/example/test.ts +215 -0
- package/index.d.mts +12 -0
- package/index.mjs +280 -59
- package/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,201 +1,74 @@
|
|
|
1
1
|
# chat-layout
|
|
2
2
|
|
|
3
|
-
Canvas-based
|
|
3
|
+
Canvas-based layout primitives for chat and timeline UIs.
|
|
4
4
|
|
|
5
|
-
The current
|
|
5
|
+
The current v2-style APIs are:
|
|
6
6
|
|
|
7
|
-
- `Flex
|
|
8
|
-
- `FlexItem
|
|
9
|
-
- `Place
|
|
10
|
-
- `MultilineText
|
|
11
|
-
- `ChatRenderer`
|
|
12
|
-
- `memoRenderItem`
|
|
7
|
+
- `Flex`: row/column layout
|
|
8
|
+
- `FlexItem`: explicit `grow` / `shrink` / `alignSelf`
|
|
9
|
+
- `Place`: place a single child at `start` / `center` / `end`
|
|
10
|
+
- `MultilineText`: text layout with logical `align` or physical `physicalAlign`
|
|
11
|
+
- `ChatRenderer` + `ListState`: virtualized chat rendering
|
|
12
|
+
- `memoRenderItem` / `memoRenderItemBy`: item render memoization
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
`Flex` and `Place` split layout concerns more clearly than the older API:
|
|
14
|
+
## Quick example
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
- Use `new FlexItem(child, { grow: 1 })` when a child should consume remaining space.
|
|
19
|
-
- `Flex` shrink-wraps on the cross axis by default; `maxWidth` / `maxHeight` act as measurement caps rather than implicit fill signals.
|
|
20
|
-
- Use `alignItems` / `alignSelf: "stretch"` when a specific child should fill the container's computed cross axis.
|
|
21
|
-
- Use `new Place(child, { align: "start" | "center" | "end" })` when a single child should fill available width and then be placed left/center/right.
|
|
22
|
-
- Use `justifyContent`, `alignItems`, and `alignSelf` for container/item placement.
|
|
23
|
-
- Use `align: "start" | "center" | "end"` on `MultilineText` for logical alignment that matches `Place.align`.
|
|
24
|
-
- Use `physicalAlign: "left" | "center" | "right"` on `MultilineText` only when you explicitly want physical left/right semantics.
|
|
25
|
-
- `Text` / `MultilineText` preserve blank lines and edge whitespace by default; opt into cleanup with `whitespace: "trim-and-collapse"`.
|
|
26
|
-
|
|
27
|
-
**Example**
|
|
28
|
-
This is the recommended chat bubble shape used by [example/chat.ts](./example/chat.ts):
|
|
16
|
+
Use `Flex` to build structure, `FlexItem` to control resize behavior, and `Place` to align the final bubble:
|
|
29
17
|
|
|
30
18
|
```ts
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const messageText = new FlexItem(
|
|
52
|
-
new MultilineText(item.content, {
|
|
53
|
-
lineHeight: 20,
|
|
54
|
-
font: "16px system-ui",
|
|
55
|
-
style: "black",
|
|
56
|
-
align: "start",
|
|
57
|
-
}),
|
|
58
|
-
{ alignSelf: "start" },
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
const bubbleChildren: Node<C>[] = [];
|
|
62
|
-
if (item.reply != null) {
|
|
63
|
-
bubbleChildren.push(
|
|
64
|
-
new FlexItem(
|
|
65
|
-
new RoundedBox(
|
|
66
|
-
new Flex<C>(
|
|
67
|
-
[
|
|
68
|
-
new Text(item.reply.sender, {
|
|
69
|
-
lineHeight: 14,
|
|
70
|
-
font: "11px system-ui",
|
|
71
|
-
style: "#666",
|
|
72
|
-
}),
|
|
73
|
-
new MultilineText(item.reply.content, {
|
|
74
|
-
lineHeight: 16,
|
|
75
|
-
font: "13px system-ui",
|
|
76
|
-
style: "#444",
|
|
77
|
-
align: "start",
|
|
78
|
-
}),
|
|
79
|
-
],
|
|
80
|
-
{
|
|
81
|
-
direction: "column",
|
|
82
|
-
gap: 2,
|
|
83
|
-
alignItems: "start",
|
|
84
|
-
},
|
|
85
|
-
),
|
|
86
|
-
{
|
|
87
|
-
top: 5,
|
|
88
|
-
bottom: 5,
|
|
89
|
-
left: 8,
|
|
90
|
-
right: 8,
|
|
91
|
-
radii: 6,
|
|
92
|
-
fill: "#e2e2e2",
|
|
93
|
-
},
|
|
94
|
-
),
|
|
95
|
-
{ alignSelf: "stretch" },
|
|
96
|
-
),
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
bubbleChildren.push(messageText);
|
|
100
|
-
|
|
101
|
-
const bubbleColumn = new Flex<C>(bubbleChildren, {
|
|
102
|
-
direction: "column",
|
|
103
|
-
gap: 6,
|
|
104
|
-
// The bubble itself stays intrinsic on the cross axis.
|
|
105
|
-
// Only the reply preview stretches to the bubble width.
|
|
106
|
-
alignItems: "start",
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
const content = new RoundedBox(
|
|
110
|
-
bubbleColumn,
|
|
111
|
-
{
|
|
112
|
-
top: 6,
|
|
113
|
-
bottom: 6,
|
|
114
|
-
left: 10,
|
|
115
|
-
right: 10,
|
|
116
|
-
radii: 8,
|
|
117
|
-
fill: "#ccc",
|
|
118
|
-
},
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
const body = new Flex<C>([senderLine, content], {
|
|
122
|
-
direction: "column",
|
|
123
|
-
alignItems: item.sender === "A" ? "end" : "start",
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
const alignedBody = new Place<C>(body, {
|
|
127
|
-
align: item.sender === "A" ? "end" : "start",
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
return new Place(
|
|
131
|
-
new PaddingBox(
|
|
132
|
-
new Flex<C>(
|
|
133
|
-
[
|
|
134
|
-
avatar,
|
|
135
|
-
new FlexItem(alignedBody, { grow: 1 }),
|
|
136
|
-
new Fixed(32, 0),
|
|
137
|
-
],
|
|
138
|
-
{
|
|
139
|
-
direction: "row",
|
|
140
|
-
gap: 4,
|
|
141
|
-
reverse: item.sender === "A",
|
|
142
|
-
},
|
|
143
|
-
),
|
|
144
|
-
{
|
|
145
|
-
top: 4,
|
|
146
|
-
bottom: 4,
|
|
147
|
-
left: 4,
|
|
148
|
-
right: 4,
|
|
149
|
-
},
|
|
150
|
-
),
|
|
151
|
-
{
|
|
152
|
-
align: item.sender === "A" ? "end" : "start",
|
|
153
|
-
},
|
|
154
|
-
);
|
|
19
|
+
const bubble = new RoundedBox(
|
|
20
|
+
new MultilineText(item.content, {
|
|
21
|
+
lineHeight: 20,
|
|
22
|
+
font: "16px system-ui",
|
|
23
|
+
style: "black",
|
|
24
|
+
align: "start",
|
|
25
|
+
}),
|
|
26
|
+
{ top: 6, bottom: 6, left: 10, right: 10, radii: 8, fill: "#ccc" },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const row = new Flex(
|
|
30
|
+
[
|
|
31
|
+
avatar,
|
|
32
|
+
new FlexItem(bubble, { grow: 1, shrink: 1 }),
|
|
33
|
+
],
|
|
34
|
+
{ direction: "row", gap: 4, reverse: item.sender === "A" },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return new Place(row, {
|
|
38
|
+
align: item.sender === "A" ? "end" : "start",
|
|
155
39
|
});
|
|
156
40
|
```
|
|
157
41
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
-
|
|
163
|
-
-
|
|
164
|
-
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
- `
|
|
173
|
-
-
|
|
174
|
-
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
-
|
|
181
|
-
|
|
182
|
-
-
|
|
183
|
-
|
|
184
|
-
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
- `new MultilineText(text, { alignment: "left" })`
|
|
189
|
-
- After:
|
|
190
|
-
- `new MultilineText(text, { align: "start" })`
|
|
191
|
-
- or `new MultilineText(text, { physicalAlign: "left" })` when physical left/right semantics are required
|
|
192
|
-
- Before:
|
|
193
|
-
- `list.position = Number.NaN`
|
|
194
|
-
- After:
|
|
195
|
-
- `list.resetScroll()`
|
|
196
|
-
- or `list.setAnchor(index, offset)` for an explicit anchor
|
|
197
|
-
|
|
198
|
-
**Development**
|
|
42
|
+
See [example/chat.ts](./example/chat.ts) for a full chat example.
|
|
43
|
+
|
|
44
|
+
## Layout notes
|
|
45
|
+
|
|
46
|
+
- `Flex` handles the main axis only. It shrink-wraps on the cross axis unless you opt into stretch behavior.
|
|
47
|
+
- `maxWidth` / `maxHeight` limit measurement, but do not automatically make children fill the cross axis.
|
|
48
|
+
- Use `alignItems: "stretch"` or `alignSelf: "stretch"` when a child should fill the computed cross size.
|
|
49
|
+
- `Place` is the simplest way to align a single bubble left, center, or right.
|
|
50
|
+
- `MultilineText.align` uses logical values: `start`, `center`, `end`.
|
|
51
|
+
- `MultilineText.physicalAlign` uses physical values: `left`, `center`, `right`.
|
|
52
|
+
- `Text` and `MultilineText` preserve blank lines and edge whitespace by default. Use `whitespace: "trim-and-collapse"` if you want cleanup.
|
|
53
|
+
|
|
54
|
+
## Shrink behavior
|
|
55
|
+
|
|
56
|
+
- `FlexItemOptions.shrink` defaults to `0`, so old layouts keep their previous behavior unless you opt in.
|
|
57
|
+
- Shrink only applies when there is a finite main-axis constraint and total content size overflows it.
|
|
58
|
+
- Overflow is redistributed by `shrink * basis`; today `basis` is internal-only and always `"auto"`.
|
|
59
|
+
- Custom nodes can implement `measureMinContent()` for better shrink results.
|
|
60
|
+
- Known limitation: column shrink with `MultilineText` does not clip drawing by itself.
|
|
61
|
+
|
|
62
|
+
## Migration notes
|
|
63
|
+
|
|
64
|
+
- Use `memoRenderItemBy(keyOf, renderItem)` when list items are primitives.
|
|
65
|
+
- `FlexItem` exposes `grow`, `shrink`, and `alignSelf`; `basis` is no longer public.
|
|
66
|
+
- `MultilineText` now uses `align` / `physicalAlign` instead of `alignment`.
|
|
67
|
+
- `ListState.position` uses `undefined` for the renderer default anchor.
|
|
68
|
+
- Use `list.resetScroll()` or `list.setAnchor(index, offset)` instead of assigning `Number.NaN`.
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
199
72
|
Install dependencies:
|
|
200
73
|
|
|
201
74
|
```bash
|
package/example/build.ts
ADDED
package/example/chat.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChatRenderer,
|
|
3
|
+
Flex,
|
|
4
|
+
FlexItem,
|
|
5
|
+
Fixed,
|
|
6
|
+
ListState,
|
|
7
|
+
MultilineText,
|
|
8
|
+
PaddingBox,
|
|
9
|
+
Place,
|
|
10
|
+
Text,
|
|
11
|
+
Wrapper,
|
|
12
|
+
memoRenderItem,
|
|
13
|
+
type Context,
|
|
14
|
+
type DynValue,
|
|
15
|
+
type HitTest,
|
|
16
|
+
type Node,
|
|
17
|
+
type RenderFeedback,
|
|
18
|
+
} from "chat-layout";
|
|
19
|
+
|
|
20
|
+
const sampleWords = [
|
|
21
|
+
"hello",
|
|
22
|
+
"world",
|
|
23
|
+
"chat",
|
|
24
|
+
"layout",
|
|
25
|
+
"message",
|
|
26
|
+
"render",
|
|
27
|
+
"bubble",
|
|
28
|
+
"timeline",
|
|
29
|
+
"virtualized",
|
|
30
|
+
"canvas",
|
|
31
|
+
"stream",
|
|
32
|
+
"session",
|
|
33
|
+
"update",
|
|
34
|
+
"typing",
|
|
35
|
+
"history",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
type C = CanvasRenderingContext2D;
|
|
39
|
+
|
|
40
|
+
class RoundedBox extends PaddingBox<C> {
|
|
41
|
+
readonly radii: number | DOMPointInit | (number | DOMPointInit)[];
|
|
42
|
+
readonly stroke: DynValue<C, string | undefined>;
|
|
43
|
+
readonly fill: DynValue<C, string | undefined>;
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
inner: Node<C>,
|
|
47
|
+
{
|
|
48
|
+
radii,
|
|
49
|
+
stroke,
|
|
50
|
+
fill,
|
|
51
|
+
...options
|
|
52
|
+
}: {
|
|
53
|
+
top?: number;
|
|
54
|
+
bottom?: number;
|
|
55
|
+
left?: number;
|
|
56
|
+
right?: number;
|
|
57
|
+
stroke?: DynValue<C, string | undefined>;
|
|
58
|
+
fill?: DynValue<C, string | undefined>;
|
|
59
|
+
radii: number | DOMPointInit | (number | DOMPointInit)[];
|
|
60
|
+
},
|
|
61
|
+
) {
|
|
62
|
+
super(inner, options);
|
|
63
|
+
this.radii = radii;
|
|
64
|
+
this.stroke = stroke;
|
|
65
|
+
this.fill = fill;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
draw(ctx: Context<C>, x: number, y: number): boolean {
|
|
69
|
+
// Reuse the current layout constraints so the background matches wrapped text.
|
|
70
|
+
const { width, height } = ctx.measureNode(this, ctx.constraints);
|
|
71
|
+
ctx.with((g) => {
|
|
72
|
+
const fill =
|
|
73
|
+
this.fill == null ? undefined : ctx.resolveDynValue(this.fill);
|
|
74
|
+
const stroke =
|
|
75
|
+
this.stroke == null ? undefined : ctx.resolveDynValue(this.stroke);
|
|
76
|
+
g.beginPath();
|
|
77
|
+
g.roundRect(x, y, width, height, this.radii);
|
|
78
|
+
if (fill != null) {
|
|
79
|
+
g.fillStyle = fill;
|
|
80
|
+
}
|
|
81
|
+
if (stroke != null) {
|
|
82
|
+
g.strokeStyle = stroke;
|
|
83
|
+
}
|
|
84
|
+
if (fill != null) {
|
|
85
|
+
g.fill();
|
|
86
|
+
}
|
|
87
|
+
if (stroke != null) {
|
|
88
|
+
g.stroke();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return super.draw(ctx, x, y);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class Circle extends Fixed<C> {
|
|
96
|
+
constructor(
|
|
97
|
+
readonly size: number,
|
|
98
|
+
readonly options: {
|
|
99
|
+
fill: DynValue<C, string>;
|
|
100
|
+
},
|
|
101
|
+
) {
|
|
102
|
+
super(size, size);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
draw(ctx: Context<C>, x: number, y: number): boolean {
|
|
106
|
+
ctx.with((g) => {
|
|
107
|
+
g.fillStyle = ctx.resolveDynValue(this.options.fill);
|
|
108
|
+
g.beginPath();
|
|
109
|
+
const radius = this.size / 2;
|
|
110
|
+
g.arc(x + radius, y + radius, radius, 0, 2 * Math.PI, false);
|
|
111
|
+
g.fill();
|
|
112
|
+
});
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function button(text: string, action: () => void): void {
|
|
118
|
+
const btn = document.body.appendChild(document.createElement("button"));
|
|
119
|
+
btn.textContent = text;
|
|
120
|
+
btn.onclick = action;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const canvas = document.body.appendChild(document.createElement("canvas"));
|
|
124
|
+
canvas.width = canvas.clientWidth * devicePixelRatio;
|
|
125
|
+
canvas.height = canvas.clientHeight * devicePixelRatio;
|
|
126
|
+
|
|
127
|
+
const context = canvas.getContext("2d");
|
|
128
|
+
if (context == null) {
|
|
129
|
+
throw new Error("Failed to initial canvas");
|
|
130
|
+
}
|
|
131
|
+
const ctx: C = context;
|
|
132
|
+
ctx.scale(devicePixelRatio, devicePixelRatio);
|
|
133
|
+
|
|
134
|
+
type ChatItem = {
|
|
135
|
+
sender: string;
|
|
136
|
+
content: string;
|
|
137
|
+
reply?: {
|
|
138
|
+
sender: string;
|
|
139
|
+
content: string;
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
let currentHover: ChatItem | undefined;
|
|
144
|
+
|
|
145
|
+
class HoverDetector extends Wrapper<C> {
|
|
146
|
+
constructor(
|
|
147
|
+
inner: Node<C>,
|
|
148
|
+
readonly item: ChatItem,
|
|
149
|
+
) {
|
|
150
|
+
super(inner);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
hittest(_ctx: Context<C>, _test: HitTest): boolean {
|
|
154
|
+
currentHover = this.item;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
|
|
160
|
+
const senderLine = new Flex<C>(
|
|
161
|
+
[
|
|
162
|
+
new HoverDetector(
|
|
163
|
+
new Circle(15, {
|
|
164
|
+
fill: "blue",
|
|
165
|
+
}),
|
|
166
|
+
item,
|
|
167
|
+
),
|
|
168
|
+
new RoundedBox(
|
|
169
|
+
new Text(item.sender, {
|
|
170
|
+
lineHeight: 15,
|
|
171
|
+
font: "12px system-ui",
|
|
172
|
+
style: "black",
|
|
173
|
+
}),
|
|
174
|
+
{
|
|
175
|
+
top: 0,
|
|
176
|
+
bottom: 0,
|
|
177
|
+
left: 0,
|
|
178
|
+
right: 0,
|
|
179
|
+
radii: 2,
|
|
180
|
+
fill: () => (currentHover === item ? "red" : "transparent"),
|
|
181
|
+
},
|
|
182
|
+
),
|
|
183
|
+
],
|
|
184
|
+
{
|
|
185
|
+
direction: "row",
|
|
186
|
+
gap: 4,
|
|
187
|
+
mainAxisSize: "fit-content",
|
|
188
|
+
reverse: item.sender === "A",
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const messageText = new FlexItem(
|
|
193
|
+
new MultilineText(item.content, {
|
|
194
|
+
lineHeight: 20,
|
|
195
|
+
font: "16px system-ui",
|
|
196
|
+
style: "black",
|
|
197
|
+
align: "start",
|
|
198
|
+
}),
|
|
199
|
+
{ alignSelf: "start" },
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const bubbleChildren: Node<C>[] = [];
|
|
203
|
+
if (item.reply != null) {
|
|
204
|
+
const replyPreview = new FlexItem(
|
|
205
|
+
new RoundedBox(
|
|
206
|
+
new Flex<C>(
|
|
207
|
+
[
|
|
208
|
+
new Text(item.reply.sender, {
|
|
209
|
+
lineHeight: 14,
|
|
210
|
+
font: "11px system-ui",
|
|
211
|
+
style: () => (currentHover === item ? "#4d4d4d" : "#666"),
|
|
212
|
+
}),
|
|
213
|
+
new MultilineText(item.reply.content, {
|
|
214
|
+
lineHeight: 16,
|
|
215
|
+
font: "13px system-ui",
|
|
216
|
+
style: () => (currentHover === item ? "#222" : "#444"),
|
|
217
|
+
align: "start",
|
|
218
|
+
}),
|
|
219
|
+
],
|
|
220
|
+
{
|
|
221
|
+
direction: "column",
|
|
222
|
+
gap: 2,
|
|
223
|
+
alignItems: "start",
|
|
224
|
+
},
|
|
225
|
+
),
|
|
226
|
+
{
|
|
227
|
+
top: 5,
|
|
228
|
+
bottom: 5,
|
|
229
|
+
left: 8,
|
|
230
|
+
right: 8,
|
|
231
|
+
radii: 6,
|
|
232
|
+
fill: () => (currentHover === item ? "#c2c2c2" : "#e2e2e2"),
|
|
233
|
+
},
|
|
234
|
+
),
|
|
235
|
+
{ alignSelf: "stretch" },
|
|
236
|
+
);
|
|
237
|
+
bubbleChildren.push(replyPreview);
|
|
238
|
+
}
|
|
239
|
+
bubbleChildren.push(messageText);
|
|
240
|
+
|
|
241
|
+
const bubbleColumn = new Flex<C>(bubbleChildren, {
|
|
242
|
+
direction: "column",
|
|
243
|
+
gap: 6,
|
|
244
|
+
// The bubble itself shrink-wraps on the cross axis; only the reply preview stretches.
|
|
245
|
+
alignItems: "start",
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const content = new RoundedBox(bubbleColumn, {
|
|
249
|
+
top: 6,
|
|
250
|
+
bottom: 6,
|
|
251
|
+
left: 10,
|
|
252
|
+
right: 10,
|
|
253
|
+
radii: 8,
|
|
254
|
+
fill: () => (currentHover === item ? "#aaa" : "#ccc"),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const body = new Flex<C>([senderLine, content], {
|
|
258
|
+
direction: "column",
|
|
259
|
+
gap: 4,
|
|
260
|
+
alignItems: item.sender === "A" ? "end" : "start",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const alignedBody = new Place<C>(body, {
|
|
264
|
+
align: item.sender === "A" ? "end" : "start",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const row = new Flex<C>(
|
|
268
|
+
[
|
|
269
|
+
new Circle(32, { fill: "red" }),
|
|
270
|
+
// Opt into shrink so narrow viewports wrap the bubble body instead of overflowing the row.
|
|
271
|
+
new FlexItem(alignedBody, { grow: 1, shrink: 1 }),
|
|
272
|
+
new Fixed(32, 0),
|
|
273
|
+
],
|
|
274
|
+
{
|
|
275
|
+
direction: "row",
|
|
276
|
+
gap: 4,
|
|
277
|
+
reverse: item.sender === "A",
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const padded = new PaddingBox(row, {
|
|
282
|
+
top: 4,
|
|
283
|
+
bottom: 4,
|
|
284
|
+
left: 4,
|
|
285
|
+
right: 4,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return new Place(padded, {
|
|
289
|
+
align: item.sender === "A" ? "end" : "start",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const list = new ListState<ChatItem>([
|
|
294
|
+
{
|
|
295
|
+
sender: "A",
|
|
296
|
+
content:
|
|
297
|
+
"hello world chat layout message render bubble timeline virtualized canvas",
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
sender: "B",
|
|
301
|
+
content: "aaaa",
|
|
302
|
+
reply: {
|
|
303
|
+
sender: "A",
|
|
304
|
+
content: "hello world chat layout message render",
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
{ sender: "B", content: "aaaabbb" },
|
|
308
|
+
{ sender: "B", content: "测试中文" },
|
|
309
|
+
{ sender: "B", content: "测试aa中文aaa" },
|
|
310
|
+
{
|
|
311
|
+
sender: "A",
|
|
312
|
+
content: randomText(8),
|
|
313
|
+
reply: {
|
|
314
|
+
sender: "B",
|
|
315
|
+
content: "测试aa中文aaa",
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{ sender: "B", content: randomText(5) },
|
|
319
|
+
]);
|
|
320
|
+
const renderer = new ChatRenderer(ctx, {
|
|
321
|
+
renderItem,
|
|
322
|
+
list,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
function drawFrame(): void {
|
|
326
|
+
const feedback: RenderFeedback = {
|
|
327
|
+
minIdx: Number.NaN,
|
|
328
|
+
maxIdx: Number.NaN,
|
|
329
|
+
min: Number.NaN,
|
|
330
|
+
max: Number.NaN,
|
|
331
|
+
};
|
|
332
|
+
renderer.render(feedback);
|
|
333
|
+
|
|
334
|
+
ctx.save();
|
|
335
|
+
ctx.textBaseline = "top";
|
|
336
|
+
ctx.font = "12px system-ui";
|
|
337
|
+
ctx.fillStyle = "black";
|
|
338
|
+
ctx.strokeStyle = "white";
|
|
339
|
+
ctx.lineWidth = 4;
|
|
340
|
+
ctx.lineJoin = "round";
|
|
341
|
+
const text = JSON.stringify(feedback);
|
|
342
|
+
ctx.strokeText(text, 10, 10);
|
|
343
|
+
ctx.fillText(text, 10, 10);
|
|
344
|
+
ctx.restore();
|
|
345
|
+
|
|
346
|
+
requestAnimationFrame(drawFrame);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
requestAnimationFrame(drawFrame);
|
|
350
|
+
|
|
351
|
+
canvas.addEventListener("wheel", (e) => {
|
|
352
|
+
list.applyScroll(-e.deltaY);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
canvas.addEventListener("pointermove", (e) => {
|
|
356
|
+
const { top, left } = canvas.getBoundingClientRect();
|
|
357
|
+
const result = renderer.hittest({
|
|
358
|
+
x: e.clientX - left,
|
|
359
|
+
y: e.clientY - top,
|
|
360
|
+
type: "hover",
|
|
361
|
+
});
|
|
362
|
+
if (!result) {
|
|
363
|
+
currentHover = undefined;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
function randomText(words: number): string {
|
|
368
|
+
const out: string[] = [];
|
|
369
|
+
for (let i = 0; i < words; i += 1) {
|
|
370
|
+
out.push(sampleWords[Math.floor(Math.random() * sampleWords.length)]);
|
|
371
|
+
}
|
|
372
|
+
return out.join(" ");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
button("unshift", () => {
|
|
376
|
+
list.unshift({
|
|
377
|
+
sender: Math.random() < 0.5 ? "A" : "B",
|
|
378
|
+
content: randomText(10 + Math.floor(200 * Math.random())),
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
button("push", () => {
|
|
383
|
+
list.push({
|
|
384
|
+
sender: Math.random() < 0.5 ? "A" : "B",
|
|
385
|
+
content: randomText(10 + Math.floor(200 * Math.random())),
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
button("jump middle", () => {
|
|
390
|
+
renderer.jumpTo(Math.floor(list.items.length / 2));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
button("jump middle (center)", () => {
|
|
394
|
+
renderer.jumpTo(Math.floor(list.items.length / 2), {
|
|
395
|
+
block: "center",
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
button("jump latest (no anim)", () => {
|
|
400
|
+
renderer.jumpTo(list.items.length - 1, {
|
|
401
|
+
animated: false,
|
|
402
|
+
});
|
|
403
|
+
});
|