@miso.ai/client-sdk-ui 1.14.0-beta.2 → 1.14.0-beta.4
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/dist/cjs/asset/svgs.js +4 -2
- package/dist/cjs/constants.js +2 -0
- package/dist/cjs/defaults/ask/index.js +2 -1
- package/dist/cjs/defaults/ask/templates.js +2 -1
- package/dist/cjs/defaults/hybrid-search/index.js +2 -1
- package/dist/cjs/defaults/hybrid-search/templates.js +2 -1
- package/dist/cjs/defaults/index.js +2 -1
- package/dist/cjs/defaults.js +107 -10
- package/dist/cjs/element/container/index.js +14 -0
- package/dist/cjs/element/container/miso-message-item.js +91 -0
- package/dist/cjs/element/container/miso-thread-item.js +91 -0
- package/dist/cjs/element/index.js +2 -1
- package/dist/cjs/element/role/index.js +7 -0
- package/dist/cjs/element/role/miso-delete.js +20 -0
- package/dist/cjs/layout/button/button.js +45 -5
- package/dist/cjs/layout/button/checkbox.js +2 -1
- package/dist/cjs/layout/container/item-container.js +99 -0
- package/dist/cjs/layout/index.js +14 -0
- package/dist/cjs/layout/input/search-box.js +9 -4
- package/dist/cjs/layout/list/collection.js +21 -4
- package/dist/cjs/layout/list/messages.js +261 -224
- package/dist/cjs/layout/list/threads.js +106 -150
- package/dist/cjs/layout/templates.js +98 -38
- package/dist/cjs/layout/text/question.js +71 -0
- package/dist/cjs/layout/text/typewriter/index.js +15 -21
- package/dist/cjs/plugin.js +6 -3
- package/dist/cjs/util/dom.js +21 -0
- package/dist/cjs/util/notify.js +74 -0
- package/dist/cjs/version.js +1 -1
- package/package.json +3 -3
- package/src/CLAUDE.md +7 -2
- package/src/asset/svgs.js +3 -1
- package/src/constants.js +2 -0
- package/src/defaults.js +75 -5
- package/src/element/container/index.js +2 -0
- package/src/element/container/miso-message-item.js +94 -0
- package/src/element/container/miso-thread-item.js +94 -0
- package/src/element/role/index.js +1 -0
- package/src/element/role/miso-delete.js +16 -0
- package/src/layout/button/button.js +40 -4
- package/src/layout/button/checkbox.js +2 -1
- package/src/layout/container/item-container.js +99 -0
- package/src/layout/index.js +2 -0
- package/src/layout/input/search-box.js +4 -2
- package/src/layout/list/collection.js +22 -5
- package/src/layout/list/messages.js +231 -201
- package/src/layout/list/threads.js +107 -128
- package/src/layout/templates.js +100 -38
- package/src/layout/text/question.js +58 -0
- package/src/layout/text/typewriter/index.js +11 -15
- package/src/plugin.js +3 -2
- package/src/util/dom.js +16 -0
- package/src/util/notify.js +68 -0
- package/src/version.js +1 -1
|
@@ -1,29 +1,64 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import CollectionLayout from './collection.js';
|
|
5
|
-
import { cursorClassName } from '../text/typewriter/utils.js';
|
|
6
|
-
import { messageAuthor } from '../templates.js';
|
|
1
|
+
import { STATUS, LAYOUT_TYPE } from '../../constants.js';
|
|
2
|
+
import CollectionLayout, { hasStatusTemplate } from './collection.js';
|
|
3
|
+
import { listError, isSessionExpired, SESSION_EXPIRED_MESSAGE } from '../templates.js';
|
|
7
4
|
import { setOrRemoveAttribute } from '../../util/dom.js';
|
|
8
5
|
|
|
9
6
|
const TYPE = LAYOUT_TYPE.MESSAGES;
|
|
10
7
|
const DEFAULT_CLASSNAME = 'miso-messages';
|
|
11
|
-
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TEMPLATES = Object.freeze({
|
|
10
|
+
root,
|
|
11
|
+
[STATUS.ERRONEOUS]: listError,
|
|
12
|
+
errorMessage,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const INHERITED_DEFAULT_TEMPLATES = Object.freeze({
|
|
16
|
+
...CollectionLayout.defaultTemplates,
|
|
17
|
+
...DEFAULT_TEMPLATES,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// the infinite scroll trigger sits ABOVE the list — older messages load as
|
|
21
|
+
// the user scrolls up, unlike the stock collection root's bottom trigger
|
|
22
|
+
function root(layout, state) {
|
|
23
|
+
const { className, role, templates, options } = layout;
|
|
24
|
+
const { status } = state;
|
|
25
|
+
const roleAttr = role ? ` data-role="${role}"` : '';
|
|
26
|
+
const itemTypeAttr = options.itemType ? ` data-item-type="${options.itemType}"` : '';
|
|
27
|
+
return `<div class="${className} ${status}"${roleAttr}${itemTypeAttr}>${templates.trigger(layout, state)}${hasStatusTemplate(status) ? templates[status](layout, state) : ''}${templates.loading(layout, state)}</div>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function errorMessage(layout, error) {
|
|
31
|
+
return isSessionExpired(error) ? SESSION_EXPIRED_MESSAGE : 'This conversation could not be loaded.';
|
|
32
|
+
}
|
|
12
33
|
|
|
13
34
|
/**
|
|
14
|
-
* The conversation panel of the chat history interface: a
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* (
|
|
35
|
+
* The conversation panel of the chat history interface: a shell that renders
|
|
36
|
+
* one <miso-message-item> container element per message item, incrementally —
|
|
37
|
+
* fresh items land at either end of the list: appended messages (a posted
|
|
38
|
+
* follow-up question) render as new items at the bottom, while an older
|
|
39
|
+
* page fetched by the more flow (infinite scroll: the trigger, placed above
|
|
40
|
+
* the list, fires the `more` hub field as the user scrolls up) renders as
|
|
41
|
+
* prepended items — told apart by which boundary the already-rendered
|
|
42
|
+
* records line up with.
|
|
43
|
+
* The content of a message is not rendered here: each <miso-message-item> is
|
|
44
|
+
* assigned its item subworkflow (workflow._getMessageWorkflow, off the item
|
|
45
|
+
* binding) in the post-render sync pass, and the role elements inside
|
|
46
|
+
* (question, answer, ...) render through that workflow's own layouts.
|
|
19
47
|
*
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
48
|
+
* All message-level presentation is the message workflow's own: a record
|
|
49
|
+
* without its answer body presents as status `loading`, stamped on the
|
|
50
|
+
* <miso-message-item> element by its container layout — there is deliberately no
|
|
51
|
+
* per-message loading icon; the answer typewriter's blinking caret is the
|
|
52
|
+
* loading indication — and the question bubble (text, authorship
|
|
53
|
+
* attributes) renders through the `question` layout.
|
|
54
|
+
*
|
|
55
|
+
* The shell keeps the panel-level behaviors: scroll pinning (the view sticks
|
|
56
|
+
* to the bottom while the user has not scrolled up, following content growth
|
|
57
|
+
* as answers type out), scroll anchoring while scrolled up (prepended items
|
|
58
|
+
* — and their contents, rendering in later through their own workflows —
|
|
59
|
+
* must not shift the reading position), and the `ongoing` view state read
|
|
60
|
+
* off the data — the last message's answer absent or unfinished — which
|
|
61
|
+
* e.g. disables the search box while an answer is on its way.
|
|
27
62
|
*/
|
|
28
63
|
export default class MessagesLayout extends CollectionLayout {
|
|
29
64
|
|
|
@@ -35,162 +70,149 @@ export default class MessagesLayout extends CollectionLayout {
|
|
|
35
70
|
return DEFAULT_CLASSNAME;
|
|
36
71
|
}
|
|
37
72
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this._renderedAnswers = new WeakMap(); // answer element -> rendered markdown
|
|
41
|
-
this._typewriters = new WeakMap(); // answer element -> MessageTypewriter
|
|
42
|
-
this._readiness = new Resolution();
|
|
43
|
-
this._pinned = true; // whether the view sticks to the bottom on updates
|
|
44
|
-
this._displaying = false; // whether a typewriter is still displaying an answer
|
|
45
|
-
// kick off sooner
|
|
46
|
-
MessagesLayout.MisoClient.plugins.install('std:ui-markdown');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
initialize(view) {
|
|
50
|
-
super.initialize(view);
|
|
51
|
-
this._setup();
|
|
73
|
+
static get defaultTemplates() {
|
|
74
|
+
return INHERITED_DEFAULT_TEMPLATES;
|
|
52
75
|
}
|
|
53
76
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return; // destroyed
|
|
60
|
-
}
|
|
61
|
-
this._markdown = plugin.getContext(this._view.workflow._client);
|
|
62
|
-
this._readiness.resolve();
|
|
63
|
-
} catch (e) {
|
|
64
|
-
this._readiness.reject(e);
|
|
65
|
-
}
|
|
77
|
+
constructor({ className = DEFAULT_CLASSNAME, templates, ...options } = {}) {
|
|
78
|
+
super({ className, templates: { ...DEFAULT_TEMPLATES, ...templates }, ...options });
|
|
79
|
+
this._pinned = true; // whether the view sticks to the bottom on updates
|
|
80
|
+
this._anchor = undefined; // the scroll anchor while prepended content settles
|
|
81
|
+
this._ongoing = undefined;
|
|
66
82
|
}
|
|
67
83
|
|
|
68
|
-
|
|
69
|
-
|
|
84
|
+
// item identity: the question id, or the local placeholder id standing in
|
|
85
|
+
// for it while a posted question awaits its response — the placeholder id
|
|
86
|
+
// survives the settle, so the element and its workflow do too
|
|
87
|
+
_getItemKey(message) {
|
|
88
|
+
return message.placeholder_id || message.question_id || message;
|
|
70
89
|
}
|
|
71
90
|
|
|
72
91
|
// render //
|
|
73
92
|
async render(element, state, controls = {}) {
|
|
74
|
-
// capture the view state callback
|
|
75
|
-
// `ongoing` state outside the render cycle
|
|
93
|
+
// capture the view state callback for the ongoing report
|
|
76
94
|
this._notifyUpdate = controls.notifyUpdate;
|
|
77
95
|
await super.render(element, state, controls);
|
|
78
96
|
}
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
98
|
+
// incremental renders cover a pure append (a posted follow-up at the
|
|
99
|
+
// bottom) or a pure prepend (an older page above) — anything else
|
|
100
|
+
// re-renders in full
|
|
101
|
+
_shallRenderIncrementally(state, rendered) {
|
|
102
|
+
if (!super._shallRenderIncrementally(state, rendered)) {
|
|
103
|
+
return false;
|
|
84
104
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
const values = this._getItems(state) || [];
|
|
106
|
+
const renderedValues = rendered.value;
|
|
107
|
+
const count = values.length - renderedValues.length;
|
|
108
|
+
return count === 0 || // in-place changes only
|
|
109
|
+
this._getItemKey(values[0]) === this._getItemKey(renderedValues[0]) || // appended
|
|
110
|
+
this._getItemKey(values[count]) === this._getItemKey(renderedValues[0]); // prepended
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// the fresh items land at the tail (a posted follow-up) or at the head
|
|
114
|
+
// (an older page fetched by the more flow): told apart by which boundary
|
|
115
|
+
// the already-rendered records line up with
|
|
116
|
+
_html(state, rendered, incremental) {
|
|
117
|
+
if (!incremental) {
|
|
118
|
+
return this.templates.root(this, state);
|
|
119
|
+
}
|
|
120
|
+
const values = this._getItems(state) || [];
|
|
121
|
+
const renderedValues = rendered.value;
|
|
122
|
+
const count = values.length - renderedValues.length;
|
|
123
|
+
if (count === 0) {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
if (this._getItemKey(values[0]) === this._getItemKey(renderedValues[0])) {
|
|
127
|
+
const fresh = values.slice(renderedValues.length);
|
|
128
|
+
return {
|
|
129
|
+
position: 'beforeend',
|
|
130
|
+
items: this.templates.items(this, state, fresh, { offset: renderedValues.length }),
|
|
131
|
+
};
|
|
89
132
|
}
|
|
90
|
-
|
|
133
|
+
const fresh = values.slice(0, count);
|
|
134
|
+
return {
|
|
135
|
+
position: 'afterbegin',
|
|
136
|
+
items: this.templates.items(this, state, fresh, { offset: 0 }),
|
|
137
|
+
};
|
|
91
138
|
}
|
|
92
139
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
this._syncQuestion(item, message);
|
|
106
|
-
this._syncAnswer(item, message, { last });
|
|
107
|
-
if (last) {
|
|
108
|
-
displaying = this._isDisplaying(item, message);
|
|
140
|
+
_render(element, { state }, { notifyUpdate }) {
|
|
141
|
+
const { incremental, html } = state;
|
|
142
|
+
if (incremental) {
|
|
143
|
+
if (html) {
|
|
144
|
+
const listElement = this._getListElement(element);
|
|
145
|
+
if (html.position === 'afterbegin') {
|
|
146
|
+
this._prependAnchored(listElement, html.items);
|
|
147
|
+
} else {
|
|
148
|
+
listElement.insertAdjacentHTML('beforeend', html.items);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
notifyUpdate(false);
|
|
109
152
|
}
|
|
153
|
+
} else {
|
|
154
|
+
element.innerHTML = html;
|
|
110
155
|
}
|
|
111
|
-
this._setDisplaying(displaying);
|
|
112
156
|
}
|
|
113
157
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
158
|
+
_afterRender(element, state) {
|
|
159
|
+
super._afterRender(element, state); // syncs bindings and (re)arms the trigger
|
|
160
|
+
if (!state.incremental) {
|
|
161
|
+
// a fresh render (thread load): jump to the bottom
|
|
162
|
+
this._dropAnchor();
|
|
163
|
+
this._scrollToBottom({ force: true });
|
|
164
|
+
} else if (state.html && state.html.position === 'beforeend') {
|
|
165
|
+
// appended items (posted follow-up): jump to the bottom
|
|
166
|
+
this._scrollToBottom({ force: true });
|
|
122
167
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
168
|
+
this._watchGrowth(this._getListElement(element) || element);
|
|
169
|
+
this._syncWorkflows(element);
|
|
170
|
+
this._syncItems(element);
|
|
171
|
+
this._syncOngoing(state);
|
|
126
172
|
}
|
|
127
173
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
// the authorship arrives with the answers response, after the stub render
|
|
134
|
-
setOrRemoveAttribute(questionElement, 'data-author', messageAuthor(message));
|
|
135
|
-
setOrRemoveAttribute(questionElement, 'data-generated-by', getGeneratedBy(message));
|
|
136
|
-
const { question } = message;
|
|
137
|
-
if (!question || questionElement.textContent === question) {
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
questionElement.textContent = question;
|
|
141
|
-
questionElement.hidden = false;
|
|
174
|
+
_unrender() {
|
|
175
|
+
super._unrender();
|
|
176
|
+
this._dropAnchor();
|
|
142
177
|
}
|
|
143
178
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
// an ongoing typewriter keeps consuming updates until it finishes typing
|
|
150
|
-
const typewriter = this._typewriters.get(answerElement);
|
|
151
|
-
if (typewriter) {
|
|
152
|
-
typewriter.update(message);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
if (!hasAnswer(message)) {
|
|
156
|
-
return; // still loading; the spinner stays
|
|
157
|
-
}
|
|
158
|
-
if (message.live || message.finished === false) {
|
|
159
|
-
// an answer being generated live in this session: drive it with a
|
|
160
|
-
// typewriter (even if the data arrived complete in one shot)
|
|
161
|
-
const typewriter = new MessageTypewriter(this._markdown, answerElement, {
|
|
162
|
-
onUpdate: () => this._scrollToBottom(), // keep pinned to the bottom while typing
|
|
163
|
-
onDone: () => this._setDisplaying(false),
|
|
164
|
-
});
|
|
165
|
-
this._typewriters.set(answerElement, typewriter);
|
|
166
|
-
typewriter.update(message);
|
|
179
|
+
// assign each <miso-message-item> its item subworkflow, off the item binding
|
|
180
|
+
_syncWorkflows(element) {
|
|
181
|
+
const workflow = this._view && this._view.workflow;
|
|
182
|
+
if (!workflow || typeof workflow._getMessageWorkflow !== 'function') {
|
|
167
183
|
return;
|
|
168
184
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
for (const item of this._getItemElements(element)) {
|
|
186
|
+
const binding = this._bindings.get(item);
|
|
187
|
+
if (binding && item.isContainer) {
|
|
188
|
+
item.workflow = workflow._getMessageWorkflow(binding.value);
|
|
189
|
+
}
|
|
172
190
|
}
|
|
173
|
-
this._renderedAnswers.set(answerElement, message.answer);
|
|
174
|
-
this._transform(answerElement, message).then(() => {
|
|
175
|
-
last && this._scrollToBottom();
|
|
176
|
-
}).catch(error => {
|
|
177
|
-
this._renderedAnswers.delete(answerElement);
|
|
178
|
-
console.error(error);
|
|
179
|
-
});
|
|
180
191
|
}
|
|
181
192
|
|
|
182
|
-
|
|
183
|
-
|
|
193
|
+
// sync in-place item state changes from the bound values onto the existing
|
|
194
|
+
// item elements: the live mark — the item contents render through the
|
|
195
|
+
// message workflows, outside this layout's render cycle
|
|
196
|
+
_syncItems(element) {
|
|
197
|
+
for (const item of this._getItemElements(element)) {
|
|
198
|
+
const binding = this._bindings.get(item);
|
|
199
|
+
binding && setOrRemoveAttribute(item, 'data-live', binding.value.live ? '' : undefined);
|
|
200
|
+
}
|
|
184
201
|
}
|
|
185
202
|
|
|
186
|
-
// whether
|
|
187
|
-
// the `ongoing` view state, e.g. for the search box to disable
|
|
188
|
-
|
|
189
|
-
|
|
203
|
+
// whether an answer is on its way — pending, or still being generated —
|
|
204
|
+
// reported as the `ongoing` view state, e.g. for the search box to disable
|
|
205
|
+
// submission
|
|
206
|
+
_syncOngoing(state) {
|
|
207
|
+
const messages = state.value || [];
|
|
208
|
+
const last = messages[messages.length - 1];
|
|
209
|
+
// a message settled by a failed content fetch is not on its way anymore
|
|
210
|
+
const ongoing = !!last && !last.error && (last.answer === undefined || last.finished === false);
|
|
211
|
+
if (this._ongoing === ongoing) {
|
|
190
212
|
return;
|
|
191
213
|
}
|
|
192
|
-
this.
|
|
193
|
-
this._notifyUpdate && this._notifyUpdate({ ongoing
|
|
214
|
+
this._ongoing = ongoing;
|
|
215
|
+
this._notifyUpdate && this._notifyUpdate({ ongoing });
|
|
194
216
|
}
|
|
195
217
|
|
|
196
218
|
// scrolling //
|
|
@@ -230,6 +252,14 @@ export default class MessagesLayout extends CollectionLayout {
|
|
|
230
252
|
this._unwatchScroll && this._unwatchScroll();
|
|
231
253
|
const onScroll = () => {
|
|
232
254
|
this._pinned = scrollable.scrollHeight - scrollable.scrollTop - scrollable.clientHeight < 40;
|
|
255
|
+
if (this._pinned) {
|
|
256
|
+
// back at the bottom: the reading position needs no anchor
|
|
257
|
+
this._anchor = undefined;
|
|
258
|
+
} else if (this._anchor && this._anchor.element.isConnected) {
|
|
259
|
+
// follow the user: the anchor holds the position they scrolled to,
|
|
260
|
+
// not the one where the prepend happened
|
|
261
|
+
this._anchor.top = this._relativeTop(this._anchor.element, scrollable);
|
|
262
|
+
}
|
|
233
263
|
};
|
|
234
264
|
scrollable.addEventListener('scroll', onScroll, { passive: true });
|
|
235
265
|
this._watchedScrollable = scrollable;
|
|
@@ -241,78 +271,78 @@ export default class MessagesLayout extends CollectionLayout {
|
|
|
241
271
|
this._unsubscribes.push(() => this._unwatchScroll === unwatch && unwatch());
|
|
242
272
|
}
|
|
243
273
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
this.
|
|
257
|
-
this.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
this.
|
|
263
|
-
cursorClass: cursorClassName(TYPEWRITER_CLASSNAME),
|
|
264
|
-
getSource: index => (this._sources || [])[index],
|
|
265
|
-
});
|
|
266
|
-
this._getNextCursor = pacer();
|
|
267
|
-
this._rendered = this._renderer.clear(element);
|
|
268
|
-
this._timestamp = undefined;
|
|
269
|
-
this._value = '';
|
|
270
|
-
this._sources = undefined;
|
|
271
|
-
this._dataDone = false;
|
|
272
|
-
this._doneAt = undefined;
|
|
273
|
-
this._requested = false;
|
|
274
|
+
// the message contents render (and type out) through their own workflows,
|
|
275
|
+
// outside this layout's render cycle: follow the content growth — pinned,
|
|
276
|
+
// the view sticks to the bottom; scrolled up with an anchor active (a
|
|
277
|
+
// prepended page still rendering its contents in), the anchor is held
|
|
278
|
+
// stationary instead
|
|
279
|
+
_watchGrowth(element) {
|
|
280
|
+
if (!element || this._watchedGrowth === element || typeof ResizeObserver === 'undefined') {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this._unwatchGrowth && this._unwatchGrowth();
|
|
284
|
+
const observer = new ResizeObserver(() => this._onContentGrowth());
|
|
285
|
+
observer.observe(element);
|
|
286
|
+
this._watchedGrowth = element;
|
|
287
|
+
const unwatch = this._unwatchGrowth = () => {
|
|
288
|
+
observer.disconnect();
|
|
289
|
+
this._watchedGrowth = undefined;
|
|
290
|
+
this._unwatchGrowth = undefined;
|
|
291
|
+
};
|
|
292
|
+
this._unsubscribes.push(() => this._unwatchGrowth === unwatch && unwatch());
|
|
274
293
|
}
|
|
275
294
|
|
|
276
|
-
|
|
277
|
-
|
|
295
|
+
_onContentGrowth() {
|
|
296
|
+
if (this._pinned) {
|
|
297
|
+
this._scrollToBottom();
|
|
298
|
+
} else {
|
|
299
|
+
this._restoreAnchor();
|
|
300
|
+
}
|
|
278
301
|
}
|
|
279
302
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
303
|
+
// scroll anchoring for prepended content //
|
|
304
|
+
// The anchor is the previously-first item: everything prepended — and
|
|
305
|
+
// its content, rendering in later through the item workflows — sits
|
|
306
|
+
// above it, so holding the anchor stationary holds the reading position,
|
|
307
|
+
// while growth below it (a streaming answer at the bottom) moves nothing
|
|
308
|
+
// above and needs no compensation. Not the browser's own scroll
|
|
309
|
+
// anchoring, which not every engine provides.
|
|
310
|
+
_prependAnchored(listElement, items) {
|
|
311
|
+
this._setAnchor();
|
|
312
|
+
listElement.insertAdjacentHTML('afterbegin', items);
|
|
313
|
+
this._restoreAnchor();
|
|
285
314
|
}
|
|
286
315
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
316
|
+
_setAnchor() {
|
|
317
|
+
const scrollable = this._getScrollable();
|
|
318
|
+
const element = this._getItemElements(this._element)[0];
|
|
319
|
+
this._anchor = scrollable && element
|
|
320
|
+
? { element, scrollable, top: this._relativeTop(element, scrollable) }
|
|
321
|
+
: undefined;
|
|
293
322
|
}
|
|
294
323
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (!
|
|
298
|
-
// the element is gone (re-render, thread switch); stop typing
|
|
299
|
-
this._onDone && this._onDone();
|
|
324
|
+
_restoreAnchor() {
|
|
325
|
+
const anchor = this._anchor;
|
|
326
|
+
if (!anchor) {
|
|
300
327
|
return;
|
|
301
328
|
}
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
329
|
+
const { element, scrollable, top } = anchor;
|
|
330
|
+
if (!element.isConnected) {
|
|
331
|
+
this._anchor = undefined;
|
|
332
|
+
return;
|
|
306
333
|
}
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
this._onUpdate && this._onUpdate();
|
|
311
|
-
if (!this._rendered.done) {
|
|
312
|
-
this._requestFrame();
|
|
313
|
-
} else {
|
|
314
|
-
this._onDone && this._onDone();
|
|
334
|
+
const delta = this._relativeTop(element, scrollable) - top;
|
|
335
|
+
if (delta) {
|
|
336
|
+
scrollable.scrollTop += delta;
|
|
315
337
|
}
|
|
316
338
|
}
|
|
317
339
|
|
|
340
|
+
_relativeTop(element, scrollable) {
|
|
341
|
+
return element.getBoundingClientRect().top - scrollable.getBoundingClientRect().top;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_dropAnchor() {
|
|
345
|
+
this._anchor = undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
318
348
|
}
|