Skip to content

Commit

Permalink
Merge pull request #33 from askorama/newScrollBehavior
Browse files Browse the repository at this point in the history
feat: new scroll behavior
  • Loading branch information
rjborba authored Aug 22, 2024
2 parents dd8f08d + c1a98b5 commit 4b2ee2d
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 139 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, Host, Prop, h } from '@stencil/core'
import { Component, Host, Prop, h, Element, State } from '@stencil/core'
import { chatContext, type TChatInteraction } from '@/context/chatContext'

@Component({
Expand All @@ -8,18 +8,44 @@ import { chatContext, type TChatInteraction } from '@/context/chatContext'
})
export class OramaChatMessagesContainer {
@Prop() interactions: TChatInteraction[]
@Element() el: HTMLElement

@State() latestInteractionMinHeight = 0

// TODO: I'm not sure about having this here as we're breaking our rule of maintain service access only to the very top level component
onSuggestionClick = (suggestion: string) => {
chatContext.chatService?.sendQuestion(suggestion)
}

resizeObserver = new ResizeObserver((entries) => {
// FIXME: We are removing the margin with a constant value. It should be calculated
this.latestInteractionMinHeight = entries[0].target.clientHeight - 32
})

componentDidLoad() {
// FIXME: We should get the element in another way. I tried findById or class and it was not working.
// probable something related to the shadow dom
const messagesWrapperElement = this.el.parentElement.parentElement

this.resizeObserver.observe(messagesWrapperElement)
}

render() {
return (
<Host>
<div class="messages-container">
{this.interactions.map((interaction) => (
<div key={interaction.interactionId}>
{this.interactions.map((interaction, interactionIndex) => (
<div
key={interaction.interactionId}
class="interaction-wrapper"
// Hack to put the message on top when auto scrolling
style={{
minHeight:
this.interactions.length > 1 && interactionIndex === this.interactions.length - 1
? `${this.latestInteractionMinHeight}px`
: '0px',
}}
>
<orama-chat-user-message interaction={{ ...interaction }} />
<orama-chat-assistent-message interaction={{ ...interaction }} />
{interaction.latest && interaction.status === 'done' && !!interaction.relatedQueries?.length && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export class OramaChatSuggestions {
<button
type="button"
class={`suggestion-button-${classSuffix}`}
onClick={() => this.handleClick(suggestion)}
onClick={(e) => {
e.preventDefault()
this.handleClick(suggestion)
}}
>
{this.icon}
{suggestion}
Expand Down
215 changes: 153 additions & 62 deletions packages/ui-stencil/src/components/internal/orama-chat/orama-chat.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, Host, Prop, State, Watch, h } from '@stencil/core'
import { chatContext, TAnswerStatus } from '@/context/chatContext'
import { chatContext, chatStore, TAnswerStatus } from '@/context/chatContext'
import type { SourcesMap } from '@/types'
import '@phosphor-icons/webcomponents/dist/icons/PhPaperPlaneTilt.mjs'
import '@phosphor-icons/webcomponents/dist/icons/PhStopCircle.mjs'
Expand All @@ -21,6 +21,7 @@ export class OramaChat {
@Prop() suggestions?: string[]

@State() inputValue = ''
@State() showGoToBottomButton = false

@Watch('defaultTerm')
handleDefaultTermChange() {
Expand All @@ -35,85 +36,110 @@ export class OramaChat {
}

messagesContainerRef!: HTMLElement
nonScrollableMessagesContainerRef!: HTMLElement
textareaRef!: HTMLOramaTextareaElement
isScrolling = false
prevScrollTop = 0
scrollTarget = 0

pendingNewInteractionSideEffects = false

scrollableContainerResizeObserver: ResizeObserver
nonScrollableContainerResizeObserver: ResizeObserver

lockScrollOnBottom = false

handleFocus = () => {
if (this.focusInput) {
this.textareaRef?.focus()
}
}

isScrollOnBottom = () => {
calculateIsScrollOnBottom = () => {
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
scrollToBottom = (
options: { animated: boolean; onScrollDone?: () => void } = { animated: true, onScrollDone: () => {} },
) => {
if (!this.messagesContainerRef) {
return
}

if (!options.animated) {
this.messagesContainerRef.scrollTop = this.messagesContainerRef.scrollHeight
options.onScrollDone()
return
}

this.isScrolling = true
const startTime = performance.now()
const startPosition = this.messagesContainerRef.scrollTop

const duration = 300 // Custom duration in milliseconds

chatContext.lockScrollOnBottom = true
const animateScroll = (currentTime: number) => {
if (!this.messagesContainerRef || !this.isScrolling) {
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
}
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
options.onScrollDone()
}

requestAnimationFrame(animateScroll)
}

requestAnimationFrame(animateScroll)
}

// Easing function for smooth scroll animation
easeInOutQuad = (t: number) => {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
}

recalculateLockOnBottom = () => {
recalculateGoBoToBottomButton = () => {
const isContainerOverflowing = this.calculateIsContainerOverflowing()
if (!isContainerOverflowing) {
this.showGoToBottomButton = false
return
}

this.showGoToBottomButton = !this.calculateIsScrollOnBottom()
}

handleWheel = (e: WheelEvent) => {
const isContainerOverflowing = this.calculateIsContainerOverflowing()
if (!isContainerOverflowing) {
this.lockScrollOnBottom = false
this.showGoToBottomButton = false
return
}

// Get the current scroll position
const currentScrollTop = this.messagesContainerRef.scrollTop

const scrollOnBottom = this.isScrollOnBottom()
this.showGoToBottomButton = !this.calculateIsScrollOnBottom()

chatContext.lockScrollOnBottom = scrollOnBottom
if (scrollOnBottom) {
this.lockScrollOnBottom = !this.showGoToBottomButton
if (!this.showGoToBottomButton) {
this.isScrolling = false
}

// Update the previous scroll position
this.prevScrollTop = currentScrollTop
}

handleWheel = (e: WheelEvent) => {
this.recalculateLockOnBottom()
}

setSources = () => {
chatContext.sourceBaseURL = this.sourceBaseUrl
chatContext.sourcesMap = {
Expand All @@ -128,14 +154,59 @@ export class OramaChat {

componentDidLoad() {
this.messagesContainerRef.addEventListener('wheel', this.handleWheel)
this.recalculateLockOnBottom()
this.setSources()

this.scrollableContainerResizeObserver = new ResizeObserver(() => {
this.recalculateGoBoToBottomButton()
})
this.scrollableContainerResizeObserver.observe(this.messagesContainerRef)

this.nonScrollableContainerResizeObserver = new ResizeObserver(() => {
if (this.pendingNewInteractionSideEffects) {
this.pendingNewInteractionSideEffects = false
this.lockScrollOnBottom = false
this.scrollToBottom({
animated: true,
onScrollDone: () => {
this.recalculateGoBoToBottomButton()
},
})

return
}

if (this.lockScrollOnBottom && !this.isScrolling) {
this.scrollToBottom({
animated: false,
onScrollDone: () => {
this.recalculateGoBoToBottomButton()
},
})
}

this.recalculateGoBoToBottomButton()
})

this.nonScrollableContainerResizeObserver.observe(this.nonScrollableMessagesContainerRef)
}

componentDidUpdate() {
if (chatContext.lockScrollOnBottom && !this.isScrolling) {
this.scrollToBottom({ animated: false })
}
connectedCallback() {
chatStore.on('set', (prop, newInteractions, oldInteractions) => {
if (prop !== 'interactions') {
return
}

if (oldInteractions?.length < newInteractions?.length) {
this.lockScrollOnBottom = false
this.pendingNewInteractionSideEffects = true
}
})
}

disconnectedCallback() {
this.messagesContainerRef.removeEventListener('wheel', this.handleWheel)
this.scrollableContainerResizeObserver.disconnect()
this.nonScrollableContainerResizeObserver.disconnect()
}

handleSubmit = (e: Event) => {
Expand All @@ -154,10 +225,22 @@ export class OramaChat {
}

handleSuggestionClick = (suggestion: string) => {
if (chatContext.chatService === null) {
throw new Error('Chat Service is not initialized')
}

chatContext.chatService.sendQuestion(suggestion)
this.inputValue = ''
}

calculateIsContainerOverflowing = () => {
if (!this.messagesContainerRef) {
return false
}

return this.messagesContainerRef.scrollHeight > this.messagesContainerRef.clientHeight
}

render() {
const lastInteraction = chatContext.interactions?.[chatContext.interactions.length - 1]
const lastInteractionStatus = lastInteraction?.status
Expand All @@ -182,26 +265,31 @@ export class OramaChat {
class={`messages-container-wrapper ${!chatContext.interactions?.length ? 'isEmpty' : ''}`}
ref={(ref) => (this.messagesContainerRef = ref)}
>
{chatContext.interactions?.length ? (
<orama-chat-messages-container interactions={chatContext.interactions} />
) : null}

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

{/* TODO: Provide a better animation */}
{!chatContext.interactions?.length && !!this.suggestions?.length ? (
<div class="suggestions-wrapper">
<orama-chat-suggestions
suggestions={this.suggestions}
suggestionClicked={this.handleSuggestionClick}
/>
</div>
) : null}
{/* TODO: not required for chatbox, but maybe required for Searchbox v2 */}
{/* <orama-logo-icon /> */}
</div>
</div>
{!chatContext.lockScrollOnBottom && (
{this.showGoToBottomButton && (
<button
class="lock-scroll-on-bottom-button-wrapper"
type="button"
onClick={() => {
chatContext.lockScrollOnBottom = true
this.scrollToBottom({ animated: true })
this.lockScrollOnBottom = true
this.scrollToBottom({ animated: true, onScrollDone: () => this.recalculateGoBoToBottomButton() })
}}
>
<ph-arrow-down size={'18px'} />
Expand Down Expand Up @@ -230,11 +318,14 @@ export class OramaChat {
placeholder={this.placeholder}
>
<div slot="adornment-end">
{[TAnswerStatus.streaming, TAnswerStatus.loading].includes(lastInteractionStatus) ? (
{[TAnswerStatus.streaming, TAnswerStatus.rendering, TAnswerStatus.loading].includes(
lastInteractionStatus,
) ? (
<orama-button
type="submit"
onClick={this.handleAbortAnswerClick}
onKeyDown={this.handleAbortAnswerClick}
disabled={lastInteractionStatus !== TAnswerStatus.rendering}
aria-label="Abort answer"
>
<ph-stop-circle size={16} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
| `placeholder` | `placeholder` | | `string` | `'Ask me anything'` |
| `showClearChat` | `show-clear-chat` | | `boolean` | `true` |
| `sourceBaseUrl` | `source-base-url` | | `string` | `''` |
| `sourcesMap` | -- | | `{ title?: string; path?: string; description?: string; }` | `undefined` |
| `sourcesMap` | -- | | `{ title?: string; description?: string; path?: string; }` | `undefined` |
| `suggestions` | -- | | `string[]` | `undefined` |


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
| `index` | -- | | `{ api_key: string; endpoint: string; }` | `undefined` |
| `placeholder` | `placeholder` | | `string` | `undefined` |
| `sourceBaseUrl` | `source-base-url` | | `string` | `undefined` |
| `sourcesMap` | -- | | `{ title?: string; path?: string; description?: string; }` | `undefined` |
| `sourcesMap` | -- | | `{ title?: string; description?: string; path?: string; }` | `undefined` |
| `suggestions` | -- | | `string[]` | `undefined` |


Expand Down
Loading

0 comments on commit 4b2ee2d

Please sign in to comment.