Skip to content
36 changes: 28 additions & 8 deletions components/form/prompt-input/PromptInput.es6.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
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

React.Children.forEach(children, child => {
if (child.type && child.type.displayName === 'PromptInputTextarea') {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we check if child.type is defined once at the top?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactored the code and created separate helper for type checking logic

textarea = child
} else if (child.type && child.type.displayName === 'PromptInputActions') {
actions = child
} else if (child.type && child.type.name === 'TextRevealOverlay') {
overlay = child
}
})

function PromptInput ({ children, className }) {
return (
<div className={classnames('ds-prompt-input', className)}>
{children}
{textarea}
{overlay}
{actions}
{React.Children.map(children, child => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Some thoughts here:

  • We do something similar above, so we can move type checking logic to a separate helper, wyt?
  • Are we doing this to make sure the components that are not textarea, overlay, and actions will go after these three?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  • Refactored the code and created separate helper for type checking logic
  • Yes, the first 3 components should be rendered first before the other children because it affects the positioning of the text when it will be displayed by the overlay.

if ((child.type && child.type.displayName !== 'PromptInputTextarea' &&
child.type.displayName !== 'PromptInputActions') &&
(child.type && child.type.name !== 'TextRevealOverlay')) {
return child
}
return null
})}
</div>
)
}
Expand All @@ -15,10 +40,5 @@ PromptInput.propTypes = {
children: PropTypes.node
}

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

export default PromptInput

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

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])

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])


useEffect(() => {
if (lastParagraphIndex === paragraphs.length - 1 && paragraphs.length > 0) {
setIsAnimatingLastParagraph(true)
}
}, [lastParagraphIndex, paragraphs.length])


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)'
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 use template string here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactored code to use template string.


// 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={`ds-prompt-input__text-reveal-overlay ${className || ''}`}
aria-hidden="true"
>
{paragraphs.map((paragraph, index) => (
<p
key={index}
className={`ds-prompt-input__text-reveal-paragraph ${
Copy link
Contributor

Choose a reason for hiding this comment

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

use classnames?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactored code to use classnames

index === lastParagraphIndex ? 'ds-prompt-input__text-reveal-paragraph--animating' : ''
}`}
>
{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