Skip to content

Commit f3a48a4

Browse files
feat(ai-chat-log): add typewriter animations (#4199)
* 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>
1 parent 1715302 commit f3a48a4

File tree

10 files changed

+885
-17
lines changed

10 files changed

+885
-17
lines changed

.changeset/tough-moles-film.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@twilio-paste/ai-chat-log": minor
3+
"@twilio-paste/core": minor
4+
---
5+
6+
[AI Chat Log] added optional typewriter animation to AIChatMessageBody

packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { HTMLPasteProps } from "@twilio-paste/types";
44
import * as React from "react";
55

66
import { AIMessageContext } from "./AIMessageContext";
7+
import { useAnimatedText } from "./utils";
78

89
const Sizes: Record<string, BoxStyleProps> = {
910
default: {
@@ -35,11 +36,59 @@ export interface AIChatMessageBodyProps extends HTMLPasteProps<"div"> {
3536
* @memberof AIChatMessageBodyProps
3637
*/
3738
size?: "default" | "fullScreen";
39+
/**
40+
* Whether the text should be animated with type writer effect
41+
*
42+
* @default false
43+
* @type {boolean}
44+
* @memberof AIChatMessageBodyProps
45+
*/
46+
animated?: boolean;
47+
/**
48+
* A callback when the animation is started
49+
*
50+
* @default false
51+
* @type {() => void}
52+
* @memberof AIChatMessageBodyProps
53+
*/
54+
onAnimationStart?: () => void;
55+
/**
56+
* A callback when the animation is complete
57+
*
58+
* @default false
59+
* @type {() => void}
60+
* @memberof AIChatMessageBodyProps
61+
*/
62+
onAnimationEnd?: () => void;
3863
}
3964

4065
export const AIChatMessageBody = React.forwardRef<HTMLDivElement, AIChatMessageBodyProps>(
41-
({ children, size = "default", element = "AI_CHAT_MESSAGE_BODY", ...props }, ref) => {
66+
(
67+
{
68+
children,
69+
size = "default",
70+
element = "AI_CHAT_MESSAGE_BODY",
71+
animated = false,
72+
onAnimationEnd,
73+
onAnimationStart,
74+
...props
75+
},
76+
ref,
77+
) => {
4278
const { id } = React.useContext(AIMessageContext);
79+
const [showAnimation] = React.useState(animated && children !== undefined);
80+
const animationSpeed = size === "fullScreen" ? 8 : 10;
81+
const { animatedChildren, isAnimating } = useAnimatedText(children, animationSpeed, showAnimation);
82+
83+
React.useEffect(() => {
84+
if (onAnimationStart && animated && isAnimating) {
85+
onAnimationStart();
86+
}
87+
88+
if (animated && !isAnimating && onAnimationEnd) {
89+
onAnimationEnd();
90+
}
91+
}, [isAnimating, showAnimation]);
4392

4493
return (
4594
<Box
@@ -55,7 +104,7 @@ export const AIChatMessageBody = React.forwardRef<HTMLDivElement, AIChatMessageB
55104
whiteSpace="pre-wrap"
56105
id={id}
57106
>
58-
{children}
107+
{animatedChildren}
59108
</Box>
60109
);
61110
},
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React, { useEffect, useState } from "react";
2+
3+
// Hook to animate text content of React elements
4+
export const useAnimatedText = (
5+
children: React.ReactNode,
6+
speed = 10,
7+
enabled = true,
8+
): { animatedChildren: React.ReactNode; isAnimating: boolean } => {
9+
const [animatedChildren, setAnimatedChildren] = useState<React.ReactNode>();
10+
const [textIndex, setTextIndex] = useState(0);
11+
12+
// Effect to increment textIndex at a specified speed
13+
useEffect(() => {
14+
const interval = setInterval(() => {
15+
setTextIndex((prevIndex) => prevIndex + 1);
16+
}, speed);
17+
18+
return () => clearInterval(interval);
19+
}, [speed]);
20+
21+
// Function to calculate the total length of text within nested elements
22+
const calculateTotalTextLength = (nodes: React.ReactNode): number => {
23+
let length = 0;
24+
React.Children.forEach(nodes, (child) => {
25+
if (typeof child === "string") {
26+
length += child.length;
27+
} else if (React.isValidElement(child)) {
28+
length += calculateTotalTextLength(child.props.children);
29+
}
30+
});
31+
return length;
32+
};
33+
34+
// Function to recursively clone children and apply text animation
35+
const cloneChildren = (nodes: React.ReactNode, currentIndex: number): React.ReactNode => {
36+
let currentTextIndex = currentIndex;
37+
return React.Children.map(nodes, (child) => {
38+
if (typeof child === "string") {
39+
// Only include text nodes if their animation has started
40+
if (currentTextIndex > 0) {
41+
const visibleText = child.slice(0, currentTextIndex);
42+
currentTextIndex -= child.length;
43+
return visibleText;
44+
}
45+
return null;
46+
} else if (React.isValidElement(child)) {
47+
const totalChildTextLength = calculateTotalTextLength(child.props.children);
48+
// Only include elements if their text animation has started
49+
if (currentTextIndex > 0) {
50+
const clonedChild = React.cloneElement(child, {}, cloneChildren(child.props.children, currentTextIndex));
51+
currentTextIndex -= totalChildTextLength;
52+
return clonedChild;
53+
} else if (currentTextIndex === 0 && totalChildTextLength === 0) {
54+
return child;
55+
}
56+
return null;
57+
}
58+
59+
return child;
60+
});
61+
};
62+
63+
// Effect to update animated children based on the current text index
64+
useEffect(() => {
65+
if (enabled) {
66+
const totaLength = calculateTotalTextLength(children);
67+
if (textIndex <= totaLength) {
68+
setAnimatedChildren(cloneChildren(children, textIndex));
69+
}
70+
}
71+
}, [children, textIndex, enabled]);
72+
73+
return {
74+
animatedChildren: enabled ? animatedChildren : children,
75+
isAnimating: enabled && textIndex < calculateTotalTextLength(children),
76+
};
77+
};
78+
79+
export default useAnimatedText;

packages/paste-core/components/ai-chat-log/stories/composer.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const BotMessage = (props): JSX.Element => {
5959
) : (
6060
<AIChatMessage variant="bot">
6161
<AIChatMessageAuthor aria-label="Bot said">Good Bot</AIChatMessageAuthor>
62-
<AIChatMessageBody>{props.message as string}</AIChatMessageBody>
62+
<AIChatMessageBody animated>{props.message as string}</AIChatMessageBody>
6363
</AIChatMessage>
6464
);
6565
};

0 commit comments

Comments
 (0)