@haikit/client 0.6.1 → 0.6.2
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/package.json +1 -1
- package/src/transcript.d.ts +5 -3
- package/src/transcript.js +99 -2
package/package.json
CHANGED
package/src/transcript.d.ts
CHANGED
|
@@ -12,8 +12,10 @@ export function h<K extends keyof HTMLElementTagNameMap>(
|
|
|
12
12
|
): HTMLElementTagNameMap[K];
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* The default transcript renderer. Replaces `root`'s children on every call
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* The default transcript renderer. Replaces `root`'s children on every call.
|
|
16
|
+
* While the reader is at the bottom, it keeps the newest content in view,
|
|
17
|
+
* surfaces included once they have mounted and as they grow. Once the reader
|
|
18
|
+
* scrolls up, it keeps their place until they scroll back down. Deliberately
|
|
19
|
+
* plain — this is the part you are expected to replace.
|
|
18
20
|
*/
|
|
19
21
|
export function renderTranscript(root: HTMLElement, chat: Chat): void;
|
package/src/transcript.js
CHANGED
|
@@ -15,12 +15,109 @@ export const h = (tag, className, text) => {
|
|
|
15
15
|
|
|
16
16
|
export function renderTranscript(root, chat) {
|
|
17
17
|
const { state } = chat;
|
|
18
|
-
const
|
|
18
|
+
const follow = follower(root);
|
|
19
|
+
|
|
20
|
+
// reset() gives a new conversation a new blocks array, and a new
|
|
21
|
+
// conversation starts at the bottom, wherever the reader was in the last.
|
|
22
|
+
if (state.blocks !== follow.blocks) Object.assign(follow, { blocks: state.blocks, stick: true, top: 0 });
|
|
23
|
+
// A scroll the reader made since the last render, whose event hasn't arrived
|
|
24
|
+
// yet: taken now, or the pin below would overwrite it. Not while an earlier
|
|
25
|
+
// render in this task is still mounting: the reader can't scroll within a
|
|
26
|
+
// task, and the position read then is one clamped to a shorter transcript.
|
|
27
|
+
else if (!follow.mounting) follow.noticeScroll();
|
|
19
28
|
|
|
20
29
|
root.replaceChildren();
|
|
21
30
|
for (const block of state.blocks) root.append(renderBlock(block, chat));
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
// Surfaces mount in microtasks queued by renderBlock, in block order. This
|
|
33
|
+
// one is queued after them, so it runs once they have content and height:
|
|
34
|
+
// scrolling any earlier aims at a bottom that is about to move.
|
|
35
|
+
follow.mounting = true;
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
follow.mounting = false;
|
|
38
|
+
follow.stick ? follow.pin() : follow.hold();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Surfaces can grow after they mount, as a table expands or an image loads.
|
|
42
|
+
// While following, the observer keeps the newest content in view as they do.
|
|
43
|
+
// Otherwise it returns the reader to their place, which a rebuild may have
|
|
44
|
+
// had to clamp while the surfaces above it were still short. The browser
|
|
45
|
+
// reports scrolls before resizes in a frame, so a scroll the reader has just
|
|
46
|
+
// made is already their place by then.
|
|
47
|
+
if (follow.observer) {
|
|
48
|
+
follow.observer.disconnect();
|
|
49
|
+
follow.observer.observe(root);
|
|
50
|
+
for (const child of root.children) follow.observer.observe(child);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Within this many pixels of the bottom, the reader is following new content. */
|
|
55
|
+
const NEAR_BOTTOM = 40;
|
|
56
|
+
|
|
57
|
+
const followers = new WeakMap();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a transcript follows new content, and where its reader is when it
|
|
61
|
+
* doesn't, kept per element across renders.
|
|
62
|
+
*
|
|
63
|
+
* Only the reader's own scrolling changes either. Read from the geometry on
|
|
64
|
+
* each render instead, both go wrong. Following breaks for good the first time
|
|
65
|
+
* content grows after the scroll, which a surface mounting does every time: the
|
|
66
|
+
* bottom is then too far away to count, and nothing scrolls there again. And
|
|
67
|
+
* one chunk of the stream can render several times before any surface mounts,
|
|
68
|
+
* so a later render reads a position the browser has already clamped to the
|
|
69
|
+
* shorter transcript.
|
|
70
|
+
*/
|
|
71
|
+
function follower(root) {
|
|
72
|
+
let follow = followers.get(root);
|
|
73
|
+
if (follow) return follow;
|
|
74
|
+
|
|
75
|
+
// where the transcript's own last scroll landed, so the scroll event that
|
|
76
|
+
// follows is not taken for the reader's
|
|
77
|
+
let landed = null;
|
|
78
|
+
const land = () => (landed = root.scrollTop);
|
|
79
|
+
|
|
80
|
+
follow = {
|
|
81
|
+
stick: true,
|
|
82
|
+
// where the reader last scrolled to
|
|
83
|
+
top: 0,
|
|
84
|
+
// the blocks array of the conversation shown
|
|
85
|
+
blocks: null,
|
|
86
|
+
// a render's surfaces are still to mount
|
|
87
|
+
mounting: false,
|
|
88
|
+
// the reader has scrolled, unless this is where the transcript's own
|
|
89
|
+
// last scroll landed
|
|
90
|
+
noticeScroll() {
|
|
91
|
+
if (landed !== null && Math.abs(root.scrollTop - landed) < 1) return;
|
|
92
|
+
// Content shrinking under a reader who scrolled up pulls them up to the
|
|
93
|
+
// new bottom. That is the layout moving them, not the reader, whose own
|
|
94
|
+
// scroll up always leaves the bottom: keep their place for when the
|
|
95
|
+
// content grows back.
|
|
96
|
+
const bottom = root.scrollHeight - root.clientHeight;
|
|
97
|
+
if (!follow.stick && root.scrollTop < follow.top && root.scrollTop >= bottom - 1) return;
|
|
98
|
+
landed = null;
|
|
99
|
+
follow.top = root.scrollTop;
|
|
100
|
+
follow.stick = root.scrollHeight - root.scrollTop - root.clientHeight < NEAR_BOTTOM;
|
|
101
|
+
},
|
|
102
|
+
pin() {
|
|
103
|
+
root.scrollTop = root.scrollHeight;
|
|
104
|
+
land();
|
|
105
|
+
},
|
|
106
|
+
// keep a reader who has scrolled up where they were, through a rebuild
|
|
107
|
+
hold() {
|
|
108
|
+
root.scrollTop = follow.top;
|
|
109
|
+
land();
|
|
110
|
+
},
|
|
111
|
+
observer:
|
|
112
|
+
typeof ResizeObserver === "undefined"
|
|
113
|
+
? null
|
|
114
|
+
: new ResizeObserver(() => (follow.stick ? follow.pin() : follow.hold())),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
root.addEventListener("scroll", () => follow.noticeScroll(), { passive: true });
|
|
118
|
+
|
|
119
|
+
followers.set(root, follow);
|
|
120
|
+
return follow;
|
|
24
121
|
}
|
|
25
122
|
|
|
26
123
|
function renderBlock(block, chat) {
|