chat-layout 1.0.0-2 → 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,213 +1,74 @@
1
1
  # chat-layout
2
2
 
3
- Canvas-based chat and timeline layout primitives with a v2 flex-style layout model.
4
-
5
- The current recommended APIs are:
6
-
7
- - `Flex` for row/column layout
8
- - `FlexItem` for explicit `grow` / `shrink`
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
13
-
14
- **Layout Model**
15
- `Flex` and `Place` split layout concerns more clearly than the older API:
16
-
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
- - Use `new FlexItem(child, { shrink: 1 })` when a child should participate in overflow redistribution under a finite main-axis cap.
20
- - `Flex` shrink-wraps on the cross axis by default; `maxWidth` / `maxHeight` act as measurement caps rather than implicit fill signals.
21
- - Use `alignItems` / `alignSelf: "stretch"` when a specific child should fill the container's computed cross axis.
22
- - Use `new Place(child, { align: "start" | "center" | "end" })` when a single child should fill available width and then be placed left/center/right.
23
- - Use `justifyContent`, `alignItems`, and `alignSelf` for container/item placement.
24
- - Use `align: "start" | "center" | "end"` on `MultilineText` for logical alignment that matches `Place.align`.
25
- - Use `physicalAlign: "left" | "center" | "right"` on `MultilineText` only when you explicitly want physical left/right semantics.
26
- - `Text` / `MultilineText` preserve blank lines and edge whitespace by default; opt into cleanup with `whitespace: "trim-and-collapse"`.
27
-
28
- **Example**
29
- This is the recommended chat bubble shape used by [example/chat.ts](./example/chat.ts):
3
+ Canvas-based layout primitives for chat and timeline UIs.
4
+
5
+ The current v2-style APIs are:
6
+
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
+
14
+ ## Quick example
15
+
16
+ Use `Flex` to build structure, `FlexItem` to control resize behavior, and `Place` to align the final bubble:
30
17
 
31
18
  ```ts
32
- type ChatItem = {
33
- sender: string;
34
- content: string;
35
- reply?: {
36
- sender: string;
37
- content: string;
38
- };
39
- };
40
-
41
- const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
42
- const senderLine = new Flex<C>(
43
- [avatarDot, senderLabel],
44
- {
45
- direction: "row",
46
- gap: 4,
47
- mainAxisSize: "fit-content",
48
- reverse: item.sender === "A",
49
- },
50
- );
51
-
52
- const messageText = new FlexItem(
53
- new MultilineText(item.content, {
54
- lineHeight: 20,
55
- font: "16px system-ui",
56
- style: "black",
57
- align: "start",
58
- }),
59
- { alignSelf: "start" },
60
- );
61
-
62
- const bubbleChildren: Node<C>[] = [];
63
- if (item.reply != null) {
64
- bubbleChildren.push(
65
- new FlexItem(
66
- new RoundedBox(
67
- new Flex<C>(
68
- [
69
- new Text(item.reply.sender, {
70
- lineHeight: 14,
71
- font: "11px system-ui",
72
- style: "#666",
73
- }),
74
- new MultilineText(item.reply.content, {
75
- lineHeight: 16,
76
- font: "13px system-ui",
77
- style: "#444",
78
- align: "start",
79
- }),
80
- ],
81
- {
82
- direction: "column",
83
- gap: 2,
84
- alignItems: "start",
85
- },
86
- ),
87
- {
88
- top: 5,
89
- bottom: 5,
90
- left: 8,
91
- right: 8,
92
- radii: 6,
93
- fill: "#e2e2e2",
94
- },
95
- ),
96
- { alignSelf: "stretch" },
97
- ),
98
- );
99
- }
100
- bubbleChildren.push(messageText);
101
-
102
- const bubbleColumn = new Flex<C>(bubbleChildren, {
103
- direction: "column",
104
- gap: 6,
105
- // The bubble itself stays intrinsic on the cross axis.
106
- // Only the reply preview stretches to the bubble width.
107
- alignItems: "start",
108
- });
109
-
110
- const content = new RoundedBox(
111
- bubbleColumn,
112
- {
113
- top: 6,
114
- bottom: 6,
115
- left: 10,
116
- right: 10,
117
- radii: 8,
118
- fill: "#ccc",
119
- },
120
- );
121
-
122
- const body = new Flex<C>([senderLine, content], {
123
- direction: "column",
124
- alignItems: item.sender === "A" ? "end" : "start",
125
- });
126
-
127
- const alignedBody = new Place<C>(body, {
128
- align: item.sender === "A" ? "end" : "start",
129
- });
130
-
131
- return new Place(
132
- new PaddingBox(
133
- new Flex<C>(
134
- [
135
- avatar,
136
- // Opt into shrink so narrow viewports wrap the bubble body instead of overflowing the row.
137
- new FlexItem(alignedBody, { grow: 1, shrink: 1 }),
138
- new Fixed(32, 0),
139
- ],
140
- {
141
- direction: "row",
142
- gap: 4,
143
- reverse: item.sender === "A",
144
- },
145
- ),
146
- {
147
- top: 4,
148
- bottom: 4,
149
- left: 4,
150
- right: 4,
151
- },
152
- ),
153
- {
154
- align: item.sender === "A" ? "end" : "start",
155
- },
156
- );
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",
157
39
  });
158
40
  ```
159
41
 
160
- That combination gives you:
161
-
162
- - explicit row/column structure
163
- - explicit grow/shrink behavior through `FlexItem`
164
- - left/right chat placement through `Place`
165
- - wrapped message bubbles that respect available width without becoming full-width by default
166
- - nested reply previews that use item-level cross-axis `stretch` to fill the bubble width
167
- - opt-in overflow redistribution on the message body when the row runs out of main-axis space
168
-
169
- 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).
170
-
171
- ## Flex shrink
172
-
173
- - `FlexItemOptions.shrink` defaults to `0`, so existing layouts keep their old behavior unless you opt in.
174
- - When a finite main-axis cap is present and the summed basis sizes overflow it, negative space is redistributed by `shrink * basis`.
175
- - `basis` is still internal-only and fixed to `"auto"`; the library currently measures the natural content size first, then decides whether to grow or shrink.
176
- - Custom nodes can implement `measureMinContent()` for accurate saturation. Nodes that do not implement it fall back to their regular `measure()` result, so shrink stays safe but may be more conservative.
177
- - Known limitation: `column` shrink with `MultilineText` does not clip visual overflow on its own. If you need clipping, add it in your draw layer or wrap the node in a clipping primitive.
178
-
179
- ## API notes
180
-
181
- - `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)`.
182
- - `FlexItemOptions` exposes `grow`, `shrink`, and `alignSelf`. `shrink` uses a compatibility-first default of `0`, while `basis` remains internal-only and fixed to `"auto"`.
183
- - `ListState.position` now uses `undefined` as the explicit “use renderer default anchor” state. Use `list.setAnchor(position, offset)` to opt into a concrete anchor.
184
- - `ListState` can be seeded with `new ListState(items)` and reset with `list.reset(nextItems)`.
185
- - `MultilineText` now uses only `align` / `physicalAlign`; the old `alignment` field has been removed.
186
-
187
- ### Migration notes
188
-
189
- - Before:
190
- - `memoRenderItem((item: number) => ...)`
191
- - After:
192
- - `memoRenderItemBy((item: number) => item, (item) => ...)`
193
- - Before:
194
- - `new FlexItem(node, { grow: 1, shrink: 1, basis: 100 })`
195
- - After:
196
- - `new FlexItem(node, { grow: 1, shrink: 1 })` if you want overflow redistribution
197
- - `new FlexItem(node, { grow: 1 })` if you want to preserve the pre-shrink behavior
198
- - `basis` stays internal-only (`"auto"`); unsupported sizing semantics beyond that should still be modeled explicitly in node measurement/layout
199
- - Before:
200
- - `new MultilineText(text, { alignment: "left" })`
201
- - After:
202
- - `new MultilineText(text, { align: "start" })`
203
- - or `new MultilineText(text, { physicalAlign: "left" })` when physical left/right semantics are required
204
- - Before:
205
- - `list.position = Number.NaN`
206
- - After:
207
- - `list.resetScroll()`
208
- - or `list.setAnchor(index, offset)` for an explicit anchor
209
-
210
- **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
+
211
72
  Install dependencies:
212
73
 
213
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
+ });
@@ -0,0 +1,215 @@
1
+ import {
2
+ DebugRenderer,
3
+ Flex,
4
+ MultilineText,
5
+ PaddingBox,
6
+ Place,
7
+ Text,
8
+ Wrapper,
9
+ type Context,
10
+ type DynValue,
11
+ type HitTest,
12
+ type Node,
13
+ } from "chat-layout";
14
+
15
+ type C = CanvasRenderingContext2D;
16
+
17
+ class RoundedBox extends PaddingBox<C> {
18
+ readonly radii: number | DOMPointInit | (number | DOMPointInit)[];
19
+ readonly stroke: DynValue<C, string | undefined>;
20
+ readonly fill: DynValue<C, string | undefined>;
21
+
22
+ constructor(
23
+ inner: Node<C>,
24
+ {
25
+ radii,
26
+ stroke,
27
+ fill,
28
+ ...options
29
+ }: {
30
+ top: number;
31
+ bottom: number;
32
+ left: number;
33
+ right: number;
34
+ stroke?: DynValue<C, string | undefined>;
35
+ fill?: DynValue<C, string | undefined>;
36
+ radii: number | DOMPointInit | (number | DOMPointInit)[];
37
+ },
38
+ ) {
39
+ super(inner, options);
40
+ this.radii = radii;
41
+ this.stroke = stroke;
42
+ this.fill = fill;
43
+ }
44
+
45
+ draw(ctx: Context<C>, x: number, y: number): boolean {
46
+ // Reuse the current layout constraints so the background matches wrapped text.
47
+ const { width, height } = ctx.measureNode(this, ctx.constraints);
48
+ ctx.with((g) => {
49
+ const fill = this.fill == null ? undefined : ctx.resolveDynValue(this.fill);
50
+ const stroke = this.stroke == null ? undefined : ctx.resolveDynValue(this.stroke);
51
+ g.beginPath();
52
+ g.roundRect(x, y, width, height, this.radii);
53
+ if (fill != null) {
54
+ g.fillStyle = fill;
55
+ }
56
+ if (stroke != null) {
57
+ g.strokeStyle = stroke;
58
+ }
59
+ if (fill != null) {
60
+ g.fill();
61
+ }
62
+ if (stroke != null) {
63
+ g.stroke();
64
+ }
65
+ });
66
+ return super.draw(ctx, x, y);
67
+ }
68
+ }
69
+
70
+ const canvas = document.createElement("canvas");
71
+ document.body.appendChild(canvas);
72
+ canvas.width = canvas.clientWidth * devicePixelRatio;
73
+ canvas.height = canvas.clientHeight * devicePixelRatio;
74
+
75
+ const context = canvas.getContext("2d");
76
+ if (context == null) {
77
+ throw new Error("Failed to initial canvas");
78
+ }
79
+ const ctx: C = context;
80
+ ctx.scale(devicePixelRatio, devicePixelRatio);
81
+
82
+ const renderer = new DebugRenderer(ctx, {});
83
+
84
+ let color = "green";
85
+
86
+ class ClickDetect extends Wrapper<C> {
87
+ hittest(_ctx: Context<C>, _test: HitTest): boolean {
88
+ color = "red";
89
+ return true;
90
+ }
91
+ }
92
+
93
+ const node = new RoundedBox(
94
+ new Flex<C>(
95
+ [
96
+ new Place(
97
+ new MultilineText("测试居中".repeat(20), {
98
+ lineHeight: 20,
99
+ font: "400 16px monospace",
100
+ align: "center",
101
+ style: "black",
102
+ }),
103
+ { align: "center" },
104
+ ),
105
+ new Place(
106
+ new RoundedBox(
107
+ new Flex<C>(
108
+ [
109
+ new ClickDetect(
110
+ new RoundedBox(
111
+ new Text("测试3".repeat(2), {
112
+ lineHeight: 20,
113
+ font: "400 16px monospace",
114
+ style: () => color,
115
+ }),
116
+ {
117
+ left: 14,
118
+ right: 14,
119
+ bottom: 10,
120
+ top: 10,
121
+ fill: "#aaa",
122
+ radii: 8,
123
+ },
124
+ ),
125
+ ),
126
+ new RoundedBox(
127
+ new MultilineText("测试2".repeat(5), {
128
+ lineHeight: 16,
129
+ font: "400 12px monospace",
130
+ align: "center",
131
+ style: "black",
132
+ }),
133
+ {
134
+ left: 10,
135
+ right: 10,
136
+ bottom: 5,
137
+ top: 5,
138
+ fill: "#aaa",
139
+ radii: 8,
140
+ },
141
+ ),
142
+ ],
143
+ {
144
+ direction: "row",
145
+ reverse: true,
146
+ gap: 10,
147
+ },
148
+ ),
149
+ {
150
+ left: 10,
151
+ right: 10,
152
+ bottom: 10,
153
+ top: 10,
154
+ fill: "#ddd",
155
+ radii: 16,
156
+ },
157
+ ),
158
+ { align: "center" },
159
+ ),
160
+ new Place(
161
+ new RoundedBox(
162
+ new MultilineText("文本右对齐".repeat(10), {
163
+ lineHeight: 20,
164
+ font: "400 16px monospace",
165
+ physicalAlign: "right",
166
+ style: "black",
167
+ }),
168
+ {
169
+ left: 10,
170
+ right: 10,
171
+ bottom: 10,
172
+ top: 10,
173
+ fill: "#ccc",
174
+ radii: 16,
175
+ },
176
+ ),
177
+ { align: "center" },
178
+ ),
179
+ ],
180
+ {
181
+ direction: "column",
182
+ gap: 10,
183
+ },
184
+ ),
185
+ {
186
+ left: 10,
187
+ right: 10,
188
+ bottom: 10,
189
+ top: 10,
190
+ fill: "#eee",
191
+ radii: 20,
192
+ },
193
+ );
194
+
195
+ console.log(renderer.measureNode(node));
196
+ renderer.draw(node);
197
+
198
+ function drawFrame(): void {
199
+ renderer.draw(node);
200
+ requestAnimationFrame(drawFrame);
201
+ }
202
+
203
+ requestAnimationFrame(drawFrame);
204
+
205
+ canvas.addEventListener("pointermove", (e) => {
206
+ const { top, left } = canvas.getBoundingClientRect();
207
+ const result = renderer.hittest(node, {
208
+ x: e.clientX - left,
209
+ y: e.clientY - top,
210
+ type: "hover",
211
+ });
212
+ if (!result) {
213
+ color = "green";
214
+ }
215
+ });
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "repository": {
9
9
  "url": "https://github.com/codehz/chat-layout.git"
10
10
  },
11
- "version": "1.0.0-2",
11
+ "version": "1.0.0-3",
12
12
  "main": "./index.mjs",
13
13
  "types": "./index.d.mts",
14
14
  "exports": {