From d71287d366057b643414f1e011546ca797804e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Jos=C3=A9=20Borba=20Fernandes?= Date: Thu, 25 Jul 2024 00:31:00 -0300 Subject: [PATCH] feat: auto scroll + scroll to bottom --- .../orama-chat-assistent-message.scss | 6 + .../orama-chat-assistent-message.tsx | 57 ++++---- .../internal/orama-chat/orama-chat.scss | 44 +++++++ .../internal/orama-chat/orama-chat.tsx | 122 ++++++++++++++++-- .../orama-chat-box/orama-chat-box.scss | 1 + .../ui-stencil/src/context/chatContext.ts | 5 +- .../ui-stencil/src/services/ChatService.ts | 4 + 7 files changed, 196 insertions(+), 43 deletions(-) diff --git a/packages/ui-stencil/src/components/internal/orama-chat-messages-container/orama-chat-assistent-message/orama-chat-assistent-message.scss b/packages/ui-stencil/src/components/internal/orama-chat-messages-container/orama-chat-assistent-message/orama-chat-assistent-message.scss index 29c25759..c6935d6b 100644 --- a/packages/ui-stencil/src/components/internal/orama-chat-messages-container/orama-chat-assistent-message/orama-chat-assistent-message.scss +++ b/packages/ui-stencil/src/components/internal/orama-chat-messages-container/orama-chat-assistent-message/orama-chat-assistent-message.scss @@ -28,6 +28,12 @@ margin-top: var(--spacing-m, $spacing-m); gap: var(--spacing-s, $spacing-s); justify-content: end; + transition: opacity 0.2s ease-in-out; +} + +.hidden { + opacity: 0; + pointer-events: none; } .sources-wrapper { diff --git a/packages/ui-stencil/src/components/internal/orama-chat-messages-container/orama-chat-assistent-message/orama-chat-assistent-message.tsx b/packages/ui-stencil/src/components/internal/orama-chat-messages-container/orama-chat-assistent-message/orama-chat-assistent-message.tsx index 47698408..a6941ddd 100644 --- a/packages/ui-stencil/src/components/internal/orama-chat-messages-container/orama-chat-assistent-message/orama-chat-assistent-message.tsx +++ b/packages/ui-stencil/src/components/internal/orama-chat-messages-container/orama-chat-assistent-message/orama-chat-assistent-message.tsx @@ -75,40 +75,39 @@ export class OramaChatAssistentMessage { )}
- {this.interaction.status === TAnswerStatus.done && ( -
- {this.interaction.latest && this.interaction.status === 'done' && ( - - - - )} - - - + +
+ {this.interaction.latest && this.interaction.status === 'done' && ( - {this.isDisliked ? : } + -
- )} + )} + + + + + {this.isDisliked ? : } + +
) diff --git a/packages/ui-stencil/src/components/internal/orama-chat/orama-chat.scss b/packages/ui-stencil/src/components/internal/orama-chat/orama-chat.scss index 00f6127c..d484c3ee 100644 --- a/packages/ui-stencil/src/components/internal/orama-chat/orama-chat.scss +++ b/packages/ui-stencil/src/components/internal/orama-chat/orama-chat.scss @@ -1,4 +1,5 @@ orama-chat { + position: relative; display: flex; flex-direction: column; flex-grow: 1; @@ -14,6 +15,49 @@ orama-chat { display: flex; flex-direction: column; overflow: auto; + + // Maybe move somewhere else to reuse in some somponents, but not globally + &::-webkit-scrollbar { + width: 0.3em; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + // TODO: Should it be a variable? + background-color: #b2b2b285; + border-radius: var(--border-radius-l, $radius-l); + } +} + +.messages-container-wrapper-non-scrollable { + position: relative; + z-index: 1; + flex-grow: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.lock-scroll-on-bottom-button-wrapper { + bottom: 0; + left: 50%; + transform: translateX(-50%); + z-index: 2; + display: flex; + position: absolute; + background-color: transparent; + border: none; + cursor: pointer; + padding: var(--spacing-s, $spacing-s); + background-color: var(--background-color-tertiary, background-color('tertiary')); + color: var(--icon-color-tertiary, text-color('tertiary')); + width: fit-content; + height: fit-content; + justify-content: center; + align-items: center; + font-family: var(--font-primary, font('primary')); + border-radius: 50%; } .chat-form-wrapper { diff --git a/packages/ui-stencil/src/components/internal/orama-chat/orama-chat.tsx b/packages/ui-stencil/src/components/internal/orama-chat/orama-chat.tsx index 7d74dd2b..cd4fda4e 100644 --- a/packages/ui-stencil/src/components/internal/orama-chat/orama-chat.tsx +++ b/packages/ui-stencil/src/components/internal/orama-chat/orama-chat.tsx @@ -1,7 +1,8 @@ -import { Component, Fragment, Host, Prop, State, h } from '@stencil/core' +import { Component, Host, Prop, State, Watch, h } from '@stencil/core' import { chatContext, TAnswerStatus } from '@/context/chatContext' import '@phosphor-icons/webcomponents/dist/icons/PhPaperPlaneTilt.mjs' import '@phosphor-icons/webcomponents/dist/icons/PhStop.mjs' +import '@phosphor-icons/webcomponents/dist/icons/PhArrowCircleDown.mjs' // TODO: Hardcoding suggestions for now const SUGGESTIONS = [ @@ -10,6 +11,8 @@ const SUGGESTIONS = [ 'What are the steps to implement?', ] +const BOTTOM_THRESHOLD = 1 + @Component({ tag: 'orama-chat', styleUrl: 'orama-chat.scss', @@ -17,6 +20,83 @@ const SUGGESTIONS = [ export class OramaChat { @Prop() placeholder?: string = 'Ask me anything' @State() inputValue = '' + messagesContainerRef!: HTMLElement + isScrolling = false + prevScrollTop = 0 + scrollTarget = 0 + + isScrollOnBottom = () => { + const scrollableHeight = this.messagesContainerRef.scrollHeight - this.messagesContainerRef.clientHeight + + return this.messagesContainerRef.scrollTop + BOTTOM_THRESHOLD >= scrollableHeight + } + + scrollToBottom = (options = { animated: true }) => { + if (this.messagesContainerRef) { + if (!options.animated) { + this.messagesContainerRef.scrollTop = this.messagesContainerRef.scrollHeight + + chatContext.lockScrollOnBottom = true + return + } + + this.isScrolling = true + const startTime = performance.now() + const startPosition = this.messagesContainerRef.scrollTop + + const duration = 300 // Custom duration in milliseconds + + const animateScroll = (currentTime: number) => { + if (!this.messagesContainerRef || !this.isScrolling) { + return + } + const scrollTarget = this.messagesContainerRef.scrollHeight - this.messagesContainerRef.clientHeight + const elapsedTime = currentTime - startTime + const scrollProgress = Math.min(1, elapsedTime / duration) + const easeFunction = this.easeInOutQuad(scrollProgress) + const scrollTo = startPosition + (scrollTarget - startPosition) * easeFunction + + this.messagesContainerRef.scrollTo(0, scrollTo) + + if (elapsedTime < duration) { + requestAnimationFrame(animateScroll) + } else { + this.isScrolling = false + } + } + + requestAnimationFrame(animateScroll) + } + } + + // Easing function for smooth scroll animation + easeInOutQuad = (t: number) => { + return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t + } + + recalculateLockOnBottom = () => { + // Get the current scroll position + const currentScrollTop = this.messagesContainerRef.scrollTop + + const scrollOnBottom = this.isScrollOnBottom() + + chatContext.lockScrollOnBottom = scrollOnBottom + if (scrollOnBottom) { + this.isScrolling = false + } + + // Update the previous scroll position + this.prevScrollTop = currentScrollTop + } + + handleWheel = (e: WheelEvent) => { + this.recalculateLockOnBottom() + } + + componentDidLoad() { + this.messagesContainerRef.addEventListener('wheel', this.handleWheel) + this.recalculateLockOnBottom() + } handleSubmit = (e: Event) => { e.preventDefault() @@ -44,20 +124,38 @@ export class OramaChat { const lastInteractionStatus = lastInteraction?.status const lastInteractionStreaming = lastInteractionStatus === TAnswerStatus.streaming + if (chatContext.lockScrollOnBottom && !this.isScrolling) { + this.scrollToBottom({ animated: false }) + } + return ( {/* CHAT MESSAGES */} -
- {chatContext.interactions?.length ? ( - - ) : null} - - {/* TODO: Provide a better animation */} - {!chatContext.interactions?.length ? ( - - ) : null} - {/* TODO: not required for chatbox, but maybe required for Searchbox v2 */} - {/* */} +
+
(this.messagesContainerRef = ref)}> + {chatContext.interactions?.length ? ( + + ) : null} + + {/* TODO: Provide a better animation */} + {!chatContext.interactions?.length ? ( + + ) : null} + {/* TODO: not required for chatbox, but maybe required for Searchbox v2 */} + {/* */} +
+ {!chatContext.lockScrollOnBottom && ( + + )}
{/* CHAT INPUT */} diff --git a/packages/ui-stencil/src/components/orama-chat-box/orama-chat-box.scss b/packages/ui-stencil/src/components/orama-chat-box/orama-chat-box.scss index 6d37a63f..40caf59f 100644 --- a/packages/ui-stencil/src/components/orama-chat-box/orama-chat-box.scss +++ b/packages/ui-stencil/src/components/orama-chat-box/orama-chat-box.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; overflow: hidden; + max-height: 400px; } .header { diff --git a/packages/ui-stencil/src/context/chatContext.ts b/packages/ui-stencil/src/context/chatContext.ts index 0b9fad93..d7544c64 100644 --- a/packages/ui-stencil/src/context/chatContext.ts +++ b/packages/ui-stencil/src/context/chatContext.ts @@ -33,9 +33,10 @@ export type TChatInteraction = { relatedQueries?: string[] } -const { state: chatContext } = createStore({ +const { state: chatContext, ...chatStore } = createStore({ chatService: null as ChatService | null, interactions: [] as TChatInteraction[], + lockScrollOnBottom: true as boolean, }) -export { chatContext } +export { chatContext, chatStore } diff --git a/packages/ui-stencil/src/services/ChatService.ts b/packages/ui-stencil/src/services/ChatService.ts index 35627c0b..7a17c624 100644 --- a/packages/ui-stencil/src/services/ChatService.ts +++ b/packages/ui-stencil/src/services/ChatService.ts @@ -15,6 +15,8 @@ export class ChatService { throw new OramaClientNotInitializedError() } + chatContext.lockScrollOnBottom = true + // TODO: possibly fix on Orama Client chatContext.interactions = [...chatContext.interactions, { query: term, status: TAnswerStatus.loading }] @@ -100,6 +102,8 @@ export class ChatService { } this.answerSession.clearSession() + // TODO: Not sure if this is the right place to do it + chatContext.lockScrollOnBottom = true chatContext.interactions = [] } }