Skip to content

Commit

Permalink
feat: auto scroll + scroll to bottom
Browse files Browse the repository at this point in the history
  • Loading branch information
rjborba committed Jul 25, 2024
1 parent 64984ff commit d71287d
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,40 +75,39 @@ export class OramaChatAssistentMessage {
)}
<div class="message-wrapper">
<orama-markdown content={this.interaction.response} />
{this.interaction.status === TAnswerStatus.done && (
<div class="message-actions">
{this.interaction.latest && this.interaction.status === 'done' && (
<orama-button
type="button"
variant="icon"
onClick={this.handleRetryMessage}
onKeyDown={this.handleRetryMessage}
aria-label="Retry message"
>
<ph-arrows-clockwise size="16px" />
</orama-button>
)}
<orama-button
type="button"
variant="icon"
onClick={this.handleCopyToClipboard}
onKeyDown={this.handleCopyToClipboard}
withTooltip={this.isCopied ? 'Copied!' : undefined}
aria-label="Copy message"
>
<ph-copy size="16px" />
</orama-button>

<div class={{ 'message-actions': true, hidden: this.interaction.status !== TAnswerStatus.done }}>
{this.interaction.latest && this.interaction.status === 'done' && (
<orama-button
type="button"
variant="icon"
onClick={this.handleDislikeMessage}
onKeyDown={this.handleDislikeMessage}
aria-label="Dislike message"
onClick={this.handleRetryMessage}
onKeyDown={this.handleRetryMessage}
aria-label="Retry message"
>
{this.isDisliked ? <ph-thumbs-down weight="fill" size="16px" /> : <ph-thumbs-down size="16px" />}
<ph-arrows-clockwise size="16px" />
</orama-button>
</div>
)}
)}
<orama-button
type="button"
variant="icon"
onClick={this.handleCopyToClipboard}
onKeyDown={this.handleCopyToClipboard}
withTooltip={this.isCopied ? 'Copied!' : undefined}
aria-label="Copy message"
>
<ph-copy size="16px" />
</orama-button>
<orama-button
type="button"
variant="icon"
onClick={this.handleDislikeMessage}
onKeyDown={this.handleDislikeMessage}
aria-label="Dislike message"
>
{this.isDisliked ? <ph-thumbs-down weight="fill" size="16px" /> : <ph-thumbs-down size="16px" />}
</orama-button>
</div>
</div>
</Host>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
orama-chat {
position: relative;
display: flex;
flex-direction: column;
flex-grow: 1;
Expand All @@ -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 {
Expand Down
122 changes: 110 additions & 12 deletions packages/ui-stencil/src/components/internal/orama-chat/orama-chat.tsx
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -10,13 +11,92 @@ const SUGGESTIONS = [
'What are the steps to implement?',
]

const BOTTOM_THRESHOLD = 1

@Component({
tag: 'orama-chat',
styleUrl: 'orama-chat.scss',
})
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()
Expand Down Expand Up @@ -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 (
<Host>
{/* CHAT MESSAGES */}
<div class="messages-container-wrapper">
{chatContext.interactions?.length ? (
<orama-chat-messages-container interactions={chatContext.interactions} />
) : null}

{/* TODO: Provide a better animation */}
{!chatContext.interactions?.length ? (
<orama-chat-suggestions suggestions={SUGGESTIONS} suggestionClicked={this.handleSuggestionClick} />
) : null}
{/* TODO: not required for chatbox, but maybe required for Searchbox v2 */}
{/* <orama-logo-icon /> */}
<div class={'messages-container-wrapper-non-scrollable'}>
<div class="messages-container-wrapper" ref={(ref) => (this.messagesContainerRef = ref)}>
{chatContext.interactions?.length ? (
<orama-chat-messages-container interactions={chatContext.interactions} />
) : null}

{/* TODO: Provide a better animation */}
{!chatContext.interactions?.length ? (
<orama-chat-suggestions suggestions={SUGGESTIONS} suggestionClicked={this.handleSuggestionClick} />
) : null}
{/* TODO: not required for chatbox, but maybe required for Searchbox v2 */}
{/* <orama-logo-icon /> */}
</div>
{!chatContext.lockScrollOnBottom && (
<button
class="lock-scroll-on-bottom-button-wrapper"
type="button"
onClick={() => {
chatContext.lockScrollOnBottom = true
this.scrollToBottom({ animated: true })
}}
>
<ph-arrow-circle-down size={'18px'} />
</button>
)}
</div>

{/* CHAT INPUT */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 400px;
}

.header {
Expand Down
5 changes: 3 additions & 2 deletions packages/ui-stencil/src/context/chatContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
4 changes: 4 additions & 0 deletions packages/ui-stencil/src/services/ChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]

Expand Down Expand Up @@ -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 = []
}
}

0 comments on commit d71287d

Please sign in to comment.