Skip to content

Commit 4c7cbbc

Browse files
committed
Implement new and improved auto-scroll logic
1 parent 93cce86 commit 4c7cbbc

File tree

7 files changed

+134
-100
lines changed

7 files changed

+134
-100
lines changed

js/chat/chat.ts

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ type ShinyChatMessage = {
2121
obj: Message;
2222
};
2323

24-
type requestScrollEvent = {
25-
cancelIfScrolledUp: boolean;
26-
};
27-
2824
type UpdateUserInput = {
2925
value?: string;
3026
placeholder?: string;
@@ -39,7 +35,6 @@ declare global {
3935
"shiny-chat-clear-messages": CustomEvent;
4036
"shiny-chat-update-user-input": CustomEvent<UpdateUserInput>;
4137
"shiny-chat-remove-loading-message": CustomEvent;
42-
"shiny-chat-request-scroll": CustomEvent<requestScrollEvent>;
4338
}
4439
}
4540

@@ -57,16 +52,6 @@ const ICONS = {
5752
'<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>',
5853
};
5954

60-
const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
61-
el.dispatchEvent(
62-
new CustomEvent("shiny-chat-request-scroll", {
63-
detail: { cancelIfScrolledUp },
64-
bubbles: true,
65-
composed: true,
66-
})
67-
);
68-
};
69-
7055
class ChatMessage extends LightElement {
7156
@property() content = "...";
7257
@property() content_type: ContentType = "markdown";
@@ -236,10 +221,6 @@ class ChatContainer extends LightElement {
236221
"shiny-chat-remove-loading-message",
237222
this.#onRemoveLoadingMessage
238223
);
239-
this.addEventListener("shiny-chat-request-scroll", this.#onRequestScroll);
240-
241-
this.resizeObserver = new ResizeObserver(() => requestScroll(this, true));
242-
this.resizeObserver.observe(this);
243224
}
244225

245226
disconnectedCallback(): void {
@@ -260,12 +241,6 @@ class ChatContainer extends LightElement {
260241
"shiny-chat-remove-loading-message",
261242
this.#onRemoveLoadingMessage
262243
);
263-
this.removeEventListener(
264-
"shiny-chat-request-scroll",
265-
this.#onRequestScroll
266-
);
267-
268-
this.resizeObserver.disconnect();
269244
}
270245

271246
// When user submits input, append it to the chat, and add a loading message
@@ -359,22 +334,6 @@ class ChatContainer extends LightElement {
359334
#finalizeMessage(): void {
360335
this.input.disabled = false;
361336
}
362-
363-
#onRequestScroll(event: CustomEvent<requestScrollEvent>): void {
364-
// When streaming or resizing, only scroll if the user near the bottom
365-
const { cancelIfScrolledUp } = event.detail;
366-
if (cancelIfScrolledUp) {
367-
if (this.scrollTop + this.clientHeight < this.scrollHeight - 100) {
368-
return;
369-
}
370-
}
371-
372-
// Smooth scroll to the bottom if we're not streaming or resizing
373-
this.scroll({
374-
top: this.scrollHeight,
375-
behavior: cancelIfScrolledUp ? "auto" : "smooth",
376-
});
377-
}
378337
}
379338

380339
// ------- Register custom elements and shiny bindings ---------
@@ -393,6 +352,7 @@ $(function () {
393352
detail: message.obj,
394353
});
395354
const el = document.getElementById(message.id);
355+
// TODO: throw an error if the element is not found?
396356
el?.dispatchEvent(evt);
397357
}
398358
);

js/markdown-stream/markdown-stream.ts

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { LitElement, html } from "lit";
1+
import { PropertyValues, html } from "lit";
22
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
33
import { property } from "lit/decorators.js";
44

@@ -35,7 +35,7 @@ function isStreamingMessage(
3535
}
3636

3737
// SVG dot to indicate content is currently streaming
38-
const SVG_DOT_CLASS = "chat-streaming-dot";
38+
const SVG_DOT_CLASS = "markdown-stream-dot";
3939
const SVG_DOT = createSVGIcon(
4040
`<svg width="12" height="12" xmlns="http://www.w3.org/2000/svg" class="${SVG_DOT_CLASS}" style="margin-left:.25em;margin-top:-.25em"><circle cx="6" cy="6" r="6"/></svg>`
4141
);
@@ -76,18 +76,35 @@ class MarkdownElement extends LightElement {
7676
@property() content_type: ContentType = "markdown";
7777
@property({ type: Boolean, reflect: true }) streaming = false;
7878

79-
render(): ReturnType<LitElement["render"]> {
80-
const content = contentToHTML(this.content, this.content_type);
81-
return html`${content}`;
79+
render() {
80+
return html`${contentToHTML(this.content, this.content_type)}`;
8281
}
8382

84-
updated(changedProperties: Map<string, unknown>): void {
83+
disconnectedCallback(): void {
84+
super.disconnectedCallback();
85+
this.#cleanup();
86+
}
87+
88+
protected willUpdate(changedProperties: PropertyValues): void {
89+
if (changedProperties.has("content")) {
90+
this.#updateScrollableElement();
91+
this.#isContentBeingAdded = true;
92+
}
93+
}
94+
95+
protected updated(changedProperties: Map<string, unknown>): void {
8596
if (changedProperties.has("content")) {
86-
this.#highlightAndCodeCopy();
97+
try {
98+
this.#highlightAndCodeCopy();
99+
} catch (error) {
100+
console.warn("Failed to highlight code:", error);
101+
}
102+
87103
if (this.streaming) this.#appendStreamingDot();
88-
// TODO: throw an event here that content has rendered and catch it in SHINY_CHAT_MESSAGE
89-
// requestScroll(this, this.streaming);
104+
this.#isContentBeingAdded = false;
105+
this.#maybeScrollToBottom();
90106
}
107+
91108
if (changedProperties.has("streaming")) {
92109
this.streaming ? this.#appendStreamingDot() : this.#removeStreamingDot();
93110
}
@@ -101,23 +118,25 @@ class MarkdownElement extends LightElement {
101118
this.querySelector(`svg.${SVG_DOT_CLASS}`)?.remove();
102119
}
103120

104-
// Highlight code blocks after the element is rendered
105121
#highlightAndCodeCopy(): void {
106122
const el = this.querySelector("pre code");
107123
if (!el) return;
108124
this.querySelectorAll<HTMLElement>("pre code").forEach((el) => {
109-
// Highlight the code
125+
if (el.dataset.highlighted === "yes") return;
126+
110127
hljs.highlightElement(el);
111-
// Add a button to the code block to copy to clipboard
128+
129+
// Add copy button
112130
const btn = createElement("button", {
113131
class: "code-copy-button",
114132
title: "Copy to clipboard",
115133
});
116134
btn.innerHTML = '<i class="bi"></i>';
117135
el.prepend(btn);
118-
// Add the clipboard functionality
136+
137+
// Setup clipboard
119138
const clipboard = new ClipboardJS(btn, { target: () => el });
120-
clipboard.on("success", function (e: ClipboardJS.Event) {
139+
clipboard.on("success", (e) => {
121140
btn.classList.add("code-copy-button-checked");
122141
setTimeout(
123142
() => btn.classList.remove("code-copy-button-checked"),
@@ -127,13 +146,68 @@ class MarkdownElement extends LightElement {
127146
});
128147
});
129148
}
149+
150+
// ------- Scrolling logic -------
151+
152+
// Nearest scrollable parent element (if any)
153+
#scrollableElement: HTMLElement | null = null;
154+
// Whether content is currently being added to the element
155+
#isContentBeingAdded = false;
156+
// Whether the user has scrolled away from the bottom
157+
#isUserScrolled = false;
158+
159+
#onScroll = (): void => {
160+
if (!this.#isContentBeingAdded) {
161+
this.#isUserScrolled = !this.#isNearBottom();
162+
}
163+
};
164+
165+
#isNearBottom(): boolean {
166+
const el = this.#scrollableElement;
167+
if (!el) return false;
168+
169+
return el.scrollHeight - (el.scrollTop + el.clientHeight) < 50;
170+
}
171+
172+
#updateScrollableElement(): void {
173+
const el = this.#findScrollableParent();
174+
175+
if (el !== this.#scrollableElement) {
176+
this.#scrollableElement?.removeEventListener("scroll", this.#onScroll);
177+
this.#scrollableElement = el;
178+
this.#scrollableElement?.addEventListener("scroll", this.#onScroll);
179+
}
180+
}
181+
182+
#findScrollableParent(): HTMLElement | null {
183+
// eslint-disable-next-line
184+
let el: HTMLElement | null = this;
185+
while (el) {
186+
if (el.scrollHeight > el.clientHeight) return el;
187+
el = el.parentElement;
188+
}
189+
return null;
190+
}
191+
192+
#maybeScrollToBottom(): void {
193+
const el = this.#scrollableElement;
194+
if (!el || this.#isUserScrolled) return;
195+
196+
el.scroll({
197+
top: el.scrollHeight - el.clientHeight,
198+
behavior: this.streaming ? "instant" : "smooth",
199+
});
200+
}
201+
202+
#cleanup(): void {
203+
this.#scrollableElement?.removeEventListener("scroll", this.#onScroll);
204+
}
130205
}
131206

132207
// ------- Register custom elements and shiny bindings ---------
133208

134-
if (!customElements.get("shiny-markdown-stream")) {
209+
customElements.get("shiny-markdown-stream") ||
135210
customElements.define("shiny-markdown-stream", MarkdownElement);
136-
}
137211

138212
function handleMessage(message: ContentMessage | IsStreamingMessage): void {
139213
const el = document.getElementById(message.id) as MarkdownElement;

js/utils/_utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@ function showShinyClientMessage({
4949
);
5050
}
5151

52-
export { LightElement, createElement, createSVGIcon, showShinyClientMessage };
52+
export { LightElement, createElement, createSVGIcon, showShinyClientMessage, };

shiny/www/py-shiny/chat/chat.js

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shiny/www/py-shiny/chat/chat.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shiny/www/py-shiny/markdown-stream/markdown-stream.js

Lines changed: 26 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shiny/www/py-shiny/markdown-stream/markdown-stream.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)