@hidemikimura/chit-ui 0.1.0
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/LICENSE +21 -0
- package/README.md +440 -0
- package/dist/chit-ui.iife.min.js +964 -0
- package/dist/chit-ui.iife.min.js.map +1 -0
- package/dist/chit-ui.min.js +964 -0
- package/dist/chit-ui.min.js.map +1 -0
- package/dist/types/bundle.d.ts +7 -0
- package/dist/types/chit-ui.d.ts +251 -0
- package/dist/types/controllers/breakpoint-controller.d.ts +29 -0
- package/dist/types/controllers/composer-controller.d.ts +54 -0
- package/dist/types/controllers/scroll-controller.d.ts +44 -0
- package/dist/types/controllers/state-controller.d.ts +61 -0
- package/dist/types/controllers/theme-controller.d.ts +47 -0
- package/dist/types/element.d.ts +1 -0
- package/dist/types/events.d.ts +28 -0
- package/dist/types/global.d.ts +25 -0
- package/dist/types/i18n/labels.d.ts +68 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/render/composer.d.ts +15 -0
- package/dist/types/render/content.d.ts +75 -0
- package/dist/types/render/launcher.d.ts +18 -0
- package/dist/types/render/message-list.d.ts +17 -0
- package/dist/types/render/message.d.ts +14 -0
- package/dist/types/render/panel.d.ts +10 -0
- package/dist/types/styles/adopted-sheet.d.ts +20 -0
- package/dist/types/styles/composer.css.d.ts +1 -0
- package/dist/types/styles/content.css.d.ts +9 -0
- package/dist/types/styles/host.css.d.ts +9 -0
- package/dist/types/styles/launcher.css.d.ts +1 -0
- package/dist/types/styles/message.css.d.ts +1 -0
- package/dist/types/styles/panel.css.d.ts +1 -0
- package/dist/types/theme/default-theme.d.ts +97 -0
- package/dist/types/theme/merge-theme.d.ts +33 -0
- package/dist/types/theme/theme-to-css.d.ts +24 -0
- package/dist/types/types.d.ts +268 -0
- package/package.json +71 -0
- package/src/bundle.js +11 -0
- package/src/chit-ui.js +548 -0
- package/src/controllers/breakpoint-controller.js +82 -0
- package/src/controllers/composer-controller.js +215 -0
- package/src/controllers/scroll-controller.js +252 -0
- package/src/controllers/state-controller.js +316 -0
- package/src/controllers/theme-controller.js +75 -0
- package/src/element.js +4 -0
- package/src/events.js +33 -0
- package/src/global.d.ts +25 -0
- package/src/i18n/labels.js +72 -0
- package/src/index.js +9 -0
- package/src/render/composer.js +72 -0
- package/src/render/content.js +211 -0
- package/src/render/launcher.js +64 -0
- package/src/render/message-list.js +67 -0
- package/src/render/message.js +82 -0
- package/src/render/panel.js +77 -0
- package/src/styles/adopted-sheet.js +72 -0
- package/src/styles/composer.css.js +108 -0
- package/src/styles/content.css.js +119 -0
- package/src/styles/host.css.js +160 -0
- package/src/styles/launcher.css.js +139 -0
- package/src/styles/message.css.js +192 -0
- package/src/styles/panel.css.js +126 -0
- package/src/theme/default-theme.js +111 -0
- package/src/theme/merge-theme.js +100 -0
- package/src/theme/theme-to-css.js +94 -0
- package/src/types.js +143 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Events, emit } from '../events.js';
|
|
3
|
+
|
|
4
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
5
|
+
/** @import { Device } from '../types.js' */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks whether the viewport is narrow enough to count as a phone.
|
|
9
|
+
*
|
|
10
|
+
* Uses matchMedia rather than a resize listener: it only fires when the
|
|
11
|
+
* boundary is actually crossed, so nothing runs while the user drags a window
|
|
12
|
+
* around inside one bucket.
|
|
13
|
+
*
|
|
14
|
+
* @implements {ReactiveController}
|
|
15
|
+
*/
|
|
16
|
+
export class BreakpointController {
|
|
17
|
+
/** @type {LitElement} */
|
|
18
|
+
#host;
|
|
19
|
+
/** @type {MediaQueryList | null} */
|
|
20
|
+
#query = null;
|
|
21
|
+
/** @type {number} */
|
|
22
|
+
#breakpoint = 0;
|
|
23
|
+
/** @type {Device} */
|
|
24
|
+
#device = 'pc';
|
|
25
|
+
/** Set once the first measurement is in, so start-up is not a "change". */
|
|
26
|
+
#measured = false;
|
|
27
|
+
|
|
28
|
+
/** @param {LitElement} host */
|
|
29
|
+
constructor(host) {
|
|
30
|
+
this.#host = host;
|
|
31
|
+
host.addController(this);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @returns {Device} */
|
|
35
|
+
get device() {
|
|
36
|
+
return this.#device;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
hostDisconnected() {
|
|
40
|
+
this.#query?.removeEventListener('change', this.#onChange);
|
|
41
|
+
this.#query = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Point the controller at a breakpoint. Safe to call on every update; the
|
|
46
|
+
* media query is only rebuilt when the value actually changes.
|
|
47
|
+
*
|
|
48
|
+
* @param {number} breakpoint
|
|
49
|
+
*/
|
|
50
|
+
observe(breakpoint) {
|
|
51
|
+
if (breakpoint === this.#breakpoint && this.#query) return;
|
|
52
|
+
this.#breakpoint = breakpoint;
|
|
53
|
+
this.#query?.removeEventListener('change', this.#onChange);
|
|
54
|
+
this.#query = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
|
55
|
+
this.#query.addEventListener('change', this.#onChange);
|
|
56
|
+
this.#apply(this.#query.matches ? 'mobile' : 'pc');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {MediaQueryListEvent} event */
|
|
60
|
+
#onChange = (event) => {
|
|
61
|
+
this.#apply(event.matches ? 'mobile' : 'pc');
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Record the device and announce it, unless this is the first measurement —
|
|
66
|
+
* start-up is not a change. A later flip is announced whether it came from
|
|
67
|
+
* the viewport resizing or from the theme moving the breakpoint, because
|
|
68
|
+
* either way the answer a consumer asked for is now different.
|
|
69
|
+
*
|
|
70
|
+
* @param {Device} device
|
|
71
|
+
*/
|
|
72
|
+
#apply(device) {
|
|
73
|
+
if (device === this.#device && this.#host.dataset.device) return;
|
|
74
|
+
|
|
75
|
+
this.#device = device;
|
|
76
|
+
this.#host.dataset.device = device;
|
|
77
|
+
this.#host.requestUpdate();
|
|
78
|
+
|
|
79
|
+
if (this.#measured) emit(this.#host, Events.BREAKPOINT_CHANGE, { device });
|
|
80
|
+
this.#measured = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Events, emit } from '../events.js';
|
|
3
|
+
|
|
4
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
5
|
+
|
|
6
|
+
/** Browsers that size a textarea to its content need no JS help. */
|
|
7
|
+
const SUPPORTS_FIELD_SIZING =
|
|
8
|
+
typeof CSS !== 'undefined' && CSS.supports?.('field-sizing', 'content');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Owns the composer: what has been typed, whether an IME is mid-composition,
|
|
12
|
+
* and when a keystroke counts as "send".
|
|
13
|
+
*
|
|
14
|
+
* The typed text is deliberately not a reactive property. A keystroke would
|
|
15
|
+
* otherwise re-render the whole shadow tree, `repeat` over every message
|
|
16
|
+
* included; instead the two things that depend on it — the send button's
|
|
17
|
+
* enabled state and the character counter — are updated in place.
|
|
18
|
+
*
|
|
19
|
+
* @implements {ReactiveController}
|
|
20
|
+
*/
|
|
21
|
+
export class ComposerController {
|
|
22
|
+
/** @type {LitElement & ComposerHost} */
|
|
23
|
+
#host;
|
|
24
|
+
|
|
25
|
+
#value = '';
|
|
26
|
+
|
|
27
|
+
/** True between compositionstart and compositionend. */
|
|
28
|
+
#composing = false;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True for one turn of the event loop after a composition ends.
|
|
32
|
+
*
|
|
33
|
+
* WebKit dispatches the Enter that confirmed an IME candidate as a plain
|
|
34
|
+
* keydown *after* compositionend, with `isComposing` already false — so
|
|
35
|
+
* without this flag, confirming a Japanese candidate would send the message.
|
|
36
|
+
*/
|
|
37
|
+
#justComposed = false;
|
|
38
|
+
|
|
39
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
40
|
+
#composedTimer;
|
|
41
|
+
|
|
42
|
+
/** @param {LitElement & ComposerHost} host */
|
|
43
|
+
constructor(host) {
|
|
44
|
+
this.#host = host;
|
|
45
|
+
host.addController(this);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @returns {string} */
|
|
49
|
+
get value() {
|
|
50
|
+
return this.#value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
set value(next) {
|
|
54
|
+
this.#value = next ?? '';
|
|
55
|
+
const input = this.#input;
|
|
56
|
+
if (input && input.value !== this.#value) input.value = this.#value;
|
|
57
|
+
this.#grow();
|
|
58
|
+
this.#syncDerived();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @returns {boolean} True while an IME candidate window is open. */
|
|
62
|
+
get composing() {
|
|
63
|
+
return this.#composing;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @returns {number} Characters typed, counted by code point. */
|
|
67
|
+
get length() {
|
|
68
|
+
return Array.from(this.#value).length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @returns {boolean} Whether the current text could be sent right now. */
|
|
72
|
+
get canSend() {
|
|
73
|
+
if (this.#host.busy || this.#host.inputDisabled) return false;
|
|
74
|
+
if (this.#value.trim() === '') return false;
|
|
75
|
+
const limit = this.#host.maxLength;
|
|
76
|
+
return limit === undefined || this.length <= limit;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
hostDisconnected() {
|
|
80
|
+
clearTimeout(this.#composedTimer);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
hostUpdated() {
|
|
84
|
+
const input = this.#input;
|
|
85
|
+
if (input && input.value !== this.#value) input.value = this.#value;
|
|
86
|
+
this.#grow();
|
|
87
|
+
this.#syncDerived();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @returns {HTMLTextAreaElement | null} */
|
|
91
|
+
get #input() {
|
|
92
|
+
return /** @type {HTMLTextAreaElement | null} */ (
|
|
93
|
+
this.#host.renderRoot.querySelector('[part~="input"]')
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {Event} event */
|
|
98
|
+
onInput(event) {
|
|
99
|
+
this.#value = /** @type {HTMLTextAreaElement} */ (event.target).value;
|
|
100
|
+
this.#grow();
|
|
101
|
+
this.#syncDerived();
|
|
102
|
+
emit(this.#host, Events.INPUT, { value: this.#value });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onCompositionStart() {
|
|
106
|
+
this.#composing = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onCompositionEnd() {
|
|
110
|
+
this.#composing = false;
|
|
111
|
+
this.#justComposed = true;
|
|
112
|
+
clearTimeout(this.#composedTimer);
|
|
113
|
+
this.#composedTimer = setTimeout(() => {
|
|
114
|
+
this.#justComposed = false;
|
|
115
|
+
}, 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {KeyboardEvent} event */
|
|
119
|
+
onKeydown(event) {
|
|
120
|
+
if (event.key !== 'Enter') return;
|
|
121
|
+
if (!this.#host.sendOnEnter) return;
|
|
122
|
+
// Shift+Enter is a newline; the others are the browser's or the page's.
|
|
123
|
+
if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
124
|
+
|
|
125
|
+
// Chrome, Edge and Firefox mark the confirming Enter on the event itself.
|
|
126
|
+
// Older engines only set keyCode 229.
|
|
127
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
128
|
+
// Safari's post-compositionend Enter, and Android soft keyboards that send
|
|
129
|
+
// Enter while a composition is still open.
|
|
130
|
+
if (this.#composing || this.#justComposed) return;
|
|
131
|
+
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
this.submit();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Hand the text to the consumer and clear the box.
|
|
138
|
+
*
|
|
139
|
+
* The text is passed as typed: leading and trailing newlines are theirs to
|
|
140
|
+
* keep or strip, since only they know whether the message is prose or code.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} [text] Defaults to what is in the box.
|
|
143
|
+
* @returns {boolean} False when there was nothing to send.
|
|
144
|
+
*/
|
|
145
|
+
submit(text) {
|
|
146
|
+
const value = text ?? this.#value;
|
|
147
|
+
if (text === undefined && !this.canSend) return false;
|
|
148
|
+
if (text !== undefined && (this.#host.busy || this.#host.inputDisabled)) return false;
|
|
149
|
+
if (value.trim() === '') return false;
|
|
150
|
+
|
|
151
|
+
const allowed = emit(this.#host, Events.SUBMIT, { text: value }, { cancelable: true });
|
|
152
|
+
if (!allowed) return false;
|
|
153
|
+
|
|
154
|
+
this.clear();
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
clear() {
|
|
159
|
+
this.value = '';
|
|
160
|
+
this.focus();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
focus() {
|
|
164
|
+
this.#input?.focus();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Grow the box with its content, up to the theme's row limit. */
|
|
168
|
+
#grow() {
|
|
169
|
+
if (SUPPORTS_FIELD_SIZING) return;
|
|
170
|
+
const input = this.#input;
|
|
171
|
+
if (!input) return;
|
|
172
|
+
input.style.height = 'auto';
|
|
173
|
+
input.style.height = `${input.scrollHeight}px`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Update the parts that depend on the typed text without a re-render.
|
|
178
|
+
*/
|
|
179
|
+
#syncDerived() {
|
|
180
|
+
const root = this.#host.renderRoot;
|
|
181
|
+
|
|
182
|
+
const send = /** @type {HTMLButtonElement | null} */ (
|
|
183
|
+
root.querySelector('[part~="send-button"]')
|
|
184
|
+
);
|
|
185
|
+
if (send) send.disabled = !this.canSend;
|
|
186
|
+
|
|
187
|
+
const input = this.#input;
|
|
188
|
+
const counter = /** @type {HTMLElement | null} */ (root.querySelector('[part~="counter"]'));
|
|
189
|
+
const limit = this.#host.maxLength;
|
|
190
|
+
if (limit === undefined) return;
|
|
191
|
+
|
|
192
|
+
const remaining = limit - this.length;
|
|
193
|
+
const over = remaining < 0;
|
|
194
|
+
input?.setAttribute('aria-invalid', over ? 'true' : 'false');
|
|
195
|
+
|
|
196
|
+
if (!counter) return;
|
|
197
|
+
counter.textContent = String(remaining);
|
|
198
|
+
counter.toggleAttribute('data-over', over);
|
|
199
|
+
// The digits alone ("-14") say nothing out loud, so the counter carries a
|
|
200
|
+
// sentence for assistive tech while showing just the number.
|
|
201
|
+
const labels = this.#host.currentLabels;
|
|
202
|
+
const template = over ? labels.overLimit : labels.charactersLeft;
|
|
203
|
+
counter.setAttribute('aria-label', template.replace('{n}', String(Math.abs(remaining))));
|
|
204
|
+
counter.hidden = this.length <= limit * 0.8;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @typedef {Object} ComposerHost
|
|
210
|
+
* @property {boolean} busy
|
|
211
|
+
* @property {boolean} inputDisabled
|
|
212
|
+
* @property {boolean} sendOnEnter
|
|
213
|
+
* @property {number | undefined} maxLength
|
|
214
|
+
* @property {import('../i18n/labels.js').Labels} currentLabels
|
|
215
|
+
*/
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Events, emit } from '../events.js';
|
|
3
|
+
|
|
4
|
+
/** @import { ReactiveController, LitElement } from 'lit' */
|
|
5
|
+
/** @import { Message } from '../types.js' */
|
|
6
|
+
|
|
7
|
+
/** How close to the end still counts as "at the bottom", in pixels. */
|
|
8
|
+
const BOTTOM_SLACK = 24;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How long to keep treating the viewport as "ours" after starting a smooth
|
|
12
|
+
* scroll, when the browser has no scrollend event to tell us it finished.
|
|
13
|
+
*/
|
|
14
|
+
const SMOOTH_GRACE_MS = 700;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Keeps the conversation pinned to the newest message while the reader is at
|
|
18
|
+
* the bottom, and gets out of the way the moment they scroll up.
|
|
19
|
+
*
|
|
20
|
+
* Two things follow from that rule. A reply arriving while the reader is up in
|
|
21
|
+
* the history must not yank them down — it raises the "jump to latest" button
|
|
22
|
+
* instead. And their own message always scrolls into view, because they just
|
|
23
|
+
* pressed send and expect to see it.
|
|
24
|
+
*
|
|
25
|
+
* @implements {ReactiveController}
|
|
26
|
+
*/
|
|
27
|
+
export class ScrollController {
|
|
28
|
+
/** @type {LitElement & { messages: Message[] }} */
|
|
29
|
+
#host;
|
|
30
|
+
/** @type {() => 'smooth' | 'instant'} */
|
|
31
|
+
#preferredBehavior;
|
|
32
|
+
/** @type {HTMLElement | null} */
|
|
33
|
+
#viewport = null;
|
|
34
|
+
/** @type {ResizeObserver | null} */
|
|
35
|
+
#resize = null;
|
|
36
|
+
|
|
37
|
+
#atBottom = true;
|
|
38
|
+
#hasUnseen = false;
|
|
39
|
+
#atTop = false;
|
|
40
|
+
#followAfterRender = true;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True while a scroll we started is still animating. Scroll events during
|
|
44
|
+
* that window report positions we caused, not where the reader went.
|
|
45
|
+
*/
|
|
46
|
+
#selfScrolling = false;
|
|
47
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
48
|
+
#selfScrollTimer;
|
|
49
|
+
/** The first jump after the panel opens has nothing to animate from. */
|
|
50
|
+
#openingJump = true;
|
|
51
|
+
|
|
52
|
+
/** @type {string | undefined} */
|
|
53
|
+
#lastId;
|
|
54
|
+
#lastCount = 0;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {LitElement & { messages: Message[] }} host
|
|
58
|
+
* @param {{ behavior: () => 'smooth' | 'instant' }} options
|
|
59
|
+
*/
|
|
60
|
+
constructor(host, { behavior }) {
|
|
61
|
+
this.#host = host;
|
|
62
|
+
this.#preferredBehavior = behavior;
|
|
63
|
+
host.addController(this);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @returns {boolean} True when newer content arrived while scrolled up. */
|
|
67
|
+
get hasUnseen() {
|
|
68
|
+
return this.#hasUnseen;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @returns {boolean} */
|
|
72
|
+
get atBottom() {
|
|
73
|
+
return this.#atBottom;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
hostDisconnected() {
|
|
77
|
+
this.#unbind();
|
|
78
|
+
clearTimeout(this.#selfScrollTimer);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Runs before the render, so a decision made here lands in the same frame.
|
|
83
|
+
* Whether the conversation grew is knowable from the array alone; only the
|
|
84
|
+
* scrolling itself has to wait for the DOM.
|
|
85
|
+
*/
|
|
86
|
+
hostUpdate() {
|
|
87
|
+
const messages = this.#host.messages;
|
|
88
|
+
const last = messages[messages.length - 1];
|
|
89
|
+
const grew = messages.length > this.#lastCount;
|
|
90
|
+
const newTail = last?.id !== this.#lastId;
|
|
91
|
+
|
|
92
|
+
this.#lastId = last?.id;
|
|
93
|
+
this.#lastCount = messages.length;
|
|
94
|
+
|
|
95
|
+
if (!grew && !newTail) return;
|
|
96
|
+
|
|
97
|
+
// Their own message always scrolls into view: they just pressed send.
|
|
98
|
+
if (this.#atBottom || last?.role === 'user') this.#followAfterRender = true;
|
|
99
|
+
else this.#hasUnseen = true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
hostUpdated() {
|
|
103
|
+
const viewport = /** @type {HTMLElement | null} */ (
|
|
104
|
+
this.#host.renderRoot.querySelector('[part~="messages"]')
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (viewport !== this.#viewport) {
|
|
108
|
+
this.#unbind();
|
|
109
|
+
this.#viewport = viewport;
|
|
110
|
+
this.#bind();
|
|
111
|
+
// A freshly opened panel starts at the newest message, with no animation
|
|
112
|
+
// to watch: there was nothing on screen to move away from.
|
|
113
|
+
if (viewport) {
|
|
114
|
+
this.#atBottom = true;
|
|
115
|
+
this.#hasUnseen = false;
|
|
116
|
+
this.#followAfterRender = true;
|
|
117
|
+
this.#openingJump = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (this.#followAfterRender) {
|
|
122
|
+
const behavior = this.#followBehavior;
|
|
123
|
+
this.#followAfterRender = false;
|
|
124
|
+
this.#scrollNow({ behavior });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#bind() {
|
|
129
|
+
if (!this.#viewport) return;
|
|
130
|
+
this.#viewport.addEventListener('scroll', this.#onScroll, { passive: true });
|
|
131
|
+
// Precise end-of-scroll where it exists (Chrome 114, Firefox 109,
|
|
132
|
+
// Safari 17.4); the timer below covers the rest.
|
|
133
|
+
this.#viewport.addEventListener('scrollend', this.#onScrollEnd);
|
|
134
|
+
|
|
135
|
+
// Images finishing and streamed text growing both change the height
|
|
136
|
+
// without a scroll event, so the follow has to react to size too.
|
|
137
|
+
const inner = this.#viewport.firstElementChild;
|
|
138
|
+
if (inner && typeof ResizeObserver !== 'undefined') {
|
|
139
|
+
this.#resize = new ResizeObserver(() => {
|
|
140
|
+
if (!this.#atBottom) return;
|
|
141
|
+
// Content settling (an image loading, a tall block laying out) right
|
|
142
|
+
// after a message arrived must not cut the follow short, so while our
|
|
143
|
+
// own animation is running it is re-aimed rather than replaced. With
|
|
144
|
+
// nothing in flight this is a streamed reply growing, and that is
|
|
145
|
+
// followed instantly: an animation restarted every few tens of
|
|
146
|
+
// milliseconds never lands.
|
|
147
|
+
this.#scrollNow({ behavior: this.#selfScrolling ? 'smooth' : 'instant' });
|
|
148
|
+
});
|
|
149
|
+
this.#resize.observe(inner);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#unbind() {
|
|
154
|
+
this.#viewport?.removeEventListener('scroll', this.#onScroll);
|
|
155
|
+
this.#viewport?.removeEventListener('scrollend', this.#onScrollEnd);
|
|
156
|
+
this.#resize?.disconnect();
|
|
157
|
+
this.#resize = null;
|
|
158
|
+
this.#viewport = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#onScrollEnd = () => {
|
|
162
|
+
this.#endSelfScroll();
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
#onScroll = () => {
|
|
166
|
+
const viewport = this.#viewport;
|
|
167
|
+
if (!viewport) return;
|
|
168
|
+
|
|
169
|
+
const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
|
170
|
+
const atBottom = distance <= BOTTOM_SLACK;
|
|
171
|
+
|
|
172
|
+
// Our own animation passing through the middle of the list is not the
|
|
173
|
+
// reader scrolling up.
|
|
174
|
+
if (this.#selfScrolling) {
|
|
175
|
+
if (atBottom) this.#endSelfScroll();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (atBottom !== this.#atBottom) {
|
|
180
|
+
this.#atBottom = atBottom;
|
|
181
|
+
if (atBottom && this.#hasUnseen) {
|
|
182
|
+
this.#hasUnseen = false;
|
|
183
|
+
this.#host.requestUpdate();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// One event per visit to the top, so a consumer loading older messages is
|
|
188
|
+
// not asked again while the reader sits there.
|
|
189
|
+
const atTop = viewport.scrollTop <= 0;
|
|
190
|
+
if (atTop && !this.#atTop) emit(this.#host, Events.SCROLL_TOP, {});
|
|
191
|
+
this.#atTop = atTop;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {{ smooth?: boolean }} [options] Defaults to the theme's setting.
|
|
196
|
+
*/
|
|
197
|
+
scrollToBottom({ smooth } = {}) {
|
|
198
|
+
const behavior =
|
|
199
|
+
smooth === undefined ? this.#followBehavior : smooth ? 'smooth' : 'instant';
|
|
200
|
+
this.#scrollNow({ behavior });
|
|
201
|
+
|
|
202
|
+
if (this.#hasUnseen) {
|
|
203
|
+
this.#hasUnseen = false;
|
|
204
|
+
this.#host.requestUpdate();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What the next follow should look like: the theme's choice, unless the
|
|
210
|
+
* reader asked for less motion, or this is the jump that happens as the
|
|
211
|
+
* panel opens.
|
|
212
|
+
*
|
|
213
|
+
* @returns {'smooth' | 'instant'}
|
|
214
|
+
*/
|
|
215
|
+
get #followBehavior() {
|
|
216
|
+
if (this.#openingJump) return 'instant';
|
|
217
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return 'instant';
|
|
218
|
+
return this.#preferredBehavior();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {{ behavior?: 'smooth' | 'instant' }} [options] */
|
|
222
|
+
#scrollNow({ behavior = 'instant' } = {}) {
|
|
223
|
+
const viewport = this.#viewport;
|
|
224
|
+
if (!viewport) return;
|
|
225
|
+
|
|
226
|
+
this.#openingJump = false;
|
|
227
|
+
if (behavior === 'smooth') this.#beginSelfScroll();
|
|
228
|
+
|
|
229
|
+
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
|
|
230
|
+
this.#atBottom = true;
|
|
231
|
+
this.#atTop = false;
|
|
232
|
+
this.#hasUnseen = false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#beginSelfScroll() {
|
|
236
|
+
this.#selfScrolling = true;
|
|
237
|
+
clearTimeout(this.#selfScrollTimer);
|
|
238
|
+
this.#selfScrollTimer = setTimeout(() => this.#endSelfScroll(), SMOOTH_GRACE_MS);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#endSelfScroll() {
|
|
242
|
+
if (!this.#selfScrolling) return;
|
|
243
|
+
this.#selfScrolling = false;
|
|
244
|
+
clearTimeout(this.#selfScrollTimer);
|
|
245
|
+
|
|
246
|
+
// Take a reading now that the viewport has settled.
|
|
247
|
+
const viewport = this.#viewport;
|
|
248
|
+
if (!viewport) return;
|
|
249
|
+
const distance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
|
250
|
+
this.#atBottom = distance <= BOTTOM_SLACK;
|
|
251
|
+
}
|
|
252
|
+
}
|