Skip to content

Commit

Permalink
feat(ai-chat-log): add typewriter animations (#4199)
Browse files Browse the repository at this point in the history
* feat(ai-chat-log): wip typewriter animations

* fix(ai-chat-log): remove nested span

* feat(ai-chat-log): typewiter code

* feat(docs): udpate docs and changesets

* chore(ai-chat-log): linting

* docs(ai-chat-log): update definition

* docs(ai-chat-log): docs spelling

* feat(ai-chat-log): typewiter speeds

* chore(docs): rearrange ai chat log sections

* docs(ai-chat-log): added scollable example

* docs(ai-chat-log): modified scrollable exmaple

* chore(ai-chat-log): typedocs

* feat(ai-chat-log): story for user cancel scroll to end

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
krisantrobus and kodiakhq[bot] authored Jan 22, 2025
1 parent 1715302 commit f3a48a4
Show file tree
Hide file tree
Showing 10 changed files with 885 additions and 17 deletions.
6 changes: 6 additions & 0 deletions .changeset/tough-moles-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@twilio-paste/ai-chat-log": minor
"@twilio-paste/core": minor
---

[AI Chat Log] added optional typewriter animation to AIChatMessageBody
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { HTMLPasteProps } from "@twilio-paste/types";
import * as React from "react";

import { AIMessageContext } from "./AIMessageContext";
import { useAnimatedText } from "./utils";

const Sizes: Record<string, BoxStyleProps> = {
default: {
Expand Down Expand Up @@ -35,11 +36,59 @@ export interface AIChatMessageBodyProps extends HTMLPasteProps<"div"> {
* @memberof AIChatMessageBodyProps
*/
size?: "default" | "fullScreen";
/**
* Whether the text should be animated with type writer effect
*
* @default false
* @type {boolean}
* @memberof AIChatMessageBodyProps
*/
animated?: boolean;
/**
* A callback when the animation is started
*
* @default false
* @type {() => void}
* @memberof AIChatMessageBodyProps
*/
onAnimationStart?: () => void;
/**
* A callback when the animation is complete
*
* @default false
* @type {() => void}
* @memberof AIChatMessageBodyProps
*/
onAnimationEnd?: () => void;
}

export const AIChatMessageBody = React.forwardRef<HTMLDivElement, AIChatMessageBodyProps>(
({ children, size = "default", element = "AI_CHAT_MESSAGE_BODY", ...props }, ref) => {
(
{
children,
size = "default",
element = "AI_CHAT_MESSAGE_BODY",
animated = false,
onAnimationEnd,
onAnimationStart,
...props
},
ref,
) => {
const { id } = React.useContext(AIMessageContext);
const [showAnimation] = React.useState(animated && children !== undefined);
const animationSpeed = size === "fullScreen" ? 8 : 10;
const { animatedChildren, isAnimating } = useAnimatedText(children, animationSpeed, showAnimation);

React.useEffect(() => {
if (onAnimationStart && animated && isAnimating) {
onAnimationStart();
}

if (animated && !isAnimating && onAnimationEnd) {
onAnimationEnd();
}
}, [isAnimating, showAnimation]);

return (
<Box
Expand All @@ -55,7 +104,7 @@ export const AIChatMessageBody = React.forwardRef<HTMLDivElement, AIChatMessageB
whiteSpace="pre-wrap"
id={id}
>
{children}
{animatedChildren}
</Box>
);
},
Expand Down
79 changes: 79 additions & 0 deletions packages/paste-core/components/ai-chat-log/src/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React, { useEffect, useState } from "react";

// Hook to animate text content of React elements
export const useAnimatedText = (
children: React.ReactNode,
speed = 10,
enabled = true,
): { animatedChildren: React.ReactNode; isAnimating: boolean } => {
const [animatedChildren, setAnimatedChildren] = useState<React.ReactNode>();
const [textIndex, setTextIndex] = useState(0);

// Effect to increment textIndex at a specified speed
useEffect(() => {
const interval = setInterval(() => {
setTextIndex((prevIndex) => prevIndex + 1);
}, speed);

return () => clearInterval(interval);
}, [speed]);

// Function to calculate the total length of text within nested elements
const calculateTotalTextLength = (nodes: React.ReactNode): number => {
let length = 0;
React.Children.forEach(nodes, (child) => {
if (typeof child === "string") {
length += child.length;
} else if (React.isValidElement(child)) {
length += calculateTotalTextLength(child.props.children);
}
});
return length;
};

// Function to recursively clone children and apply text animation
const cloneChildren = (nodes: React.ReactNode, currentIndex: number): React.ReactNode => {
let currentTextIndex = currentIndex;
return React.Children.map(nodes, (child) => {
if (typeof child === "string") {
// Only include text nodes if their animation has started
if (currentTextIndex > 0) {
const visibleText = child.slice(0, currentTextIndex);
currentTextIndex -= child.length;
return visibleText;
}
return null;
} else if (React.isValidElement(child)) {
const totalChildTextLength = calculateTotalTextLength(child.props.children);
// Only include elements if their text animation has started
if (currentTextIndex > 0) {
const clonedChild = React.cloneElement(child, {}, cloneChildren(child.props.children, currentTextIndex));
currentTextIndex -= totalChildTextLength;
return clonedChild;
} else if (currentTextIndex === 0 && totalChildTextLength === 0) {
return child;
}
return null;
}

return child;
});
};

// Effect to update animated children based on the current text index
useEffect(() => {
if (enabled) {
const totaLength = calculateTotalTextLength(children);
if (textIndex <= totaLength) {
setAnimatedChildren(cloneChildren(children, textIndex));
}
}
}, [children, textIndex, enabled]);

return {
animatedChildren: enabled ? animatedChildren : children,
isAnimating: enabled && textIndex < calculateTotalTextLength(children),
};
};

export default useAnimatedText;
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const BotMessage = (props): JSX.Element => {
) : (
<AIChatMessage variant="bot">
<AIChatMessageAuthor aria-label="Bot said">Good Bot</AIChatMessageAuthor>
<AIChatMessageBody>{props.message as string}</AIChatMessageBody>
<AIChatMessageBody animated>{props.message as string}</AIChatMessageBody>
</AIChatMessage>
);
};
Expand Down
Loading

0 comments on commit f3a48a4

Please sign in to comment.