Skip to content
55 changes: 47 additions & 8 deletions components/form/prompt-input/PromptInput.es6.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,55 @@
import React from 'react'
import classnames from 'classnames'
import PropTypes from 'prop-types'
import classnames from 'classnames'

function PromptInput ({ className, children }) {
let textarea = null
let actions = null
let overlay = null
const otherChildren = []

const identifyChildComponent = (child) => {
if (child && child.type) {
if (child.type.displayName === 'PromptInputTextarea') {
return 'textarea'
} else if (child.type.displayName === 'PromptInputActions') {
return 'actions'
} else if (child.type.name === 'TextRevealOverlay') {
return 'overlay'
} else {
return 'other'
}
}
return null
}

React.Children.forEach(children, child => {
const componentType = identifyChildComponent(child)

switch (componentType) {
case 'textarea':
textarea = child
break
case 'actions':
actions = child
break
case 'overlay':
overlay = child
break
case 'other':
otherChildren.push(child)
break
default:
break
}
})

function PromptInput ({ children, className }) {
return (
<div className={classnames('ds-prompt-input', className)}>
{children}
{textarea}
{overlay}
{actions}
{otherChildren}
</div>
)
}
Expand All @@ -15,10 +59,5 @@ PromptInput.propTypes = {
children: PropTypes.node
}

PromptInput.defaultProps = {
className: '',
children: null
}

export default PromptInput

190 changes: 190 additions & 0 deletions components/form/prompt-input/TextRevealOverlay.es6.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React, { useEffect, useState, useRef } from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'

function TextRevealOverlay({
text,
isRevealing,
lastParagraphIndex,
className,
textareaRef,
onAnimationComplete
}) {
const [paragraphs, setParagraphs] = useState([])
const [isAnimatingLastParagraph, setIsAnimatingLastParagraph] = useState(false)
const overlayRef = useRef(null)

// Split text into paragraphs whenever text changes
useEffect(() => {
if (text) {
const split = text.split(/\n{2,}|\r\n{2,}/)
.map(p => p.trim())
.filter(p => p.length > 0)
setParagraphs(split)
} else {
setParagraphs([])
}
}, [text])

// Handle animation completion when the last paragraph finishes animating
useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add some short comments to all useEffect hooks in this component?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be good to move all those useEffects to a separate hook?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some comments to the useEffect but did not move into a separate hook since it's only being used by this component.

if (isAnimatingLastParagraph && lastParagraphIndex === paragraphs.length - 1) {
const timer = setTimeout(() => {
setIsAnimatingLastParagraph(false)
if (onAnimationComplete) {
onAnimationComplete()
}
}, 500)

return () => clearTimeout(timer)
}
}, [isAnimatingLastParagraph, lastParagraphIndex, paragraphs.length, onAnimationComplete])

// Detect when the last paragraph appears and mark it for animation
useEffect(() => {
if (lastParagraphIndex === paragraphs.length - 1 && paragraphs.length > 0) {
setIsAnimatingLastParagraph(true)
}
}, [lastParagraphIndex, paragraphs.length])

// Sync overlay dimensions, positioning and scrolling with the textarea
useEffect(() => {
if (!textareaRef || !textareaRef.current || !overlayRef.current || !isRevealing) return

const textarea = textareaRef.current
const overlay = overlayRef.current

// Function to update dimensions whenever they change
const updateDimensions = () => {
// Position relative to the prompt-input container
const promptInput = textarea.closest('.ds-prompt-input')
let paddingLeft = '16px'
let paddingTop = '16px'

if (promptInput) {
const promptStyles = window.getComputedStyle(promptInput)
if (promptStyles.paddingLeft) {
paddingLeft = promptStyles.paddingLeft
}
if (promptStyles.paddingTop) {
paddingTop = promptStyles.paddingTop
}
}

// Get current styles
const textareaStyles = window.getComputedStyle(textarea)

// Apply exact dimensions and styling
overlay.style.width = textareaStyles.width
overlay.style.fontSize = textareaStyles.fontSize
overlay.style.lineHeight = textareaStyles.lineHeight
overlay.style.fontFamily = textareaStyles.fontFamily
overlay.style.fontWeight = textareaStyles.fontWeight
overlay.style.color = textareaStyles.color

// Set exact position
overlay.style.position = 'absolute'
overlay.style.top = paddingTop
overlay.style.left = paddingLeft
overlay.style.width = `calc(100% - ${parseInt(paddingLeft) * 2}px)`


// Set max-height to match textarea max-height
overlay.style.maxHeight = textareaStyles.maxHeight

// Match the actual height if it's calculated
if (textarea.style.height) {
overlay.style.height = textarea.style.height
}
}

// Initial update
updateDimensions()

// Set up mutation observer to detect height changes
let observer = null
if (typeof MutationObserver !== 'undefined') {
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'style') {
updateDimensions()
}
})
})

observer.observe(textarea, {
attributes: true,
attributeFilter: ['style']
})
}

const syncScroll = () => {
if (overlayRef.current && isRevealing) {
overlayRef.current.scrollTop = textarea.scrollTop
}
}

textarea.addEventListener('scroll', syncScroll)

window.addEventListener('resize', updateDimensions)

syncScroll()

return () => {
textarea.removeEventListener('scroll', syncScroll)
window.removeEventListener('resize', updateDimensions)
if (observer) {
observer.disconnect()
}
}
}, [textareaRef, isRevealing, paragraphs])

if (!isRevealing || paragraphs.length === 0) {
return null
}

return (
<div
ref={overlayRef}
className={classnames(
'ds-prompt-input__text-reveal-overlay',
className
)}
aria-hidden="true"
>
{paragraphs.map((paragraph, index) => (
<p
key={index}
className={classnames(
'ds-prompt-input__text-reveal-paragraph',
{
'ds-prompt-input__text-reveal-paragraph--animating': index === lastParagraphIndex
}
)}
>
{paragraph}
</p>
))}
</div>
)
}

TextRevealOverlay.propTypes = {
text: PropTypes.string,
isRevealing: PropTypes.bool,
lastParagraphIndex: PropTypes.number,
className: PropTypes.string,
textareaRef: PropTypes.object,
onAnimationComplete: PropTypes.func
}

TextRevealOverlay.defaultProps = {
text: '',
isRevealing: false,
lastParagraphIndex: -1,
className: '',
textareaRef: null,
onAnimationComplete: null
}

export default TextRevealOverlay
124 changes: 124 additions & 0 deletions components/lib/useProgressiveTextReveal.es6.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@

import { useState, useEffect, useRef, useCallback } from 'react'

function useProgressiveTextReveal({
onComplete = () => {},
paragraphDelay = 400,
onCompleteTimeout = 600,
onParagraphAdded = () => {}
} = {}) {
const [isRevealing, setIsRevealing] = useState(false)
const [currentText, setCurrentText] = useState('')
const [newParagraphAdded, setNewParagraphAdded] = useState(false)
const [lastParagraphIndex, setLastParagraphIndex] = useState(-1)
const timeoutsRef = useRef([])
const paragraphsRef = useRef([])
const currentIndexRef = useRef(0)

const clearAllTimeouts = useCallback(() => {
timeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId))
timeoutsRef.current = []
}, [])

// Reset the reveal state
const resetTextReveal = useCallback(() => {
clearAllTimeouts()
setCurrentText('')
setIsRevealing(false)
setNewParagraphAdded(false)
setLastParagraphIndex(-1)
paragraphsRef.current = []
currentIndexRef.current = 0
}, [clearAllTimeouts])

// This function adds a single paragraph with animation trigger
const addNextParagraph = useCallback(() => {
if (currentIndexRef.current >= paragraphsRef.current.length) {

// Allow time for the final animation to complete before signaling done
const finalTimeout = setTimeout(() => {
onComplete(currentText)
}, onCompleteTimeout)

timeoutsRef.current.push(finalTimeout)
return
}

// First paragraph or append with double newline
const paragraphToAdd = paragraphsRef.current[currentIndexRef.current]

if (currentIndexRef.current === 0) {
setCurrentText(paragraphToAdd)
} else {
setCurrentText(prev => `${prev}\n\n${paragraphToAdd}`)
}

// Track the index of the paragraph we just added
setLastParagraphIndex(currentIndexRef.current)

// Signal that a new paragraph was added (for animation)
setNewParagraphAdded(true)

// Call the paragraph added callback
if (onParagraphAdded) {
onParagraphAdded(currentIndexRef.current)
}

// Reset animation flag after animation completes
const resetTimeout = setTimeout(() => {
setNewParagraphAdded(false)
}, 500)

timeoutsRef.current.push(resetTimeout)


currentIndexRef.current++

// Schedule next paragraph if there are more
if (currentIndexRef.current < paragraphsRef.current.length) {
const nextTimeout = setTimeout(addNextParagraph, paragraphDelay)
timeoutsRef.current.push(nextTimeout)
}
}, [currentText, onComplete, onParagraphAdded, paragraphDelay])

// Start revealing text paragraph by paragraph
const revealText = useCallback((text) => {
if (!text) return

resetTextReveal()
setIsRevealing(true)

// Split text into paragraphs (handles different line break formats)
const paragraphs = text.split(/\n{2,}|\r\n{2,}/)
.map(p => p.trim())
.filter(p => p.length > 0)

if (paragraphs.length === 0) {
setIsRevealing(false)
return
}

paragraphsRef.current = paragraphs
currentIndexRef.current = 0


addNextParagraph()

}, [resetTextReveal, addNextParagraph])


useEffect(() => {
return () => clearAllTimeouts()
}, [clearAllTimeouts])

return {
revealText,
resetTextReveal,
currentText,
isRevealing,
newParagraphAdded,
lastParagraphIndex
}
}

export default useProgressiveTextReveal
Loading
Loading