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 CHANGED
@@ -1,201 +1,74 @@
1
1
  # chat-layout
2
2
 
3
- Canvas-based chat and timeline layout primitives with a v2 flex-style layout model.
3
+ Canvas-based layout primitives for chat and timeline UIs.
4
4
 
5
- The current recommended APIs are:
5
+ The current v2-style APIs are:
6
6
 
7
- - `Flex` for row/column layout
8
- - `FlexItem` for explicit `grow`
9
- - `Place` for single-child horizontal placement
10
- - `MultilineText` `align` / `physicalAlign` for text content alignment
11
- - `ChatRenderer` plus `ListState` for virtualized chat rendering
12
- - `memoRenderItem` for object items, or `memoRenderItemBy` when your stable key is primitive / explicit
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
- **Layout Model**
15
- `Flex` and `Place` split layout concerns more clearly than the older API:
14
+ ## Quick example
16
15
 
17
- - Use `new Flex(children, { direction: "row" | "column" })` for main-axis layout.
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
- type ChatItem = {
32
- sender: string;
33
- content: string;
34
- reply?: {
35
- sender: string;
36
- content: string;
37
- };
38
- };
39
-
40
- const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
41
- const senderLine = new Flex<C>(
42
- [avatarDot, senderLabel],
43
- {
44
- direction: "row",
45
- gap: 4,
46
- mainAxisSize: "fit-content",
47
- reverse: item.sender === "A",
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
- That combination gives you:
159
-
160
- - explicit row/column structure
161
- - explicit grow behavior through `FlexItem`
162
- - left/right chat placement through `Place`
163
- - wrapped message bubbles that respect available width without becoming full-width by default
164
- - nested reply previews that use item-level cross-axis `stretch` to fill the bubble width
165
-
166
- In other words: a finite `maxWidth` / `maxHeight` limits measurement, but does not force the `Flex` container to fill the cross axis. If you want a child to fill the computed bubble width, mark that child with `alignSelf: "stretch"` (or inherit `alignItems: "stretch"` from the parent).
167
-
168
- ## API notes
169
-
170
- - `memoRenderItem()` now only accepts object items. If your list item is a primitive or you want to memoize by an explicit id, use `memoRenderItemBy(keyOf, renderItem)`.
171
- - `FlexItemOptions` intentionally exposes only the implemented item-level controls: `grow` and `alignSelf`. The previously documented `shrink` / `basis` fields were removed because they were never implemented.
172
- - `ListState.position` now uses `undefined` as the explicit “use renderer default anchor” state. Use `list.setAnchor(position, offset)` to opt into a concrete anchor.
173
- - `ListState` can be seeded with `new ListState(items)` and reset with `list.reset(nextItems)`.
174
- - `MultilineText` now uses only `align` / `physicalAlign`; the old `alignment` field has been removed.
175
-
176
- ### Migration notes
177
-
178
- - Before:
179
- - `memoRenderItem((item: number) => ...)`
180
- - After:
181
- - `memoRenderItemBy((item: number) => item, (item) => ...)`
182
- - Before:
183
- - `new FlexItem(node, { grow: 1, shrink: 1, basis: 100 })`
184
- - After:
185
- - `new FlexItem(node, { grow: 1 })`
186
- - unsupported sizing semantics should be modeled explicitly in node measurement/layout instead of `shrink` / `basis`
187
- - Before:
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
@@ -0,0 +1,12 @@
1
+ const { success, logs } = await Bun.build({
2
+ entrypoints: ["chat.ts"],
3
+ outdir: "build",
4
+ });
5
+
6
+ for (const log of logs) {
7
+ console.log(log);
8
+ }
9
+
10
+ if (!success) {
11
+ process.exit(1);
12
+ }
@@ -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
+ });