chat-layout 1.1.0-2 → 1.1.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/example/chat.ts +138 -25
- package/index.d.mts +31 -9
- package/index.mjs +255 -34
- package/index.mjs.map +1 -1
- package/package.json +1 -1
package/example/chat.ts
CHANGED
|
@@ -131,18 +131,42 @@ if (context == null) {
|
|
|
131
131
|
const ctx: C = context;
|
|
132
132
|
ctx.scale(devicePixelRatio, devicePixelRatio);
|
|
133
133
|
|
|
134
|
-
type
|
|
134
|
+
type ReplyPreview = {
|
|
135
135
|
sender: string;
|
|
136
136
|
content: string;
|
|
137
|
-
reply?: {
|
|
138
|
-
sender: string;
|
|
139
|
-
content: string;
|
|
140
|
-
};
|
|
141
137
|
};
|
|
142
138
|
|
|
139
|
+
type BaseChatItem = {
|
|
140
|
+
id: number;
|
|
141
|
+
sender: string;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
type MessageItem = BaseChatItem & {
|
|
145
|
+
kind: "message";
|
|
146
|
+
content: string;
|
|
147
|
+
reply?: ReplyPreview;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
type RevokedItem = BaseChatItem & {
|
|
151
|
+
kind: "revoked";
|
|
152
|
+
original: MessageItem;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type ChatItem = MessageItem | RevokedItem;
|
|
156
|
+
|
|
143
157
|
let currentHover: ChatItem | undefined;
|
|
158
|
+
const REPLACE_ANIMATION_DURATION = 320;
|
|
159
|
+
|
|
160
|
+
function revokeMessage(item: MessageItem): RevokedItem {
|
|
161
|
+
return {
|
|
162
|
+
id: item.id,
|
|
163
|
+
sender: item.sender,
|
|
164
|
+
kind: "revoked",
|
|
165
|
+
original: item,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
144
168
|
|
|
145
|
-
class
|
|
169
|
+
class ItemDetector extends Wrapper<C> {
|
|
146
170
|
constructor(
|
|
147
171
|
inner: Node<C>,
|
|
148
172
|
readonly item: ChatItem,
|
|
@@ -150,21 +174,65 @@ class HoverDetector extends Wrapper<C> {
|
|
|
150
174
|
super(inner);
|
|
151
175
|
}
|
|
152
176
|
|
|
153
|
-
hittest(_ctx: Context<C>,
|
|
177
|
+
hittest(_ctx: Context<C>, test: HitTest): boolean {
|
|
154
178
|
currentHover = this.item;
|
|
179
|
+
if (test.type === "click") {
|
|
180
|
+
const index = list.items.indexOf(this.item);
|
|
181
|
+
if (index < 0) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
const nextItem = this.item.kind === "revoked" ? this.item.original : revokeMessage(this.item);
|
|
185
|
+
currentHover = nextItem;
|
|
186
|
+
list.replace(index, nextItem, {
|
|
187
|
+
duration: REPLACE_ANIMATION_DURATION,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
155
190
|
return true;
|
|
156
191
|
}
|
|
157
192
|
}
|
|
158
193
|
|
|
159
194
|
const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
|
|
195
|
+
if (item.kind === "revoked") {
|
|
196
|
+
return new ItemDetector(
|
|
197
|
+
new Place(
|
|
198
|
+
new PaddingBox(
|
|
199
|
+
new RoundedBox(
|
|
200
|
+
new Text(`${item.sender}已撤回一条消息`, {
|
|
201
|
+
lineHeight: 18,
|
|
202
|
+
font: "14px system-ui",
|
|
203
|
+
style: () => (currentHover?.id === item.id ? "#525252" : "#666"),
|
|
204
|
+
overflow: "ellipsis",
|
|
205
|
+
}),
|
|
206
|
+
{
|
|
207
|
+
top: 10,
|
|
208
|
+
bottom: 10,
|
|
209
|
+
left: 12,
|
|
210
|
+
right: 12,
|
|
211
|
+
radii: 999,
|
|
212
|
+
fill: () => (currentHover?.id === item.id ? "#d9d9d9" : "#ececec"),
|
|
213
|
+
stroke: () => (currentHover?.id === item.id ? "#bcbcbc" : "#d3d3d3"),
|
|
214
|
+
},
|
|
215
|
+
),
|
|
216
|
+
{
|
|
217
|
+
top: 8,
|
|
218
|
+
bottom: 8,
|
|
219
|
+
left: 4,
|
|
220
|
+
right: 4,
|
|
221
|
+
},
|
|
222
|
+
),
|
|
223
|
+
{
|
|
224
|
+
align: item.sender === "A" ? "end" : "start",
|
|
225
|
+
},
|
|
226
|
+
),
|
|
227
|
+
item,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
160
231
|
const senderLine = new Flex<C>(
|
|
161
232
|
[
|
|
162
|
-
new
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}),
|
|
166
|
-
item,
|
|
167
|
-
),
|
|
233
|
+
new Circle(15, {
|
|
234
|
+
fill: "blue",
|
|
235
|
+
}),
|
|
168
236
|
new RoundedBox(
|
|
169
237
|
new Text(item.sender, {
|
|
170
238
|
lineHeight: 15,
|
|
@@ -177,7 +245,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
|
|
|
177
245
|
left: 0,
|
|
178
246
|
right: 0,
|
|
179
247
|
radii: 2,
|
|
180
|
-
fill: () => (currentHover === item ? "red" : "transparent"),
|
|
248
|
+
fill: () => (currentHover?.id === item.id ? "red" : "transparent"),
|
|
181
249
|
},
|
|
182
250
|
),
|
|
183
251
|
],
|
|
@@ -209,12 +277,12 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
|
|
|
209
277
|
new Text(item.reply.sender, {
|
|
210
278
|
lineHeight: 14,
|
|
211
279
|
font: "11px system-ui",
|
|
212
|
-
style: () => (currentHover === item ? "#4d4d4d" : "#666"),
|
|
280
|
+
style: () => (currentHover?.id === item.id ? "#4d4d4d" : "#666"),
|
|
213
281
|
}),
|
|
214
282
|
new MultilineText(item.reply.content, {
|
|
215
283
|
lineHeight: 16,
|
|
216
284
|
font: "13px system-ui",
|
|
217
|
-
style: () => (currentHover === item ? "#222" : "#444"),
|
|
285
|
+
style: () => (currentHover?.id === item.id ? "#222" : "#444"),
|
|
218
286
|
align: "start",
|
|
219
287
|
overflow: "ellipsis",
|
|
220
288
|
overflowWrap: "anywhere",
|
|
@@ -233,7 +301,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
|
|
|
233
301
|
left: 8,
|
|
234
302
|
right: 8,
|
|
235
303
|
radii: 6,
|
|
236
|
-
fill: () => (currentHover === item ? "#c2c2c2" : "#e2e2e2"),
|
|
304
|
+
fill: () => (currentHover?.id === item.id ? "#c2c2c2" : "#e2e2e2"),
|
|
237
305
|
},
|
|
238
306
|
),
|
|
239
307
|
{ alignSelf: "stretch" },
|
|
@@ -255,7 +323,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
|
|
|
255
323
|
left: 10,
|
|
256
324
|
right: 10,
|
|
257
325
|
radii: 8,
|
|
258
|
-
fill: () => (currentHover === item ? "#aaa" : "#ccc"),
|
|
326
|
+
fill: () => (currentHover?.id === item.id ? "#aaa" : "#ccc"),
|
|
259
327
|
});
|
|
260
328
|
|
|
261
329
|
const body = new Flex<C>([senderLine, content], {
|
|
@@ -289,18 +357,25 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
|
|
|
289
357
|
right: 4,
|
|
290
358
|
});
|
|
291
359
|
|
|
292
|
-
return new
|
|
293
|
-
|
|
294
|
-
|
|
360
|
+
return new ItemDetector(
|
|
361
|
+
new Place(padded, {
|
|
362
|
+
align: item.sender === "A" ? "end" : "start",
|
|
363
|
+
}),
|
|
364
|
+
item,
|
|
365
|
+
);
|
|
295
366
|
});
|
|
296
367
|
|
|
297
368
|
const list = new ListState<ChatItem>([
|
|
298
369
|
{
|
|
370
|
+
id: 1,
|
|
371
|
+
kind: "message",
|
|
299
372
|
sender: "A",
|
|
300
373
|
content:
|
|
301
374
|
"hello world chat layout message render bubble timeline virtualized canvas",
|
|
302
375
|
},
|
|
303
376
|
{
|
|
377
|
+
id: 2,
|
|
378
|
+
kind: "message",
|
|
304
379
|
sender: "B",
|
|
305
380
|
content: "aaaa",
|
|
306
381
|
reply: {
|
|
@@ -308,15 +383,19 @@ const list = new ListState<ChatItem>([
|
|
|
308
383
|
content: "hello world chat layout message render",
|
|
309
384
|
},
|
|
310
385
|
},
|
|
311
|
-
{ sender: "B", content: "aaaabbb" },
|
|
312
|
-
{ sender: "B", content: "测试中文" },
|
|
386
|
+
{ id: 3, kind: "message", sender: "B", content: "aaaabbb" },
|
|
387
|
+
{ id: 4, kind: "message", sender: "B", content: "测试中文" },
|
|
313
388
|
{
|
|
389
|
+
id: 5,
|
|
390
|
+
kind: "message",
|
|
314
391
|
sender: "A",
|
|
315
392
|
content:
|
|
316
393
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
317
394
|
},
|
|
318
|
-
{ sender: "B", content: "测试aa中文aaa" },
|
|
395
|
+
{ id: 6, kind: "message", sender: "B", content: "测试aa中文aaa" },
|
|
319
396
|
{
|
|
397
|
+
id: 7,
|
|
398
|
+
kind: "message",
|
|
320
399
|
sender: "A",
|
|
321
400
|
content: randomText(8),
|
|
322
401
|
reply: {
|
|
@@ -326,6 +405,8 @@ const list = new ListState<ChatItem>([
|
|
|
326
405
|
},
|
|
327
406
|
},
|
|
328
407
|
{
|
|
408
|
+
id: 8,
|
|
409
|
+
kind: "message",
|
|
329
410
|
sender: "B",
|
|
330
411
|
content: "这里是一条会展示回复预览省略效果的消息。",
|
|
331
412
|
reply: {
|
|
@@ -334,12 +415,13 @@ const list = new ListState<ChatItem>([
|
|
|
334
415
|
"这是一条非常长的回复预览,用来演示 MultilineText 在 chat example 里的末尾 ellipsis 能力。它应该被限制在两行之内,而不是把整个气泡一路撑到天花板。",
|
|
335
416
|
},
|
|
336
417
|
},
|
|
337
|
-
{ sender: "B", content: randomText(5) },
|
|
418
|
+
{ id: 9, kind: "message", sender: "B", content: randomText(5) },
|
|
338
419
|
]);
|
|
339
420
|
const renderer = new ChatRenderer(ctx, {
|
|
340
421
|
renderItem,
|
|
341
422
|
list,
|
|
342
423
|
});
|
|
424
|
+
let nextMessageId = list.items.length + 1;
|
|
343
425
|
|
|
344
426
|
function drawFrame(): void {
|
|
345
427
|
const feedback: RenderFeedback = {
|
|
@@ -383,6 +465,22 @@ canvas.addEventListener("pointermove", (e) => {
|
|
|
383
465
|
}
|
|
384
466
|
});
|
|
385
467
|
|
|
468
|
+
canvas.addEventListener("pointerleave", () => {
|
|
469
|
+
currentHover = undefined;
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
canvas.addEventListener("click", (e) => {
|
|
473
|
+
const { top, left } = canvas.getBoundingClientRect();
|
|
474
|
+
const result = renderer.hittest({
|
|
475
|
+
x: e.clientX - left,
|
|
476
|
+
y: e.clientY - top,
|
|
477
|
+
type: "click",
|
|
478
|
+
});
|
|
479
|
+
if (!result) {
|
|
480
|
+
currentHover = undefined;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
386
484
|
function randomText(words: number): string {
|
|
387
485
|
const out: string[] = [];
|
|
388
486
|
for (let i = 0; i < words; i += 1) {
|
|
@@ -393,6 +491,8 @@ function randomText(words: number): string {
|
|
|
393
491
|
|
|
394
492
|
button("unshift", () => {
|
|
395
493
|
list.unshift({
|
|
494
|
+
id: nextMessageId++,
|
|
495
|
+
kind: "message",
|
|
396
496
|
sender: Math.random() < 0.5 ? "A" : "B",
|
|
397
497
|
content: randomText(10 + Math.floor(200 * Math.random())),
|
|
398
498
|
});
|
|
@@ -400,6 +500,8 @@ button("unshift", () => {
|
|
|
400
500
|
|
|
401
501
|
button("push", () => {
|
|
402
502
|
list.push({
|
|
503
|
+
id: nextMessageId++,
|
|
504
|
+
kind: "message",
|
|
403
505
|
sender: Math.random() < 0.5 ? "A" : "B",
|
|
404
506
|
content: randomText(10 + Math.floor(200 * Math.random())),
|
|
405
507
|
});
|
|
@@ -420,3 +522,14 @@ button("jump latest (no anim)", () => {
|
|
|
420
522
|
animated: false,
|
|
421
523
|
});
|
|
422
524
|
});
|
|
525
|
+
|
|
526
|
+
button("revoke first", () => {
|
|
527
|
+
const item = list.items.find((entry): entry is MessageItem => entry.kind === "message");
|
|
528
|
+
if (item == null) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const index = list.items.indexOf(item);
|
|
532
|
+
list.replace(index, revokeMessage(item), {
|
|
533
|
+
duration: REPLACE_ANIMATION_DURATION,
|
|
534
|
+
});
|
|
535
|
+
});
|
package/index.d.mts
CHANGED
|
@@ -448,13 +448,20 @@ declare class DebugRenderer<C extends CanvasRenderingContext2D> extends BaseRend
|
|
|
448
448
|
/**
|
|
449
449
|
* Mutable list state shared with virtualized renderers.
|
|
450
450
|
*/
|
|
451
|
+
interface ReplaceListItemAnimationOptions {
|
|
452
|
+
/** Animation duration in milliseconds. */
|
|
453
|
+
duration?: number;
|
|
454
|
+
}
|
|
451
455
|
declare class ListState<T extends {}> {
|
|
456
|
+
#private;
|
|
452
457
|
/** Pixel offset from the anchored item edge. */
|
|
453
458
|
offset: number;
|
|
454
459
|
/** Anchor item index, or `undefined` to use the renderer default. */
|
|
455
460
|
position: number | undefined;
|
|
456
461
|
/** Items currently managed by the renderer. */
|
|
457
|
-
items: T[];
|
|
462
|
+
get items(): T[];
|
|
463
|
+
/** Replaces the full item collection while preserving scroll state. */
|
|
464
|
+
set items(value: T[]);
|
|
458
465
|
/**
|
|
459
466
|
* @param items Initial list items.
|
|
460
467
|
*/
|
|
@@ -467,6 +474,10 @@ declare class ListState<T extends {}> {
|
|
|
467
474
|
push(...items: T[]): void;
|
|
468
475
|
/** Appends an array of items. */
|
|
469
476
|
pushAll(items: T[]): void;
|
|
477
|
+
/**
|
|
478
|
+
* Replaces an existing item by index.
|
|
479
|
+
*/
|
|
480
|
+
replace(index: number, item: T, animation?: ReplaceListItemAnimationOptions): void;
|
|
470
481
|
/**
|
|
471
482
|
* Sets the current anchor item and pixel offset.
|
|
472
483
|
*/
|
|
@@ -530,6 +541,10 @@ interface JumpToOptions {
|
|
|
530
541
|
/** Called after the jump completes or finishes animating. */
|
|
531
542
|
onComplete?: () => void;
|
|
532
543
|
}
|
|
544
|
+
type VirtualizedResolvedItem<C extends CanvasRenderingContext2D> = {
|
|
545
|
+
draw: (y: number) => boolean;
|
|
546
|
+
hittest: (test: HitTest, y: number) => boolean;
|
|
547
|
+
};
|
|
533
548
|
/**
|
|
534
549
|
* Shared base class for virtualized list renderers.
|
|
535
550
|
*/
|
|
@@ -541,6 +556,10 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
|
|
|
541
556
|
static readonly MIN_JUMP_DURATION = 160;
|
|
542
557
|
static readonly MAX_JUMP_DURATION = 420;
|
|
543
558
|
static readonly JUMP_DURATION_PER_ITEM = 28;
|
|
559
|
+
constructor(graphics: C, options: {
|
|
560
|
+
renderItem: (item: T) => Node<C>;
|
|
561
|
+
list: ListState<T>;
|
|
562
|
+
});
|
|
544
563
|
/** Current anchor item index. */
|
|
545
564
|
get position(): number | undefined;
|
|
546
565
|
/** Updates the current anchor item index. */
|
|
@@ -569,23 +588,24 @@ declare abstract class VirtualizedRenderer<C extends CanvasRenderingContext2D, T
|
|
|
569
588
|
jumpTo(index: number, options?: JumpToOptions): void;
|
|
570
589
|
protected _resetRenderFeedback(feedback?: RenderFeedback): void;
|
|
571
590
|
protected _accumulateRenderFeedback(feedback: RenderFeedback, idx: number, top: number, height: number): void;
|
|
572
|
-
protected _renderDrawList(list: VisibleWindow<
|
|
573
|
-
protected _renderVisibleWindow(window: VisibleWindow<
|
|
574
|
-
protected _hittestVisibleWindow(window: VisibleWindow<
|
|
575
|
-
x: number;
|
|
576
|
-
y: number;
|
|
577
|
-
type: "click" | "auxclick" | "hover";
|
|
578
|
-
}): boolean;
|
|
591
|
+
protected _renderDrawList(list: VisibleWindow<VirtualizedResolvedItem<C>>["drawList"], shift: number, feedback?: RenderFeedback): boolean;
|
|
592
|
+
protected _renderVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem<C>>, feedback?: RenderFeedback): boolean;
|
|
593
|
+
protected _hittestVisibleWindow(window: VisibleWindow<VirtualizedResolvedItem<C>>, test: HitTest): boolean;
|
|
579
594
|
protected _prepareRender(): boolean;
|
|
580
595
|
protected _finishRender(requestRedraw: boolean): boolean;
|
|
581
596
|
protected _clampItemIndex(index: number): number;
|
|
582
597
|
protected _getItemHeight(index: number): number;
|
|
598
|
+
protected _resolveItem(item: T, index: number, now: number): {
|
|
599
|
+
value: VirtualizedResolvedItem<C>;
|
|
600
|
+
height: number;
|
|
601
|
+
};
|
|
583
602
|
protected _getAnchorAtOffset(index: number, offset: number): number;
|
|
584
603
|
protected abstract _normalizeListState(state: VisibleListState): NormalizedListState;
|
|
585
604
|
protected abstract _readAnchor(state: NormalizedListState): number;
|
|
586
605
|
protected abstract _applyAnchor(anchor: number): void;
|
|
587
606
|
protected abstract _getDefaultJumpBlock(): NonNullable<JumpToOptions["block"]>;
|
|
588
607
|
protected abstract _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
|
|
608
|
+
protected abstract _getAnimatedLayerOffset(slotHeight: number, nodeHeight: number): number;
|
|
589
609
|
}
|
|
590
610
|
//#endregion
|
|
591
611
|
//#region src/renderer/virtualized/chat.d.ts
|
|
@@ -599,6 +619,7 @@ declare class ChatRenderer<C extends CanvasRenderingContext2D, T extends {}> ext
|
|
|
599
619
|
protected _readAnchor(state: NormalizedListState): number;
|
|
600
620
|
protected _applyAnchor(anchor: number): void;
|
|
601
621
|
protected _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
|
|
622
|
+
protected _getAnimatedLayerOffset(slotHeight: number, nodeHeight: number): number;
|
|
602
623
|
render(feedback?: RenderFeedback): boolean;
|
|
603
624
|
hittest(test: HitTest): boolean;
|
|
604
625
|
}
|
|
@@ -614,9 +635,10 @@ declare class TimelineRenderer<C extends CanvasRenderingContext2D, T extends {}>
|
|
|
614
635
|
protected _readAnchor(state: NormalizedListState): number;
|
|
615
636
|
protected _applyAnchor(anchor: number): void;
|
|
616
637
|
protected _getTargetAnchor(index: number, block: NonNullable<JumpToOptions["block"]>): number;
|
|
638
|
+
protected _getAnimatedLayerOffset(_slotHeight: number, _nodeHeight: number): number;
|
|
617
639
|
render(feedback?: RenderFeedback): boolean;
|
|
618
640
|
hittest(test: HitTest): boolean;
|
|
619
641
|
}
|
|
620
642
|
//#endregion
|
|
621
|
-
export { Axis, BaseRenderer, Box, ChatRenderer, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, JumpToOptions, LayoutConstraints, LayoutRect, ListState, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, RenderFeedback, RendererOptions, Text, TextAlign, TextEllipsisPosition, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
|
|
643
|
+
export { Axis, BaseRenderer, Box, ChatRenderer, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, 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 };
|
|
622
644
|
//# sourceMappingURL=index.d.mts.map
|