1- import { LitElement , html } from "lit" ;
1+ import { PropertyValues , html } from "lit" ;
22import { unsafeHTML } from "lit-html/directives/unsafe-html.js" ;
33import { 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" ;
3939const 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
138212function handleMessage ( message : ContentMessage | IsStreamingMessage ) : void {
139213 const el = document . getElementById ( message . id ) as MarkdownElement ;
0 commit comments