Compare commits

...

3 Commits

Author SHA1 Message Date
MyPrototypeWhat
d7e79353fc feat: add fadeInWithBlur animation to Tailwind CSS and update Markdown component
- Introduced a new `fadeInWithBlur` keyframe animation in Tailwind CSS for enhanced visual effects.
- Removed inline fade animation styles from the `Markdown` component to streamline rendering.
- Updated the `SmoothFade` function to utilize the new animation, improving the user experience during content transitions.
2025-09-26 19:34:49 +08:00
MyPrototypeWhat
93e972a5da chore: remove @radix-ui/react-slot dependency and update utility functions
- Removed `@radix-ui/react-slot` dependency from package.json and corresponding entries in yarn.lock to streamline dependencies.
- Adjusted the `PlaceholderBlock` component's margin styling for improved layout.
- Refactored utility functions by exporting `cn` from `@heroui/react`, enhancing class name management.
2025-09-26 19:17:19 +08:00
MyPrototypeWhat
12d08e4748 feat: add Radix UI slot component and enhance Markdown rendering
- Added `@radix-ui/react-slot` dependency to package.json for improved component composition.
- Introduced a new `Loader` component with various loading styles to enhance user experience during asynchronous operations.
- Updated `PlaceholderBlock` to utilize the new `Loader` component, improving loading state representation.
- Enhanced `Markdown` component to support smooth fade animations based on streaming status, improving visual feedback during content updates.
- Refactored utility functions to include a new `cn` function for class name merging, streamlining component styling.
2025-09-26 17:46:51 +08:00
6 changed files with 423 additions and 32 deletions

View File

@@ -163,6 +163,13 @@
}
}
@layer components {
@keyframes fadeInWithBlur {
from { opacity: 0; filter: blur(2px); }
to { opacity: 1; filter: blur(0px); }
}
}
:root {
background-color: unset;
}

View File

@@ -12,6 +12,7 @@ import { removeSvgEmptyLines } from '@renderer/utils/formats'
import { processLatexBrackets } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import { type FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
@@ -64,6 +65,8 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
initialText: block.content
})
const isStreaming = block.status === 'streaming'
useEffect(() => {
const newContent = block.content || ''
const oldContent = prevContentRef.current || ''
@@ -85,9 +88,8 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
prevBlockIdRef.current = block.id
// 更新 stream 状态
const isStreaming = block.status === 'streaming'
setIsStreamDone(!isStreaming)
}, [block.content, block.id, block.status, addChunk, reset])
}, [block.content, block.id, block.status, addChunk, reset, isStreaming])
const remarkPlugins = useMemo(() => {
const plugins = [
@@ -130,14 +132,16 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
table: (props: any) => <Table {...props} blockId={block.id} />,
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
p: (props) => {
p: SmoothFade((props) => {
const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
if (hasImage) return <div {...props} />
return <p {...props} />
},
svg: MarkdownSvgRenderer
}, isStreaming),
svg: MarkdownSvgRenderer,
li: SmoothFade((props) => <li {...props} />, isStreaming),
span: SmoothFade((props) => <span {...props} />, isStreaming)
} as Partial<Components>
}, [block.id])
}, [block.id, isStreaming])
if (/<style\b[^>]*>/i.test(messageContent)) {
components.style = MarkdownShadowDOMRenderer as any
@@ -168,3 +172,23 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
}
export default memo(Markdown)
const SmoothFade = (Comp: React.ElementType, isStreaming: boolean) => {
const handleAnimationEnd = (e: React.AnimationEvent) => {
// 动画结束后移除类名
if (e.animationName === 'fadeInWithBlur') {
e.currentTarget.classList.remove('animate-[fadeInWithBlur_500ms_ease-out_forwards]')
e.currentTarget.classList.remove('opacity-0')
}
}
return ({ children, ...rest }) => {
return (
<Comp
{...rest}
className={isStreaming ? 'animate-[fadeInWithBlur_500ms_ease-out_forwards] opacity-0' : ''}
onAnimationEnd={handleAnimationEnd}>
{children}
</Comp>
)
}
}

View File

@@ -1,27 +1,20 @@
import { Spinner } from '@heroui/react'
import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { Loader } from '@renderer/ui/loader'
import React from 'react'
import styled from 'styled-components'
interface PlaceholderBlockProps {
block: PlaceholderMessageBlock
status: MessageBlockStatus
type: MessageBlockType
}
const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ block }) => {
if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) {
const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ status, type }) => {
if (status === MessageBlockStatus.PROCESSING && type === MessageBlockType.UNKNOWN) {
return (
<MessageContentLoading>
<Spinner color="current" variant="dots" />
</MessageContentLoading>
<div className="-mt-2">
<Loader variant="terminal" />
</div>
)
}
return null
}
const MessageContentLoading = styled.div`
display: flex;
flex-direction: row;
align-items: center;
height: 32px;
margin-top: -5px;
margin-bottom: 5px;
`
export default React.memo(PlaceholderBlock)

View File

@@ -215,15 +215,7 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
})}
{isProcessing && (
<AnimatedBlockWrapper key="message-loading-placeholder" enableAnimation={true}>
<PlaceholderBlock
block={{
id: `loading-${message.id}`,
messageId: message.id,
type: MessageBlockType.UNKNOWN,
status: MessageBlockStatus.PROCESSING,
createdAt: new Date().toISOString()
}}
/>
<PlaceholderBlock type={MessageBlockType.UNKNOWN} status={MessageBlockStatus.PROCESSING} />
</AnimatedBlockWrapper>
)}
</AnimatePresence>

View File

@@ -0,0 +1,374 @@
import { cn } from '@renderer/utils/index'
export interface LoaderProps {
variant?:
| 'circular'
| 'classic'
| 'pulse'
| 'pulse-dot'
| 'dots'
| 'typing'
| 'wave'
| 'bars'
| 'terminal'
| 'text-blink'
| 'text-shimmer'
| 'loading-dots'
size?: 'sm' | 'md' | 'lg'
text?: string
className?: string
}
export function CircularLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'size-4',
md: 'size-5',
lg: 'size-6'
}
return (
<div
className={cn(
'animate-spin rounded-full border-2 border-primary border-t-transparent',
sizeClasses[size],
className
)}>
<span className="sr-only">Loading</span>
</div>
)
}
export function ClassicLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'size-4',
md: 'size-5',
lg: 'size-6'
}
const barSizes = {
sm: { height: '6px', width: '1.5px' },
md: { height: '8px', width: '2px' },
lg: { height: '10px', width: '2.5px' }
}
return (
<div className={cn('relative', sizeClasses[size], className)}>
<div className="absolute h-full w-full">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="absolute animate-[spinner-fade_1.2s_linear_infinite] rounded-full bg-primary"
style={{
top: '0',
left: '50%',
marginLeft: size === 'sm' ? '-0.75px' : size === 'lg' ? '-1.25px' : '-1px',
transformOrigin: `${size === 'sm' ? '0.75px' : size === 'lg' ? '1.25px' : '1px'} ${size === 'sm' ? '10px' : size === 'lg' ? '14px' : '12px'}`,
transform: `rotate(${i * 30}deg)`,
opacity: 0,
animationDelay: `${i * 0.1}s`,
height: barSizes[size].height,
width: barSizes[size].width
}}
/>
))}
</div>
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'size-4',
md: 'size-5',
lg: 'size-6'
}
return (
<div className={cn('relative', sizeClasses[size], className)}>
<div className="absolute inset-0 animate-[thin-pulse_1.5s_ease-in-out_infinite] rounded-full border-2 border-primary" />
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseDotLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'size-1',
md: 'size-2',
lg: 'size-3'
}
return (
<div
className={cn(
'animate-[pulse-dot_1.2s_ease-in-out_infinite] rounded-full bg-primary',
sizeClasses[size],
className
)}>
<span className="sr-only">Loading</span>
</div>
)
}
export function DotsLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const dotSizes = {
sm: 'h-1.5 w-1.5',
md: 'h-2 w-2',
lg: 'h-2.5 w-2.5'
}
const containerSizes = {
sm: 'h-4',
md: 'h-5',
lg: 'h-6'
}
return (
<div className={cn('flex items-center space-x-1', containerSizes[size], className)}>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn('animate-[bounce-dots_1.4s_ease-in-out_infinite] rounded-full bg-primary', dotSizes[size])}
style={{
animationDelay: `${i * 160}ms`
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function TypingLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const dotSizes = {
sm: 'h-1 w-1',
md: 'h-1.5 w-1.5',
lg: 'h-2 w-2'
}
const containerSizes = {
sm: 'h-4',
md: 'h-5',
lg: 'h-6'
}
return (
<div className={cn('flex items-center space-x-1', containerSizes[size], className)}>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn('animate-[typing_1s_infinite] rounded-full bg-primary', dotSizes[size])}
style={{
animationDelay: `${i * 250}ms`
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function WaveLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const barWidths = {
sm: 'w-0.5',
md: 'w-0.5',
lg: 'w-1'
}
const containerSizes = {
sm: 'h-4',
md: 'h-5',
lg: 'h-6'
}
const heights = {
sm: ['6px', '9px', '12px', '9px', '6px'],
md: ['8px', '12px', '16px', '12px', '8px'],
lg: ['10px', '15px', '20px', '15px', '10px']
}
return (
<div className={cn('flex items-center gap-0.5', containerSizes[size], className)}>
{[...Array(5)].map((_, i) => (
<div
key={i}
className={cn('animate-[wave_1s_ease-in-out_infinite] rounded-full bg-primary', barWidths[size])}
style={{
animationDelay: `${i * 100}ms`,
height: heights[size][i]
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function BarsLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const barWidths = {
sm: 'w-1',
md: 'w-1.5',
lg: 'w-2'
}
const containerSizes = {
sm: 'h-4 gap-1',
md: 'h-5 gap-1.5',
lg: 'h-6 gap-2'
}
return (
<div className={cn('flex', containerSizes[size], className)}>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn('h-full animate-[wave-bars_1.2s_ease-in-out_infinite] bg-primary', barWidths[size])}
style={{
animationDelay: `${i * 0.2}s`
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function TerminalLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const cursorSizes = {
sm: 'h-3 w-1.5',
md: 'h-4 w-2',
lg: 'h-5 w-2.5'
}
const textSizes = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}
const containerSizes = {
sm: 'h-4',
md: 'h-5',
lg: 'h-6'
}
return (
<div className={cn('flex items-center space-x-1', containerSizes[size], className)}>
<span className={cn('font-mono text-primary', textSizes[size])}>{'>'}</span>
<div className={cn('animate-[blink_1s_step-end_infinite] bg-primary', cursorSizes[size])} />
<span className="sr-only">Loading</span>
</div>
)
}
export function TextBlinkLoader({
text = 'Thinking',
className,
size = 'md'
}: {
text?: string
className?: string
size?: 'sm' | 'md' | 'lg'
}) {
const textSizes = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}
return (
<div className={cn('animate-[text-blink_2s_ease-in-out_infinite] font-medium', textSizes[size], className)}>
{text}
</div>
)
}
export function TextShimmerLoader({
text = 'Thinking',
className,
size = 'md'
}: {
text?: string
className?: string
size?: 'sm' | 'md' | 'lg'
}) {
const textSizes = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}
return (
<div
className={cn(
'bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]',
'bg-[200%_auto] bg-clip-text font-medium text-transparent',
'animate-[shimmer_4s_infinite_linear]',
textSizes[size],
className
)}>
{text}
</div>
)
}
export function TextDotsLoader({
className,
text = 'Thinking',
size = 'md'
}: {
className?: string
text?: string
size?: 'sm' | 'md' | 'lg'
}) {
const textSizes = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}
return (
<div className={cn('inline-flex items-center', className)}>
<span className={cn('font-medium text-primary', textSizes[size])}>{text}</span>
<span className="inline-flex">
<span className="animate-[loading-dots_1.4s_infinite_0.2s] text-primary">.</span>
<span className="animate-[loading-dots_1.4s_infinite_0.4s] text-primary">.</span>
<span className="animate-[loading-dots_1.4s_infinite_0.6s] text-primary">.</span>
</span>
</div>
)
}
function Loader({ variant = 'circular', size = 'md', text, className }: LoaderProps) {
switch (variant) {
case 'circular':
return <CircularLoader size={size} className={className} />
case 'classic':
return <ClassicLoader size={size} className={className} />
case 'pulse':
return <PulseLoader size={size} className={className} />
case 'pulse-dot':
return <PulseDotLoader size={size} className={className} />
case 'dots':
return <DotsLoader size={size} className={className} />
case 'typing':
return <TypingLoader size={size} className={className} />
case 'wave':
return <WaveLoader size={size} className={className} />
case 'bars':
return <BarsLoader size={size} className={className} />
case 'terminal':
return <TerminalLoader size={size} className={className} />
case 'text-blink':
return <TextBlinkLoader text={text} size={size} className={className} />
case 'text-shimmer':
return <TextShimmerLoader text={text} size={size} className={className} />
case 'loading-dots':
return <TextDotsLoader text={text} size={size} className={className} />
default:
return <CircularLoader size={size} className={className} />
}
}
export { Loader }

View File

@@ -234,3 +234,4 @@ export * from './match'
export * from './naming'
export * from './sort'
export * from './style'
export { cn } from '@heroui/react'