Skip to content

Commit

Permalink
feat(ai-chat-log): wip typewriter animations
Browse files Browse the repository at this point in the history
  • Loading branch information
krisantrobus committed Dec 17, 2024
1 parent 77ec1d1 commit 2c100d8
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as React from "react";


export interface AIChatLogContextProps {
isAnimating: boolean;
setIsAnimating: (animating: boolean) => void;
}
export const AIChatLogContext = React.createContext<AIChatLogContextProps>({} as any);
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Box, safelySpreadBoxProps } from "@twilio-paste/box";
import type { BoxElementProps } from "@twilio-paste/box";
import type { ThemeShape } from "@twilio-paste/theme";
import type { HTMLPasteProps } from "@twilio-paste/types";
import * as React from "react";
import { useAnimatedText } from "./utils";

const Variants = {
default: {
fontSize: "fontSize30" as ThemeShape["fontSizes"],
lineHeight: "lineHeight30" as ThemeShape["lineHeights"],
},
fullScreen: {
fontSize: "fontSize40" as ThemeShape["fontSizes"],
lineHeight: "lineHeight40" as ThemeShape["lineHeights"],
},
};

export interface AIChatMessageBodyTypeWriterProps extends HTMLPasteProps<"div"> {
children?: React.ReactNode;
/**
* Overrides the default element name to apply unique styles with the Customization Provider
*
* @default "AI_CHAT_MESSAGE_BODY_TYPE_WRITER"
* @type {BoxProps["element"]}
* @memberof AIChatMessageBodyTypeWriterProps
*/
element?: BoxElementProps["element"];
/**
* Override the font size for full screen experiences.
*
* @default "default"
* @type {"default" | "fullScreen"}
* @memberof AIChatMessageBodyTypeWriterProps
*/
variant?: "default" | "fullScreen";
/**
* Whether the text should be animated with type writer effect
*
* @default true
* @type {boolean}
* @memberof AIChatMessageBodyTypeWriterProps
*/
animated?: boolean;
}

export const AIChatMessageBodyTypeWriter = React.forwardRef<HTMLDivElement, AIChatMessageBodyTypeWriterProps>(
({ children, variant = "default", element = "AI_CHAT_MESSAGE_BODY_TYPE_WRITER", animated = true, onAnimationEnd, onAnimationStart, ...props }, ref) => {
const animatedChildren = useAnimatedText(children);

return (
<Box
{...safelySpreadBoxProps(props)}
{...Variants[variant]}
display="inline-block"
color="colorText"
wordWrap="break-word"
maxWidth="100%"
minWidth={0}
element={element}
ref={ref}
whiteSpace="pre-wrap"
>
{animated
? animatedChildren
: children}
</Box>
);
},
);

AIChatMessageBodyTypeWriter.displayName = "AIChatMessageBodyTypeWriter";
3 changes: 3 additions & 0 deletions packages/paste-core/components/ai-chat-log/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export { AIChatMessageActionCard } from "./AIChatMessageActionCard";
export type { AIChatMessageActionCardProps } from "./AIChatMessageActionCard";
export { AIChatMessageLoading } from "./AIChatMessageLoading";
export type { AIChatMessageLoadingProps } from "./AIChatMessageLoading";
export { AIChatMessageBodyTypeWriter } from "./AIChatMessageBodyTypeWriter";
export type { AIChatMessageBodyTypeWriterProps } from "./AIChatMessageBodyTypeWriter";
export { AIChatLogContext } from "./AIChatLogContext";

export { AIChatLog } from "./AIChatLog";
export type { AIChatLogProps } from "./AIChatLog";
Expand Down
77 changes: 77 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,77 @@
import React, { useState, useEffect } from "react";
import { AIChatLogContext } from "./AIChatLogContext";

// Hook to animate text content of React elements
export const useAnimatedText = (children: React.ReactNode, speed: number = 10): React.ReactNode => {
const {setIsAnimating, isAnimating} = React.useContext(AIChatLogContext);
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 = (children: React.ReactNode): number => {
let length = 0;
React.Children.forEach(children, (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 = (children: React.ReactNode, currentIndex: number): React.ReactNode => {
let currentTextIndex = currentIndex;
return React.Children.map(children, (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 <span>{visibleText}</span>;
}
return null;
}

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;
}
return null;
}

return child;
});
};

// Effect to update animated children based on the current text index
useEffect(() => {
const totaLength = calculateTotalTextLength(children);
if (textIndex <= totaLength) {
setAnimatedChildren(cloneChildren(children, textIndex));
if(!isAnimating){
setIsAnimating && setIsAnimating(true);
}
} else if(isAnimating){
setIsAnimating && setIsAnimating(false);
}
}, [children, textIndex]);

return animatedChildren
};

export default useAnimatedText;
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
// eslint-disable-next-line eslint-comments/disable-enable-pair
/* eslint-disable import/no-extraneous-dependencies */
import { Anchor } from "@twilio-paste/anchor";
import { Box } from "@twilio-paste/box";
import { Button } from "@twilio-paste/button";
import { ButtonGroup } from "@twilio-paste/button-group";
import { Disclosure, DisclosureContent, DisclosureHeading } from "@twilio-paste/disclosure";
import { Heading } from "@twilio-paste/heading";
import { CopyIcon } from "@twilio-paste/icons/esm/CopyIcon";
import { RefreshIcon } from "@twilio-paste/icons/esm/RefreshIcon";
import { ThumbsDownIcon } from "@twilio-paste/icons/esm/ThumbsDownIcon";
import { ThumbsUpIcon } from "@twilio-paste/icons/esm/ThumbsUpIcon";
import { UserIcon } from "@twilio-paste/icons/esm/UserIcon";
import { InlineCode } from "@twilio-paste/inline-code";
import { ListItem, UnorderedList } from "@twilio-paste/list";
import { Paragraph } from "@twilio-paste/paragraph";
import * as React from "react";

import {
Expand All @@ -18,6 +23,7 @@ import {
AIChatMessageActionGroup,
AIChatMessageAuthor,
AIChatMessageBody,
AIChatMessageBodyTypeWriter,
AIChatMessageLoading,
} from "../src";

Expand Down Expand Up @@ -204,3 +210,63 @@ export const FullAIMessage = (): React.ReactNode => {
</AIChatLog>
);
};

export const MessageBodyTypeWriter = (): React.ReactNode => {
return (
<Box>
<Heading as="h2" variant="heading20">
With enriched text
</Heading>
<Box marginBottom="space60">
<AIChatMessageBodyTypeWriter>
<Paragraph>
<span style={{ fontWeight: 600 }}>Lorem ipsum dolor, sit amet consectetur adipisicing elit.</span> Deserunt
delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex
fugiat quisquam itaque, earum sit <a href="https://google.com">nesciunt impedit repellat assumenda.</a> new
text,{" "}
<Anchor showExternal href="https://google.com">
434324
</Anchor>
</Paragraph>
<UnorderedList>
<ListItem>Item 1</ListItem>
<ListItem>Item 2</ListItem>
<ListItem>Item 3</ListItem>
</UnorderedList>
<Disclosure visible>
<DisclosureHeading as="h4" variant="heading40">
Between the World and Me by Ta-Nehisi Coates
</DisclosureHeading>
<DisclosureContent>
But race is the child of racism, not the father. And the process of naming “the people” has never been a
matter of genealogy and physiognomy so much as one of hierarchy. Difference in hue and hair is old. But
the belief in the preeminence of hue and hair, the notion that these factors can correctly organize a
society and that they signify deeper attributes, which are indelible—this is the new idea at the heart of
these new people who have been brought up hopelessly, tragically, deceitfully, to believe that they are
white.
</DisclosureContent>
</Disclosure>
</AIChatMessageBodyTypeWriter>
<br />
<AIChatMessageBody>

</AIChatMessageBody>
</Box>
{/* <Heading as="h2" variant="heading20">
Without enriched text [fullscreen variant]:
</Heading>
<Box>
<AIChatMessageBodyTypeWriter variant="fullScreen">
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt delectus fuga, necessitatibus eligendiiure
adipisci facilis exercitationem officiis dolorem laborum, ex fugiat quisquam itaque, earum sit
</AIChatMessageBodyTypeWriter>
<UnorderedList>
<ListItem>Item 1</ListItem>
<ListItem>Item 2</ListItem>
<ListItem>Item 3</ListItem>
</UnorderedList>
</Box> */}
</Box>
);
};

0 comments on commit 2c100d8

Please sign in to comment.