├── eslint.config.js ├── .eslintignore ├── .gitignore ├── stories ├── tailwind.css ├── Markdown.stories.tsx ├── TestMarkdown.ts ├── ExampleCustomComponent.tsx ├── Controls.tsx ├── RandomMarkdownSender.tsx └── Text.stories.tsx ├── src ├── index.ts ├── utils │ └── animations.ts ├── components │ ├── CodeRenderer.tsx │ ├── AnimatedImage.tsx │ ├── DefaultCode.tsx │ ├── AnimatedText.tsx │ ├── SplitText.tsx │ └── AnimatedMarkdown.tsx └── styles.css ├── postcss.config.js ├── tailwind.config.js ├── .storybook ├── preview.ts └── main.ts ├── .eslintrc ├── tsconfig.json ├── package.json ├── .npmignore └── README.md /eslint.config.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log 4 | *storybook.log -------------------------------------------------------------------------------- /stories/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {default as AnimatedMarkdown} from './components/AnimatedMarkdown'; 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // postcss.config.js 2 | module.exports = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './stories/**/*.{js,ts,jsx,tsx,mdx}', 5 | './stories/**/*.{js,ts,jsx,tsx,mdx}', 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [require('@tailwindcss/typography')], 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | import '../stories/tailwind.css'; 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/i, 10 | }, 11 | }, 12 | }, 13 | }; 14 | 15 | export default preview; 16 | -------------------------------------------------------------------------------- /src/utils/animations.ts: -------------------------------------------------------------------------------- 1 | export const animations = { 2 | fadeIn: 'ft-fadeIn', 3 | slideUp: 'ft-slideUp', 4 | wave: 'ft-wave', 5 | elastic: 'ft-elastic', 6 | bounceIn: 'ft-bounceIn', 7 | rotateIn: 'ft-rotateIn', 8 | colorTransition: 'ft-colorTransition', 9 | fadeAndScale: 'ft-fadeAndScale', 10 | slideInFromLeft: 'ft-slideInFromLeft', 11 | blurIn: 'ft-blurIn', 12 | typewriter: 'ft-typewriter', 13 | highlight: 'ft-highlight', 14 | blurAndSharpen: 'ft-blurAndSharpen', 15 | dropIn: 'ft-dropIn' 16 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:@typescript-eslint/eslint-recommended", 7 | "plugin:@typescript-eslint/recommended" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "plugins": [ 11 | "@typescript-eslint", 12 | "react", 13 | "react-hooks" 14 | ], 15 | "rules": { 16 | "react-hooks/rules-of-hooks": "error", 17 | "react-hooks/exhaustive-deps": "warn", 18 | "@typescript-eslint/no-non-null-assertion": "off", 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "@typescript-eslint/no-explicit-any": "off" 21 | }, 22 | "settings": { 23 | "react": { 24 | "version": "detect" 25 | } 26 | }, 27 | "env": { 28 | "browser": true, 29 | "node": true 30 | }, 31 | "globals": { 32 | "JSX": true 33 | } 34 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], /* Include only the src directory */ 3 | "compilerOptions": { 4 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 5 | "module": "commonjs" /* Specify what module code is generated. */, 6 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 7 | "outDir": "dist", 8 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 9 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 10 | "strict": true /* Enable all strict type-checking options. */, 11 | "skipLibCheck": true /* Skip type checking all .d.ts files. */, 12 | "jsx": "react" /* Specify what JSX code is generated. */, 13 | } 14 | } -------------------------------------------------------------------------------- /stories/Markdown.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { text } from './TestMarkdown'; 3 | import RandomTextSender from './RandomMarkdownSender'; 4 | import CustomComponent from './ExampleCustomComponent'; 5 | 6 | // This is the default export that defines the component title and other configuration 7 | export default { 8 | title: 'Components/Markdown', 9 | component: RandomTextSender, 10 | }; 11 | 12 | // Here we define a "story" for the default view of SmoothText 13 | export const DefaultMarkdown = () => { 18 | return ; 19 | }, 20 | 'ArticlePreview': ({ title, description }: any) => { 21 | console.log('title', title); 22 | return
23 |

{title}

24 |

{description}

25 |
; 26 | } 27 | }} 28 | />; 29 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-webpack5"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../**/*.mdx", "../**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | addons: [ 6 | "@storybook/addon-webpack5-compiler-swc", 7 | { 8 | name: '@storybook/addon-postcss', 9 | options: { 10 | cssLoaderOptions: { 11 | // When you have splitted your css over multiple files 12 | // and use @import('./other-styles.css') 13 | importLoaders: 1, 14 | }, 15 | postcssLoaderOptions: { 16 | // When using postCSS 8 17 | implementation: require('postcss'), 18 | postcssOptions: { 19 | plugins: [ 20 | require('tailwindcss'), 21 | require('autoprefixer') 22 | ], 23 | }, 24 | }, 25 | }, 26 | }, 27 | "@storybook/addon-onboarding", 28 | "@storybook/addon-links", 29 | "@storybook/addon-essentials", 30 | "@chromatic-com/storybook", 31 | "@storybook/addon-interactions", 32 | "@storybook/addon-styling-webpack" 33 | ], 34 | framework: { 35 | name: "@storybook/react-webpack5", 36 | options: {}, 37 | }, 38 | }; 39 | export default config; 40 | -------------------------------------------------------------------------------- /stories/TestMarkdown.ts: -------------------------------------------------------------------------------- 1 | export const text = ` 2 | # Main Heading(H1) 3 | 4 | ![Alt](https://placehold.co/150) 5 | 6 | ## Subheading(H2) 7 | 8 | ### Another Subheading(H3) 9 | 10 | *Regular* text is just written as plain text. You can add **bold** text, *italic* text, and even ***bold italic*** text. 11 | 12 | You can also create hyperlinks: [OpenAI](https://www.openai.com) 13 | 14 | --- 15 | 16 | ### Lists 17 | 18 | #### Unordered List 19 | 20 | - Item 1 and some *more* 21 | - Item 2 22 | - Subitem 2.1 23 | - Subitem 2.2 24 | - Item 3 25 | 26 | #### Ordered List 27 | 28 | 1. First Item 29 | 2. Second Item 30 | 3. Third Item 31 | 32 | --- 33 | 34 | ### Code 35 | 36 | \`Inline code\` with backticks. 37 | 38 | \`\`\`python 39 | # Python code block 40 | def hello_world(): 41 | print("Hello, world!") 42 | \`\`\` 43 | 44 | ### Blockquotes 45 | 46 | > This is a blockquote. 47 | > 48 | > This is part of the same quote. 49 | 50 | ### Tables 51 | 52 | A table: 53 | 54 | | a | b | 55 | | - | - | 56 | | 1 | 2 | 57 | | 3 | 4 | 58 | 59 | --- 60 | 61 | ### Images 62 | 63 | ![Alt Text](https://via.placeholder.com/150 "Image Title") 64 | 65 | ### Horizontal Rule 66 | 67 | --- 68 | 69 | ### Task List 70 | 71 | - [x] Task 1 completed 72 | - [ ] Task 2 not completed 73 | - [ ] Task 3 not completed 74 | ` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowtoken", 3 | "version": "1.0.35", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc && cp src/*.css dist && rm -rf dist/stories && rm -rf dist/src", 8 | "lint": "eslint \"{**/*,*}.{js,ts,jsx,tsx}\"", 9 | "storybook": "storybook dev -p 6006", 10 | "build-storybook": "storybook build" 11 | }, 12 | "keywords": [ 13 | "ai", 14 | "generative ui", 15 | "flowtoken", 16 | "tokenflow", 17 | "ai react", 18 | "react ai library", 19 | "flowtoken ai", 20 | "tokenflow ai" 21 | ], 22 | "author": "ephibbs", 23 | "license": "ISC", 24 | "devDependencies": { 25 | "@chromatic-com/storybook": "^3.2.2", 26 | "@storybook/addon-essentials": "^8.4.1", 27 | "@storybook/addon-interactions": "^8.4.1", 28 | "@storybook/addon-links": "^8.4.1", 29 | "@storybook/addon-onboarding": "^8.4.1", 30 | "@storybook/addon-postcss": "^2.0.0", 31 | "@storybook/addon-styling-webpack": "^1.0.1", 32 | "@storybook/addon-webpack5-compiler-swc": "^1.0.5", 33 | "@storybook/blocks": "^8.4.1", 34 | "@storybook/react": "^8.4.1", 35 | "@storybook/react-webpack5": "^8.4.1", 36 | "@storybook/test": "^8.4.1", 37 | "@tailwindcss/typography": "^0.5.15", 38 | "@types/react": "^18.3.3", 39 | "@types/react-syntax-highlighter": "^15.5.0", 40 | "autoprefixer": "^10.4.20", 41 | "gh-pages": "^6.1.1", 42 | "postcss": "^8.4.47", 43 | "react": "^18.3.1", 44 | "react-dom": "^18.3.1", 45 | "storybook": "^8.4.1", 46 | "tailwindcss": "^3.4.14", 47 | "typescript": "^5.5.3" 48 | }, 49 | "dependencies": { 50 | "react-markdown": "^9.0.1", 51 | "react-syntax-highlighter": "^15.5.0", 52 | "regexp-tree": "^0.1.27", 53 | "rehype-raw": "^7.0.0", 54 | "remark-gfm": "^4.0.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/CodeRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface CustomRendererProps { 4 | rows: any[]; 5 | stylesheet: any; 6 | useInlineStyles: boolean; 7 | } 8 | 9 | // Not a react component, but returns a function that returns a react component to be used as a custom code renderer in the SyntaxHighlighter component 10 | const customCodeRenderer = ({ animation, animationDuration, animationTimingFunction }: any) => { 11 | return ({rows, stylesheet, useInlineStyles}: CustomRendererProps) => rows.map((node, i) => ( 12 |
13 | {node.children.map((token: any, key: string) => { 14 | // Extract and apply styles from the stylesheet if available and inline styles are used 15 | const tokenStyles = useInlineStyles && stylesheet ? { ...stylesheet[token?.properties?.className[1]], ...token.properties?.style } : token.properties?.style || {}; 16 | return ( 17 | 18 | {token.children && token.children[0].value.split(' ').map((word: string, index: number) => ( 19 | 27 | {word + (index < token.children[0].value.split(' ').length - 1 ? ' ' : '')} 28 | 29 | ))} 30 | 31 | ); 32 | })} 33 |
34 | )); 35 | }; 36 | 37 | export default customCodeRenderer; 38 | -------------------------------------------------------------------------------- /src/components/AnimatedImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface AnimatedImageProps { 4 | src: string; 5 | alt: string; 6 | animation: string; 7 | animationDuration: string; 8 | animationTimingFunction: string; 9 | animationIterationCount: number; 10 | height?: string; // Optional height prop 11 | width?: string; // Optional width prop 12 | objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'; // Control how the image fits 13 | } 14 | 15 | const AnimatedImage: React.FC = ({ 16 | src, 17 | alt, 18 | animation, 19 | animationDuration, 20 | animationTimingFunction, 21 | animationIterationCount, 22 | height, 23 | width, 24 | objectFit = 'contain' // Default to 'contain' to maintain aspect ratio 25 | }) => { 26 | const [isLoaded, setIsLoaded] = React.useState(false); 27 | 28 | // Base styles that apply both before and after loading 29 | const baseStyle = { 30 | height: height || 'auto', 31 | width: width || 'auto', 32 | objectFit: objectFit, // This maintains aspect ratio 33 | maxWidth: '100%', // Ensure image doesn't overflow container 34 | }; 35 | 36 | const imageStyle = isLoaded ? { 37 | ...baseStyle, 38 | animationName: animation, 39 | animationDuration: animationDuration, 40 | animationTimingFunction: animationTimingFunction, 41 | animationIterationCount: animationIterationCount, 42 | whiteSpace: 'pre-wrap', 43 | } : { 44 | ...baseStyle, 45 | opacity: 0.0, // Slightly transparent before loading 46 | backgroundColor: '#f0f0f0', // Light gray background before loading 47 | }; 48 | 49 | return ( 50 | {alt} setIsLoaded(true)} 54 | style={imageStyle} 55 | /> 56 | ); 57 | }; 58 | 59 | export default AnimatedImage; 60 | -------------------------------------------------------------------------------- /stories/ExampleCustomComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const CustomComponent = ({ content }: { content: string }) => { 4 | const removedBraces = content.replace(/{{|}}/g, ''); 5 | const timeoutRef = React.useRef(); 6 | 7 | const handleMouseEnter = (e: React.MouseEvent) => { 8 | if (timeoutRef.current) { 9 | clearTimeout(timeoutRef.current); 10 | } 11 | const div = e.currentTarget; 12 | div.classList.remove('invisible'); 13 | div.classList.add('visible'); 14 | }; 15 | 16 | const handleMouseLeave = (e: React.MouseEvent) => { 17 | const div = e.currentTarget; 18 | timeoutRef.current = setTimeout(() => { 19 | div.classList.remove('visible'); 20 | div.classList.add('invisible'); 21 | }, 200); // 200ms delay before hiding 22 | }; 23 | 24 | return ( 25 | 29 | {removedBraces} 30 |
35 | 41 | View on Wikipedia 42 | 43 |

Click to learn more about this term on Wikipedia

44 |
45 |
46 | ); 47 | }; 48 | 49 | export default CustomComponent; -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @keyframes ft-fadeIn { 2 | from { 3 | opacity: 0; 4 | } 5 | to { 6 | opacity: 1; 7 | } 8 | } 9 | 10 | @keyframes ft-blurIn { 11 | from { 12 | opacity: 0; 13 | filter: blur(5px); 14 | } 15 | to { 16 | opacity: 1; 17 | filter: blur(0px); 18 | } 19 | } 20 | 21 | @keyframes ft-typewriter { 22 | from { 23 | width: 0; 24 | overflow: hidden; 25 | } 26 | to { 27 | width: fit-content; 28 | } 29 | } 30 | 31 | @keyframes ft-slideInFromLeft { 32 | from { 33 | transform: translateX(-100%); 34 | opacity: 0; 35 | } 36 | to { 37 | transform: translateX(0%); 38 | opacity: 1; 39 | } 40 | } 41 | 42 | @keyframes ft-fadeAndScale { 43 | from { 44 | transform: scale(0.5); 45 | opacity: 0; 46 | } 47 | to { 48 | transform: scale(1); 49 | opacity: 1; 50 | } 51 | } 52 | 53 | @keyframes ft-colorTransition { 54 | from { 55 | color: red; 56 | } 57 | to { 58 | color: black; 59 | } 60 | } 61 | 62 | @keyframes ft-rotateIn { 63 | from { 64 | transform: rotate(-360deg); 65 | opacity: 0; 66 | } 67 | to { 68 | transform: rotate(0deg); 69 | opacity: 1; 70 | } 71 | } 72 | 73 | @keyframes ft-bounceIn { 74 | 0%, 40%, 80%, 100% { 75 | transform: translateY(0); 76 | } 77 | 20% { 78 | transform: translateY(-10%); 79 | } 80 | 60% { 81 | transform: translateY(-5%); 82 | } 83 | } 84 | 85 | @keyframes ft-elastic { 86 | 0%, 100% { 87 | transform: scale(1); 88 | } 89 | 10% { 90 | transform: scale(1.2); 91 | } 92 | } 93 | 94 | @keyframes ft-highlight { 95 | from { 96 | background-color: yellow; 97 | } 98 | to { 99 | background-color: transparent; 100 | } 101 | } 102 | 103 | @keyframes ft-blurAndSharpen { 104 | from { 105 | filter: blur(5px); 106 | opacity: 0; 107 | } 108 | to { 109 | filter: blur(0); 110 | opacity: 1; 111 | } 112 | } 113 | 114 | @keyframes ft-dropIn { 115 | from { 116 | transform: translateY(-10%); 117 | opacity: 0; 118 | } 119 | to { 120 | transform: translateY(0); 121 | opacity: 1; 122 | } 123 | } 124 | 125 | @keyframes ft-slideUp { 126 | from { 127 | transform: translateY(10%); 128 | opacity: 0; 129 | } 130 | to { 131 | transform: translateY(0); 132 | opacity: 1; 133 | } 134 | } 135 | 136 | @keyframes ft-wave { 137 | from { 138 | transform: translateY(0); 139 | } 140 | 50% { 141 | transform: translateY(-10%); 142 | } 143 | to { 144 | transform: translateY(0); 145 | } 146 | } 147 | 148 | :root { 149 | --ft-marker-animation: none; 150 | } 151 | 152 | .ft-custom-li::marker { 153 | animation: var(--ft-marker-animation); 154 | } 155 | 156 | .ft-code-block { 157 | animation: var(--ft-marker-animation); 158 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variables file 76 | .env 77 | .env.* 78 | !.env.example 79 | 80 | # parcel-bundler cache (https://parceljs.org/) 81 | .cache 82 | .parcel-cache 83 | 84 | # Next.js build output 85 | .next 86 | out 87 | 88 | # Nuxt.js build output 89 | .nuxt 90 | dist 91 | 92 | # Remix build output 93 | .cache/ 94 | build/ 95 | public/build/ 96 | 97 | # Docusaurus cache and generated files 98 | .docusaurus 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and not Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # Serverless directories 110 | .serverless/ 111 | 112 | # FuseBox cache 113 | .fusebox/ 114 | 115 | # DynamoDB Local files 116 | .dynamodb/ 117 | 118 | # TernJS port file 119 | .tern-port 120 | 121 | # Stores VSCode versions used for testing VSCode extensions 122 | .vscode-test 123 | 124 | # yarn v2 125 | .yarn/cache 126 | .yarn/unplugged 127 | .yarn/build-state.yml 128 | .yarn/install-state.gz 129 | .pnp.* 130 | 131 | # Tests 132 | test/ 133 | tests/ 134 | __tests__/ 135 | *.test.js 136 | *.spec.js 137 | 138 | # Config files 139 | *.config.js 140 | *.config.ts 141 | *.config.mjs 142 | *.config.cjs 143 | tsconfig.json 144 | jsconfig.json 145 | .eslintrc.js 146 | .prettierrc.js 147 | 148 | # Source directory (if you compile to a different directory like 'dist' or 'lib') 149 | # src/ 150 | 151 | # Build output (adjust if your output directory is different) 152 | dist/ 153 | lib/ 154 | build/ 155 | 156 | # IDE/Editor specific 157 | .vscode/ 158 | .idea/ 159 | *.sublime-project 160 | *.sublime-workspace 161 | 162 | # OS specific 163 | .DS_Store 164 | Thumbs.db 165 | -------------------------------------------------------------------------------- /src/components/DefaultCode.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 4 | import customCodeRenderer from './CodeRenderer'; // Assuming CodeRenderer is in the same directory or adjust path 5 | 6 | interface DefaultCodeProps { 7 | node: any; 8 | className?: string; 9 | children: React.ReactNode & React.ReactNode[]; 10 | style?: React.CSSProperties; // For animationStyle 11 | codeStyle?: any; 12 | animateText: (text: any) => React.ReactNode; 13 | animation?: string; 14 | animationDuration?: string; 15 | animationTimingFunction?: string; 16 | } 17 | 18 | const DefaultCode: React.FC = ({ 19 | node, 20 | className, 21 | children, 22 | style, 23 | codeStyle, 24 | animateText, 25 | animation, 26 | animationDuration, 27 | animationTimingFunction, 28 | ...props 29 | }) => { 30 | const [copied, setCopied] = React.useState(false); 31 | 32 | const handleCopy = () => { 33 | // Ensure children is a string for navigator.clipboard.writeText 34 | const textToCopy = Array.isArray(children) ? children.join('') : String(children); 35 | navigator.clipboard.writeText(textToCopy); 36 | setCopied(true); 37 | setTimeout(() => setCopied(false), 2000); 38 | }; 39 | 40 | if (!className || !className.startsWith("language-")) { 41 | return 42 | {animateText(children)} 43 | ; 44 | } 45 | 46 | return
47 | 74 | 79 | {String(children) /* Ensure children is string for SyntaxHighlighter */} 80 | 81 |
; 82 | }; 83 | 84 | export default DefaultCode; -------------------------------------------------------------------------------- /src/components/AnimatedText.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | 3 | interface StreamingFadeInTextProps { 4 | incomingText: string; // Each new token received for display 5 | animation?: string; // Animation name 6 | sep?: string; // Token separator 7 | } 8 | 9 | const StreamingFadeInText: React.FC = ({ incomingText, animation="", sep="token" }) => { 10 | const [animatingTokens, setAnimatingTokens] = useState<{token: string, id: number}[]>([]); 11 | const [completedTokens, setCompletedTokens] = useState([]); 12 | const lastTokenTime = useRef(performance.now()); 13 | const numId = useRef(0); 14 | const receivedText = useRef(''); 15 | const animationDuration = '0.5s'; 16 | const animationTimingFunction = 'ease-in-out'; 17 | 18 | useEffect(() => { 19 | if (incomingText) { 20 | const textToSplit = incomingText.slice(receivedText.current.length); 21 | 22 | // Split the text and include spaces in the tokens list 23 | let newTokens: string[] = []; 24 | if (sep === 'token') { 25 | newTokens = textToSplit.split(/(\s+)/).filter(token => token.length > 0); 26 | } else if (sep === 'char') { 27 | newTokens = textToSplit.split(''); 28 | // console.log('New tokens:', newTokens); 29 | } else { 30 | throw new Error('Invalid separator'); 31 | } 32 | const newTokenObjects = newTokens.map(token => ({ token, id: numId.current++ })); 33 | if (newTokenObjects.length === 0) return; 34 | newTokenObjects.forEach((token, index) => { 35 | const delay = 10 - (performance.now() - (lastTokenTime.current || 0)); 36 | lastTokenTime.current = Math.max(performance.now() + delay, lastTokenTime.current || 0); 37 | setTimeout(() => { 38 | setAnimatingTokens(prev => [...prev, token]); 39 | }, delay); 40 | }); 41 | // setAnimatingTokens(prev => [...prev, ...newTokenObjects]); 42 | receivedText.current = incomingText; 43 | } 44 | }, [incomingText]); 45 | 46 | // const handleAnimationEnd = (token?: string) => { 47 | // console.log('Animation:', animatingTokens); 48 | // setAnimatingTokens((prev) => { 49 | // const prevToken = prev[0].token; 50 | // console.log('Token:', prevToken); 51 | // setCompletedTokens(prev => [...prev, prevToken]); 52 | // return prev.slice(1); 53 | // }); 54 | // }; 55 | 56 | return ( 57 |
58 | {completedTokens.join('')} 60 | {animatingTokens.map(({token, id}) => { 61 | if (token === '\n') return
; 62 | 63 | return handleAnimationEnd(token)} 74 | > 75 | {token} 76 | 77 | })} 78 |
79 | ); 80 | }; 81 | 82 | export default StreamingFadeInText; -------------------------------------------------------------------------------- /stories/Controls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Controls { 4 | animation: string; 5 | sep: string; 6 | windowSize: number; 7 | delayMultiplier: number; 8 | animationDuration: number; 9 | animationTimingFunction: string; 10 | simulateNetworkIssue: boolean; 11 | generationSpeed: number; 12 | } 13 | 14 | const Controls = ({ controls, setControls }: { controls: Controls, setControls: React.Dispatch> }) => { 15 | const { animation, sep, windowSize, delayMultiplier, animationDuration, animationTimingFunction 16 | } = controls; 17 | 18 | const handleAnimationChange = (e: React.ChangeEvent) => { 19 | setControls({ ...controls, animation: e.target.value }); 20 | }; 21 | 22 | const handleSepChange = (e: React.ChangeEvent) => { 23 | setControls({ ...controls, sep: e.target.value }); 24 | }; 25 | 26 | const handleWindowSizeChange = (e: React.ChangeEvent) => { 27 | setControls({ ...controls, windowSize: parseInt(e.target.value) }); 28 | }; 29 | 30 | const handleDelayMultiplierChange = (e: React.ChangeEvent) => { 31 | setControls({ ...controls, delayMultiplier: parseFloat(e.target.value) }); 32 | }; 33 | 34 | const handleAnimationDurationChange = (e: React.ChangeEvent) => { 35 | setControls({ ...controls, animationDuration: parseFloat(e.target.value) }); 36 | }; 37 | 38 | const handleAnimationTimingFunctionChange = (e: React.ChangeEvent) => { 39 | setControls({ ...controls, animationTimingFunction: e.target.value }); 40 | }; 41 | 42 | return ( 43 |
44 | 63 | 70 | 74 | 78 | 82 | 91 |
92 | ); 93 | } 94 | 95 | export default Controls; 96 | -------------------------------------------------------------------------------- /stories/RandomMarkdownSender.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import AnimatedMarkdown from '../src/components/AnimatedMarkdown'; 3 | import '../src/styles.css'; 4 | import Controls from './Controls'; 5 | 6 | interface RandomTextSenderProps { 7 | initialText: string; 8 | windowSize: number; // Propagate this to SmoothText for consistency 9 | animation?: string; // Animation name 10 | sep?: string; // Token separator 11 | customComponents: { [key: string]: ({ content }: { content: string }) => React.ReactNode }; 12 | htmlComponents?: { [key: string]: ({ content }: { content: string }) => React.ReactNode }; 13 | } 14 | 15 | const RandomTextSender: React.FC = ({ initialText, customComponents, htmlComponents={} }) => { 16 | const [currentText, setCurrentText] = useState(''); 17 | const [remainingTokens, setRemainingTokens] = useState([]); 18 | const [baseLatency, setBaseLatency] = useState(10); 19 | const [tokenCount, setTokenCount] = useState(0); 20 | const [controls, setControls] = useState({ 21 | animation: "fadeIn", 22 | sep: "word", 23 | windowSize: 5, 24 | delayMultiplier: 1.4, 25 | animationDuration: 0.6, 26 | animationTimingFunction: "ease-in-out", 27 | generationSpeed: 3, 28 | simulateNetworkIssue: false 29 | }); 30 | const [slowSection, setSlowSection] = useState(false); 31 | const [numId, setNumId] = useState(0); 32 | // console.log('Controls:', controls); 33 | useEffect(() => { 34 | let extra = 0; 35 | if (tokenCount > 0 && tokenCount % 5 === 0 && controls.simulateNetworkIssue) { 36 | extra = (Math.random() > 0.5 ? 400 : 0); // Randomly choose between 200ms and 800m 37 | } 38 | const newBaseLatency = 1000 / controls.generationSpeed + extra 39 | setBaseLatency(newBaseLatency); 40 | setSlowSection(extra > 0); 41 | }, [tokenCount, controls]); 42 | 43 | useEffect(() => { 44 | //reset the text when the animation changes 45 | setNumId((prev) => prev + 1); 46 | }, [controls]); 47 | 48 | // Function to send a token at random intervals 49 | useEffect(() => { 50 | if (remainingTokens.length > 0) { 51 | // Jitter is up to 100ms more based on windowSize (unused) 52 | const jitter = Math.random() * 5; 53 | const networkDelay = baseLatency + jitter; 54 | 55 | const timeout = setTimeout(() => { 56 | const nextToken = remainingTokens[0]; 57 | setCurrentText(prev => prev ? `${prev} ${nextToken}` : nextToken); 58 | setRemainingTokens(prev => prev.slice(1)); 59 | setTokenCount(prev => prev + 1); // Increment token count 60 | }, networkDelay); 61 | 62 | return () => clearTimeout(timeout); 63 | } else { 64 | // reset the text when the animation changes 65 | setTimeout(() => { 66 | setNumId((prev) => prev + 1); 67 | }, 1000); 68 | } 69 | }, [remainingTokens, baseLatency]); 70 | 71 | // Initialize the tokens 72 | useEffect(() => { 73 | setRemainingTokens(initialText.split(' ')); // Assuming space-separated tokens 74 | setCurrentText(''); 75 | setTokenCount(0); 76 | }, [initialText, numId]); 77 | 78 | const animationDurationString = `${controls.animationDuration}s`; 79 | return ( 80 |
81 |
82 |

FlowToken

83 |
84 | In development 85 | Github 86 |
87 |

FlowToken is a text visualization library to animate and smooth streaming LLM token generation.

88 | 89 |
90 | {slowSection &&

Simulated Network Issue

} 91 |
92 |
93 |
94 | {currentText.length > 0 && 95 | 96 | } 97 |
98 |
99 | ); 100 | }; 101 | 102 | export default RandomTextSender; -------------------------------------------------------------------------------- /src/components/SplitText.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, ReactElement } from 'react'; 2 | 3 | interface TokenWithSource { 4 | text: string; 5 | source: number; 6 | } 7 | 8 | type TokenType = string | TokenWithSource | ReactElement; 9 | 10 | const TokenizedText = ({ input, sep, animation, animationDuration, animationTimingFunction, animationIterationCount }: any) => { 11 | // Track previous input to detect changes 12 | const prevInputRef = useRef(''); 13 | // Track tokens with their source for proper keying in diff mode 14 | const tokensWithSources = useRef([]); 15 | 16 | // For detecting and handling duplicated content 17 | const fullTextRef = useRef(''); 18 | 19 | const tokens = React.useMemo(() => { 20 | if (React.isValidElement(input)) return [input]; 21 | 22 | if (typeof input !== 'string') return null; 23 | 24 | // For diff mode, we need to handle things differently 25 | if (sep === 'diff') { 26 | // If this is the first render or we've gone backward, reset everything 27 | if (!prevInputRef.current || input.length < prevInputRef.current.length) { 28 | tokensWithSources.current = []; 29 | fullTextRef.current = ''; 30 | } 31 | 32 | // Only process input if it's different from previous 33 | if (input !== prevInputRef.current) { 34 | // Find the true unique content by comparing with our tracked full text 35 | // This handles cases where the input contains duplicates 36 | 37 | // First check if we're just seeing the same content repeated 38 | if (input.includes(fullTextRef.current)) { 39 | const uniqueNewContent = input.slice(fullTextRef.current.length); 40 | 41 | // Only add if there's actual new content 42 | if (uniqueNewContent.length > 0) { 43 | tokensWithSources.current.push({ 44 | text: uniqueNewContent, 45 | source: tokensWithSources.current.length 46 | }); 47 | 48 | // Update our full text tracking 49 | fullTextRef.current = input; 50 | } 51 | } else { 52 | // Handle case when input completely changes 53 | // Just take the whole thing as a new token 54 | tokensWithSources.current = [{ 55 | text: input, 56 | source: 0 57 | }]; 58 | fullTextRef.current = input; 59 | } 60 | } 61 | 62 | // Return the tokensWithSources directly 63 | return tokensWithSources.current; 64 | } 65 | 66 | // Original word/char splitting logic 67 | let splitRegex; 68 | if (sep === 'word') { 69 | splitRegex = /(\s+)/; 70 | } else if (sep === 'char') { 71 | splitRegex = /(.)/; 72 | } else { 73 | throw new Error('Invalid separator: must be "word", "char", or "diff"'); 74 | } 75 | 76 | return input.split(splitRegex).filter(token => token.length > 0); 77 | }, [input, sep]); 78 | 79 | // Update previous input after processing 80 | useEffect(() => { 81 | if (typeof input === 'string') { 82 | prevInputRef.current = input; 83 | } 84 | }, [input]); 85 | 86 | // Helper function to check if token is a TokenWithSource type 87 | const isTokenWithSource = (token: TokenType): token is TokenWithSource => { 88 | return token !== null && typeof token === 'object' && 'text' in token && 'source' in token; 89 | }; 90 | 91 | return ( 92 | <> 93 | {tokens?.map((token, index) => { 94 | // Determine the key and text based on token type 95 | let key = index; 96 | let text = ''; 97 | 98 | if (isTokenWithSource(token)) { 99 | key = token.source; 100 | text = token.text; 101 | } else if (typeof token === 'string') { 102 | key = index; 103 | text = token; 104 | } else if (React.isValidElement(token)) { 105 | key = index; 106 | text = ''; 107 | return React.cloneElement(token, { key }); 108 | } 109 | 110 | return ( 111 | 119 | {text} 120 | 121 | ); 122 | })} 123 | 124 | ); 125 | }; 126 | 127 | export default TokenizedText; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FlowToken 🌊 2 | ### A smooth Animation Library for LLM Text Streaming 3 | 4 | ![flow token demo](https://nextjs-omega-five-46.vercel.app/demo.gif) 5 | 6 | FlowToken is a React component library designed to enhance the visual presentation of text streaming from large language models (LLMs). This library offers a variety of animations that make the text appear smoothly and dynamically, providing an engaging user experience. 7 | 8 | ## Demo 9 | 10 | Try the demo here: [Demo link](https://nextjs-omega-five-46.vercel.app/) 11 | 12 | ## Features 13 | 14 | FlowToken includes several key features: 15 | 16 | - **Customizable Animations:** A range of animations such as fade, blur-in, drop-in, slide from the left, typewriter effect, word pull-up, flip text, gradual spacing, and more. 17 | - **Smooth Text Streaming:** Options to control the speed and manner of text appearance to handle the variability in text generation speed. 18 | - **Responsive and Lightweight:** Optimized for performance and compatibility across all modern browsers. 19 | 20 | ## Installation 21 | 22 | Install FlowToken using npm: 23 | 24 | ```bash 25 | npm install flowtoken 26 | ``` 27 | 28 | Or using yarn: 29 | 30 | ```bash 31 | yarn add flowtoken 32 | ``` 33 | 34 | ## Usage 35 | 36 | ## Markdown Support 37 | 38 | To use markdown, import the `AnimatedMarkdown` component. 39 | 40 | ```jsx 41 | import React from 'react'; 42 | 43 | import { AnimatedMarkdown } from 'flowtoken'; 44 | // import the flowtoken css in order to use the animations 45 | import 'flowtoken/dist/styles.css'; 46 | 47 | const App = () => { 48 | return ( 49 | 55 | ); 56 | }; 57 | 58 | export default App; 59 | ``` 60 | 61 | ### Real World with Vercel AI SDK 62 | 63 | ```jsx 64 | 'use client' 65 | 66 | import { useChat } from 'ai/react' 67 | import { AnimatedMarkdown } from 'flowtoken'; 68 | import 'flowtoken/dist/styles.css'; 69 | 70 | export default function Chat() { 71 | const { messages, input, handleInputChange, handleSubmit } = useChat() 72 | 73 | return ( 74 |
75 | {messages.map(m => ( 76 |
77 | {m.role}: 82 |
83 | ))} 84 | 85 |
86 | 93 |
94 |
95 | ) 96 | } 97 | ``` 98 | 99 | ### Custom Components 100 | 101 | You can use custom components by passing a `customComponents` prop to the `AnimatedMarkdown` component where the key is xml tag (ex. `MyComponent`) to match and the value is the component to render. Then just prompt your LLM to output the custom component syntax and it will be rendered with your custom component. 102 | 103 | ```jsx 104 | const customComponents = { 105 | 'customcomponent': ({ animateText, node, children, ...props }: any) => { 106 | return ( 107 | <> 108 | {animateText(
{children}
)} 109 | 110 | ) 111 | }, 112 | } 113 | ... 114 | 115 | ``` 116 | 117 | #### Example 118 | 119 | This is an example of a custom component. 120 | 121 | 122 | ### AnimatedMarkdown Props 123 | 124 | - **content** (string): The text to be displayed. 125 | - **sep** (`"word"` | `"char"`): How to split and animate the content. Defaults to `"word"`. 126 | - **animation** (string | `null`): Name of the CSS animation to apply (e.g. `fadeIn`, `dropIn`). Set to `null` to disable animations on completed messages. 127 | - **animationDuration** (string): CSS duration of the animation (e.g. `0.6s`). 128 | - **animationTimingFunction** (string): CSS timing function for the animation (e.g. `ease`, `ease-in-out`). 129 | - **codeStyle** (object): The syntax-highlighter style object to use for code blocks. 130 | - **customComponents** (Record): 131 | Map of regex patterns or custom tag names to React components. Use this to render arbitrary LLM-emitted syntax. 132 | - **imgHeight** (string): Default height for rendered images (e.g. `200px`). 133 | 134 | ## Animations 135 | 136 | FlowToken supports various CSS animations: 137 | - **fadeIn** 138 | - **blurIn** 139 | - **typewriter** 140 | - **slideInFromLeft** 141 | - **fadeAndScale** 142 | - **rotateIn** 143 | - **bounceIn** 144 | - **elastic** 145 | - **highlight** 146 | - **blurAndSharpen** 147 | - **dropIn** 148 | - **slideUp** 149 | - **wave** 150 | 151 | For custom animations, define your keyframes in CSS wrap it in a class and pass the animation name to the `animation` prop. 152 | 153 | ```css 154 | /* custom-styles.css */ 155 | 156 | @keyframes custom-animation { 157 | from { 158 | opacity: 0; 159 | } 160 | to { 161 | opacity: 1; 162 | } 163 | } 164 | 165 | .custom-animation { 166 | animation: custom-animation 1s ease-in-out; 167 | } 168 | ``` 169 | 170 | ```jsx 171 | import 'custom-styles.css'; 172 | ... 173 | 174 | ``` 175 | 176 | ### Notes 177 | 178 | To lower the memory footprint, disable animations by setting the `animation` parameter to `null` on any completed messages. 179 | 180 | If using tailwind with generated markdown, be sure to setup tailwind typography: [https://github.com/tailwindlabs/tailwindcss-typography](here) 181 | 182 | and add `prose lg:prose-md prose-pre:p-0 prose-pre:m-0 prose-pre:bg-transparent` to your flowtoken markdown container. 183 | 184 | ## Contributing 185 | 186 | Contributions are welcome! Please feel free to submit pull requests or open issues to suggest features or report bugs. 187 | 188 | ## License 189 | 190 | FlowToken is MIT licensed. 191 | -------------------------------------------------------------------------------- /stories/Text.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import StreamingFadeInText from '../src/components/AnimatedText'; 3 | 4 | interface RandomTextSenderProps { 5 | initialText: string; 6 | windowSize: number; // Propagate this to SmoothText for consistency 7 | animation?: string; // Animation name 8 | sep?: string; // Token separator 9 | } 10 | 11 | const RandomTextSender: React.FC = ({ initialText, windowSize, animation, sep }) => { 12 | const [currentText, setCurrentText] = useState(''); 13 | const [remainingTokens, setRemainingTokens] = useState([]); 14 | const [baseLatency, setBaseLatency] = useState(100); 15 | const [tokenCount, setTokenCount] = useState(0); 16 | 17 | // Initialize the tokens 18 | useEffect(() => { 19 | setRemainingTokens(initialText.split(' ')); // Assuming space-separated tokens 20 | }, [initialText]); 21 | 22 | // Update base latency every 10 tokens 23 | useEffect(() => { 24 | if (tokenCount > 0 && tokenCount % 10 === 0) { 25 | const newBaseLatency = baseLatency + (Math.random() > 0.5 ? 20 : 0); // Randomly choose between 200ms and 800ms 26 | setBaseLatency(newBaseLatency); 27 | console.log(`Base latency updated to: ${newBaseLatency}ms`); 28 | } 29 | }, [tokenCount]); 30 | 31 | // Function to send a token at random intervals 32 | useEffect(() => { 33 | if (remainingTokens.length > 0) { 34 | // Jitter is up to 100ms more based on windowSize (unused) 35 | const jitter = Math.random() * 10; 36 | const networkDelay = baseLatency + jitter; 37 | 38 | const timeout = setTimeout(() => { 39 | const nextToken = remainingTokens[0]; 40 | setCurrentText(prev => prev ? `${prev} ${nextToken}` : nextToken); 41 | setRemainingTokens(prev => prev.slice(1)); 42 | setTokenCount(prev => prev + 1); // Increment token count 43 | }, networkDelay); 44 | 45 | return () => clearTimeout(timeout); 46 | } 47 | }, [remainingTokens, baseLatency, windowSize]); 48 | 49 | return ( 50 |
51 | {/* */} 52 | 53 | {/*
54 | {currentText} 55 |
*/} 56 |
57 | ); 58 | }; 59 | 60 | // This is the default export that defines the component title and other configuration 61 | export default { 62 | title: 'Components/FadeIn', 63 | component: RandomTextSender, 64 | }; 65 | 66 | const text = `To be, or not to be, that is the question: 67 | Whether 'tis nobler in the mind to suffer 68 | The slings and arrows of outrageous fortune, 69 | Or to take arms against a sea of troubles 70 | And by opposing end them. To die—to sleep, 71 | No more; and by a sleep to say we end 72 | The heart-ache and the thousand natural shocks 73 | That flesh is heir to: 'tis a consummation 74 | Devoutly to be wish'd. To die, to sleep; 75 | To sleep, perchance to dream—ay, there's the rub: 76 | For in that sleep of death what dreams may come, 77 | When we have shuffled off this mortal coil, 78 | Must give us pause—there's the respect 79 | That makes calamity of so long life. 80 | For who would bear the whips and scorns of time, 81 | Th'oppressor's wrong, the proud man's contumely, 82 | The pangs of dispriz'd love, the law's delay, 83 | The insolence of office, and the spurns 84 | That patient merit of th'unworthy takes, 85 | When he himself might his quietus make 86 | With a bare bodkin? Who would fardels bear, 87 | To grunt and sweat under a weary life, 88 | But that the dread of something after death, 89 | The undiscovere'd country, from whose bourn 90 | No traveller returns, puzzles the will, 91 | And makes us rather bear those ills we have 92 | Than fly to others that we know not of? 93 | Thus conscience doth make cowards of us all, 94 | And thus the native hue of resolution 95 | Is sicklied o'er with the pale cast of thought, 96 | And enterprises of great pith and moment 97 | With this regard their currents turn awry 98 | And lose the name of action. 99 | ` 100 | 101 | // Here we define a "story" for the default view of SmoothText 102 | export const Default = () => ; 103 | export const DefaultChar = () => ; 104 | 105 | // You can add more stories to showcase different props or states 106 | export const fadeIn = () => ; 107 | export const AllAtOnceFadeIn = () => ; 108 | 109 | export const blurIn = () => ; 110 | export const blurInChar = () => ; 111 | 112 | // export const typewriter = () => ; 113 | 114 | export const slideInFromLeft = () => ; 115 | export const slideInFromLeftChar = () => ; 116 | 117 | export const fadeAndScale = () => ; 118 | export const fadeAndScaleChar = () => ; 119 | 120 | export const colorTransition = () => ; 121 | 122 | export const rotateIn = () => ; 123 | export const rotateInChar = () => ; 124 | 125 | export const bounceIn = () => ; 126 | export const bounceInChar = () => ; 127 | 128 | export const elastic = () => ; 129 | export const elasticChar = () => ; 130 | 131 | export const highlight = () => ; 132 | export const highlightChar = () => ; 133 | 134 | export const blurAndSharpen = () => ; 135 | 136 | export const wave = () => ; 137 | export const waveChar = () => ; 138 | 139 | export const dropIn = () => ; 140 | export const dropInChar = () => ; -------------------------------------------------------------------------------- /src/components/AnimatedMarkdown.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | import ReactMarkdown from 'react-markdown'; 4 | import remarkGfm from 'remark-gfm' 5 | import rehypeRaw from 'rehype-raw'; 6 | import style from 'react-syntax-highlighter/dist/esm/styles/hljs/docco' 7 | import TokenizedText from './SplitText'; 8 | import AnimatedImage from './AnimatedImage'; 9 | import { animations } from '../utils/animations'; 10 | import DefaultCode from './DefaultCode'; 11 | 12 | interface MarkdownAnimateTextProps { 13 | content: string; 14 | sep?: string; 15 | animation?: string; 16 | animationDuration?: string; 17 | animationTimingFunction?: string; 18 | codeStyle?: any; 19 | customComponents?: Record; 20 | imgHeight?: string; 21 | } 22 | 23 | const MarkdownAnimateText: React.FC = ({ 24 | content, 25 | sep = "diff", 26 | animation: animationName = "fadeIn", 27 | animationDuration = "1s", 28 | animationTimingFunction = "ease-in-out", 29 | codeStyle=null, 30 | customComponents = {}, 31 | imgHeight = '20rem' 32 | }) => { 33 | const animation = animations[animationName as keyof typeof animations] || animationName; 34 | 35 | codeStyle = codeStyle || style.docco; 36 | const animationStyle: any 37 | = { 38 | 'animation': `${animation} ${animationDuration} ${animationTimingFunction}`, 39 | }; 40 | 41 | // Enhanced hidePartialCustomComponents function that also handles tag attributes 42 | const hidePartialCustomComponents = React.useCallback((input: string): React.ReactNode => { 43 | if (!input || Object.keys(customComponents).length === 0) return input; 44 | 45 | // Check for any opening tag without a closing '>' 46 | const lastOpeningBracketIndex = input.lastIndexOf('<'); 47 | if (lastOpeningBracketIndex !== -1) { 48 | const textAfterLastOpeningBracket = input.substring(lastOpeningBracketIndex); 49 | 50 | // If there's no closing bracket, then it's potentially a partial tag 51 | if (!textAfterLastOpeningBracket.includes('>')) { 52 | // Check if it starts with any of our custom component names 53 | for (const tag of Object.keys(customComponents)) { 54 | // Check if the text starts with the tag name (allowing for partial tag name) 55 | // For example, ') => React.ReactNode = React.useCallback((text: string | Array) => { 72 | text = Array.isArray(text) ? text : [text]; 73 | let keyCounter = 0; 74 | const processText: (input: any, keyPrefix?: string) => React.ReactNode = (input: any, keyPrefix: string = 'item') => { 75 | if (Array.isArray(input)) { 76 | // Process each element in the array 77 | return input.map((element, index) => ( 78 | 79 | {processText(element, `${keyPrefix}-${index}`)} 80 | 81 | )); 82 | } else if (typeof input === 'string') { 83 | // if (!animation) return input; 84 | return ; 93 | } else { 94 | // Return non-string, non-element inputs unchanged (null, undefined, etc.) 95 | return 103 | {input} 104 | ; 105 | } 106 | }; 107 | if (!animation) { 108 | return text; 109 | } 110 | return processText(text); 111 | }, [animation, animationDuration, animationTimingFunction, sep, hidePartialCustomComponents]); 112 | 113 | // Memoize components object to avoid redefining components unnecessarily 114 | const components: any 115 | = React.useMemo(() => ({ 116 | text: ({ node, ...props }: any) => animateText(props.children), 117 | h1: ({ node, ...props }: any) =>

{animateText(props.children)}

, 118 | h2: ({ node, ...props }: any) =>

{animateText(props.children)}

, 119 | h3: ({ node, ...props }: any) =>

{animateText(props.children)}

, 120 | h4: ({ node, ...props }: any) =>

{animateText(props.children)}

, 121 | h5: ({ node, ...props }: any) =>
{animateText(props.children)}
, 122 | h6: ({ node, ...props }: any) =>
{animateText(props.children)}
, 123 | p: ({ node, ...props }: any) =>

{animateText(props.children)}

, 124 | li: ({ node, ...props }: any) =>
  • {animateText(props.children)}
  • , 125 | a: ({ node, ...props }: any) => {animateText(props.children)}, 126 | strong: ({ node, ...props }: any) => {animateText(props.children)}, 127 | em: ({ node, ...props }: any) => {animateText(props.children)}, 128 | code: ({ node, className, children, ...props }: any) => { 129 | return 140 | {children} 141 | ; 142 | }, 143 | hr: ({ node, ...props }: any) =>
    , 150 | img: ({ node, ...props }: any) => , 151 | table: ({ node, ...props }: any) => {props.children}
    , 152 | tr: ({ node, ...props }: any) => {animateText(props.children)}, 153 | td: ({ node, ...props }: any) => {animateText(props.children)}, 154 | ...Object.entries(customComponents).reduce((acc, [key, value]) => { 155 | acc[key] = (elements: any) => value({...elements, animateText}); 156 | return acc; 157 | }, {} as Record React.ReactNode>), 158 | }), [animateText, customComponents, animation, animationDuration, animationTimingFunction]); 159 | 160 | return 161 | {content} 162 | ; 163 | }; 164 | 165 | export default MarkdownAnimateText; --------------------------------------------------------------------------------