🎨 style: Address Minor UI Refresh Issues (#6552)

* 🎨 style: Adjust isSelected svg layout of ModelSpecItem

* style: fix modelSpec URL image beeing off-center; style: selected svg centered vertically

* style: Update CustomMenu component to use rounded-lg and enhance focus styles

* style: SidePanel top padding same as NewChat

* fix: prevent unnecessary space rendering in SplitText component

* style: Fix class names and enhance layout in Badge components

* feat: disable temporary chat when in chat

* style: handle > 1 lines in title Landing

* feat: enhance dynamic margin calculation based on line count and content height in Landing component
This commit is contained in:
Marco Beretta 2025-03-26 23:57:29 +01:00 committed by GitHub
parent 6b58547c63
commit 3ba7c4eb19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 147 additions and 40 deletions

View file

@ -17,11 +17,13 @@ import store from '~/store';
interface BadgeRowProps { interface BadgeRowProps {
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void; onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
onToggle?: (badgeId: string, currentActive: boolean) => void; onToggle?: (badgeId: string, currentActive: boolean) => void;
isInChat: boolean;
} }
interface BadgeWrapperProps { interface BadgeWrapperProps {
badge: BadgeItem; badge: BadgeItem;
isEditing: boolean; isEditing: boolean;
isInChat: boolean;
onToggle: (badge: BadgeItem) => void; onToggle: (badge: BadgeItem) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onMouseDown: (e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => void; onMouseDown: (e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => void;
@ -30,7 +32,7 @@ interface BadgeWrapperProps {
const BadgeWrapper = React.memo( const BadgeWrapper = React.memo(
forwardRef<HTMLDivElement, BadgeWrapperProps>( forwardRef<HTMLDivElement, BadgeWrapperProps>(
({ badge, isEditing, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => { ({ badge, isEditing, isInChat, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => {
const isActive = badge.atom ? useRecoilValue(badge.atom) : false; const isActive = badge.atom ? useRecoilValue(badge.atom) : false;
return ( return (
@ -49,11 +51,13 @@ const BadgeWrapper = React.memo(
className={isEditing ? 'ios-wiggle badge-icon h-full' : 'badge-icon h-full'} className={isEditing ? 'ios-wiggle badge-icon h-full' : 'badge-icon h-full'}
> >
<Badge <Badge
id={badge.id}
icon={badge.icon as LucideIcon} icon={badge.icon as LucideIcon}
label={badge.label} label={badge.label}
isActive={isActive} isActive={isActive}
isEditing={isEditing} isEditing={isEditing}
isAvailable={badge.isAvailable} isAvailable={badge.isAvailable}
isInChat={isInChat}
onToggle={() => onToggle(badge)} onToggle={() => onToggle(badge)}
onBadgeAction={() => onDelete(badge.id)} onBadgeAction={() => onDelete(badge.id)}
/> />
@ -64,6 +68,7 @@ const BadgeWrapper = React.memo(
(prevProps, nextProps) => (prevProps, nextProps) =>
prevProps.badge.id === nextProps.badge.id && prevProps.badge.id === nextProps.badge.id &&
prevProps.isEditing === nextProps.isEditing && prevProps.isEditing === nextProps.isEditing &&
prevProps.isInChat === nextProps.isInChat &&
prevProps.onToggle === nextProps.onToggle && prevProps.onToggle === nextProps.onToggle &&
prevProps.onDelete === nextProps.onDelete && prevProps.onDelete === nextProps.onDelete &&
prevProps.onMouseDown === nextProps.onMouseDown && prevProps.onMouseDown === nextProps.onMouseDown &&
@ -121,7 +126,7 @@ const dragReducer = (state: DragState, action: DragAction): DragState => {
} }
}; };
export function BadgeRow({ onChange, onToggle }: BadgeRowProps) { export function BadgeRow({ onChange, onToggle, isInChat }: BadgeRowProps) {
const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]); const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]);
const [dragState, dispatch] = useReducer(dragReducer, { const [dragState, dispatch] = useReducer(dragReducer, {
draggedBadge: null, draggedBadge: null,
@ -301,17 +306,20 @@ export function BadgeRow({ onChange, onToggle }: BadgeRowProps) {
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && ( {dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
<div className="badge-icon h-full"> <div className="badge-icon h-full">
<Badge <Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon} icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label} label={ghostBadge.label}
isActive={dragState.draggedBadgeActive} isActive={dragState.draggedBadgeActive}
isEditing={isEditing} isEditing={isEditing}
isAvailable={ghostBadge.isAvailable} isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/> />
</div> </div>
)} )}
<BadgeWrapper <BadgeWrapper
badge={badge} badge={badge}
isEditing={isEditing} isEditing={isEditing}
isInChat={isInChat}
onToggle={handleBadgeToggle} onToggle={handleBadgeToggle}
onDelete={handleDelete} onDelete={handleDelete}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@ -322,11 +330,13 @@ export function BadgeRow({ onChange, onToggle }: BadgeRowProps) {
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && ( {dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
<div className="badge-icon h-full"> <div className="badge-icon h-full">
<Badge <Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon} icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label} label={ghostBadge.label}
isActive={dragState.draggedBadgeActive} isActive={dragState.draggedBadgeActive}
isEditing={isEditing} isEditing={isEditing}
isAvailable={ghostBadge.isAvailable} isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/> />
</div> </div>
)} )}
@ -343,10 +353,12 @@ export function BadgeRow({ onChange, onToggle }: BadgeRowProps) {
}} }}
> >
<Badge <Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon} icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label} label={ghostBadge.label}
isActive={dragState.draggedBadgeActive} isActive={dragState.draggedBadgeActive}
isAvailable={ghostBadge.isAvailable} isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
isEditing isEditing
isDragging isDragging
/> />

View file

@ -278,7 +278,12 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}> <div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
<AttachFileChat disableInputs={disableInputs} /> <AttachFileChat disableInputs={disableInputs} />
</div> </div>
<BadgeRow onChange={(newBadges) => setBadges(newBadges)} /> <BadgeRow
onChange={(newBadges) => setBadges(newBadges)}
isInChat={
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
}
/>
<div className="mx-auto flex" /> <div className="mx-auto flex" />
{SpeechToText && ( {SpeechToText && (
<AudioRecorder <AudioRecorder

View file

@ -1,5 +1,5 @@
import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
import { easings } from '@react-spring/web'; import { easings } from '@react-spring/web';
import { useMemo, useCallback } from 'react';
import { EModelEndpoint } from 'librechat-data-provider'; import { EModelEndpoint } from 'librechat-data-provider';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers'; import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
@ -20,6 +20,11 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
const { user } = useAuthContext(); const { user } = useAuthContext();
const localize = useLocalize(); const localize = useLocalize();
const [textHasMultipleLines, setTextHasMultipleLines] = useState(false);
const [lineCount, setLineCount] = useState(1);
const [contentHeight, setContentHeight] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
const endpointType = useMemo(() => { const endpointType = useMemo(() => {
let ep = conversation?.endpoint ?? ''; let ep = conversation?.endpoint ?? '';
if ( if (
@ -81,13 +86,46 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
} }
}, [localize, startupConfig?.interface?.customWelcome]); }, [localize, startupConfig?.interface?.customWelcome]);
const handleLineCountChange = useCallback((count: number) => {
setTextHasMultipleLines(count > 1);
setLineCount(count);
}, []);
useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.offsetHeight);
}
}, [lineCount, description]);
const getDynamicMargin = useMemo(() => {
let margin = 'mb-0';
if (lineCount > 2 || (description && description.length > 100)) {
margin = 'mb-10';
} else if (lineCount > 1 || (description && description.length > 0)) {
margin = 'mb-6';
} else if (textHasMultipleLines) {
margin = 'mb-4';
}
if (contentHeight > 200) {
margin = 'mb-16';
} else if (contentHeight > 150) {
margin = 'mb-12';
}
return margin;
}, [lineCount, description, textHasMultipleLines, contentHeight]);
return ( return (
<div <div
className={`flex h-full transform-gpu flex-col items-center justify-center pb-16 transition-all duration-200 ${centerFormOnLanding ? 'max-h-full sm:max-h-0' : 'max-h-full'}`} className={`flex h-full transform-gpu flex-col items-center justify-center pb-16 transition-all duration-200 ${centerFormOnLanding ? 'max-h-full sm:max-h-0' : 'max-h-full'} ${getDynamicMargin}`}
> >
<div className="flex flex-col items-center gap-0 p-2"> <div ref={contentRef} className="flex flex-col items-center gap-0 p-2">
<div className="flex flex-col items-center justify-center gap-4 md:flex-row"> <div
<div className="relative size-10 justify-center"> className={`flex ${textHasMultipleLines ? 'flex-col' : 'flex-col md:flex-row'} items-center justify-center gap-4`}
>
<div className={`relative size-10 justify-center ${textHasMultipleLines ? 'mb-2' : ''}`}>
<ConvoIcon <ConvoIcon
agentsMap={agentsMap} agentsMap={agentsMap}
assistantMap={assistantMap} assistantMap={assistantMap}
@ -120,6 +158,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
easing={easings.easeOutCubic} easing={easings.easeOutCubic}
threshold={0} threshold={0}
rootMargin="0px" rootMargin="0px"
onLineCountChange={handleLineCountChange}
/> />
</div> </div>
) : ( ) : (
@ -134,6 +173,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
easing={easings.easeOutCubic} easing={easings.easeOutCubic}
threshold={0} threshold={0}
rootMargin="0px" rootMargin="0px"
onLineCountChange={handleLineCountChange}
/> />
)} )}
</div> </div>

View file

@ -77,8 +77,9 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
autoSelect autoSelect
render={combobox} render={combobox}
className={cn( className={cn(
'h-10 w-full rounded border-none bg-transparent px-2 text-base', 'h-10 w-full rounded-lg border-none bg-transparent px-2 text-base',
'sm:h-8 sm:text-sm', 'sm:h-8 sm:text-sm',
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-white',
)} )}
/> />
</div> </div>

View file

@ -18,8 +18,7 @@ export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) {
key={spec.name} key={spec.name}
onClick={() => handleSelectSpec(spec)} onClick={() => handleSelectSpec(spec)}
className={cn( className={cn(
'flex w-full cursor-pointer justify-between rounded-lg px-2 text-sm', 'flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm',
spec.description ? 'items-start' : 'items-center',
)} )}
> >
<div <div
@ -41,7 +40,7 @@ export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) {
</div> </div>
</div> </div>
{isSelected && ( {isSelected && (
<div className={cn('flex-shrink-0', spec.description ? 'pt-1' : '')}> <div className="flex-shrink-0 self-center">
<svg <svg
width="16" width="16"
height="16" height="16"

View file

@ -22,7 +22,15 @@ const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) =>
if (!iconURL.includes('http')) { if (!iconURL.includes('http')) {
Icon = (icons[iconKey] ?? icons.unknown) as IconType; Icon = (icons[iconKey] ?? icons.unknown) as IconType;
} else if (iconURL) { } else if (iconURL) {
return <URLIcon iconURL={iconURL} altName={currentSpec.name} />; return (
<URLIcon
iconURL={iconURL}
altName={currentSpec.name}
containerStyle={{ width: 20, height: 20 }}
className="icon-md shrink-0 overflow-hidden rounded-full"
endpoint={endpoint || undefined}
/>
);
} else { } else {
Icon = (icons[endpoint ?? ''] ?? icons.unknown) as IconType; Icon = (icons[endpoint ?? ''] ?? icons.unknown) as IconType;
} }

View file

@ -1,14 +1,14 @@
import React, { memo, useState } from 'react'; import React, { memo, useState } from 'react';
import { AlertCircle } from 'lucide-react';
import { icons } from '~/hooks/Endpoint/Icons'; import { icons } from '~/hooks/Endpoint/Icons';
import { AlertCircle } from 'lucide-react'; // Assuming you have lucide-react for icons
export const URLIcon = memo( export const URLIcon = memo(
({ ({
iconURL, iconURL,
altName, altName,
containerStyle = { width: '20', height: '20' }, containerStyle = { width: 20, height: 20 },
imageStyle = { width: '100%', height: '100%' }, imageStyle = { width: '100%', height: '100%' },
className = 'icon-xl mr-1 shrink-0 overflow-hidden rounded-full', className = 'icon-md mr-1 shrink-0 overflow-hidden rounded-full',
endpoint, endpoint,
}: { }: {
iconURL: string; iconURL: string;
@ -26,11 +26,12 @@ export const URLIcon = memo(
const DefaultIcon: React.ElementType = const DefaultIcon: React.ElementType =
endpoint && icons[endpoint] ? icons[endpoint]! : icons.unknown!; endpoint && icons[endpoint] ? icons[endpoint]! : icons.unknown!;
if (imageError || !iconURL) { if (imageError || !iconURL) {
return ( return (
<div className="relative" style={{ ...containerStyle, margin: '2px' }}> <div className="relative" style={{ ...containerStyle, margin: '2px' }}>
<div className={className}> <div className={className}>
<DefaultIcon endpoint={endpoint} context="menu-item" /> <DefaultIcon endpoint={endpoint} context="menu-item" size={containerStyle.width} />
</div> </div>
{imageError && iconURL && ( {imageError && iconURL && (
<div <div
@ -48,10 +49,14 @@ export const URLIcon = memo(
<div className={className} style={containerStyle}> <div className={className} style={containerStyle}>
<img <img
src={iconURL} src={iconURL}
alt={altName ?? ''} alt={altName ?? 'Icon'}
style={imageStyle} style={imageStyle}
className="object-cover" className="object-cover"
onError={handleImageError} onError={handleImageError}
loading="lazy"
decoding="async"
width={Number(containerStyle.width)}
height={Number(containerStyle.height)}
/> />
</div> </div>
); );

View file

@ -25,7 +25,7 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<div className="flex h-full min-h-0 flex-col opacity-100 transition-opacity"> <div className="flex h-full min-h-0 flex-col opacity-100 transition-opacity">
<div className="scrollbar-trigger relative h-full w-full flex-1 items-start border-white/20"> <div className="scrollbar-trigger relative h-full w-full flex-1 items-start border-white/20">
<div className="flex h-full w-full flex-col gap-1 px-3 pb-3.5 group-[[data-collapsed=true]]:items-center group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"> <div className="flex h-full w-full flex-col gap-1 px-3 py-2.5 group-[[data-collapsed=true]]:items-center group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
{links.map((link, index) => { {links.map((link, index) => {
const variant = getVariant(link); const variant = getVariant(link);
return isCollapsed ? ( return isCollapsed ? (

View file

@ -1,3 +1,5 @@
import type React from 'react';
import { X, Plus } from 'lucide-react'; import { X, Plus } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { ButtonHTMLAttributes } from 'react'; import type { ButtonHTMLAttributes } from 'react';
@ -7,10 +9,12 @@ import { cn } from '~/utils';
interface BadgeProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface BadgeProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon?: LucideIcon; icon?: LucideIcon;
label: string; label: string;
id?: string;
isActive?: boolean; isActive?: boolean;
isEditing?: boolean; isEditing?: boolean;
isDragging?: boolean; isDragging?: boolean;
isAvailable: boolean; isAvailable: boolean;
isInChat?: boolean;
onBadgeAction?: () => void; onBadgeAction?: () => void;
onToggle?: () => void; onToggle?: () => void;
} }
@ -18,18 +22,27 @@ interface BadgeProps extends ButtonHTMLAttributes<HTMLButtonElement> {
export default function Badge({ export default function Badge({
icon: Icon, icon: Icon,
label, label,
id,
isActive = false, isActive = false,
isEditing = false, isEditing = false,
isDragging = false, isDragging = false,
isAvailable = true, isAvailable = true,
isInChat = false,
onBadgeAction, onBadgeAction,
onToggle, onToggle,
className, className,
...props ...props
}: BadgeProps) { }: BadgeProps) {
const isMoveable = isEditing && isAvailable; const isMoveable = isEditing && isAvailable;
const isDisabled = id === '1' && isInChat;
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => { const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (!isEditing && onToggle) { if (!isEditing && onToggle) {
if (typeof window !== 'undefined' && window.innerWidth >= 768) { if (typeof window !== 'undefined' && window.innerWidth >= 768) {
e.preventDefault(); e.preventDefault();
@ -44,27 +57,30 @@ export default function Badge({
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5', 'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
'border border-border-medium text-sm font-medium transition-shadow', 'border border-border-medium text-sm font-medium transition-shadow md:w-full',
'size-9 p-2 md:p-3',
isActive isActive
? 'bg-surface-active shadow-md' ? 'bg-surface-active shadow-md'
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md', : 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:scale-95 active:shadow-inner',
isMoveable && 'cursor-move', isMoveable && 'cursor-move',
isDisabled && 'cursor-not-allowed opacity-50 hover:shadow-sm',
className, className,
)} )}
animate={{ animate={{
scale: isDragging ? 1.1 : 1, scale: isDragging ? 1.1 : 1,
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined, boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
}} }}
whileTap={{ scale: isDragging ? 1.1 : 0.97 }} whileTap={{ scale: isDragging ? 1.1 : isDisabled ? 1 : 0.97 }}
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }} transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
{...props} {...props}
> >
{Icon && <Icon className="relative h-4 w-4" />} {Icon && <Icon className={cn('relative h-5 w-5 md:h-4 md:w-4', !label && 'mx-auto')} />}
<span className="relative hidden md:inline">{label}</span> <span className="relative hidden md:inline">{label}</span>
{isEditing && !isDragging && ( {isEditing && !isDragging && (
<motion.button <motion.button
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary" className="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary shadow-sm md:h-5 md:w-5"
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }} exit={{ opacity: 0, scale: 0.8 }}

View file

@ -12,6 +12,7 @@ interface SplitTextProps {
rootMargin?: string; rootMargin?: string;
textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end'; textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end';
onLetterAnimationComplete?: () => void; onLetterAnimationComplete?: () => void;
onLineCountChange?: (lineCount: number) => void;
} }
const SplitText: React.FC<SplitTextProps> = ({ const SplitText: React.FC<SplitTextProps> = ({
@ -25,6 +26,7 @@ const SplitText: React.FC<SplitTextProps> = ({
rootMargin = '-100px', rootMargin = '-100px',
textAlign = 'center', textAlign = 'center',
onLetterAnimationComplete, onLetterAnimationComplete,
onLineCountChange,
}) => { }) => {
const words = text.split(' ').map((word) => word.split('')); const words = text.split(' ').map((word) => word.split(''));
const letters = words.flat(); const letters = words.flat();
@ -32,6 +34,24 @@ const SplitText: React.FC<SplitTextProps> = ({
const ref = useRef<HTMLParagraphElement>(null); const ref = useRef<HTMLParagraphElement>(null);
const animatedCount = useRef(0); const animatedCount = useRef(0);
const springs = useSprings(
letters.length,
letters.map((_, i) => ({
from: animationFrom,
to: inView
? async (next: (props: any) => Promise<void>) => {
await next(animationTo);
animatedCount.current += 1;
if (animatedCount.current === letters.length && onLetterAnimationComplete) {
onLetterAnimationComplete();
}
}
: animationFrom,
delay: i * delay,
config: { easing },
})),
);
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
@ -52,23 +72,22 @@ const SplitText: React.FC<SplitTextProps> = ({
return () => observer.disconnect(); return () => observer.disconnect();
}, [threshold, rootMargin]); }, [threshold, rootMargin]);
const springs = useSprings( useEffect(() => {
letters.length, if (ref.current && inView) {
letters.map((_, i) => ({ const element = ref.current;
from: animationFrom, setTimeout(() => {
to: inView const lineHeight =
? async (next: (props: any) => Promise<void>) => { parseInt(getComputedStyle(element).lineHeight) ||
await next(animationTo); parseInt(getComputedStyle(element).fontSize) * 1.2;
animatedCount.current += 1; const height = element.offsetHeight;
if (animatedCount.current === letters.length && onLetterAnimationComplete) { const lines = Math.round(height / lineHeight);
onLetterAnimationComplete();
if (onLineCountChange) {
onLineCountChange(lines);
} }
}, 100);
} }
: animationFrom, }, [inView, text, onLineCountChange]);
delay: i * delay,
config: { easing },
})),
);
return ( return (
<p <p
@ -92,7 +111,9 @@ const SplitText: React.FC<SplitTextProps> = ({
</animated.span> </animated.span>
); );
})} })}
{wordIndex < words.length - 1 && (
<span style={{ display: 'inline-block', width: '0.3em' }}>&nbsp;</span> <span style={{ display: 'inline-block', width: '0.3em' }}>&nbsp;</span>
)}
</span> </span>
))} ))}
</p> </p>